Skip to content

结构化 Action Protocol

本课交付结果

Fake Model 已经能稳定返回脚本响应,但“稳定返回”不等于“可以执行”。模型可能输出一段解释文字、一个缺少工具名的对象、拼错的动作类型,甚至给出看似 JSON 实际字段含义错误的值。如果 Runtime 把任何对象都当成工具指令,模型的一次格式漂移就会穿过安全边界,最终变成文件或进程副作用。

本课交付 parseAgentAction(value)。它接受 unknown,只允许三种动作:message 表示加入对话但不执行工具;tool_call 表示带唯一 callId、非空工具名和明确 input 字段的调用请求;finish 表示模型提出结束并给出摘要。解析器拒绝数组、null、空字符串、未知 type、缺失 input,并保留“字段缺失”和“字段存在但值为 undefined”的区别。

你会把模型输出看成一种网络协议,而不是“模型说的话”。协议的任务是把概率输出收缩成有限状态,让调度器可以穷举处理分支,让 Trace 能关联调用,让测试能证明坏输入不会执行。完成后,模型能力与系统授权被彻底分开:模型可以提出动作,只有通过协议、权限和工具合同的动作才可能执行。

岗位问题

早期 Agent 原型常让模型输出诸如 CALL read_file README.md,再用正则或字符串切割提取工具名和参数。演示时很直观,遇到换行、转义、嵌套 JSON、同名文本或模型解释就会误判。更危险的是,解析器往往采取“尽量猜”的策略:缺少字段时填默认值,未知动作退回 message。系统因此无法区分模型故意选择、格式错误和解析器猜测。

结构化输出也不是天然安全。JSON.parse 只能证明字符串符合 JSON 语法,不能证明对象符合领域协议。{ "type": "tool_call", "tool": 1 } 是合法 JSON,却不是合法工具调用。TypeScript 接口不会在运行时自动验证来自模型的数据。模型输出、HTTP 响应和插件消息都应从 unknown 开始,经过显式解析后才能进入内部类型。

动作协议还影响可观测性和并发。callId 不是装饰字段,它把 assistant 提出的调用、tool 返回结果和 Trace 事件连接起来。没有稳定 id,多个同名工具调用难以对应;重试时也无法判断是新调用还是重复响应。协议越含糊,后续恢复、评测和审计越只能依赖文本猜测。

最后,finish 只是模型提出完成,不代表系统已经验证完成。第 4 课先建立动作形状,第 5 课最小循环会接受 finish;后续课程会逐渐加入测试、Diff 和 Evaluation 门禁。协议把“模型意图结束”表达清楚,Runtime 再决定是否允许进入完成终态。

前置检查

前置知识快照

复习 TypeScript 可判别联合:多个对象类型共享字面量字段 type,控制流可据此缩窄其他字段。unknown 要先验证再使用。Object.prototype.hasOwnProperty.call(object, key) 能区分属性不存在与属性存在但值为 undefined。这个区别对工具输入很重要:某些无参数工具允许显式 undefined,但调用方遗漏协议字段通常表示模型格式错误。

先确认 Fake Provider 的脚本、取消和耗尽语义通过:

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 03
pnpm --dir bootcamps/coding-agent/labs/04-action-protocol/starter test

Starter 的 message 与 finish 分支已经存在,tool_call 分支故意抛出练习缺口。因此四项测试会暴露工具调用解析的所有关键不变量。不要通过删除 tool_call 测试或把它退化为 message 来“修复”。

原理拆解

动作进入执行器前必须通过一条单向协议管道:

mermaid
flowchart LR
  A[模型原始输出 unknown] --> B{普通对象?}
  B -->|否| X[协议错误]
  B -->|是| C[读取非空 type]
  C --> D{message/tool_call/finish}
  D -->|message| E[验证 content]
  D -->|tool_call| F[验证 callId/tool/input]
  D -->|finish| G[验证 summary]
  D -->|其他| X
  E --> H[AgentAction]
  F --> H
  G --> H
  H --> I[权限/工具/终态策略]

第一步只证明输入是非数组对象。第二步读取 type,并用同一个非空字符串 helper 统一错误。第三步按有限枚举进入分支。每个分支只验证自己需要的字段,返回一个新对象;未知字段不会被传播到内部协议。这样下游拿到 AgentAction 后可以写穷尽 switch,新增动作若未处理会在类型检查或测试中暴露。

