Skip to content

Agent Loop 验收决策机制重构 #571

@phantom5099

Description

@phantom5099

状态: Draft
组件: Runtime Acceptance 链路(Completion Gate → Verification Gate → Decider → AcceptanceService),Stop Reason / Progress 评估,Plan 结构化校验,Todo 生命周期管理
日期: 2026-05-07
相关技术: ReAct Loop, Plan-Execute 阶段, Task Completion Signal, Verification Orchestrator, Anti-Stall, Accept Gate


1. 摘要

本 RFC 针对 NeoCode Runtime 验收决策链路的架构级缺陷提出重设计方案。当前设计的核心矛盾是:系统靠关键词推断任务类型来选择验收标准,而不是读模型在 plan 阶段自己声明的验收条件。这导致验收标准来源错误、TaskKind 推断不可靠、Decider 硬编码建议覆盖度低、以及防循环机制可被操纵。

本方案不是增加更多补丁覆盖症状,而是从以下五个层面重新设计:

  1. 验收标准来源:Plan.Verify 改为结构化 []AcceptCheck,验收 Gate 直接映射,零推断
  2. 验收触发:引入完成声明检查,未声明完成不进入验收
  3. 验收结构:改为单一 Accept Gate,删除 Decider 决策权
  4. 执行与验收分离:验收阶段不重新执行命令,只检查执行期事实
  5. 防循环:删除 FinalIntercept,Progress 恢复硬终止,Exploration 不再 reset streak

2. 背景与问题

当前系统已经具备:

  1. planning.go 的 plan 解析与 PlanArtifact 构建能力
  2. verify/orchestrator.go 的 verifier 流水线能力
  3. controlplane/progress.go 的 Progress 评估能力
  4. task_completion 结构化信号的解析能力

但当前仍有几个关键问题:

2.1 Plan 定义了标准,验收不读标准

PlanArtifact 已包含 Verify []string——用户在 plan 阶段明确知道了"怎么算完成"并手动批准了计划。但验收逻辑从不读取 CurrentPlan.Verify,而是靠 inferTaskKindFromInput 从用户原始输入关键词推断任务类型,靠 Decider 基于 Facts 字段映射推断验证目标。

Plan 与验收是两个不连通的世界。

2.2 TaskKind 推断不可靠却被当作验收依据

InferTaskKind 基于关键词匹配("创建文件"→write,"看看"→read-only),置信度最高 0.9,大量 case 在 0.7。plan 阶段可能定义了复杂的验证标准(如"集成测试通过"),但验收时系统根据用户输入里的关键词判断这是个"chat"任务,结果 chat 的 verifier profile(几乎无验证)被应用。

2.3 Decider 的建议是硬编码规则,覆盖度低

decider/decide.goRequiredNextActions 纯靠 switch-case 硬编码:pending_todo → 建议 todo_writeunverified_write → 建议 filesystem_read_file。模型实际需要 bashgo test、需要调用子代理——Decider 一无所知。

2.4 验收与执行混为一谈

build/test/lint 在验收阶段由 verifier 重新执行。编译失败 → SoftBlock → 模型回执行阶段修复 → 再进入验收 → 再编译 → 再 SoftBlock。验收被当成了"带验证的执行循环"。

2.5 防循环机制可被操纵

finalInterceptStreak 可被 shouldPromotePendingFinalProgress 无限 reset——模型每轮读取不同文件即可续命。Progress 系统退化为纯 reminder,真正终止 Run 的是 FinalIntercept,但后者形同虚设。


3. 典型用户场景

场景 1:用户批准了 plan,但验收时系统不检查 plan 的标准

旧行为
用户说"修复登录 bug",系统在 plan 阶段生成 plan,Verify 列表写了"go test ./auth/... 通过"。用户批准 plan 后进入 build/execute。模型写了修复代码但未跑测试,直接声明完成。验收时系统根据用户原始输入里的关键词"修复"推断为 workspace_write 任务,跑 verifier profile 发现 HasUnverifiedWrites=true 但文件存在,返回 AcceptanceAccepted。用户回来发现测试根本没跑过。

新行为
plan 阶段 Verify[{"kind": "command", "target": "go test ./auth/..."}]。build/execute 阶段模型声明完成,验收 Gate 读取 Plan.Verify 发现要求测试通过,检查执行期事实发现没有 go test 验证证据 → Failed,用户界面展示"任务交付不完整:plan 要求测试通过,但未检测到测试执行证据"。

