From 6345546b1800af79fb89dda4866b2131d71bcfe4 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 09:55:27 +0000 Subject: [PATCH] Reject unsupported approval claims in material checkpoints Why: A source-valid analysis in the approved A01 sample promoted a requirement into an approved decision. Evidence: The observed claim and six display fields failed before the guard, then passed with negative/question and mixed-clause regressions; full runmgr tests passed. Impact: Keep exact material records while rejecting model-authored affirmative approval assertions before persistence; final model quality remains unverified. Refs: Agent-System-Backend#193; Agent-System-Dev#512 --- internal/runmgr/checkpoint_claim_guard.go | 82 +++++++++++++++++++ .../runmgr/checkpoint_claim_guard_test.go | 74 +++++++++++++++++ internal/runmgr/task_checkpoint.go | 3 + internal/runmgr/task_checkpoint_test.go | 30 +++++++ internal/runtime/work_checkpoint.go | 2 +- 5 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 internal/runmgr/checkpoint_claim_guard.go create mode 100644 internal/runmgr/checkpoint_claim_guard_test.go diff --git a/internal/runmgr/checkpoint_claim_guard.go b/internal/runmgr/checkpoint_claim_guard.go new file mode 100644 index 0000000..764ab2d --- /dev/null +++ b/internal/runmgr/checkpoint_claim_guard.go @@ -0,0 +1,82 @@ +package runmgr + +import ( + core "agent-system-core" + "fmt" + "strings" +) + +// Approval is a high-consequence status. A source-backed material_record is +// rendered from the exact quote by the host; free-form model analysis cannot +// turn an ordinary requirement or suggestion into an approved decision. +// This narrow language guard is not a general semantic verifier. +var affirmativeApprovalTerms = []string{ + "已批准", "已经批准", "已获批", "审批通过", "批复同意", "已通过审批", +} + +var approvalImmediateQualifiers = []string{ + "是否", "不等于", "不代表", "并非", "不是", "尚待确认是否", "需要确认是否", +} + +func approvalIsQuestionOrNegation(prefix string) bool { + for _, qualifier := range approvalImmediateQualifiers { + if strings.HasSuffix(prefix, qualifier) { + return true + } + } + for _, framing := range []string{"写成", "说成", "当作", "视为", "认定为"} { + if !strings.HasSuffix(prefix, framing) { + continue + } + for _, prevention := range []string{"防止", "避免", "不要", "不能", "不得"} { + if strings.Contains(prefix, prevention) { + return true + } + } + } + return false +} + +func hasAffirmativeApprovalClaim(text string) bool { + for _, clause := range strings.FieldsFunc(text, func(r rune) bool { + return strings.ContainsRune("。;;,,!??!\n::", r) + }) { + for _, term := range affirmativeApprovalTerms { + for start := 0; start < len(clause); { + at := strings.Index(clause[start:], term) + if at < 0 { + break + } + at += start + prefix := []rune(clause[:at]) + if len(prefix) > 16 { + prefix = prefix[len(prefix)-16:] + } + if !approvalIsQuestionOrNegation(string(prefix)) { + return true + } + start = at + len(term) + } + } + } + return false +} + +func validateMaterialApprovalClaims(candidate core.WorkCheckpointCandidate) error { + for _, text := range []string{candidate.Focus, candidate.Remaining, candidate.NextStep} { + if hasAffirmativeApprovalClaim(text) { + return fmt.Errorf("checkpoint.claim: approval needs explicit source record") + } + } + for _, finding := range candidate.Findings { + if finding.Kind == "material_record" { + continue // The host replaces all model-authored display text with exact quotes. + } + for _, text := range []string{finding.Text, finding.Impact, finding.Assumptions} { + if hasAffirmativeApprovalClaim(text) { + return fmt.Errorf("checkpoint.claim: approval needs explicit source record") + } + } + } + return nil +} diff --git a/internal/runmgr/checkpoint_claim_guard_test.go b/internal/runmgr/checkpoint_claim_guard_test.go new file mode 100644 index 0000000..83962ec --- /dev/null +++ b/internal/runmgr/checkpoint_claim_guard_test.go @@ -0,0 +1,74 @@ +package runmgr + +import ( + "strings" + "testing" +) + +func TestMaterialApprovalClaims(t *testing.T) { + tests := []struct { + name string + kind string + field string + value string + wantReject bool + }{ + {"approved text", "analysis", "text", "预算是已批准决定", true}, + {"observed A01 approval error", "analysis", "text", "需求.txt 的总预算50万元、12项需求范围与3项外部接口依赖均写为规定/包含,是已批准决定;接口交付日期尚未确认", true}, + {"approved impact", "analysis", "impact", "审批通过,可执行", true}, + {"approved assumption", "provisional", "assumptions", "批复同意", true}, + {"approved focus", "material_record", "focus", "预算已获批", true}, + {"approved remaining", "material_record", "remaining", "已通过审批", true}, + {"approved next step", "material_record", "nextStep", "预算已经批准,可以推进", true}, + {"approval incomplete", "analysis", "text", "审批尚未完成", false}, + {"approval question", "analysis", "text", "是否已批准需核对", false}, + {"prevent false approval", "analysis", "text", "防止把50万写成已批准", false}, + {"unrelated prevention does not negate approval", "analysis", "text", "防止风险且预算已批准", true}, + {"unrelated question does not negate approval", "analysis", "text", "是否要修订且预算已批准", true}, + {"later denial does not erase prior assertion", "analysis", "text", "预算已批准但会议说尚未批准", true}, + {"not an approved increase", "analysis", "text", "不等于已批准增加经费", false}, + {"record is overwritten", "material_record", "text", "上线六月已经批准", false}, + {"user question", "needs_user", "text", "请确认预算是否已批准", false}, + {"user action assertion", "needs_user", "text", "预算已批准,请确认下一步", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := checkpointEnvironment(t) + cp := validCheckpoint(c, "approval-claim") + cp.Findings[0].Kind = tc.kind + if tc.kind == "analysis" || tc.kind == "provisional" { + cp.Findings[0].Assumptions = "材料来源仍需核对" + } + switch tc.field { + case "text": + cp.Findings[0].Text = tc.value + case "impact": + cp.Findings[0].Impact = tc.value + case "assumptions": + cp.Findings[0].Assumptions = tc.value + case "focus": + cp.Focus = tc.value + case "remaining": + cp.Remaining = tc.value + case "nextStep": + cp.NextStep = tc.value + } + err := c.PublishWorkCheckpoint(cp) + if tc.wantReject && (err == nil || !strings.Contains(err.Error(), "checkpoint.claim")) { + t.Fatalf("affirmative approval accepted: %v", err) + } + if !tc.wantReject && err != nil { + t.Fatalf("negative, question, or verbatim source rejected: %v", err) + } + if !tc.wantReject && tc.kind == "material_record" { + card, getErr := c.mgr.st.GetCard(c.run.Owner, c.task.cardID) + if getErr != nil { + t.Fatal(getErr) + } + if len(card.Task.Findings) != 1 || card.Task.Findings[0].Kind != "material_record" || strings.Contains(card.Task.Findings[0].Text, "已经批准") { + t.Fatal("model paraphrase escaped material_record canonicalization") + } + } + }) + } +} diff --git a/internal/runmgr/task_checkpoint.go b/internal/runmgr/task_checkpoint.go index 217438c..e40eaff 100644 --- a/internal/runmgr/task_checkpoint.go +++ b/internal/runmgr/task_checkpoint.go @@ -21,6 +21,9 @@ func (c *runCtx) PublishWorkCheckpoint(candidate core.WorkCheckpointCandidate) e if err := core.ValidateWorkCheckpoint(candidate); err != nil { return err } + if err := validateMaterialApprovalClaims(candidate); err != nil { + return err + } raw, _ := json.Marshal(candidate) sum := sha256.Sum256(raw) digest := hex.EncodeToString(sum[:]) diff --git a/internal/runmgr/task_checkpoint_test.go b/internal/runmgr/task_checkpoint_test.go index 240c847..e358cb5 100644 --- a/internal/runmgr/task_checkpoint_test.go +++ b/internal/runmgr/task_checkpoint_test.go @@ -60,6 +60,36 @@ func TestMaterialRecordCannotAddAnUnstatedApproval(t *testing.T) { t.Fatalf("model interpretation was published as a material record: %+v", f) } } + +func TestTaskCheckpointRejectsUnsupportedApproval(t *testing.T) { + c := checkpointEnvironment(t) + before, err := c.mgr.st.GetCard(c.run.Owner, c.task.cardID) + if err != nil { + t.Fatal(err) + } + eventsBefore, err := c.mgr.st.EventsAfter(c.run.ConversationID, 0, 1000) + if err != nil { + t.Fatal(err) + } + cp := validCheckpoint(c, "unsupported-approval") + cp.Findings[0].Kind = "analysis" + cp.Findings[0].Text = "需求规定50万元,是已批准决定" + cp.Findings[0].Assumptions = "把需求条目作为项目基线" + if err = c.PublishWorkCheckpoint(cp); err == nil || !strings.Contains(err.Error(), "checkpoint.claim") { + t.Fatalf("unsupported approval was published: %v", err) + } + after, err := c.mgr.st.GetCard(c.run.Owner, c.task.cardID) + if err != nil { + t.Fatal(err) + } + eventsAfter, err := c.mgr.st.EventsAfter(c.run.ConversationID, 0, 1000) + if err != nil { + t.Fatal(err) + } + if after.Task.Revision != before.Task.Revision || len(after.Task.Findings) != len(before.Task.Findings) || len(eventsAfter) != len(eventsBefore) { + t.Fatal("rejected approval changed the card or replay events") + } +} func TestTaskCheckpointPersistentIdempotentAndCorrected(t *testing.T) { c := checkpointEnvironment(t) cp := validCheckpoint(c, "1") diff --git a/internal/runtime/work_checkpoint.go b/internal/runtime/work_checkpoint.go index d3ae8c4..3956bdf 100644 --- a/internal/runtime/work_checkpoint.go +++ b/internal/runtime/work_checkpoint.go @@ -38,7 +38,7 @@ func checkpointEnabled(rc RunCtx) bool { return true } func checkpointDecl() agentllm.ToolDecl { - return agentllm.ToolDecl{Name: workCheckpointTool, Description: "将本次材料整理中已形成的有用发现及时保存到同一任务卡。只引用已提供的 sourceId/blockIndex 和原文片段;material_record由宿主展示经核验的原文引用,不采用模型转述或影响判断;归纳、推算或判断用analysis/provisional并写明assumptions。长材料在形成阶段结果时发布,不额外调用摘要模型;不要为了进度凑发现。普通发布最多6次,每次只提交1至3条新增发现;原文quote必须是同一段落连续出现的短句,不能拼接或用省略号。需要更正以前发现时用 corrects 填其 findingKey。此工具不创建确认按钮,也不表示暂停执行。", Parameters: json.RawMessage(`{"type":"object","additionalProperties":false,"required":["checkpointId","phaseId","findings"],"properties":{"checkpointId":{"type":"string"},"phaseId":{"type":"string","enum":["reading","crosscheck","summarizing","draft-ready"]},"focus":{"type":"string"},"remaining":{"type":"string"},"nextStep":{"type":"string"},"findings":{"type":"array","minItems":1,"maxItems":3,"items":{"type":"object","additionalProperties":false,"required":["findingKey","text","kind","sources"],"properties":{"findingKey":{"type":"string"},"text":{"type":"string"},"impact":{"type":"string"},"kind":{"type":"string","enum":["material_record","analysis","provisional","needs_user"]},"assumptions":{"type":"string"},"corrects":{"type":"string"},"sources":{"type":"array","minItems":1,"items":{"type":"object","additionalProperties":false,"required":["sourceId","blockIndex","quote"],"properties":{"sourceId":{"type":"string"},"blockIndex":{"type":"integer","minimum":0},"quote":{"type":"string","description":"原文连续短句,勿拼接或用省略号"}}}}}}}}}`)} + return agentllm.ToolDecl{Name: workCheckpointTool, Description: "将本次材料整理中已形成的有用发现及时保存到同一任务卡。只引用已提供的 sourceId/blockIndex 和原文片段;material_record由宿主展示经核验的原文引用,不采用模型转述或影响判断;归纳、推算或判断用analysis/provisional并写明assumptions。审批/批准等肯定状态仅作明确原文的material_record;分析、影响、前提和任务状态不得把要求或建议写成已批准,不确定时用needs_user请用户核实。长材料在形成阶段结果时发布,不额外调用摘要模型;不要为了进度凑发现。普通发布最多6次,每次只提交1至3条新增发现;原文quote必须是同一段落连续出现的短句,不能拼接或用省略号。需要更正以前发现时用 corrects 填其 findingKey。此工具不创建确认按钮,也不表示暂停执行。", Parameters: json.RawMessage(`{"type":"object","additionalProperties":false,"required":["checkpointId","phaseId","findings"],"properties":{"checkpointId":{"type":"string"},"phaseId":{"type":"string","enum":["reading","crosscheck","summarizing","draft-ready"]},"focus":{"type":"string"},"remaining":{"type":"string"},"nextStep":{"type":"string"},"findings":{"type":"array","minItems":1,"maxItems":3,"items":{"type":"object","additionalProperties":false,"required":["findingKey","text","kind","sources"],"properties":{"findingKey":{"type":"string"},"text":{"type":"string"},"impact":{"type":"string"},"kind":{"type":"string","enum":["material_record","analysis","provisional","needs_user"]},"assumptions":{"type":"string"},"corrects":{"type":"string"},"sources":{"type":"array","minItems":1,"items":{"type":"object","additionalProperties":false,"required":["sourceId","blockIndex","quote"],"properties":{"sourceId":{"type":"string"},"blockIndex":{"type":"integer","minimum":0},"quote":{"type":"string","description":"原文连续短句,勿拼接或用省略号"}}}}}}}}}`)} } func publishCheckpoint(rc RunCtx, args string) agenttools.Result { if !checkpointEnabled(rc) {