Fix/protocol routing governance lemonc19 - #107
Merged
iancaoo merged 19 commits intoAug 17, 2026
Merged
Conversation
ACTION_NOT_ALLOWED_IN_PHASE previously told the model only what it could not do, so a mis-routed agent probed tools one by one (observed 4 rejected calls in issue datagallery-lab#100). The rejection message now carries the current phase's allowedActions as a fourth segment, and the model-facing observation surfaces them in error.details.allowedActions plus a concrete recovery instruction. The message keeps its three-segment prefix, so existing split(":") parsers and startsWith checks are unaffected.
…uctions The system prompt advertised every selected data tool regardless of the governing protocol, while general-task rejects all four data actions at the phase gate. A mis-routed run therefore told the model it had tools it could never use. Instructions now state explicitly, under general-task, that the data tools are disabled by the protocol and that protocol_handoff to data-analysis is the supported path. Also introduces protocol/data-actions.ts as the single source for the governed data action names; DATA_AGENT_TOOL_NAMES re-exports it and the instructions builder consumes it instead of a hardcoded array. Protocol definitions migrate to the shared constant in a follow-up refactor.
packages/**/*.test.ts were type-checked by tsc but never executed: CI only ran the web and TUI suites plus smoke scripts, so protocol-layer behavior had no unit gate. Adds a root test:packages script (vitest declared as a root devDependency instead of relying on hoisting) and runs it in the build-and-web-tests job.
…d data actions completeProtocolRun auto-committed a general.answer.commit for any general-task run with assistant text, then forced a terminal decision and finalized the run as completed. A mis-routed run whose data tool calls were all rejected by the phase gate therefore ended as a metadata 'completed' even though the datasource was never touched (issue datagallery-lab#100). The completion path now inspects the protocol action ledger first: when the governing protocol is general-task and it rejected data actions with ACTION_NOT_ALLOWED_IN_PHASE, the run records its terminal protocol decision for replay and finalizes as failed with an explicit DATA_ACTIONS_REJECTED_BY_PROTOCOL reason. Time-budget and other partial completions keep their existing completed semantics.
Covers the completion hard gate: general-task runs with phase-rejected data actions finalize as failed with DATA_ACTIONS_REJECTED_BY_PROTOCOL; non-data rejections and data-analysis runs keep completing. Extends the test:packages script to also execute the apps/api vitest suite, which was previously type-checked but never run.
…olution
Persists the authoritative record of what a session is working on:
{protocol_id, protocol_version, intent_text, source_run_id} keyed by
session. resolveForSession walks up session_branches lineage (bounded
depth) so a branched session inherits its ancestor's task instead of
starting blind — guessing 'the previous terminal run' cannot express
this and breaks on gap runs, branches, and suspended runs. Session
deletion cascades to intents.
…ndidate Weak continuation follow-ups (再次尝试 / retry / continue …) now inherit the session's recorded protocol through the existing deterministic candidate channel at priority 300 — above the keyword accelerator, below same-run restore — so no model call, low classifier confidence, or classifier failure can drop the run to general-task while the session is mid-analysis. For ambiguous non-continuation follow-ups the classifier still runs, but its input now carries the session intent as context and its prompt explains how to use it. Weak follow-ups without any recorded intent keep today's default-route behavior byte for byte.
…xtraction and semantic query with intent text Requirement extraction previously ran before routing and was gated by the analyticIntent keyword regex, so a classifier- or intent-routed data-analysis run started with zero user requirements — the entire assertion pipeline (contract grounding, SQL semantic validation, result checks, evidence-bound claims) silently short-circuited, and the semantic service was queried with the literal follow-up wording. Protocol definitions now register requirement-free for routing, and extraction runs after the route resolves to data-analysis (including at protocol handoff, so a general-task run moving to data-analysis starts its new segment with a full contract). Both the extractor and the semantic.context.resolve query consume the effective intent text: the recorded session intent with the follow-up appended, or the user's own words whenever they carry a task. The regex no longer gates any quality-critical path — it remains only a routing accelerator.
Before assembling a run, the server resolves the session's recorded intent (following branch lineage) and passes it into the protocol boundary. After routing, strong-signal resolutions — explicit protocol, keyword accelerator, confident classifier — persist the user's wording as the session intent. Inherited weak follow-ups, restored segments, default-route fallbacks, and blank input never overwrite the record, so '再次尝试' can inherit the task but can never become the task.
…t its role Adds common English analytic phrasing (revenue, trend, breakdown, group by, cohort, …) and further Chinese metrics vocabulary to the keyword accelerator, and documents that it is a routing accelerator only — after the extraction reorder it gates no quality-critical path, so a false hit is corrected by session-intent inheritance and a miss costs one classifier call.
…col definitions Completes the deduplication started with protocol/data-actions.ts: both formal protocol definitions now consume the shared set instead of re-declaring the four governed data action names locally.
analysis_requirements_commit and protocol_handoff hand-rolled the same ActionRouter execution body (tool-call id, fallback action id, idempotency key, observation unwrap, error shaping). Both now share createProtocolBoundExecute, which also documents why segmentId must be read at call time (handoffs move the active segment).
The run's tool set previously passed through several disconnected steps (resource-gated spreads, always-allow set, skill allow/deny filter, MCP merge) with no record of why a tool was in or out. buildToolPlan now performs the same pipeline in one place with identical semantics — deny before allow, always-allow and skill-meta exemptions, MCP joining after the skill policy under its own per-server allowlist — and every entry carries a reason trail. createDataFoundry returns the plan entries so diagnostics and audit surfaces can answer 'why does the model (not) see this tool' without a debugger. Protocol-phase permissions intentionally stay with the protocol runtime's action gate.
Helper models (protocol classifier, session title) each assembled their own prompt from a single sentence. buildHelperContext produces one compact background block — session intent, recent queries, conversation summary, relevant memories — inside reference-only delimiters so recorded history cannot act as instructions. The block is hard-capped by a registered limit (DATAFOUNDRY_HELPER_CONTEXT_MAX_CHARS) and sections drop in fixed priority order; the session intent is never dropped.
…ssion title The server assembles a helper-context block from the session intent, latest conversation summary, and recalled long-term memories, and forwards it to the protocol classifier as reference-only background for ambiguous follow-ups. The classifier prompt states explicitly that the block is history, not instructions. Session titles now prefer the session's recorded intent text, so a branched session whose first message is '再次尝试' is titled by its inherited task instead of the follow-up wording.
iancaoo
reviewed
Aug 13, 2026
wangjk9527
approved these changes
Aug 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #100.
本 PR 修复了多轮对话中协议路由丢失任务语义的问题,并将修复延伸到后续执行链路:
本 PR 不声称消除所有 LLM 误路由。确定性续接、持久化任务版本、并发冲突和执行权限由 Runtime 保证;其他歧义输入仍依赖 classifier,具体边界见“已知限制”。
问题与根因
DataFoundry 会在每次运行前选择
general-task或data-analysis协议。原实现只根据当前userInput路由,没有持久化“这个会话正在处理什么任务”。因此,用户先发起数据分析,之后输入“再次尝试”时,会发生:
general-task;general-task的 phase gate 拒绝这些数据 action;这不只是 classifier 缺少上下文,而是路由器、主 Agent 和协议执行层没有共享同一份权威任务状态。只向 classifier prompt 追加历史可以降低误分类概率,但无法保证重试、并发运行、handoff 和 session branch 使用正确的任务版本。
运行时链路
一次新运行现在按以下顺序进入主 Agent:
这条链路保证主 Agent 在开始执行工具前,本次运行使用的 protocol 和 Intent Revision 已经固定。
代码入口
apps/api/src/session-intent.tspackages/metadata/src/index.ts中的SessionIntentRepository与initializeSessionIntentSchemapackages/agent-runtime/src/protocol/protocol-router.ts与run-protocol-boundary.tspackages/agent-runtime/src/protocol/protocol-handoff-coordinator.tsapps/api/src/protocol-state-store.ts和packages/metadata/src/index.ts中的transitionSegmentpackages/agent-runtime/src/tools/tool-plan.tspackages/agent-runtime/src/context/helper-context.tsapps/api/src/protocol-run-completion.ts主要改动
1. 版本化 Session Intent
设计
Session Intent 表示“会话当前正在处理的任务”,而不是最近一条消息。例如:
用户说“谢谢”时,原任务仍然保留;用户补充“按季度拆分”时,它成为原任务的新 revision;用户改为“帮我写 README”时,创建新的 task identity。
实现
Migration
0018_session_intents新增三张表:session_intent_revisions:不可变的任务版本,保存intent_id、previous_revision_id、protocol、intent text、change kind 和 source run;session_intent_heads:每个 session 当前指向的 revision;run_intent_bindings:每次运行启动前的base_revision_id、路由后的active_revision_id和task_relation。SessionIntentRepository.commitWithinTransaction()提交新 revision 前会比较expected_head_revision_id与数据库当前 head。两个并发运行基于同一版本更新任务时,只有第一个可以提交;另一个以SESSION_INTENT_REVISION_CONFLICT在主 Agent 执行前失败。恢复已持久化的 protocol state 时,API 还会要求该 run 存在对应的 intent binding。如果只找到 protocol state 而找不到
run_intent_bindings记录,则以RUN_INTENT_BINDING_REQUIRED拒绝恢复,不允许 Runtime 在缺少确切任务版本时根据 session 最新状态猜测。commitSessionIntentFromRouteWithinTransaction()根据 task relation 完成具体写入:continue:沿用 task identity 和 intent text;protocol 不变时不创建 revision,protocol 改变时创建route-switchrevision;refine:沿用 task identity,将当前输入作为补充要求追加,创建refinerevision;replace:使用当前输入创建新 task identity 和replace/initialrevision;side-chat:不更新 session head,run 仍绑定启动时的 base revision。恢复和分支语义
RunCheckpointProjector将当前 run binding 的active_revision_id写入 checkpoint;intent_revision_id;base_revision_id,因为分支点在该 run 之前;resolveForSession()在 child 没有自己 head 时,只读取fork_intent_revision_id,不沿 lineage 读取父 session 的当前 head。因此,父会话在 fork 后切换任务,不会改变已创建 child 的任务语义。
2. Protocol 路由与任务关系分离
设计
general-task / data-analysis回答“这次应该怎么运行”;continue / refine / replace / side-chat回答“这条消息是否改变原任务”。两者不能用一个分类结果代替。实现
ProtocolRouteClassification和ProtocolRouteResult新增taskRelation。createModelProtocolClassifier()使用严格 Zod schema 解析一次模型输出,同时获取 protocol、confidence、reason codes 和 task relation,不新增第二次分类调用。ProtocolRouter.route()按下列优先级解析:PROTOCOL_SEGMENT_RESTORED + continue恢复;SESSION_INTENT_INHERITED + continue,不调用 classifier;ANALYTIC_INTENT + replace;general-task + side-chat,记录 warning 且不覆盖原任务。weakContinuationIntent()要求整条消息仅由“再次尝试 / retry / continue”等续接语及标点组成,并设有 24 字符上限。因此“继续帮我写 README”会进入 classifier,不会直接继承原任务。analyticIntent()只在没有 active intent 时参与路由,并移除sales、count、orders等单独命中就会导致误路由的词。它不再控制需求抽取或语义校验。effectiveIntentText()在路由后计算本次有效任务。Requirement extractor 和semantic.context.resolve均使用这段文本;从general-taskhandoff 到data-analysis时,Runtime 会补做 requirement extraction,使新 segment 不会以空分析要求开始。3. Protocol 状态与 Intent 的原子 Handoff
设计
Handoff 不只是换一个 protocol ID。它还会结束当前 segment、创建新 segment、更新任务使用的 protocol,并让同一 Agent 立即获知新权限。这些写入必须同成功或同失败。
实现
ProtocolStateStore.transitionSegment()增加可选的类型化intentTransition。MetadataProtocolStateStore将其交给 Metadata repository,后者在一个BEGIN IMMEDIATE事务中:handed_off或aborted;-1创建新 active segment;protocol_event_journal写入 proposed、segment-ended、accepted 和 segment-started 事件;applyIntentTransition()校验 session head 仍等于当前 run binding 的 active revision;handoffIntent Revision,更新 Session Head 和 Run Binding;任一 protocol CAS、Intent CAS、revision 或 journal 写入失败,整个 accepted handoff 回滚。被拒绝的 proposal 也会通过
compareAndSetWithEvents()持久化 proposed/rejected events,供事件回放和审计。普通 run 的 handoff 沿用当前 task identity 和 intent text,只改变 protocol;如果当前 run 是
side-chat或没有 active intent,那么 handoff 表明当前消息已成为实质任务,Runtime 会用当前 user input 创建新 task identity。ProtocolHandoffCoordinator不信任模型上报的unresolvedGoals。它调用当前 protocol definition 的completionPolicy()从服务端状态计算未完成项;data-analysis仍有严格目标未完成时,普通 handoff 不能离开该协议。原unresolvedGoals字段仅保留为模型 schema 兼容和审计输入。另外,对 classifier 产生的 replace 路由,如果
data-analysis尚在初始 phase、目标是general-task、且 action ledger 中没有成功数据 action,则允许发起route-correction。旧 segment 以aborted结束,并写入PROTOCOL_ROUTE_CORRECTED。Handoff 完成后,
handoffObservation()会基于新 segment 重算工具可用性,将activeProtocolId、activePhase、availableTools和protocolDisabledTools返回给同一 Agent。4. Tool Plan、Runtime Guard 与终态硬门
设计
工具 schema 需要保持静态,以支持同一 Agent 在 handoff 后继续运行;但“模型看得到某个工具”不等于“当前协议允许调用它”。因此本 PR 将工具组装、当前可用性和最终授权分层处理。
实现
buildToolPlan()统一了原来分散的工具组装顺序:protocol_handoff和analysis_requirements_commit。每个
ToolPlanEntry记录source、exposed、reason trail、availability: available | protocol-disabled和可选 handoff recovery target。resolveToolPlanAvailability()将静态工具名映射为 protocol action(例如protocol_handoff → protocol.handoff.propose),并与当前 phase 的allowedActions对比。createDataFoundry.toolPlan以 getter 形式每次根据当前 protocol state 重算 snapshot。Tool Plan 用于 diagnostics、Agent instructions 和 handoff observation,不作为授权凭据。所有受治理的工具最终都通过
ActionRouter和ProtocolRuntime.assertActionAllowedInState();即使模型尝试调用 protocol-disabled 工具,也会在实际执行前被拒绝。ACTION_NOT_ALLOWED_IN_PHASE现在会在错误第四段和结构化error.details.allowedActions中返回当前 phase 的可用 action,使 Agent 能从拒绝中选择恢复路径,而不是继续盲目试探。原三段式前缀保持兼容。completeProtocolRun()在通用回答提交之前检查 protocol action ledger。如果general-task存在因ACTION_NOT_ALLOWED_IN_PHASE被拒绝的数据 action,它会:ProtocolRuntime.terminateFailure()持久化 failed terminal decision;RunFinalizer.fail()将 metadata run 标记为 failed;DATA_ACTIONS_REJECTED_BY_PROTOCOL,不允许一段失败说明将该运行转为 completed。时间预算耗尽等原有 partial completion 语义未改变。
5. Helper Context:只为歧义分类提供历史
设计
Session Intent 是路由器可以直接依赖的权威任务状态;对话摘要、记忆和近期查询可能由模型或用户生成,只能作为歧义判断的参考。两类上下文不能拥有相同权限。
实现
API 在组装 Agent 前调用
buildHelperContext(),收集:最终 block 受
DATAFOUNDRY_HELPER_CONTEXT_MAX_CHARS限制,默认为 2000 字符。超限时按以下顺序删除 section:Session Intent 不会作为 section 被删除;如果只剩它仍超限,则在字段内截断。构造器始终保留 header/footer,并转义历史内容中伪造的边界 marker。
该 block 只通过
classificationInput.background传给 protocol classifier,不参与确定性候选的生成,也不直接更新 Session Intent。它是budgeted untrusted context,边界 marker 用于向模型说明内容属性,不是 prompt injection 的强安全边界。Session title 不直接消费这份 helper block;它在 route/intent commit 后重新读取 active intent text 作为标题输入。因为
side-chat不更新 head,一次闲聊不会覆盖原任务标题。已知限制
route-correction仍信任同一 Agent 发起纠错。 服务端限制了来源路由、目标协议、初始 phase 和已执行 action,但没有引入第二个 classifier、独立 permit 或用户确认。误纠正可能使尚未开始执行的有效分析任务切回general-task,但会留下 aborted segment 和 handoff events。refine通过“原任务 + 补充要求”追加后截断,尚未实现结构化 requirement merge 或超长任务的语义压缩。0018_session_intents不会为升级前的 run 回填 intent binding。因此,升级前因ask_user、submit_plan等流程处于 suspended 状态的 run,升级后恢复时会因“已有 protocol state、但没有 run intent binding”触发RUN_INTENT_BINDING_REQUIRED。这是当前项目不承诺跨版本恢复下的有意版本边界;升级前应完成或取消仍处于 suspended 状态的任务,并在 vNext release/upgrade notes 中同步说明。验证结果
npm run buildnpm run test:packagesnpm run test:webnpm --workspace @datafoundry/tui testdocs/engineering/2026-08-03-subsystem-4-apps.md中的 offline demo 字符串,该文件不在本分支 diff 中npm run smoke:sqlnpm run smoke:agui-streamnpm run smoke:agentartifact event must be a slim reference断言处失败;该断言不覆盖 #100 的 protocol routing 路径smoke:agent-protocol-deepseek、smoke:task-state与本 PR 直接相关的新增或加固回归场景包括:
data-analysis;sales pitch、count files不被分析加速规则强制路由;unresolvedGoals绕过;accepted/rejected proposals 均可从 journal replay;general-task、data-analysis各 phase 及 handoff 后重算 availability;Commit 覆盖对照
以
upstream/main@08afa7b为 merge base,当前分支的 18 个非 merge commit 对应如下:7eb3e46,cbecfcdb4b9c32,a4f62371c5ffa7,a4f6237a20f202,e005d85,a25e8f0,915244e,a461575d9a9c51,8516a0c,c5967eb918f127,671cf5a573763c11411f3548fe7a新分支按功能 commit 顺序重放,未保留原历史中 5 个不包含额外代码的阶段性 merge commit。