场景 2:简单问答被推断为写入任务,验收过度严格

旧行为
用户说"帮我看看这段代码什么意思"。InferTaskKind 匹配到"看看"关键词,但用户输入里同时含"代码",推断为 mixedworkspace_write。验收时应用写入任务的 verifier profile,要求 HasSuccessfulWorkspaceWrite 和验证证据。模型只是读了文件并回答了问题,没有写入 → AcceptanceContinue 循环,用户等不到回答。

新行为
plan 阶段模型输出 [{"kind": "output_only"}]。验收 Gate 映射为 output_presence 检查项,只要 LastAssistantText 非空就通过。没有推断,没有误判。

场景 3:用户在界面上看着 agent 转了 20 轮,不知道为什么还不停止

旧行为
用户说"修复登录 bug"。agent 开始修复,但不断读取不同文件寻找线索。用户在界面上看到一轮轮的"reading file"、"searching"、"reading file",转了 20 轮仍在继续。没有任何提示告诉用户发生了什么,也没有终止迹象。用户最终手动打断,不知道 agent 到底做了什么、进度怎样、什么时候会停。

新行为
用户看到相同的任务。agent 连续 3 轮没有实质进展时,界面提示"已连续 3 轮无实质进展,若继续当前策略任务将被终止"。agent 在第 5 轮仍无写入或验证进展,Run 自动终止,界面展示"任务因长时间无实质进展而终止"。用户知道发生了什么,知道为什么终止,可以决定是否重新发起任务。

场景 4:用户看到"任务完成",实际上编译失败了

旧行为
用户说"写个排序算法"。agent 写了代码后宣布完成,界面显示"任务完成"。用户拿着代码编译,发现编译失败,完全不知道 agent 为什么声称完成但代码根本跑不通。用户只能手动去读 agent 的长输出记录,找到错误信息,然后自己修复或重新发起任务。

新行为
agent 在执行阶段自己调用 go build 发现编译失败后自行修复,修复通过后才声明完成。如果 agent 未修复就声明完成,验收时发现执行期没有编译通过的事实 → Failed,界面直接展示"编译失败(syntax error at line 12),任务未交付"。用户看到明确的失败原因,可以基于错误信息决定下一步。


4. 设计目标

本方案要求同时满足:

  1. 验收标准必须来自模型在 plan 阶段自己声明的条件,不是系统推断。
  2. 完成声明(task_completion)是验收的必要条件。
  3. 执行与验收严格分离——验收不重新执行命令,只做一次性确认。
  4. 防循环机制不可被操纵——Exploration Progress 不再 reset streak。
  5. 删除无价值的抽象——Decider、TaskKind 推断、SoftBlock/HardBlock 区分。
  6. 验收只有两种结果:Accepted / Failed,没有 Continue。

5. 非目标

本 RFC 不处理:

  1. 不修改 model provider 协议或 tool schema。
  2. 不改动 plan 阶段的前端交互(只改 Verify 字段类型)。
  3. 不重新设计 todo 系统的状态机(只改生命周期边界和清理策略)。
  4. 不在验收阶段引入 LLM 作为判断者(保持确定性检查)。
  5. 不删除 PR 569 中合理的非 runtime 改动。

6. 核心设计

6.1 Plan.Verify 结构化:验收标准的唯一锚点

唯一的小改动:Verify 从自然语言 []string 改为结构化 []AcceptCheck

type summaryCandidate struct {
Goal          string        `json:"goal"`
KeySteps      []string      `json:"key_steps"`
Constraints   []string      `json:"constraints"`
Verify        []AcceptCheck `json:"verify"`         // 从 []string 改为结构化
ActiveTodoIDs []string      `json:"active_todo_ids"`
}

type AcceptCheck struct {
Kind   string `json:"kind"`   // "command", "file_exists", "output_only"
Target string `json:"target"`
}

简单问答的 plan:"verify": [{"kind": "output_only"}]
写入/修复的 plan:"verify": [{"kind": "command", "target": "go test ./..."}, {"kind": "file_exists", "target": "login.go"}]

验收阶段逐条映射为 Accept Gate 检查项,无需推断、无中间层。

这样设计的原因是:当前 Verify 是自然语言,系统无法直接消费,导致验收时只能靠 InferTaskKind 推断任务类型、靠 Decider 硬编码规则来猜验收标准。改为结构化后,模型在 plan 阶段直接声明"我要检查什么",验收阶段直接执行,人和系统读同一份数据。改动量极小(只加一个字段类型),但消除了整个推断层。