三类动作对应三种系统语义

message 是无工具副作用的对话推进。它可以保存模型解释、计划或澄清,但仍会消耗步骤和 token。tool_call 是副作用提案,不直接代表授权;Runtime 还要查找工具、验证 input schema、检查权限和预算。finish 是终态提案,摘要用于人类输出和 Trace,但是否完成还应由测试、Diff 与评测证据决定。

把三者混成一个带大量可选字段的接口会丢失不变量。例如 { type: string; content?: string; tool?: string; summary?: string } 允许 tool_call 没有 tool,也允许 finish 同时携带 input。可判别联合让每种状态只携带合法字段,减少“组合爆炸”。解析器正是从松散外部对象创建这个受限内部集合。

callId 是因果关系,不只是唯一字符串

一次工具调用会产生至少三个事件:模型提出调用、工具开始、工具结束。消息历史还会加入 assistant 调用描述和 tool result。callId 贯穿这些对象,允许 UI 把结果显示在正确调用下,Trace 查询计算单次耗时,重试逻辑识别重复调用。即使当前循环一次只运行一个工具,提前建立关联 id 能避免未来并发时改协议。

解析器只要求 callId 非空,不负责全局唯一。唯一性需要运行上下文:Loop 或 Trace sink 可以维护已见集合,发现重复时拒绝或标记重放。把全局状态塞进纯解析器会让它不可重用,也无法单独解析历史事件。

缺失与 undefined 为什么不同

JSON 本身没有 undefined;真实模型通过 JSON 输出时,input 要么存在为 null/对象等值,要么缺失。但 TypeScript/JavaScript 调用、插件或测试可能显式传入 undefined。协议规定 tool_call 必须声明 input 字段,因为工具合同需要知道调用者考虑过输入。无参数工具可以接受显式 undefined,缺失则说明对象形状不完整。

这种严格区分还能发现序列化问题:若一个适配器先创建 { input: undefined } 再 JSON stringify,字段会被删除;重新解析后应被协议拒绝。工程师由此知道应使用 null、空对象或明确的无参数 schema,而不是让字段在传输中消失。

解析错误如何回到模型

最小 Lab 直接抛 Error,调用方测试错误文字。生产 Runtime 可以把协议错误分类为 validation failure,记录脱敏原始片段,并向模型追加结构化观察,例如“tool_call 缺少 input,请按 schema 重试”。重试必须受预算限制,原始坏对象不能进入工具调度。

不要让解析器自行调用模型修复 JSON。它是纯边界函数,不知道预算、历史或取消。恢复层决定是否重试、是否给模型反馈、是否终止。职责分离使同一解析器可用于实时响应、录制 Trace 和离线评测。

协议边界与工具边界不能合并

Action Parser 回答的是“模型是否提出了一个形状完整的工具调用”,Tool Schema 回答的是“这个具体工具是否接受这份 input”。例如 { type: 'tool_call', callId: 'c1', tool: 'read_file', input: { path: 3 } } 通过动作协议,因为 input 字段确实存在;它随后应在 read_file 的输入 schema 处失败,因为 path 不是字符串。把所有工具 schema 写进 Action Parser,会让每新增工具都修改模型包,也无法解析历史中已经卸载的工具动作。

权限边界又是第三层。一个 input 形状正确的 write_file 仍可能触及禁止路径,需要 allow、ask、deny 策略;工具存在也不代表当前任务获权。执行边界则负责真实副作用、超时与结果归一。四层顺序是:动作协议、工具输入、权限决策、执行。任何一层失败都应产生不同错误类别和 Trace 事件,评审者才能知道请求究竟在哪里被挡住。

这种分层还保护测试。协议测试只构造普通对象,不创建 Registry;工具测试直接传有效 Action 的 input,不需要模型;权限测试使用 capability 和 resource,不运行文件写入;执行器测试在隔离目录观察副作用。若一个测试必须组装所有组件才能验证空 callId,说明边界已经耦合。

协议版本与兼容演进

