fix: issues#100 protocol routing drops - #105
Conversation
wangjk9527
left a comment
There was a problem hiding this comment.
结论:Request changes。建议重写路由与完成模型,而不是继续修补当前 routingContext + warning 方案。
本 PR 能降低部分「再次尝试」被误分到 general-task 的概率,但 Issue #100 描述的是确定性的 session intent/state 延续问题,不应由 LLM classifier 和 confidence threshold 决定。分类失败或置信度低于阈值时仍会回退 general-task,因此核心不变量没有建立。
阻断问题:
- 只继承协议标签,没有继承任务语义。
run-protocol-boundary.ts的 requirements 提取仍只看当前userInput或显式协议;「再次尝试」即使被分类成data-analysis,也不会恢复原intentText/ requirements,semantic query 仍是这句弱后续。协议名延续了,分析契约没有延续。 - protocol/tool mismatch warning 不纠正状态。 warning 既不会改变路由或工具策略,也没有进入 Agent 的
governedMessages/ 恢复上下文;独立 warning 事件还绕过 state-store journal 写入。依赖 Agent 事后自行调用protocol_handoff不是可靠恢复机制。 - 假成功路径仍存在。 数据动作被协议拒绝后,只要产生 assistant message,
completeProtocolRun仍会自动执行general.answer.commit,随后无条件forceTerminal: true;partial最终又由 finalizer 写成 metadatacompleted。因此“工具未执行但文字解释后成功结束”没有被堵住。 - 测试没有证明真实路由正确。 新测试中的 classifier stub 本身实现了「看到 previousProtocol 就返回 data-analysis」,只能证明字段传递,不能覆盖真实 classifier 失败、低置信度回退、requirements 恢复或 completion 假成功。
建议当前开发期直接采用更简单的模型:
- 持久化权威
SessionIntent { protocolId, intentText };同 run resume 恢复 run state,新 run follow-up 确定性继承 session intent。classifier 仅用于无 intent 的首次请求。 - 使用
intentText初始化 data-analysis requirements 和 semantic query,而不是弱 follow-up 文本。 - 显式
protocol_handoff才更新 session intent;handoff 的 unresolved goals 由服务端 protocol state 计算,并同步重建目标协议的工具策略。 - 先确定协议,再暴露对应工具;删除 mismatch warning、历史 routingContext 字段和 follow-up confidence 分支。
- completion 使用硬门禁:必要动作被拒或 requirements 未满足时不得
completed;删除无条件forceTerminal,assistant 文字只能说明终态,不能把失败/partial 转成成功。
关键测试建议:
- data-analysis 失败后发送「再次尝试」,不调用 classifier,继承原 intent/requirements,数据工具可直接执行;
- 已有 session intent 时 classifier 失败或低置信度不影响路由;
- 数据工具被协议拒绝后,即使输出解释文字,run 也不能
completed; - handoff 后 session intent、协议状态和工具集合原子一致;
- crash/replay 后 Agent 与 UI 看到同一 journal 状态。
当前改动约 444 行,但继续修补仍需重做 session state、requirements、工具装配和 completion。无需兼容旧方案的前提下,重写会更短、更明确,也更容易证明正确。
iancaoo
left a comment
There was a problem hiding this comment.
代码评审 — 正确性风险
基于 正确性 — 真正的风险 部分的评审意见,以下是 5 条需要在合并前考虑的正确性问题。核心路由上下文机制是良好的通用设计,以下为正确性缺口与加固建议。详细分析见行内评论。
| # | 问题 | 严重性 |
|---|---|---|
| 1 | "最近的终态 run" ≠ 意图祖先 | 高 — 可能重新引入/加剧该 bug |
| 2 | 不一致的 terminalStatus 词汇表 |
中 — 误导分类器 |
| 3 | 一致性 warning 无消费者 | 中 — 描述与实现不符 |
| 4 | 一致性 guard 误报 | 中 — 噪音 |
| 5 | reasonCodes 非契约 |
低 — 仅测试安全隐患 |
| * so callers without history are unaffected. | ||
| */ | ||
| export const resolveRoutingContext = (input: ResolveRoutingContextInput): RoutingContext | undefined => { | ||
| const previousRun = input.metadataStore.runs.findPreviousRunBySession({ |
There was a problem hiding this comment.
正确性风险 #1 — "最近的终态 run" ≠ "用户正在延续的 run"
findPreviousRunBySession 返回最近的终态 run。考虑以下序列:
- 用户:“分析订单数据” →
data-analysis,completed - 用户:“谢谢” →
general-task,completed - 用户:“再试一次” → 应当继承第 1 轮的
data-analysis,但resolveRoutingContext把第 2 轮视作previousProtocol = general-task喂给分类器。分类器看到previousProtocol=general-task很可能维持general-task—— 这加剧了该 bug,而非修复。
设计把"最近的终态 run"与"承载用户正在延续意图的 run"等同起来,但二者并不总是相同。测试套件只覆盖了前一轮是 data-analysis 的情况,没有覆盖这条间隙的 general-task 场景。
建议: 要么向后遍历终态 run 直到遇到非默认协议,要么在 session 元数据里持久化一个粘性的 lastNonDefaultProtocol,让后续提示能可靠继承。并补一个测试:前一轮是 general-task 而其祖先是 data-analysis。
| protocolVersion: string; | ||
| terminalStatus?: string; | ||
| } => { | ||
| const terminalStatus = state.status === "terminal" |
There was a problem hiding this comment.
正确性风险 #2 — buildPreviousProtocol 喂给分类器的词汇表不一致
const terminalStatus = state.status === "terminal"
? state.terminalDecision?.status
: state.status;三个问题:
- 当
state.status === "terminal"但terminalDecision为undefined时,terminalStatus为undefined→ 被整个丢弃。分类器看到上一轮协议: data-analysis但无终态,丢失信号。 - 当
state.status不是"terminal"(例如"active"、"waiting"、"handed_off")时,回落到字面状态字符串 —— 分类器会看到终态=active或终态=handed_off,而对终态 run 看到的是终态=completed/degraded/partial/continue/failed。这是两套互不兼容的词汇表被喂给同一个 LLM。 terminalDecision?.status可以是"continue"(ProtocolCompletionDecision的一个变体),不是终态,却被呈现为终态=continue。
字段叫 terminalStatus 但可能携带 "active" —— 有误导性,且 LLM 无法获知这些状态的含义。
建议: 规范化到一套一致的词汇表。要么始终映射到完成决策的状态(非终态 run 则省略该字段),要么在 prompt 里描述每个状态的含义。不要向分类器展示 终态=active。
| // while being governed by a protocol that rejects every data action"). When the | ||
| // resolved protocol is general-task but the run exposes data tools and a selected | ||
| // datasource, emit a warning so the agent can self-correct via protocol_handoff. | ||
| if (route.definition.id === "general-task" && exposesDataToolPolicy(input) && input.routingContext?.selectedDatasourceId) { |
There was a problem hiding this comment.
正确性风险 #3 & #4 — 一致性 guard
#3 — warning 事件似乎无消费者: guard 发出 protocol.route.consistency.warning 携带 PROTOCOL_TOOL_POLICY_MISMATCH,PR 描述声称"agent 可经 protocol_handoff 自行修正"。但 server.ts 与 assembly 层没有该事件的消费者 —— 它被发出后即丢弃。route.warnings 携带该 code,但路由已解析完毕,boundary 层没有任何东西读 route.warnings 触发切换。
如果下游确实没有自行修正逻辑(PR 未展示),那 guard 纯粹是可观测性 —— 这没问题,但"agent 自行修正"的说法在 PR 范围内未证实。建议: 确认 protocol_handoff 路径是否真的读取此 warning,否则把描述里的说法从"agent 自行修正"降级为"为可观测性发出 warning",直到自行修正能力就位。
#4 — 合法非数据任务也会触发误报: guard 在任何选中 datasource 且解析为 general-task 时触发。但用户可能选中一个数据源然后问"这个数据源有哪些表?" —— 这是合法的 general-task(解释而非分析),guard 却会发出 PROTOCOL_TOOL_POLICY_MISMATCH 警告。启发式过粗:datasource 被选中 + 数据工具存在 + general-task 并不必然意味着分类错误。建议: 至少确认该 warning 作为可观测性噪音可接受(非阻塞);若 general-task 上的误报率高,考虑收紧触发条件(如要求分类器置信度低于某阈值)。
| `候选集合: ${input.candidates.map((item) => `${item.protocolId}@${item.protocolVersion}`).join(", ")}`, | ||
| ...(fields.length > 0 ? ["分类输入:", ...fields] : [`分类输入: ${JSON.stringify(input.value)}`]), | ||
| "只返回一个 JSON 对象,不要 Markdown。字段为 protocolId、protocolVersion、confidence、reasonCodes。", | ||
| '格式示例: {"protocolId":"data-analysis","protocolVersion":"1","confidence":0.91,"reasonCodes":["INHERITED_PRIOR_PROTOCOL"]}', |
There was a problem hiding this comment.
正确性风险 #5 — INHERITED_PRIOR_PROTOCOL 不是可强制契约
prompt 示例建议用该 code,stub 分类器也返回了它,测试 expect(boundary.route.reasonCodes).toEqual(["INHERITED_PRIOR_PROTOCOL"]) 对 stub 通过。但真实 LLM 可返回任意 [A-Z_]+ code —— schema 允许。因此 reasonCodes 在生产中不是路由来源的可靠信号;它只是信息性的。
这没问题,但测试给人一种错误的确切感。建议: 要么在文档中注明 reasonCodes 是建议性而非可强制,要么从固定集合中约束它。

问题概述
修复 issues#100
短后续提示如 再次尝试("try again")被路由到 general-task,因为协议分类器只看到孤立的后续文本,没有会话上下文。执行 agent 随后拿到会话历史并尝试调用数据工具,但 general-task 拒绝所有数据动作,导致运行退化为反复的 ACTION_NOT_ALLOWED_IN_PHASE 拒绝,最终只以一段文字借口完成。
本 PR 给分类器喂入一个紧凑的路由上下文——上一轮用户查询、上一轮运行协议及终态、已选 skill IDs、已选 datasource——让延续 data-analysis 工作流的后续提示能继承正确的协议。
根因
createRunProtocolBoundary 路由时只用当前 userInput:
analyticIntent(userInput) 是唯一的确定性来源——再次尝试 不含分析关键词,不产生候选。
classificationInput: { userText: input.userInput }——LLM 分类器只看到孤立的模糊文本 → 低置信度 → 回落 general-task。
执行 agent 随后拿到完整会话历史(governedMessages)尝试调用数据工具,但 general-task 在所有阶段都排除 DATA_ACTIONS → ACTION_NOT_ALLOWED_IN_PHASE。这就是 context split:分类器看不到历史,agent 看到历史,governor 又拒绝。
改动
紧凑路由上下文(run-protocol-boundary.ts)—— 在 CreateRunProtocolBoundaryInput 上新增可选字段 routingContext,携带 previousQuery / previousProtocol(含 terminalStatus)/ selectedSkillIds / selectedDatasourceId。这些字段被塞进 classificationInput,让分类器看到上一轮意图,而不只是孤立的后续文本。
分类器 prompt(model-protocol-classifier.ts)—— prompt 现在显式描述各路由上下文字段,并指示 LLM:当当前查询是弱后续(继续/重试/再试/try again…)且上一轮用了 data-analysis 时,倾向于延续 data-analysis,除非用户明确切换了任务主题。
一致性 guard(run-protocol-boundary.ts)—— 路由解析后,若选中 general-task 但本次 run 暴露了数据工具(inspect_schema/run_sql_readonly/…)且选了 datasource,则发 protocol.route.consistency.warning 事件(PROTOCOL_TOOL_POLICY_MISMATCH)。这是 warning,不是硬失败——agent 可经现有 protocol_handoff 工具自行修正。
API 装配层(server.ts、run-agent-assembly.ts、resolve-routing-context.ts)—— 装配 agent 前,通过 RunRepository.findPreviousRunBySession(新增)查本 session 的上一条 run,经 MetadataProtocolStateStore.find 读其协议快照,连同已选 skill IDs / datasource id 组装 routingContext。
Metadata 查询(metadata/index.ts)—— RunRepository.findPreviousRunBySession 返回本 session 最近一条终态 run(completed/failed/canceled),排除当前 run。
为什么不做确定性关键词继承
早期迭代考虑过用确定性"弱后续"关键词清单(继续/重试/再试…)产生高优先级候选。这个方案被否决了——用正则关键词去抢分类器的活很脆弱,中文变体(再来一次、再跑一下、接着上次…)很容易漏。既然分类器本身就是一次 LLM 调用,给它路由上下文让它自然处理这些变体更合理。
向后兼容
routingContext 完全可选。现有 14 个 boundary 测试无需改动全部通过。
恢复路径(persistedState / PROTOCOL_SEGMENT_RESTORED)和显式协议路径(explicitProtocol)未改动。
测试
run-protocol-boundary.test.ts +4:弱后续带 routingContext → data-analysis;不带 → 仍 general-task(回归保护);一致性 guard 触发 / 不触发。
model-protocol-classifier.test.ts +1:prompt 正确展示各路由上下文字段。
run-previous-by-session.test.ts +3:findPreviousRunBySession 查询行为。
全量:agent-runtime 164 passed、metadata 6 passed、api 16 passed。