focus/docs/architecture/01-types-and-protocol.md

17 KiB
Raw Blame History

逐行精读:agent/types.ts —— 类型契约

源文件:pi-main/packages/agent/src/types.ts430 行)

这是 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—— ThinkingLevelAgentMessage
  5. Agent 状态L322-347—— AgentState
  6. 工具结果 + 工具定义L349-396—— AgentToolResultAgentTool
  7. 事件协议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 resultreason 作为错误文本。这是权限/安全 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 组AgentLoopConfigL140-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[]>;

transformContextconvertToLlm 之前跑,操作的是 AgentMessage[]。用途:上下文窗口管理(剪枝老消息)、从外部源注入上下文。这是 AgentMessage 级的预处理,而 convertToLlmAgentMessage→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 + AgentMessageL289-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 组AgentStateL322-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;               // 最近失败/中止的错误
}

toolsmessages 是 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/endtool_execution_start/end 来更新)。


第 6 组AgentToolResult + AgentToolL349-396

AgentToolResultL350-362

export interface AgentToolResult<T> {
    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)。一个"继续"结果可以否决停止。

// L370 —— 流式更新回调
export type AgentToolUpdateCallback<T = any> = (partialResult: AgentToolResult<T>) => void;

工具执行中可调用此回调推送部分结果(如 bash 的 stdout 实时输出)。作用域是当前 execute() 调用——promise settle 后的调用被忽略。

AgentToolL373-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 在其上加:

  • labelUI 显示名(区别于 name
  • prepareArgumentsschema 验证之前的兼容性 shim——LLM 可能输出格式略有偏差,这里先规整
  • execute失败要 throw不要把错误编码进 content(注释 L381
  • executionModeper-tool 覆盖循环的默认 toolExecution

signal 必须被尊重——工具要响应取消。onUpdate 是流式更新。


第 7 组AgentEventL400-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-413agent_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. 边界分离AgentMessagetranscriptMessageLLM 协议)是两个宇宙,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 —— 看循环如何操作这些类型。