训练营的最小对象没有版本字段,适合在单一仓库内同步演进。跨进程、插件或长期存储时,Action 会成为真正的线协议,需要 schemaVersion。新增可选字段通常可以向后兼容;改变字段含义、删除动作或增加旧系统无法安全忽略的新动作,则需要新版本和明确迁移。

兼容策略不能只写“尽量支持”。接收端应声明支持哪些版本;旧版本转换函数创建当前内部 Action;未知更高版本拒绝并说明能力不匹配。Trace 保存原版本和规范化结果,便于审计。若协议要跨语言,还要避免 JavaScript 特有的 undefined,统一使用 JSON 可表达值。

版本也不应成为放松验证的借口。版本 1 中缺少 callId 就是错误,不能因“也许旧模型没输出”而随机生成;正确做法是适配器负责从确知的旧格式迁移,并记录生成规则。隐式猜测会让同一输入在不同 Runtime 得到不同语义。

结构化输出仍要防提示注入

模型可能从仓库文件中读到恶意文本,要求它输出某个工具调用。即使输出完全符合 JSON 和 AgentAction,意图仍来自不可信内容。Action Protocol 只能防格式错误,不能判断动作是否符合用户任务。因此工具调用还必须经过权限、路径、命令和预算策略,敏感动作可能要求人工确认。

同样,工具名和 input 不应直接拼进 shell。结构化协议的价值恰恰是保留字段边界:command guard 接收程序与参数数组,文件工具接收相对路径对象。若调度器最后又把它们连接成一段 shell 字符串,前面的结构化工作会被绕过。协议安全依赖端到端保持结构,而不是入口有一次 JSON.parse 就足够。

在 Trace 中记录 Action 时还要脱敏 input。文件路径通常可以记录相对形式,token、password、Authorization 等字段必须在写盘前移除。callId、tool 和 action type 可保留,用来重建因果关系。可观测性不应成为秘密泄漏通道。

代码实验

打开本课 Lab

失败实现

下面的宽松解析会把缺失字段静默补成空值:

ts
export function parseAgentAction(value: any): AgentAction {
  if (value.type === "tool_call") {
    return {
      type: "tool_call",
      callId: value.callId ?? crypto.randomUUID(),
      tool: value.tool ?? "noop",
      input: value.input,
    };
  }
  return value;
}

它使用 any、读取 null 会崩溃、替模型生成 callId、把缺工具名改成 noop,并原样放行未知动作。错误被掩盖后,Trace 会记录系统生成的虚假因果关系。

参考实现先建立对象和文本 helper:

ts
function text(value: unknown, field: string): string {
  if (typeof value !== "string" || value.trim() === "") {
    throw new Error(`${field} 必须是非空字符串`);
  }
  return value;
}

export function parseAgentAction(value: unknown): AgentAction {
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
    throw new Error("action 必须是对象");
  }
  const action = value as Record<string, unknown>;
  const type = text(action.type, "type");
  if (type === "message") return { type, content: text(action.content, "content") };
  if (type === "finish") return { type, summary: text(action.summary, "summary") };
  if (type === "tool_call") {
    const callId = text(action.callId, "callId");
    const tool = text(action.tool, "tool");
    if (!Object.prototype.hasOwnProperty.call(action, "input")) {
      throw new Error("input 字段缺失");
    }
    return { type, callId, tool, input: action.input };
  }
  throw new Error(`不支持的 action type:${type}`);
}

关键边界测试明确区分字段状态:

ts
it("distinguishes missing input from explicit undefined", () => {
  expect(() => parseAgentAction({
    type: "tool_call", callId: "c1", tool: "read_file",
  })).toThrow("input 字段缺失");
  expect(parseAgentAction({
    type: "tool_call", callId: "c1", tool: "noop", input: undefined,
  })).toMatchObject({ input: undefined });
});

关键实现讲解

helper 返回原字符串而不是 trim 后字符串,这是一个可讨论选择。验证只要求非空,保留 content 与 summary 原文可能有利于模型消息忠实性;工具名和 callId 通常应规范化。生产协议可以决定统一 trim,但必须用测试固定,避免不同适配器各自处理。本课关注拒绝空白,不扩张规范化语义。

