From 5f9e77a500613e4bfdeec946f9be58669e4fb675 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 7 Oct 2026 06:31:58 +0000 Subject: [PATCH] Consume the sunk gate mechanics and pin the WP5 library Why: N1 WP5 host side - directRun.stop and the checklist reminders now call the library gates package behind product ports, and the nested native pin moves to the merged WP5 head. Evidence: go vet clean; go test ./... 29 packages ok, including the characterization suite unmodified and the new wp5_ab_test.go (verify-cap continuation counts and gate-reason text parity). Impact: llm.go drops the local regex family and deliverableGate; verify.go drops checkDeliverables/parseVerdict/stripFence and the local caps; workflow.go switches to gates.Verdict; reminders run through gates.ProgressReminder. Behavior is pinned identical by the existing suite plus the A/B tests. Squad reminders stay out of this increment (adapter lacks per-member tool-turn signals - recorded as WP6b). Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md WP5/WP6 --- deps/agent-native | 2 +- internal/runtime/llm.go | 75 +++++++------- internal/runtime/llm_direct.go | 16 +-- internal/runtime/llm_verify_test.go | 7 +- internal/runtime/verify.go | 149 +++++----------------------- internal/runtime/workflow.go | 28 ++++-- internal/runtime/wp5_ab_test.go | 107 ++++++++++++++++++++ 7 files changed, 199 insertions(+), 185 deletions(-) create mode 100644 internal/runtime/wp5_ab_test.go diff --git a/deps/agent-native b/deps/agent-native index c5b9dca..c9c2f23 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit c5b9dca44622eb10b11f89929e107c3aacaacbfd +Subproject commit c9c2f23990a0aa62b7593adf29b411aea76f6997 diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index 527a715..dba3315 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -5,16 +5,17 @@ import ( "fmt" "math" "net/http" - "regexp" "strings" "time" nruntime "agent-native/runtime" + "agent-native/runtime/gates" agentllm "agent-native/runtime/llm" "agent-native/runtime/policy" agenttools "agent-native/runtime/tools" "agent-system-backend/internal/ctxasm" "agent-system-backend/internal/prompts" + "agent-system-backend/internal/quality" core "agent-system-core" ) @@ -453,61 +454,55 @@ func orDefault(s, def string) string { return s } -const maxDeliverableGuards = 2 - const deliverableGuardNote = "你的上一段回复声称已经生成或登记了文件,但这一轮没有产生对应的工件。请二选一:用 run_python 写出文件并调用 register_artifact 登记,或如实说明这一轮没有生成文件。不要编造工件 ID,也不要在回复里写 art_ 开头的编号。正文里的「调用工具」「工具结果」「产出工件」字样不会执行任何东西,调用工具必须用函数调用。" const discardedClaimNote = "(上一段回复声称已交付,但本轮没有任何工具调用,已作废)" const deliverableFallbackNotice = "本轮未实际生成文件(模型声称已生成,但没有调用工具)。请重试,或换一种说法再试一次。" -var ( - artIDRe = regexp.MustCompile(`art_[0-9a-f]{12}`) - fileClaimRe = regexp.MustCompile(`(?s)(?:已生成|已保存|已导出|已写入).{0,120}?\.(?:docx|xlsx|pptx|pdf|png|jpe?g|csv|md|txt|json)\b`) - softClaimRe = regexp.MustCompile(`(?s)(?:文件|文档|工件).{0,40}?已(?:正常)?(?:生成|登记|保存|导出|写入)`) - forgedCallRe = regexp.MustCompile(`\[(?:调用工具|工具结果|产出工件) `) -) - // claimsFalseDeliverable 保留给小队入口:命中即判失败,不走直连路径的纠正轮次。 func claimsFalseDeliverable(body string, produced []*core.ArtifactRef, rc RunCtx) bool { - block, _ := deliverableGate(body, produced, rc) + block, _ := deliverableGateOf(body, produced, rc) return block } -// deliverableGate 判断终轮正文是否在没有对应工件的情况下声称交付。 -// 返回值的形状与 Stop 门禁一致,P1 平移进内核时不用再改调用方。 -// 引用本轮已产出、或项目里确实存在的工件,不算谎称;正文里的伪造调用标记始终拦截。 -func deliverableGate(body string, produced []*core.ArtifactRef, rc RunCtx) (bool, string) { - if strings.TrimSpace(body) == "" { - return false, "" - } - if forgedCallRe.MatchString(body) { - return true, "回复正文里出现了工具调用标记,但本轮没有真实的工具调用" - } - producedIDs := map[string]bool{} - for _, ref := range produced { - if ref != nil && ref.ArtifactID != "" { - producedIDs[ref.ArtifactID] = true - } - } - knownExisting := false - for _, id := range artIDRe.FindAllString(body, -1) { - if producedIDs[id] { +// deliverableGateOf 把产品工件存取投影成库端口后调用下沉的 ClaimGate(N1 WP5)。 +func deliverableGateOf(body string, produced []*core.ArtifactRef, rc RunCtx) (bool, string) { + return gates.ClaimGate(body, artifactRefsOf(produced), artifactLookupOf(rc)) +} + +// artifactRefsOf 把 core.ArtifactRef 投影为库的最小形态。 +func artifactRefsOf(refs []*core.ArtifactRef) []gates.ArtifactRef { + out := make([]gates.ArtifactRef, 0, len(refs)) + for _, ref := range refs { + if ref == nil || ref.ArtifactID == "" { continue } - if _, _, ok := rc.Artifact(id); ok { - knownExisting = true - continue + out = append(out, gates.ArtifactRef{ID: ref.ArtifactID, Name: ref.Name, Kind: ref.Kind}) + } + return out +} + +// artifactLookupOf 把 rc.Artifact 投影为库的工件存取端口。 +func artifactLookupOf(rc RunCtx) gates.ArtifactLookup { + return func(id string) (string, []byte, bool) { + art, content, ok := rc.Artifact(id) + if !ok { + return "", nil, false } - return true, "回复引用了不存在的工件" + return art.Name, content, true } - if len(producedIDs) > 0 || knownExisting { - return false, "" +} + +// qualityScorer 把 quality.ScoreBytes 投影为库的评分端口。 +func qualityScorer() gates.QualityScorer { + return func(name string, content []byte) (bool, string) { + report := quality.ScoreBytes(name, content) + if report.Passed { + return true, "" + } + return false, strings.Join(report.Violations, ";") } - if fileClaimRe.MatchString(body) || softClaimRe.MatchString(body) { - return true, "回复声称已经生成或登记了文件,但这一轮没有产生对应的工件" - } - return false, "" } func summarize(out string, err error) string { diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index a3503bc..675b424 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -9,9 +9,9 @@ import ( "time" nruntime "agent-native/runtime" + "agent-native/runtime/gates" "agent-native/runtime/hooks" agentllm "agent-native/runtime/llm" - "agent-native/runtime/reminders" agenttools "agent-native/runtime/tools" "agent-system-backend/internal/ctxasm" "agent-system-backend/internal/squadadapter" @@ -51,9 +51,8 @@ type directRun struct { closedToolTurn bool checklistTouched bool toolTurnsSince int - checklistReminders int + progress *gates.ProgressReminder checks []core.CheckPart - notes *reminders.Bus servedModel string switches []string switchMu sync.Mutex @@ -348,12 +347,12 @@ func (st *directRun) onEvent(ev nruntime.Event) { } func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntime.StopDecision { - if block, why := deliverableGate(st.turnBody, st.deliverables, st.rc); block { + if block, why := deliverableGateOf(st.turnBody, st.deliverables, st.rc); block { st.demoteLastText() if st.eff.GetBool("runtime.verify", false) { st.addVerifyNote(why) } - if info.Continuations < maxDeliverableGuards { + if info.Continuations < gates.MaxDeliverableGuards { return nruntime.StopDecision{Continue: true, Replacement: discardedClaimNote, Reason: systemCheckReason(deliverableGuardNote)} } return nruntime.StopDecision{Notice: deliverableFallbackNotice} @@ -361,7 +360,8 @@ func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntim if !st.eff.GetBool("runtime.verify", false) { return nruntime.StopDecision{} } - if reasons := checkDeliverables(st.rc, st.deliverables); len(reasons) > 0 { + // N1 WP5:工件存在/非空/质量检查走下沉的 CheckArtifacts(端口在 llm.go 投影)。 + if reasons := gates.CheckArtifacts(artifactRefsOf(st.deliverables), artifactLookupOf(st.rc), qualityScorer()); len(reasons) > 0 { return st.continueOrNotice(info, strings.Join(reasons, ";")) } verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, st.deliverables) @@ -425,7 +425,7 @@ func (st *directRun) addCheck(passed bool, reason string) { } func (st *directRun) continueOrNotice(info nruntime.StopInfo, reason string) nruntime.StopDecision { - if info.Continuations < maxVerifyContinuations { + if info.Continuations < gates.MaxVerifyContinuations { st.demoteLastText() st.addVerifyNote(reason) return nruntime.StopDecision{Continue: true, Reason: systemCheckReason(reason)} @@ -728,7 +728,7 @@ func (st *directRun) pinSubagentArtifacts(text string) { seen[ref.ArtifactID] = true } } - for _, id := range artIDRe.FindAllString(text, -1) { + for _, id := range gates.ArtifactIDPattern.FindAllString(text, -1) { if seen[id] { continue } diff --git a/internal/runtime/llm_verify_test.go b/internal/runtime/llm_verify_test.go index 141b570..59f7334 100644 --- a/internal/runtime/llm_verify_test.go +++ b/internal/runtime/llm_verify_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "agent-native/runtime/gates" agentllm "agent-native/runtime/llm" core "agent-system-core" ) @@ -230,11 +231,11 @@ func TestVerifierGarbageFailsClosed(t *testing.T) { } func TestParseVerdictToleratesFence(t *testing.T) { - got, err := parseVerdict("```json\n{\"passed\":false,\"reason\":\"不够\",\"nextAction\":\"重写\"}\n```") + got, err := gates.ParseVerdict("```json\n{\"passed\":false,\"reason\":\"不够\",\"nextAction\":\"重写\"}\n```") if err != nil || got.Passed || got.Reason != "不够" { t.Fatalf("verdict = %+v %v", got, err) } - if _, err := parseVerdict(`{"reason":"no passed"}`); err == nil { + if _, err := gates.ParseVerdict(`{"reason":"no passed"}`); err == nil { t.Fatal("missing passed should fail") } } @@ -392,7 +393,7 @@ func TestFalseDeliverableFixtures(t *testing.T) { } for _, c := range doc.Cases { t.Run(c.Name, func(t *testing.T) { - block, reason := deliverableGate(c.Body, nil, &fakeRunCtx{ctx: context.Background()}) + block, reason := deliverableGateOf(c.Body, nil, &fakeRunCtx{ctx: context.Background()}) if !block || strings.TrimSpace(reason) == "" { t.Fatalf("gate = %v %q", block, reason) } diff --git a/internal/runtime/verify.go b/internal/runtime/verify.go index 6f4b258..43edcca 100644 --- a/internal/runtime/verify.go +++ b/internal/runtime/verify.go @@ -1,27 +1,15 @@ package runtime import ( - "encoding/json" "fmt" - "path/filepath" "strings" "time" + "agent-native/runtime/gates" agentllm "agent-native/runtime/llm" - "agent-native/runtime/reminders" - "agent-system-backend/internal/quality" core "agent-system-core" ) -const ( - // maxVerifyContinuations 与内核 Stop 的 3 次上限对齐。 - maxVerifyContinuations = 3 - // reminderToolGap 连续这么多个工具轮没有更新计划或待办,就提醒一次。 - reminderToolGap = 3 - // maxChecklistReminders 每个 run 最多提醒这么多次。 - maxChecklistReminders = 3 -) - const verifyCapNotice = "本轮回复未通过终轮检查。" const verifierUnavailableNotice = "本轮回复未通过独立验证(验证器不可用)" @@ -37,58 +25,12 @@ func systemCheckReason(body string) string { return "\n这是系统的终轮自动检查,不是用户发言。先用工具核实;如果检查有误就直接收尾,不要向用户提起这条检查。\n" + body + "\n" } -type verifyVerdict struct { - Passed bool `json:"passed"` - Reason string `json:"reason"` - NextAction string `json:"nextAction"` -} - -// checkDeliverables 确认本轮登记的工件存在且非空。 -// md、txt、docx 还要过 quality;其他类型只检查存在与非空。 -func checkDeliverables(rc RunCtx, refs []*core.ArtifactRef) []string { - var reasons []string - for _, ref := range refs { - if ref == nil || ref.ArtifactID == "" { - reasons = append(reasons, "工件引用缺少 ID") - continue - } - art, content, ok := rc.Artifact(ref.ArtifactID) - name := ref.Name - if name == "" { - name = string(ref.ArtifactID) - } - if !ok { - reasons = append(reasons, name+" 不存在") - continue - } - if art.Name != "" { - name = art.Name - } - if len(content) == 0 { - reasons = append(reasons, name+" 内容为空") - continue - } - switch strings.ToLower(filepath.Ext(name)) { - case ".md", ".txt", ".docx": - report := quality.ScoreBytes(name, content) - if !report.Passed { - why := strings.Join(report.Violations, ";") - if why == "" { - why = "评分未通过" - } - reasons = append(reasons, name+":"+why) - } - } - } - return reasons -} - -func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence, refs []*core.ArtifactRef) (verifyVerdict, error) { +func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence, refs []*core.ArtifactRef) (gates.Verdict, error) { route := ResolveLightweightModel(rc.Profile(), rc.Settings()) stream := l.VerifyStream if stream == nil { if route.Model == "" || route.Base == "" { - return verifyVerdict{}, fmt.Errorf("验证器调用失败") + return gates.Verdict{}, fmt.Errorf("验证器调用失败") } client := openLLMClient(route.Base, route.Key) stream = client.Stream @@ -107,16 +49,16 @@ func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence, case agentllm.KindTextDelta: b.WriteString(ev.Text) case agentllm.KindError: - return verifyVerdict{}, fmt.Errorf("验证器调用失败") + return gates.Verdict{}, fmt.Errorf("验证器调用失败") case agentllm.KindMessageEnd: if b.Len() == 0 { b.WriteString(ev.Message.Content) } } } - verdict, err := parseVerdict(b.String()) + verdict, err := gates.ParseVerdict(b.String()) if err != nil { - return verifyVerdict{}, fmt.Errorf("验证器返回无法解析") + return gates.Verdict{}, fmt.Errorf("验证器返回无法解析") } return verdict, nil } @@ -170,41 +112,6 @@ func evidencePrompt(user, body string, evidence []toolEvidence, refs []*core.Art return b.String() } -func parseVerdict(raw string) (verifyVerdict, error) { - s := stripFence(raw) - start := strings.Index(s, "{") - end := strings.LastIndex(s, "}") - if start < 0 || end < start { - return verifyVerdict{}, fmt.Errorf("验证器返回无法解析") - } - s = s[start : end+1] - var probe map[string]json.RawMessage - if err := json.Unmarshal([]byte(s), &probe); err != nil { - return verifyVerdict{}, err - } - if _, ok := probe["passed"]; !ok { - return verifyVerdict{}, fmt.Errorf("验证器返回无法解析") - } - var verdict verifyVerdict - if err := json.Unmarshal([]byte(s), &verdict); err != nil { - return verifyVerdict{}, err - } - return verdict, nil -} - -func stripFence(s string) string { - s = strings.TrimSpace(s) - if !strings.HasPrefix(s, "```") { - return s - } - s = strings.TrimPrefix(s, "```") - if i := strings.IndexByte(s, '\n'); i >= 0 { - s = s[i+1:] - } - s = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(s), "```")) - return s -} - func (st *directRun) loadChecklist() { if src, ok := st.rc.(interface{ Todos() (core.TodoList, bool) }); ok { if list, ok := src.Todos(); ok { @@ -230,49 +137,39 @@ func (st *directRun) reminders(turn int) []string { st.closedToolTurn = false st.checklistTouched = false } - if st.toolTurnsSince < reminderToolGap || st.checklistReminders >= maxChecklistReminders { + if st.toolTurnsSince < gates.ReminderToolGap { return nil } - text := st.unfinishedReminder() - if text == "" { - return nil + // N1 WP5:节流、封顶与文案走下沉的 ProgressReminder; + // 工具轮计数留在这里(它与 directRun 的事件流耦合)。 + if st.progress == nil { + st.progress = &gates.ProgressReminder{} } - if st.notes == nil { - st.notes = reminders.New(0, maxChecklistReminders) + todos, plans := st.unfinishedItems() + got := st.progress.Remind(todos, plans, time.Now()) + if got != nil { + st.toolTurnsSince = 0 } - key := fmt.Sprintf("%d:%s", st.checklistReminders, text) - if !st.notes.Admit(reminders.Note{Kind: reminders.KindTodo, Key: key, Text: text}, time.Now()) { - return nil - } - st.toolTurnsSince = 0 - st.checklistReminders++ - return []string{text} + return got } -func (st *directRun) unfinishedReminder() string { - var lines []string +// unfinishedItems 返回计划与待办的未完成项正文(状态过滤留在产品侧)。 +func (st *directRun) unfinishedItems() (todos, plans []string) { for _, item := range st.todos.Items { if item.Status == "done" { continue } - text := strings.TrimSpace(item.Text) - if text == "" { - continue + if text := strings.TrimSpace(item.Text); text != "" { + todos = append(todos, text) } - lines = append(lines, "- 待办:"+text) } for _, step := range st.plan.Steps { if step.Status == "done" || step.Status == "skipped" { continue } - text := strings.TrimSpace(step.Text) - if text == "" { - continue + if text := strings.TrimSpace(step.Text); text != "" { + plans = append(plans, text) } - lines = append(lines, "- 计划:"+text) } - if len(lines) == 0 { - return "" - } - return "计划和待办已连续 3 个工具轮没有更新。未完成项:\n" + strings.Join(lines, "\n") + "\n请更新计划或待办,或在回复里说明为什么先不做。" + return todos, plans } diff --git a/internal/runtime/workflow.go b/internal/runtime/workflow.go index a9b0592..a3cae49 100644 --- a/internal/runtime/workflow.go +++ b/internal/runtime/workflow.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "agent-native/runtime/gates" agentllm "agent-native/runtime/llm" "agent-system-backend/internal/workflow" core "agent-system-core" @@ -77,7 +78,7 @@ func planWorkflow(rc RunCtx, goal string, runner WorkflowRunner) (workflowOutcom } func parseWorkflowDef(raw string) (core.WorkflowDef, error) { - s := stripFence(raw) + s := stripFenceBlock(raw) start := strings.Index(s, "{") end := strings.LastIndex(s, "}") if start < 0 || end < start { @@ -335,7 +336,7 @@ func runPromptStep(rc RunCtx, step core.WorkflowStep) (string, []*core.ArtifactR func artifactsIn(rc RunCtx, text string) []*core.ArtifactRef { var out []*core.ArtifactRef seen := map[string]bool{} - for _, id := range artIDRe.FindAllString(text, -1) { + for _, id := range gates.ArtifactIDPattern.FindAllString(text, -1) { if seen[id] { continue } @@ -349,13 +350,13 @@ func artifactsIn(rc RunCtx, text string) []*core.ArtifactRef { return out } -func (w WorkflowRunner) judgeStep(rc RunCtx, goal, criteria, output string) verifyVerdict { +func (w WorkflowRunner) judgeStep(rc RunCtx, goal, criteria, output string) gates.Verdict { stream := w.JudgeStream model := "verify" if stream == nil { route := ResolveLightweightModel(rc.Profile(), rc.Settings()) if route.Model == "" || route.Base == "" { - return verifyVerdict{Passed: false, Reason: "验证器不可用", NextAction: "检查轻量模型配置"} + return gates.Verdict{Passed: false, Reason: "验证器不可用", NextAction: "检查轻量模型配置"} } model = route.Model client := openLLMClient(route.Base, route.Key) @@ -368,16 +369,16 @@ func (w WorkflowRunner) judgeStep(rc RunCtx, goal, criteria, output string) veri case agentllm.KindTextDelta: b.WriteString(ev.Text) case agentllm.KindError: - return verifyVerdict{Passed: false, Reason: "验证器不可用", NextAction: "稍后再试"} + return gates.Verdict{Passed: false, Reason: "验证器不可用", NextAction: "稍后再试"} case agentllm.KindMessageEnd: if b.Len() == 0 { b.WriteString(ev.Message.Content) } } } - verdict, err := parseVerdict(b.String()) + verdict, err := gates.ParseVerdict(b.String()) if err != nil { - return verifyVerdict{Passed: false, Reason: "无法解析", NextAction: "按验收标准重做"} + return gates.Verdict{Passed: false, Reason: "无法解析", NextAction: "按验收标准重做"} } return verdict } @@ -421,3 +422,16 @@ func outcome(def core.WorkflowDef, statuses []string, arts []*core.ArtifactRef, } return workflowOutcome{Summary: parts[0].Text, Parts: parts, Artifacts: arts} } + +// stripFenceBlock 剥模型输出的 ``` 围栏(工作流定义解析用;与门禁域无关,本地持有)。 +func stripFenceBlock(s string) string { + s = strings.TrimSpace(s) + if !strings.HasPrefix(s, "```") { + return s + } + s = strings.TrimPrefix(s, "```") + if i := strings.IndexByte(s, '\n'); i >= 0 { + s = s[i+1:] + } + return strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(s), "```")) +} diff --git a/internal/runtime/wp5_ab_test.go b/internal/runtime/wp5_ab_test.go new file mode 100644 index 0000000..ab4c9d5 --- /dev/null +++ b/internal/runtime/wp5_ab_test.go @@ -0,0 +1,107 @@ +package runtime + +import ( + "context" + "strings" + "testing" + + agentllm "agent-native/runtime/llm" + core "agent-system-core" +) + +// wp5_ab_test.go 是 N1 WP5 验收的 A/B 对照:门禁机制下沉后, +// 同一组脚本化模型输出的停止原因、提醒次数与工具拒绝必须与记录的 +// 旧行为逐条一致。既有套件(GuardTwiceThenNotice / VerifierRejectsThenPasses / +// VerifierGarbageFailsClosed / TodoReminder 三测)在实现切换后原样通过, +// 是性格化等价的主网;本文件补齐两个此前无覆盖的验收角度。 + +// TestVerifyCapNoticeAfterThreeContinuations:验证器持续拒绝时, +// 续跑恰为 3 次(gates.MaxVerifyContinuations),随后以 verifyCapNotice 收尾, +// 理由逐次进入过程区。 +func TestVerifyCapNoticeAfterThreeContinuations(t *testing.T) { + turns := [][]agentllm.Event{ + textDeltaTurn("答案 v1。"), + textDeltaTurn("答案 v2。"), + textDeltaTurn("答案 v3。"), + textDeltaTurn("答案 v4。"), + } + var mainCalls int + base := scriptStream(turns, nil) + r := &LLMRunner{ + Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { + mainCalls++ + return base(ctx, model, msgs, tools, opts) + }, + VerifyStream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { + return passedStream(`{"passed":false,"reason":"来源未标注","nextAction":"补上出处"}`)(ctx, model, msgs, tools, opts) + }, + } + rc := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true} + if err := r.Execute(rc); err != nil { + t.Fatalf("Execute: %v", err) + } + if mainCalls != 4 { + t.Fatalf("main calls = %d, want 4(3 次续跑 + 收尾)", mainCalls) + } + texts := textParts(rc.assistant) + last := texts[len(texts)-1] + if last.Process || !strings.Contains(last.Text, "本轮回复未通过终轮检查") { + t.Fatalf("final notice = %+v, want non-process verify cap notice", last) + } + // 理由经 CheckPart 进过程区:3 次续跑各记一次,达上限时再记一次(与旧行为一致)。 + notes := 0 + for _, p := range rc.assistant { + if p.Type == core.PartCheck && p.Check != nil && !p.Check.Passed && strings.Contains(p.Check.Reason, "来源未标注") { + notes++ + } + } + if notes != 4 { + t.Fatalf("verify notes = %d, want 4(3 次续跑 + 1 次封顶); checks = %+v", notes, rc.assistant) + } + // 前三个被降级的回复进了过程区;最后一次回复与收尾 Notice 留在正式区。 + demoted := 0 + for _, p := range texts { + if p.Process && strings.HasPrefix(p.Text, "答案 v") { + demoted++ + } + } + if demoted != 3 { + t.Fatalf("demoted answers = %d, want 3; parts = %+v", demoted, texts) + } +} + +// TestGateReasonTextParityAfterSink:下沉后门禁分支理由逐字一致—— +// verify 开启时,声称交付无工件的理由必须原样进入过程区。 +func TestGateReasonTextParityAfterSink(t *testing.T) { + turns := [][]agentllm.Event{ + textDeltaTurn("文档已生成:甲.docx"), + textDeltaTurn("文档已保存:乙.docx"), + textDeltaTurn("文档已导出:丙.docx"), + } + r := &LLMRunner{Stream: scriptStream(turns, nil)} + rc := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true} + if err := r.Execute(rc); err != nil { + t.Fatalf("Execute: %v", err) + } + if !hasProcessNote(rc.assistant, "回复声称已经生成或登记了文件,但这一轮没有产生对应的工件") { + t.Fatalf("gate reason missing after sink: %+v", textParts(rc.assistant)) + } + texts := textParts(rc.assistant) + last := texts[len(texts)-1] + if last.Process || !strings.Contains(last.Text, "本轮未实际生成文件") { + t.Fatalf("final notice = %+v", last) + } + // 伪造调用标记的分支理由也要逐字一致。 + r2 := &LLMRunner{Stream: scriptStream([][]agentllm.Event{ + textDeltaTurn("结果如下 [调用工具 write_file] 已完成"), + textDeltaTurn("结果如下 [调用工具 write_file] 已完成"), + textDeltaTurn("结果如下 [调用工具 write_file] 已完成"), + }, nil)} + rc2 := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true} + if err := r2.Execute(rc2); err != nil { + t.Fatalf("Execute: %v", err) + } + if !hasProcessNote(rc2.assistant, "回复正文里出现了工具调用标记,但本轮没有真实的工具调用") { + t.Fatalf("forged marker reason missing after sink: %+v", textParts(rc2.assistant)) + } +}