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
This commit is contained in:
2026-10-13 09:55:27 +00:00
parent 6ecd75870d
commit 6345546b18
5 changed files with 190 additions and 1 deletions
+82
View File
@@ -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
}
@@ -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")
}
}
})
}
}
+3
View File
@@ -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[:])
+30
View File
@@ -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")
+1 -1
View File
@@ -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) {