对象检查与第 1 课相同,因为两个函数都位于不可信边界。不要急于抽一个过度通用校验框架;小 helper 足以消除重复,同时错误仍贴近领域字段。合同变复杂后可以使用 schema 库,但必须保留可判别联合和稳定错误类别。

返回新对象有两个作用:丢弃外部多余字段,防止调用方在解析后修改原对象影响内部值。这里 input 仍可能是嵌套引用;真正工具执行前还会按工具 schema 验证,必要时深拷贝。若现在盲目 structuredClone 任意 input,可能对不支持类型抛错并混淆协议职责。

未知 type 直接失败而非当 message。向后兼容不能靠忽略未知语义实现。如果未来新增 request_approval,旧 Runtime 应明确“不支持”,而不是把审批请求展示成普通文本后继续执行。协议版本演进需要显式能力协商或版本字段。

解析器也不查询 Tool Registry。工具名非空只证明形状正确;是否存在、input 是否符合该工具 schema、是否允许调用属于下一层。把 registry 注入解析器会让历史 Action 在工具集变化后无法解析,也把纯函数变成环境相关操作。

穷尽处理让遗漏可见

下游处理 AgentAction 时应使用 switch 或 if 链,并在最后通过 never 检查穷尽性。这样新增动作类型后,编译器会指出尚未更新的调度器、Trace 和 UI。若代码只写“不是 tool_call 就当 message”,新增 finish 或 approval 会被错误吞并。

穷尽性只在内部类型可信时成立,这正是解析器存在的理由。未经验证的 type: string 无法让编译器证明分支完整;解析后得到字面量联合,静态类型与运行时验证才形成闭环。外部世界无限,内部状态有限,边界函数负责完成这次降维。

错误对象也可以结构化,例如 { code: 'ACTION_INPUT_MISSING', field: 'input' },CLI 再渲染中文消息。最小 Lab 使用 Error 文本保持重点,但生产系统的分类、国际化和统计会受益于稳定 code。无论形式如何,测试应断言领域语义而不是完整栈。

协议评审时可以使用一张“合法状态表”:逐行列出动作类型、必填字段、禁止字段、后续处理者和可能终态。再为每行构造一个合法对象与两个最小非法对象。这个方法比只看 TypeScript 定义更可靠,因为它迫使团队讨论运行时输入、缺失字段和跨语言序列化。表格还可以成为 Provider 提示、JSON Schema 与文档的共同来源,减少三处定义长期漂移。

当模型输出格式错误率上升时,不要第一反应放宽解析器。先按模型、提示版本和错误 code 统计,判断是适配器解码、提示合同还是供应商行为变化;必要时在受预算控制的恢复层提供一次格式纠正。严格解析器提供了可测量信号,宽松猜测只会把格式问题推迟成更昂贵的执行事故。

因此,协议的严格不是为了惩罚模型,而是为了让系统始终知道自己理解了什么、拒绝了什么,以及为什么没有执行。

这种可解释拒绝本身就是安全能力,也是后续恢复与评测能够成立的前提。

边界越清楚,系统越不需要依赖猜测。

运行与验证

bash
pnpm --dir bootcamps/coding-agent/labs/04-action-protocol/starter test
pnpm --dir bootcamps/coding-agent/labs/04-action-protocol/solution test
pnpm --filter @coding-agent/model test

真实运行输出

Starter 的 tool_call 四个行为均暴露练习缺口。Solution 真实摘要:

text
✓ ../tests/lab.test.ts (4 tests)
Test Files  1 passed (1)
Tests       4 passed (4)

四项测试覆盖三种合法动作、空 callId、错误工具名和 input 缺失语义。总项目 model 测试还会把解析器与 Provider 合同一起验证。所有测试是纯内存的,不创建文件、不访问网络。

建议做变异验证:删掉 hasOwnProperty 检查,缺失字段测试应失败;把未知 type 返回原对象,补一个 unknown 测试应失败;把数组当对象,输入 [] 应得到“action 必须是对象”。这能证明门禁覆盖的不是某个实现形式,而是协议边界。

常见失败与排查

故障案例 1

症状:模型输出 { type: "tool_call", tool: "read_file" } 后,系统执行 noop 或生成随机 callId,Trace 看似正常。根因:解析器对缺失字段提供默认值,掩盖协议错误。定位:记录归一化前后的对象差异,检查是否出现 Runtime 自行生成的动作字段。修复:必填字段缺失立即失败,恢复层再决定是否让模型重试。