6.2 Accept Gate:Plan.Verify 直接映射

Plan.Verify kind 检查函数 检查内容
"output_only" checkOutputPresence LastAssistantText 非空
"command" checkCommandSuccess 执行期有对应命令的成功验证事实
"file_exists" checkFileExists 目标文件存在
(额外兜底) checkTodoConvergence 存在 required todo 时全部终态

验收时遍历 Plan.Verify,逐条对应的检查函数执行。全部通过 → Accepted,任何一条失败 → Failed(生成完整失败报告)。

没有 Continue。没有 SoftBlock。验收是终态。

这样设计的原因是:当前 Completion Gate → Verification Gate → Decider 三层存在决策权冲突(Decider accepted 可被 Completion Gate 降级为 continue)。改为直接映射后,所有检查并行执行、结果聚合,不存在层级覆盖。Kind 和检查逻辑是固定的映射关系,不需要 NameRequired、闭包这些包装层。同时删除 Continue 结果是因为验收不是"过滤漏斗",而是"终态确认"——模型声明完成后,系统只做一次性 yes/no 判定。

6.3 完成声明是验收的必要条件

run.go 的验收触发点增加完成声明必要条件:

if !hasToolCalls {
completionSignaled, _ := maybeParseCompletionTurnOutput(turnOutput.assistant)
if !completionSignaled {
// 模型未声明完成,直接 continue,不触发任何验收逻辑
continueTurn(...)
continue
}
// 进入 Accept Gate
report := acceptgate.Evaluate(ctx, input)
}

这样设计的原因是:当前 !hasToolCalls 即触发验收,无论模型是否主动声明完成。这导致 chat/read-only 零门槛旁路——模型没有完成意愿,系统却启动完整验收链路。行业共识(Claude Code Ralph Loop、OpenCode、Devin)都是"完成 = 主观声明 + 客观验证",本设计把 task_completion 从"摆设"升级为"必要条件"。

6.4 执行与验收严格分离

执行阶段:模型写代码、调用工具、遇到错误自行修复。build/test/lint 由模型在执行阶段主动调用,结果记录在 ToolResult 中。

验收阶段command_success 检查项只检查执行期是否留下了对应命令的成功验证事实,不重新执行命令

  • 模型没调用验证工具 → Failed("缺少验证证据")
  • 验证工具在执行期返回失败且模型未修复 → Failed

这样设计的原因是:当前 verifier 在验收阶段重新执行 go build/go test,导致验收被当成了"带验证的执行循环"。行业共识(Devin、Copilot Workspace)把验证作为交付门槛,不是在验收阶段重新跑测试,而是检查执行阶段是否已经跑过且通过。分离后,编译失败应在执行阶段解决,验收阶段只确认"是否交付了正确的东西"。

6.5 删除 Decider 和 TaskKind

删除 Decider 包全部内容decide.goinfer.gotypes.go 中的决策类型)。

  • RequiredNextActions 是硬编码规则,只有 todo_write/filesystem_read_file/filesystem_glob 三种,覆盖度极低。
  • UserVisibleSummary 是固定模板("任务完成。"),信息量低。Accept Gate 的失败报告原生包含 user_visible_summary 字段,直接替代。

删除 InferTaskKindDeriveEffectiveTaskKind

  • 推断基于关键词匹配,置信度低,且推断结果不用于任何验收决策。
  • plan 阶段模型已通过 Verifykind 字段声明了任务类型,不需要再推断。

这样设计的原因是:Decider 占据了架构的核心位置,但其建议生成是硬编码规则,无法覆盖真实任务的多样性(如需要 bash 跑测试、调用子代理等)。TaskKind 推断不稳定,却被当作验收依据。两者都是"系统自己猜标准"的产物,而 plan 的 Verify 已经让模型自己声明了标准,推断层变得多余。

6.6 Progress 硬终止,删除 FinalIntercept

删除:

  • finalInterceptStreakpendingFinalProgress
  • mustUseToolAfterFinalContinuenoToolAfterFinalContinueStreak
  • lastAcceptanceBlockedReasonshouldPromotePendingFinalProgress

Progress 评估规则:

  • Business Progress(写入+验证通过 / todo 终态变化)→ reset streak,任务在推进
  • Exploration Progress(读文件、搜索)→ 不 reset streak,这不是实质进展。Exploration 有窗口上限(默认 3 轮),超过后 NoProgress 开始累计
  • 无进展 → streak 单调递增
