# 逐行精读:`agent/types.ts` —— 类型契约 > 源文件:`pi-main/packages/agent/src/types.ts`(430 行) > > 这是 agent 层的"词汇表"。理解了这里的每一个类型,就理解了 Pi 的全部抽象。 > agent 循环(`agent-loop.ts`)只是在操作这些类型——所以**必须先读懂这份文档**。 ## 阅读地图 按依赖顺序分 7 组: 1. **运行时函数类型**(L27-49)—— `StreamFn`、执行模式、队列模式 2. **工具调用相关**(L52-136)—— `AgentToolCall`、before/after hook 的入参出参 3. **循环配置**(L140-282)—— `AgentLoopConfig`(最重要的扩展点) 4. **思考级别 + 自定义消息**(L289-314)—— `ThinkingLevel`、`AgentMessage` 5. **Agent 状态**(L322-347)—— `AgentState` 6. **工具结果 + 工具定义**(L349-396)—— `AgentToolResult`、`AgentTool` 7. **事件协议**(L400-431)—— `AgentEvent` --- ## 第 1 组:运行时函数类型(L27-49) ```ts // L27-31 export type StreamFn = ( model: Model, context: Context, options?: SimpleStreamOptions, ) => AssistantMessageEventStream | Promise; ``` **`StreamFn` 是循环调用 LLM 的接口**。注意契约注释(L22-26): - **绝不能 throw 或 reject**——失败必须编码进返回的 stream 里 - 必须返回 `AssistantMessageEventStream` - 失败用 `stopReason: "error"|"aborted"` + `errorMessage` 表示 **为什么这么设计?** 因为循环是 `for await (event of response)` 形式,如果 stream 抛错会打断整个循环的事件序列。把错误编码进 stream,循环就能统一处理"正常完成"和"失败",保证 `agent_end` 事件一定会发射(避免悬挂的 listener)。 ```ts // L40-41 export type ToolExecutionMode = "sequential" | "parallel"; ``` ```ts // L49 export type QueueMode = "all" | "one-at-a-time"; ``` `QueueMode` 控制 steering/follow-up 队列在 drain point 注入多少消息: - `"all"`:全部注入 - `"one-at-a-time"`:只注入最老的一条,剩下的留给下一个 drain point --- ## 第 2 组:工具调用相关(L52-136) ```ts // L52 export type AgentToolCall = Extract; ``` **这只是个类型别名**。`AssistantMessage["content"]` 是 `(TextContent|ThinkingContent|ToolCall)[]`,`[number]` 取数组元素类型,`Extract<..., {type:"toolCall"}>` 从联合里挑出 `ToolCall`。所以 `AgentToolCall === ToolCall`。这个别名让后续 hook 的类型签名更清晰。 ### beforeToolCall 的返回(L60-63) ```ts export interface BeforeToolCallResult { block?: boolean; reason?: string; } ``` 返回 `{block: true}` → 工具不执行,循环发射一个 error tool result,`reason` 作为错误文本。这是**权限/安全 hook**的入口。 ### afterToolCall 的返回(L77-86) ```ts export interface AfterToolCallResult { content?: (TextContent | ImageContent)[]; details?: unknown; isError?: boolean; terminate?: boolean; } ``` **字段级覆盖,无深度合并**(L66-75 注释): - `content` 给了就整个替换 - `details` 给了就整个替换 - `isError` 给了就替换错误标志 - `terminate` 给了就替换停止提示 - 省略的字段保持原值 这是**结果后处理 hook**——可以脱敏、改写、强制标记错误。 ### Hook 的入参上下文(L88-114) ```ts // L88-98 export interface BeforeToolCallContext { assistantMessage: AssistantMessage; // 请求这个工具调用的 assistant 消息 toolCall: AgentToolCall; // 原始 tool call block args: unknown; // 已验证的参数 context: AgentContext; // 调用时 agent 上下文快照 } // L100-114 export interface AfterToolCallContext { assistantMessage: AssistantMessage; toolCall: AgentToolCall; args: unknown; result: AgentToolResult; // 执行后的原始结果 isError: boolean; // 当前是否被视为错误 context: AgentContext; } ``` 注意 `BeforeToolCallContext.args` 是**已验证**的参数(`validateToolArguments` 已跑过)。 ### shouldStopAfterTurn 和 prepareNextTurn 的上下文(L116-136) ```ts // L116-126 export interface ShouldStopAfterTurnContext { message: AssistantMessage; // 完成 turn 的 assistant 消息 toolResults: ToolResultMessage[]; // 前面 turn_end 发射的工具结果 context: AgentContext; // turn 的 assistant + 工具结果都加入后的上下文 newMessages: AgentMessage[]; // 本次循环若在此退出会返回的消息 } // L128-136 export interface AgentLoopTurnUpdate { context?: AgentContext; // 下一次 provider 请求的上下文 model?: Model; // 下一次的模型 thinkingLevel?: ThinkingLevel; } // L138 export interface PrepareNextTurnContext extends ShouldStopAfterTurnContext {} ``` **`prepareNextTurn` 是 harness 实现动态上下文的核心**。每个 turn 结束后,harness 在这里:从 session 重载消息、重算系统提示(可含 cwd/git status)、重建工具列表。这是模型切换、上下文压缩、工具动态加载生效的机制。 --- ## 第 3 组:AgentLoopConfig(L140-282)⭐ 最重要 这是**循环的全部扩展点**。循环本身是固定的状态机,所有可变行为通过这个 config 注入。 ```ts // L140 export interface AgentLoopConfig extends SimpleStreamOptions { model: Model; // 用哪个模型 // L169 —— 唯一必需的回调 convertToLlm: (messages: AgentMessage[]) => Message[] | Promise; ``` **`convertToLlm` 是 AgentMessage↔Message 的唯一翻译点**。默认实现(agent.ts): ```ts function defaultConvertToLlm(messages: AgentMessage[]): Message[] { return messages.filter(m => m.role === "user" || m.role === "assistant" || m.role === "toolResult" ); } ``` —— 过滤掉自定义角色,只留三种标准 LLM 消息。coding-agent 的实现更复杂:把 `bashExecution`/`compactionSummary` 等包装成 `` 标签的 user 消息。 ```ts // L191 —— 可选的上下文变换(在 convertToLlm 之前) transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; ``` `transformContext` 在 `convertToLlm` **之前**跑,操作的是 `AgentMessage[]`。用途:上下文窗口管理(剪枝老消息)、从外部源注入上下文。这是 **AgentMessage 级的预处理**,而 `convertToLlm` 是 **AgentMessage→Message 的格式转换**。 ```ts // L201 —— 动态 API key 解析 getApiKey?: (provider: string) => Promise | string | undefined; ``` 为什么需要这个?**短命 OAuth token**(如 GitHub Copilot)可能在长时间工具执行期间过期。每次 LLM 调用都重新解析 key,避免用过期 token。 ```ts // L213 —— turn 结束后是否优雅停止 shouldStopAfterTurn?: (context: ShouldStopAfterTurnContext) => boolean | Promise; ``` 返回 true → 循环在当前 turn 后停止(不 poll steering/follow-up)。用途:上下文快满了提前停。 ```ts // L220-222 —— 准备下一轮 prepareNextTurn?: ( context: PrepareNextTurnContext, ) => AgentLoopTurnUpdate | undefined | Promise; ``` 返回替换的 context/model/thinkingLevel;返回 undefined 保持当前。 ```ts // L235 —— 转向消息(agent 工作中插入) getSteeringMessages?: () => Promise; // L248 —— follow-up 消息(agent 要停下时检查) getFollowUpMessages?: () => Promise; ``` **steering vs follow-up 的本质区别**(详见循环精读文档): - steering:内层循环每次 turn 结束都 poll——"用户在 agent 工作时插话" - follow-up:只有内层循环**本来要退出**时才 poll——"agent 干完了,还有别的事吗" ```ts // L259 —— 工具执行模式默认值 toolExecution?: ToolExecutionMode; // 默认 "parallel" // L267 —— 工具执行前 hook beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise; // L281 —— 工具执行后 hook afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise; } ``` **所有回调的契约都是"不能 throw/reject"**——返回安全 fallback 而非抛错,因为抛错会打断循环的事件序列。 --- ## 第 4 组:ThinkingLevel + AgentMessage(L289-314) ```ts // L289 export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; ``` 注意 `"xhigh"` 和 `"max"` 只有部分模型族支持。 ```ts // L305-307 —— 应用可扩展的占位 export interface CustomAgentMessages { // 默认空,应用通过 declaration merging 扩展 } // L314 export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages]; ``` **declaration merging 扩展示例**(coding-agent 的 messages.ts:54-61): ```ts declare module "../types.ts" { interface CustomAgentMessages { bashExecution: BashExecutionMessage; // role: "bashExecution" custom: CustomMessage; // role: "custom" branchSummary: BranchSummaryMessage; // role: "branchSummary" compactionSummary: CompactionSummaryMessage; } } ``` 这是 TypeScript 的模块增强机制。扩展后 `AgentMessage` 联合类型自动包含这些新角色。**这些自定义角色不直接发给 LLM**,由 `convertToLlm` 转换或过滤。 **这个设计让 transcript 能承载 UI 专属信息而不污染 LLM 上下文**——这是 Pi 区别于简单 agent 框架的关键。 --- ## 第 5 组:AgentState(L322-347) ```ts // L322-347 export interface AgentState { systemPrompt: string; model: Model; thinkingLevel: ThinkingLevel; set tools(tools: AgentTool[]); // 写时复制 get tools(): AgentTool[]; set messages(messages: AgentMessage[]); // 写时复制 get messages(): AgentMessage[]; readonly isStreaming: boolean; // 正在处理 prompt/continuation readonly streamingMessage?: AgentMessage; // 当前流式 assistant 消息 readonly pendingToolCalls: ReadonlySet; // 正在执行的 tool call id readonly errorMessage?: string; // 最近失败/中止的错误 } ``` **`tools` 和 `messages` 是 accessor 属性**(不是普通字段)。赋值时复制顶层数组(agent.ts:60-94): ```ts let tools = initialState?.tools?.slice() ?? []; let messages = initialState?.messages?.slice() ?? []; return { get tools() { return tools; }, set tools(next) { tools = next.slice(); }, // 赋值即复制 get messages() { return messages; }, set messages(next) { messages = next.slice(); }, // ... }; ``` **为什么写时复制?** 防止外部持有引用后原地修改导致状态不一致。运行时字段(`isStreaming`/`streamingMessage`/`pendingToolCalls`/`errorMessage`)是 `readonly`——只能通过循环事件间接修改(agent.ts 的 `processEvents` 监听 `message_start/update/end`、`tool_execution_start/end` 来更新)。 --- ## 第 6 组:AgentToolResult + AgentTool(L349-396) ### AgentToolResult(L350-362) ```ts export interface AgentToolResult { content: (TextContent | ImageContent)[]; // 发给模型的 details: T; // 给 UI/日志的(不发给模型) addedToolNames?: string[]; // 延迟加载:这次结果引入的新工具 terminate?: boolean; // batch 级停止提示 } ``` **三个关键设计**: 1. **`content` vs `details` 分离**:`content` 是模型看到的(文本/图片),`details` 是结构化的(UI 渲染/日志),不发给模型。这让 UI 能显示丰富信息而不撑爆 LLM 上下文。 2. **`addedToolNames`**:工具结果可以引入新工具。支持"按需加载工具"——比如一个 `read_package_json` 工具返回后,注册一个针对该包的专用工具。Anthropic 的 tool search 功能对应这个。 3. **`terminate` 是 batch 级的**:只有当批内**所有**结果都设 `terminate: true` 才停止(`shouldTerminateToolBatch`)。一个"继续"结果可以否决停止。 ```ts // L370 —— 流式更新回调 export type AgentToolUpdateCallback = (partialResult: AgentToolResult) => void; ``` 工具执行中可调用此回调推送部分结果(如 bash 的 stdout 实时输出)。**作用域是当前 `execute()` 调用**——promise settle 后的调用被忽略。 ### AgentTool(L373-396) ```ts export interface AgentTool extends Tool { label: string; // UI 显示用 prepareArguments?: (args: unknown) => Static; // 预验证 shim execute: ( toolCallId: string, params: Static, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback, ) => Promise>; executionMode?: ToolExecutionMode; // per-tool 覆盖默认模式 } ``` `Tool`(pi-ai/types.ts:444-448)只有 `name`/`description`/`parameters`(TypeBox schema)。`AgentTool` 在其上加: - `label`:UI 显示名(区别于 `name`) - `prepareArguments`:schema 验证**之前**的兼容性 shim——LLM 可能输出格式略有偏差,这里先规整 - `execute`:**失败要 throw,不要把错误编码进 content**(注释 L381) - `executionMode`:per-tool 覆盖循环的默认 `toolExecution` **`signal` 必须被尊重**——工具要响应取消。`onUpdate` 是流式更新。 --- ## 第 7 组:AgentEvent(L400-431)⭐ 事件协议 这是 agent 对外暴露的全部事件。UI/日志通过 `subscribe` 监听这些事件。 ```ts export type AgentEvent = // —— Agent 生命周期 —— | { type: "agent_start" } | { type: "agent_end"; messages: AgentMessage[] } // —— Turn 生命周期(一个 turn = 一次 assistant 响应 + 它的工具调用/结果)—— | { type: "turn_start" } | { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] } // —— Message 生命周期(user/assistant/toolResult 都会触发)—— | { type: "message_start"; message: AgentMessage } | { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent } | { type: "message_end"; message: AgentMessage } // —— 工具执行生命周期 —— | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any } | { type: "tool_execution_update"; toolCallId; toolName; args; partialResult: any } | { type: "tool_execution_end"; toolCallId; toolName; result: any; isError: boolean }; ``` **事件层级**(三层嵌套): ``` agent_start ├── turn_start │ ├── message_start (user prompt) │ ├── message_end (user prompt) │ ├── message_start (assistant, 流式开始) │ ├── message_update × N (流式 delta) │ ├── message_end (assistant 完成) │ ├── tool_execution_start × N │ ├── tool_execution_update × N │ ├── tool_execution_end × N │ ├── message_start (toolResult) │ └── message_end (toolResult) ├── turn_end ├── turn_start (如果有更多工具调用) │ └── ... agent_end ``` **关键细节**(注释 L411-413):`agent_end` 是一次 run 的最后一个事件,**但被 await 的 `subscribe` listener 仍属于 run settlement 的一部分**——agent 只有在这些 listener 完成后才真正 idle。这避免了"agent_end 发了但后台写入还没完成"的竞态。 `message_update` 只在 assistant 流式过程中触发,带原始的 `assistantMessageEvent`(让 UI 能区分 text delta / thinking delta / toolcall delta)。 --- ## 总结:types.ts 的设计哲学 读完这 430 行,提炼出 Pi 的几个核心设计决策: 1. **类型驱动**:所有行为都用类型契约定义。循环是固定状态机,可变性全在 `AgentLoopConfig` 的回调里。 2. **边界分离**:`AgentMessage`(transcript)和 `Message`(LLM 协议)是两个宇宙,`convertToLlm` 是唯一桥梁。这让 transcript 能承载任意应用状态而不污染 LLM。 3. **错误编码而非抛出**:所有 hook 和 stream 的契约都是"不能 throw"。错误编码进 stream 或返回值,保证事件序列完整、`agent_end` 必达。 4. **content vs details 分离**:工具结果把"模型看到的"和"UI 看到的"分开,兼顾上下文经济和 UI 丰富度。 5. **写时复制状态**:`AgentState` 的 tools/messages 赋值即复制,防止外部引用导致状态不一致。 6. **声明合并扩展**:`CustomAgentMessages` 用 TS declaration merging 让应用无痛扩展消息类型,保持核心类型纯净。 下一步:[02-agent-loop.md](./02-agent-loop.md) —— 看循环如何操作这些类型。