故障案例 2

症状:无参数工具在 JavaScript 测试中可调用,经 JSON 传输后却被拒绝。根因input: undefined 序列化时字段消失,接收端看到缺失。定位:对发送前对象和 JSON 字符串分别检查 own property。修复:跨 JSON 协议使用 null{} 表示无参数,并让工具 schema 明确接受。

故障案例 3

症状:模型输出未来动作 request_approval,旧 Runtime 把它当 message 继续运行。根因:未知 type 被宽松降级,语义丢失。定位:为未知字面量写测试,检查 switch 是否有 default 放行。修复:未知协议动作明确失败;新增动作时同时升级类型、解析、调度、Trace 和版本策略。

还要警惕直接用 JSON.parse 的结果做类型断言。若错误只在工具层出现,说明协议验证位置太晚。排查调用链,确保任何 registry 查询或权限判定前已经得到 AgentAction

课后作业

第一项作业是加入 request_approval 动作草案,字段包含 requestIdcapabilityresourcereason。先写失败测试和类型,再实现解析;说明它与 tool_call 的授权关系,不要直接执行。

第二项作业是为协议增加 schemaVersion: 1。设计旧 Runtime 收到版本 2、新 Runtime 收到无版本对象时的行为;比较严格拒绝与兼容迁移的代价。

第三项作业是实现 safeActionPreview(value):解析成功返回不含敏感 input 的摘要,失败返回稳定错误类别。它只能用于日志,不能代替正式解析。写测试确保 token、password 字段被脱敏。

知识检查:合法 JSON 为什么不等于合法 Action?callId 在哪些事件间建立因果关系?缺失 input 与 undefined 的区别是什么?解析器为什么不检查工具是否存在?finish 为什么只是提案?

验收 Rubric

维度通过标准常见扣分
外部边界unknown 经普通对象检查any 或直接类型断言
联合语义三种动作字段互斥且完整大量可选字段的松散对象
工具调用callId/tool 非空,input own property 存在自动补默认值
演进未知 type 明确失败降级为 message
职责解析不执行、不查 registry、不重试模型边界函数产生副作用
证据Solution 四项与 model 包测试通过只覆盖合法输入

总项目增量

总项目包:@coding-agent/model

总项目路径:packages/model/src/action-parser.ts

总项目测试:packages/model/tests/model.test.ts

总项目验证命令:pnpm --filter @coding-agent/model test

本课把 action-protocol 能力加入第 1 周检查点。Fake Model 提供确定响应,Action Parser 把响应收缩为有限动作,下一层 Agent Loop 才能穷举处理。这个顺序确保模型输出在接触工具前至少经过两道合同:Provider 响应合同与 Action 协议合同。

延伸阅读

方案对比与工程取舍

纯文本标签协议最容易手工调试,却难处理转义和嵌套数据。JSON 动作协议通用、可记录、可用 schema 验证,但模型仍可能产生无效对象。供应商原生 function calling 能提高格式成功率,却把工具格式与特定 API 绑定,Runtime 仍需把返回值转换成自己的 AgentAction 并验证。推荐以内部协议为真相,外部适配器负责转换。

手写解析器透明、依赖少;schema 库可生成错误路径、组合版本和 JSON Schema。无论工具如何,不能把“库成功 parse”误解为“动作已获授权”。协议验证、工具输入验证、权限决策和执行是四个不同关口。

下一课衔接

现在模型响应已经能安全变成有限动作,但这些动作还没有时间顺序。下一课实现最小 Agent Loop:把任务送给 Provider,记录 message,执行 tool_call,把工具结果回写给模型,并在 finish、预算、重复动作、取消或失败时停止。Action Protocol 提供词汇,Agent Loop 才定义语法和生命周期。

延伸阅读可关注 discriminated union、JSON Schema 版本演进和 JSON-RPC 的 request id。读任何 Agent SDK 的工具调用格式时,都要问:哪一层只解析形状,哪一层验证输入,哪一层授权,哪一层真正产生副作用?

从零实现 Mini Code Agent Runtime