if next.NoProgressStreak >= input.NoProgressLimit {    // 默认 5
next.ShouldTerminate = true
}
if next.RepeatCycleStreak >= input.RepeatCycleLimit {  // 默认 3
next.ShouldTerminate = true
}

这样设计的原因是:当前 shouldPromotePendingFinalProgress 允许模型每轮读取不同文件就 reset finalInterceptStreak,导致死循环。Exploration 不是实质进展——读 10 个不同文件不代表任务在推进,只代表模型在找线索。只有 Business Progress(写入、验证通过、todo 收敛)才证明任务有交付推进,才应该 reset streak。

6.7 删除 SoftBlock / HardBlock,统一为 Pass/Fail

新架构只有两种 verifier 结果:Pass / Fail

  • HardBlock 场景(文件缺失、零交付、内容不匹配)→ Fail
  • SoftBlock 场景(todo pending)→ 在验收前被拦截(Layer 0/1),不进入 Accept Gate
  • SoftBlock 场景(build/test 失败)→ 不应在验收阶段出现,执行阶段就应修复;若到验收阶段仍有失败证据 → Fail

这样设计的原因是SoftBlock 被滥用于几乎所有"内容不正确"的场景(编译失败、测试失败、文件缺失全部返回 SoftBlock),本质上是把验收降级为"辅助提示系统"。行业共识(Devin CI fail = 未交付、Copilot Workspace test fail = PR 不 merge)是验证失败即终态。唯一合理的"可继续"场景是 todo pending,但这应在验收前被 Layer 0/1 拦截,不需要 SoftBlock 语义。

6.8 Run 边界与 Todo 生命周期

默认清空策略:回滚 PR 569 的"默认保留"语义,改为默认清空,只有明确续做意图才保留。

终止时统一清理:所有终止路径(Accepted / Failed / Incomplete / user_interrupt / budget_exceeded / max_turn_exceeded / fatal_error)统一清空 session.Todos

删除 stale_todo_reset 提示:如果 todo 边界正确,stale todo 本不应大规模遗留。把"判断哪些 todo 属于上一个任务"推给模型是不可靠的。

这样设计的原因是:当前 Run 终止时从不清理 todo,加上 PR 569 把语义反转为"默认保留",导致旧 todo 大量遗留。stale_todo_reset 提示是症状治疗而非根因修复——让模型为系统的边界污染买单。


7. 与现有模块的关系

runtime

  • run.go:接入 task_completion 为验收必要条件;删除 FinalIntercept 字段;Progress 评估后硬终止;删除 Decider 调用
  • state.go:删除 finalInterceptStreakpendingFinalProgressmustUseToolAfterFinalContinuenoToolAfterFinalContinueStreaklastAcceptanceBlockedReason
  • acceptance_service.go:删除多层合并逻辑,替换为单一 Accept Gate
  • turn_control.go:删除 shouldPromotePendingFinalProgress;删除 PR 569 的 HasUnverifiedWrites 捷径
  • todo_run_boundary.go:回滚 PR 569 语义反转
  • planning.goVerify[]string 改为 []AcceptCheck
  • task_kind.go:删除 InferTaskKindDeriveEffectiveTaskKind,清理文件或删除

controlplane

  • progress.go:恢复硬终止能力;删除提醒-纯提醒逻辑;Exploration Progress 增加窗口上限
  • completion.gotask_completion 信号纳入 CompletionState

verify

  • orchestrator.go:全量执行所有 verifier,删除短路
  • command_success.goSoftBlockFail
  • todo_convergence.gopermission_wait/user_input_wait/external_resource_wait 从 HardBlock 移除,转为 Execute 阶段前置状态检查
  • git_diff.go:删除,零交付检测由 WorkspaceWriteVerifier(基于 HasSuccessfulWorkspaceWrite 和 fileSnapshot diff)替代

decider

  • 删除 decide.goinfer.gotypes.go 中的决策类型
  • 删除 DecisionAccepted/DecisionFailed/DecisionContinue/DecisionIncomplete/DecisionBlocked

gateway

  • contracts.go 中引用 TaskKindDecisionStatus 的类型需要删除或更新
  • Plan 序列化:PlanArtifact.Summary.Verify 类型变更影响 JSON 编解码和 RPC 传输,gateway 侧的反序列化需要兼容新旧两种格式
  • 验收事件:AcceptanceContinue 状态消失,新增的 Accept Gate 失败报告(含逐条检查结果)需要更新 gateway 的事件 payload 结构

