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