Consume the sunk gate mechanics and pin the WP5 library (N1 WP5/WP6) #72

Merged
weichen merged 2 commits from feature/agent-stack-n1 into main 2026-10-07 06:36:10 +00:00
7 changed files with 199 additions and 185 deletions
+35 -40
View File
@@ -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 {
+8 -8
View File
@@ -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
}
+4 -3
View File
@@ -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)
}
+23 -126
View File
@@ -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 "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。先用工具核实;如果检查有误就直接收尾,不要向用户提起这条检查。\n" + body + "\n</system-reminder>"
}
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
}
+21 -7
View File
@@ -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), "```"))
}
+107
View File
@@ -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))
}
}