tui

  • Plan 审批界面:Verify 改为结构化后,可逐条渲染检查项(图标 + kind + target),而非之前的自然语言文本
  • 验收结果渲染:失败时不再显示"任务继续",而是展示逐条检查的完整报告(每个检查项、通过/失败、原因),TUI 需要对应的失败详情展示方式
  • Progress Warning:新增的"连续 N 轮无实质进展"提示应以即时状态更新展示,不能只在日志里

session

  • PlanArtifact.Summary.Verify 类型变更影响 session 持久化的序列化/反序列化
  • 已持久化的旧 session(Verify[]string)需要向后兼容:反序列化时两种格式都接受,优先 []AcceptCheck,零值时回退读取旧 []string

config

  • 删除 VerificationConfig.MaxNoProgress(与 RuntimeConfig.MaxNoProgressStreak 命名冲突)
  • 删除 verifierGitDiff 常量及默认配置(Verifiers["git_diff"]

prompt assets

  • 删除与 FinalIntercept/SoftBlock 绑定的旧提醒模板
  • 删除 stale_todo_reset 提示模板
  • 新增 Progress Warning 注入模板

8. 测试场景

  1. Plan.Verify 为 [{"kind": "output_only"}],模型声明完成且有输出 → Accepted。
  2. Plan.Verify 为 [{"kind": "command", "target": "go test ./..."}],执行期无测试验证事实 → Failed。
  3. Plan.Verify 为 [{"kind": "file_exists", "target": "login.go"}],文件不存在 → Failed。
  4. 模型无 tool calls 但未输出 task_completion → 不触发验收,直接 continue。
  5. 模型输出 task_completion 但仍有 tool calls → continue(工具优先)。
  6. Run 以 user_interrupt 终止 → session.Todos 被清空。
  7. Run 以 AcceptanceFailed 终止 → session.Todos 被清空。
  8. 模型连续 5 轮调用不同 filesystem_read_file(无 Business Progress)→ NoProgressStreak 达到 5,Run 终止为 Incomplete。
  9. 模型连续 3 轮调用相同 filesystem_read_file 读取相同文件 → RepeatCycleStreak 达到 3,Run 终止。
  10. 模型写入代码后未调用验证工具 → command_success 检查失败 → Failed。
  11. Todo pending + build 失败同时存在 → Accept Gate 报告同时包含两者。
  12. NoProgressStreak 达到 2 且存在 open required todo → 注入 <stale_todo_reset>
  13. 用户空输入触发新 Run → 旧 todo 被清空。
  14. 用户输入"继续修复" → 旧 todo 保留。

9. 假设与默认决策

  1. Plan 是 plan-execute 流的必经阶段,每个任务都有 PlanArtifact
  2. Verify 改为 []AcceptCheck 后,旧 plan(Verify[]string)需要向后兼容:读取时同时接受两种格式,[]AcceptCheck 优先。
  3. MaxNoProgressStreak 默认值 5 和 MaxRepeatCycleStreak 默认值 3 在恢复硬终止后仍然适用。
  4. 删除 finalInterceptStreak 不会导致已有测试大面积失效——已有测试 TestAcceptanceContinueWithoutToolCallStopsAsIncomplete 将改为基于 NoProgressStreak 终止。
  5. 模型在收到 VerificationFail 后能正确理解任务终止(需在 prompt assets 中明确)。
  6. PR 569 中的合理前端改动(思考流展示、checkpoint 交互优化、文件变更去重)在本次回滚范围内,仅回滚 runtime 验收链路的四个问题。
  7. ExplorationWindow 默认值设为 3(即连续 3 轮 Exploration Progress 后,第 4 轮若仍无 Business Progress,开始累计 NoProgressStreak)。
  8. WorkspaceWriteVerifier 依赖 toolExecutionSummary 中的 HasSuccessfulWorkspaceWritefileSnapshot diff 数据,这些数据在 toolexec.go 中已可靠采集。

10. 一句话结论

NeoCode 验收机制的问题不是"gate 有 bug",而是"验收标准来源错误"——系统靠关键词推断任务类型,而不是读模型在 plan 阶段自己声明的验收条件。新架构通过结构化 Plan.Verify[]AcceptCheck)让验收 Gate 直接映射检查项——模型说"output_only"就检查输出、说"command: go test"就检查测试证据,零推断,零中间层。同时通过 Progress 硬终止 替代可被操纵的 FinalIntercept,删除 Decider 决策权、TaskKind 推断、SoftBlock 消除架构冗余。

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions