跟着OpenCode学习Pi Coding Agent-05-Agent的类型系统

第 5 章:Agent 的日记本

故事场景

你的 Provider 层已经跑得挺顺了------能流式对话,换 AI 公司只改一行代码。

现在老板走过来,看了看屏幕:"不错,但它只会聊天。让它改代码试试。"

你挠了挠头。让 AI 调用工具?倒是想过,但------

"等一下,"你心里打鼓,"我连 Agent 长什么样都还没想清楚。"

  • 工具调用要告诉谁?总得有个"主体"来跑这个循环吧?
  • 循环过程中,外面的人怎么知道 Agent 在干嘛------是正在思考,还是在调工具,还是卡住了?
  • 如果用户中途说"停!换个思路",这条消息该怎么塞进去?
  • AI 回复了一次之后,怎么判断它是要继续还是已经完成了?

你看着 Provider 层的代码,突然意识到一个尴尬的事实:

Provider 只负责"拨通电话"。但拨通之后谁负责对话------怎么聊、聊几轮、什么时候挂电话------全都没定义。

你需要一个"Agent"层。但你甚至不知道 Agent 的数据结构长什么样。就像你要招一个实习生,但连他的工位(状态)、日记本(消息)、工作流程(事件)都还没画出来。

这一章,我们不写一行业务逻辑。我们先把"实习生"的档案填了------他需要记什么、他干活时会发出什么信号、他能用什么工具。

"数据结构设计得好,代码自己就写完了。" ------某位不肯透露姓名的秃头架构师

这一章要做什么

创建 src/agent/types.ts------定义 Agent 层的全量类型。做完后你的 Agent 虽然还不会动,但它的"骨骼"已经搭好了:你能告诉它有哪些工具、看它的状态、监听它的一举一动。

用大白话说:给 Agent 发一张"员工登记表"------姓名、工号、会什么技能、工作时会发出什么信号,全都写清楚。

实现

这章要新建哪个文件

复制代码
src/
  agent/
    types.ts        ← 我们新建的
  types.ts          ← 第 2 章写的 LLM 类型(Message、Context、Tool、Model)
  models.ts         ← 第 4 章写的 Provider 层
  providers/
    deepseek.ts     ← 第 4 章写的
  event-stream.ts   ← 第 3 章写的
  index.ts          ← 入口

src/agent/types.ts 依赖 src/types.ts 的基础类型,但它是 Agent 专属的------就像"公司内部人事系统"跟"外部客户 CRM"是两套概念,各有各的数据结构。

为什么需要一套独立的 Agent 类型

打个比方:

LLM 层(第 2 章) Agent 层(本章)
Message 用户说的话、AI 的回复、工具结果------LLM 能理解的格式 除了 LLM 消息,还可能有"系统通知""UI 状态""用户中断"------Agent 内部需要更丰富的消息类型
Tool 一份"说明书":名字、描述、参数 除了说明书,还要有"腿"------真正执行工具的 execute 函数
事件 AssistantMessageEvent:流式输出的文本增量 AgentEvent:Agent 的一生------启动、思考、调工具、完成、出错
状态 无状态------每次调用都是独立的一次 API 请求 有状态------系统提示词、模型、消息历史、正在流式输出的内容、正在跑的工具

换句话说:LLM 层是"单次通话",Agent 层是"整个工作流程"。 一个只管每次电话讲得好不好,另一个管整个项目怎么推进。

第一步:AgentMessage------比 LLM 消息多个"扩展槽"

Agent 内部的消息不一定是 LLM 认识的标准格式。举个例子:

  • "用户中断了当前操作"------这不是 LLM 的 user/assistant/toolResult 任何一种
  • "系统提醒:代码已自动保存"------同理
  • "上下文快满了,准备压缩历史"------也不是 LLM 消息

但这些"非标准消息"在 Agent 内部是有用的。怎么给 LLM 的 Message 类型开个"扩展槽"?

Pi 的做法是 TypeScript 声明合并(declaration merging)。这玩意儿听起来唬人,其实就两句话:

TypeScript 里,同名 interface 会自动合并成一个。你写两次 interface CustomAgentMessages {},它们的属性会合到一起。

用这个特性,我们定义一个空的 CustomAgentMessages 接口。应用层想要加自定义消息类型时,再通过 declare module 往里面塞。

为什么这样做而不是直接用 Message | MyExtraMessage

因为你的 Agent 库是一个通用框架------你不知道使用者将来想加什么类型的消息。声明合并让使用者在不修改库源码的情况下扩展类型。

创建 src/agent/types.ts,先写第一部分:

typescript 复制代码
// ─── src/agent/types.ts ───
// Agent 层的类型定义------Agent 的"员工档案"

import type {
  // 基础 LLM 类型------第 2 章定义的
  Message,              // 用户/AI/工具消息的联合类型
  AssistantMessage,     // AI 回复消息
  AssistantMessageEvent,// 流式输出事件(第 3 章)
  ToolResultMessage,    // 工具执行结果消息
  TextContent,          // 文本内容块
  Model,                // 模型描述符(第 4 章)
  Context,              // LLM 对话上下文(第 2 章)
} from "../types.js";

import type { TSchema, Static } from "@sinclair/typebox";

// ═══════════════════════════════════════════════════════════════
// 1. AgentMessage------Agent 内部的消息类型
// ═══════════════════════════════════════════════════════════════

/**
 * 自定义 Agent 消息的扩展接口。
 *
 * 默认是空的。应用层通过 TypeScript 声明合并(declaration merging)
 * 来添加自定义消息类型。
 *
 * 通俗解释:
 *   一个"活页本"------一开始是空白的,你想加什么类型的消息就加一页进去。
 *   TypeScript 的 interface 合并特性保证了多页会自动合成一本。
 *
 * @example
 * // 在你的应用代码里(比如 src/app-types.ts):
 * declare module "./agent/types.js" {
 *   interface CustomAgentMessages {
 *     notification: { type: "notification"; text: string; timestamp: number };
 *     interruption: { type: "interruption"; reason: string; timestamp: number };
 *   }
 * }
 * // 然后 AgentMessage 就会自动包含这两种新类型
 */
export interface CustomAgentMessages {
  // 空接口------等应用层通过声明合并往里加
}

/**
 * AgentMessage:LLM 标准消息 + 应用自定义消息的联合类型。
 *
 * 这是 Agent 内部传递消息的统一类型。
 * 发给 LLM 之前,通过 convertToLlm 函数筛掉 LLM 不认识的类型。
 *
 * 类比:公司内部的"综合档案"------包含了跟客户沟通的正式记录(LLM 消息),
 * 也包含内部便签、审批单(自定义消息)。发给客户时只挑正式记录。
 */
export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];

CustomAgentMessages[keyof CustomAgentMessages] 是什么鬼?

keyof CustomAgentMessages 拿到接口的所有键(比如 "notification" | "interruption"),

然后 [keyof ...] 用这些键去取对应的值类型。

效果就是:把接口里所有属性的值类型取出来,拼成一个联合类型。

因为 CustomAgentMessages 初始是空的({}),keyof {}never

所以默认情况下 AgentMessage = Message | never = Message------就是纯粹的 LLM 消息。

第二步:AgentTool------能"动手"的工具

第 2 章的 Tool 类型只是一份说明书------告诉 LLM "我能读文件,参数是 path"。但谁来真正执行 fs.readFile(path) 呢?

AgentToolTool 的基础上加了 execute 函数------这就是"腿"。

继续在 src/agent/types.ts 里写:

typescript 复制代码
// ═══════════════════════════════════════════════════════════════
// 2. AgentTool------"会跑"的工具
// ═══════════════════════════════════════════════════════════════

/**
 * 工具执行模式:
 * - "sequential":一个个来,前一个完成后一个才开始(比如写文件,顺序很重要)
 * - "parallel":同时跑,谁先完成谁先报结果(比如读多个不相关的文件)
 */
export type ToolExecutionMode = "sequential" | "parallel";

/**
 * 工具执行完返回的结果。
 *
 * - content:给 LLM 看的文本/图片("文件内容是...")
 * - details:给 UI 或日志看的结构化数据(完整的 JSON 对象,可以做渲染)
 * - terminate:true 表示"干完了,别再调其他工具了"
 */
export interface AgentToolResult<TDetails = unknown> {
  /** 返回给模型看的文本或图片内容 */
  content: TextContent[];
  /** 结构化详情------日志、UI 渲染用,LLM 看不到 */
  details: TDetails;
  /** 提示 Agent 这轮工作该停了 */
  terminate?: boolean;
}

/**
 * 工具执行过程中,用于推送"半成品"的回调。
 *
 * 比如读一个大文件,可以先推送前 100 行让 UI 显示出来,
 * 等全读完了再推送最终结果。
 */
export type AgentToolUpdateCallback<TDetails = any> = (
  partialResult: AgentToolResult<TDetails>
) => void;

/**
 * AgentTool------Agent 真正能调用的工具。
 *
 * 和 Tool(第 2 章)的区别:
 *   Tool 只是"说明书"(name + description + parameters)
 *   AgentTool 在说明书基础上加了"动手能力"(execute 函数)
 *
 * 类比:
 *   Tool = 菜谱("宫保鸡丁:鸡丁、花生、酱油...")
 *   AgentTool = 菜谱 + 一个能颠勺的厨子
 */
export interface AgentTool<
  TParameters extends TSchema = TSchema,
  TDetails = unknown
> {
  /** 工具名------LLM 会用它来指定调哪个工具 */
  name: string;
  /** 工具描述------LLM 靠这段文字判断什么时候该用它 */
  description: string;
  /** 参数的 typebox schema------LLM 必须按这个格式填参数 */
  parameters: TParameters;
  /** 人类可读的标签------在 UI 上显示用 */
  label: string;
  /**
   * 可选:在 schema 校验前对原始参数做一次"剪裁"。
   * 比如 LLM 传的参数格式有点歪,你在这里把它掰正。
   */
  prepareArguments?: (args: unknown) => Static<TParameters>;
  /**
   * 真正执行工具的异步函数。
   *
   * 参数说明:
   *   toolCallId: 这次调用的唯一 ID(用来跟结果配对)
   *   params: 校验并剪裁后的参数
   *   signal: 取消信号------用户说"停",工具也该停
   *   onUpdate: 推送半成品的回调
   *
   * 返回:AgentToolResult<TDetails>
   *   执行失败直接 throw,不要在 content 里编码错误------
   *   Agent 循环会来 catch 并生成错误消息。
   */
  execute: (
    toolCallId: string,
    params: Static<TParameters>,
    signal?: AbortSignal,
    onUpdate?: AgentToolUpdateCallback<TDetails>,
  ) => Promise<AgentToolResult<TDetails>>;
  /**
   * 单个工具的并行策略覆盖。
   * 不设就用全局的 toolExecution 配置。
   * - "sequential":这个工具必须排队执行
   * - "parallel":这个工具可以和其他工具同时跑
   */
  executionMode?: ToolExecutionMode;
}

为什么 execute 失败直接 throw 而不是在 content 里放错误信息?

因为工具的结果里,content 是要发给 LLM 看的。如果工具执行失败了,我们想让 Agent 循环统一生成 一条 isError: true 的 ToolResultMessage,而不是让每个工具自己决定"错误长什么样"。这是"关注点分离"------工具只管干活,框架管报错。

第三步:AgentState------Agent 的"健康仪表盘"

Agent 在运行中需要暴露一些状态给外界看------就像车上的仪表盘:速度、油量、水温。

继续在 src/agent/types.ts 里写:

typescript 复制代码
// ═══════════════════════════════════════════════════════════════
// 3. AgentState------Agent 的运行时"仪表盘"
// ═══════════════════════════════════════════════════════════════

/**
 * 思考/推理深度级别。
 *
 * 部分模型(如 DeepSeek V4 Pro)支持不同深度的推理。
 * - "off":不想,直接答
 * - "minimal":稍微想一下
 * - "low"/"medium"/"high":逐级加深
 * - "xhigh":往死里想(不是所有模型都支持)
 */
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";

/**
 * Agent 的公开状态。
 *
 * 外部(UI、CLI、测试)通过读这个对象来了解 Agent 现在在干嘛。
 *
 * 类比:手术室外的显示屏------"手术中"、"已消毒"、"预计还需 20 分钟"
 */
export interface AgentState {
  /** 系统提示词------每次 LLM 请求都会带上 */
  systemPrompt: string;

  /** 当前使用的模型 */
  model: Model;

  /** 当前推理深度 */
  thinkingLevel: ThinkingLevel;

  /**
   * 工具列表------用 getter/setter 让实现层可以在赋值时做"浅拷贝"。
   * setter 接收一个数组,内部存一份拷贝------防止外部偷偷改数组元素。
   */
  set tools(tools: AgentTool<any>[]);
  get tools(): AgentTool<any>[];

  /**
   * 消息历史------同理,用 getter/setter 做浅拷贝。
   */
  set messages(messages: AgentMessage[]);
  get messages(): AgentMessage[];

  /**
   * 是否正在处理请求。
   * true 期间,不要调用 prompt(),否则会报错。
   * 即使 agent_end 发出后,也要等所有事件订阅者处理完毕,
   * isStreaming 才会变回 false。
   */
  readonly isStreaming: boolean;

  /**
   * 当前正在流式输出的消息(不完整)。
   * 流式过程中,这里能看到 AI 的"草稿"。
   * 流式结束后这个值变成 undefined。
   */
  readonly streamingMessage?: AgentMessage;

  /**
   * 正在执行中的工具调用 ID 集合。
   * 空集合 = 没在跑工具。
   * 可以用于 UI 显示"正在读文件..."
   */
  readonly pendingToolCalls: ReadonlySet<string>;

  /**
   * 最近一次出错/中断的错误消息。
   * 正常时是 undefined,出问题时这里会有描述。
   */
  readonly errorMessage?: string;
}

为什么 tools 和 messages 用 getter/setter 而不是普通属性?

typescript 复制代码
// 普通属性的问题:引用泄露
agentState.messages.push(newMessage);  // 绕过了 Agent 的控制,不讲武德

// getter/setter 的做法:内部存一份,别人改不了
// Agent 实现层:
private _messages: AgentMessage[] = [];
set messages(msgs: AgentMessage[]) {
  this._messages = [...msgs];  // 浅拷贝,外面删改原数组不影响内部
}
get messages(): AgentMessage[] {
  return this._messages;
}

这就是所谓的 defensive copy(防御性拷贝)------你不是在防朋友,你是在防你自己三个月后的凌晨三点改 bug 时。

第四步:AgentContext------每次呼叫 LLM 前打包的数据

AgentContext 是一个快照------Agent 发给 LLM 之前,把系统提示词 + 消息历史 + 可用工具打成一个包。

和 LLM 的 Context(第 2 章)很像,但消息类型用的是 AgentMessage[] 而不是 Message[]------因为 Agent 内部可能有自定义消息类型。

继续在 src/agent/types.ts 里写:

typescript 复制代码
// ═══════════════════════════════════════════════════════════════
// 4. AgentContext------发给 LLM 的"包裹"
// ═══════════════════════════════════════════════════════════════

/**
 * Agent 循环内部使用的上下文快照。
 *
 * 注意和 LLM Context 的区别:
 *   LLM Context.messages 是 Message[](纯 LLM 消息)
 *   AgentContext.messages 是 AgentMessage[](可能包含自定义消息)
 *
 * 转换由 AgentLoopConfig.convertToLlm 完成。
 */
export interface AgentContext {
  /** 系统提示词 */
  systemPrompt: string;
  /** 对话历史------AgentMessage 级别,还可能包含自定义消息 */
  messages: AgentMessage[];
  /** 本次可用的工具 */
  tools?: AgentTool<any>[];
}

第五步:AgentEvent------Agent 对外广播的"语言"

Agent 运行的时候,外界需要知道它在干嘛。靠什么?事件。

Pi 的 Agent 事件是一个联合类型------所有事件都有一个 type 字段做区分(这就是 TypeScript 的 discriminated union)。外界只订阅自己感兴趣的事件类型。

继续在 src/agent/types.ts 里写:

typescript 复制代码
// ═══════════════════════════════════════════════════════════════
// 5. AgentEvent------Agent 的"广播系统"
// ═══════════════════════════════════════════════════════════════

/**
 * Agent 运行过程中发出的所有事件类型。
 *
 * 时间线:(一次 prompt 调用)
 *
 *   ┌─ agent_start ─────────────────────────────────┐
 *   │                                                 │
 *   │  ┌─ turn_start ──────────────────────────┐     │
 *   │  │                                        │     │
 *   │  │  message_start (用户消息)               │     │
 *   │  │  message_end   (用户消息)               │     │
 *   │  │                                        │     │
 *   │  │  ── LLM 开始回复 ──                     │     │
 *   │  │  message_start (AI 回复)               │     │
 *   │  │  message_update ... (流式递增)          │     │
 *   │  │  message_end   (AI 回复)               │     │
 *   │  │                                        │     │
 *   │  │  ── 如果 AI 要调工具 ──                  │     │
 *   │  │  tool_execution_start (readFile)        │     │
 *   │  │  tool_execution_update ...              │     │
 *   │  │  tool_execution_end   (readFile)        │     │
 *   │  │  (tool 可能有多个)                       │     │
 *   │  │                                        │     │
 *   │  │  turn_end(本轮结束,含工具结果)        │     │
 *   │  └────────────────────────────────────────┘     │
 *   │                                                 │
 *   │  (如果还有下一轮,重复上面的 turn_start...)       │
 *   │                                                 │
 *   └─ agent_end(本次 prompt 全部结束) ─────────────┘
 */
export type AgentEvent =
  // ─── Agent 生命周期 ───
  /** Agent 开始处理一次 prompt */
  | { type: "agent_start" }
  /** Agent 结束------messages 是本次新增的消息 */
  | { type: "agent_end"; messages: AgentMessage[] }

  // ─── Turn 生命周期 ───
  /**
   * 一轮(turn)= AI 回复一次 + 执行其中所有工具调用
   */
  /** 新的一轮开始 */
  | { type: "turn_start" }
  /** 本轮结束------message 是 AI 的最终回复,toolResults 是工具执行的结果 */
  | { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }

  // ─── 消息生命周期 ───
  /** 一条消息开始(用户消息、AI 回复、工具结果都会触发) */
  | { type: "message_start"; message: AgentMessage }
  /**
   * 消息更新------只在 AI 流式回复时触发。
   * assistantMessageEvent 是原始的流式事件,包含增量文本。
   */
  | { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
  /** 一条消息完成 */
  | { type: "message_end"; message: AgentMessage }

  // ─── 工具执行生命周期 ───
  /**
   * 工具开始执行。
   * toolCallId: 唯一调用 ID
   * toolName: 工具名
   * args: 校验后的参数
   */
  | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: unknown }
  /**
   * 工具执行过程中的部分结果(比如读大文件时报告进度)。
   * partialResult 是 AgentToolResult 的快照。
   */
  | { type: "tool_execution_update"; toolCallId: string; toolName: string; args: unknown; partialResult: unknown }
  /**
   * 工具执行完成。
   * result: 最终结果
   * isError: 是否执行失败
   */
  | { type: "tool_execution_end"; toolCallId: string; toolName: string; result: ToolResultMessage; isError: boolean };

为什么事件要用 discriminated union 而不是 class 继承?

typescript 复制代码
// 如果用类继承来处理事件:
class AgentEvent { }
class TurnStart extends AgentEvent { }
// ... 你每次处理事件都要 instanceof 检查,而且打包体积更大

// discriminated union:所有事件扁平化,靠 type 字段区分
// TypeScript 的 switch 能自动收窄类型------写了 "agent_start" 之后,
// 下面的事件字段类型自动推断出来了
function handleEvent(event: AgentEvent) {
  switch (event.type) {
    case "agent_start":
      // 这里 event 的类型自动收窄为 { type: "agent_start" }
      console.log("Agent 启动了");
      break;
    case "agent_end":
      // 这里 event.messages 有智能提示
      console.log("Agent 结束了,新增了", event.messages.length, "条消息");
      break;
    // ...
  }
}

这是 TypeScript 的一个杀手级特性------几行代码写出的联合类型,比一套类继承体系好维护十倍。

第六步:AgentLoopConfig------Agent 循环的"设置面板"

最后是配置------Agent 循环需要知道怎么跑、用什么模型、消息怎么转换、在哪里介入。

继续在 src/agent/types.ts 里写:

typescript 复制代码
// ═══════════════════════════════════════════════════════════════
// 6. AgentLoopConfig------循环的"操作手册"
// ═══════════════════════════════════════════════════════════════

/**
 * Agent 循环的配置。包含 LLM 调用设置和各种生命周期钩子。
 *
 * 类比:洗衣机的操作面板------水温、转速、洗涤模式、定时。
 */
export interface AgentLoopConfig {
  // ─── LLM 调用选项 ───
  /** 温度(0-2,越高越随机) */
  temperature?: number;
  /** 最大输出 token 数 */
  maxTokens?: number;
  /** 取消信号------调用方可以 abort 中断请求 */
  signal?: AbortSignal;

  // ─── 模型和转换 ───
  /** 使用的模型 */
  model: Model;

  /**
   * 把 AgentMessage[] 转成 LLM 认识的 Message[]。
   *
   * 这是关键桥接函数------Agent 内部可能有自定义消息类型
   * (通知、状态、中断...),LLM 不认识这些。这个函数负责:
   *   1. 筛掉 LLM 不认的(比如 UI 通知)
   *   2. 把自定义消息转换成 LLM 标准消息
   *
   * 类比:内部文件(AgentMessage) → 对外邮件(Message)
   *
   * 合同:不能 throw,不能 reject。返回安全降级值。
   *
   * @example
   * convertToLlm: (messages) => messages.flatMap(m => {
   *   // 标准 LLM 消息直接放行
   *   if (m.role === "user" || m.role === "assistant" || m.role === "toolResult") {
   *     return [m];
   *   }
   *   // 自定义消息------这里做转换
   *   // 当前我们的 CustomAgentMessages 是空的,暂时不会有自定义类型
   *   // 但钩子留在这里,后面章节会用到
   *   return [];
   * })
   */
  convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;

  /** 工具执行模式------并行还是串行,默认 "parallel" */
  toolExecution?: ToolExecutionMode;

  // ─── 可选钩子:插入自定义逻辑 ───

  /**
   * 每一轮结束后调用。返回 true 则 Agent 直接退出,不再进入下一轮。
   * 用于"该停的时候主动喊停"------比如消息太多、token 快超了。
   */
  shouldStopAfterTurn?: (
    context: {
      message: AssistantMessage;
      toolResults: ToolResultMessage[];
      context: AgentContext;
    }
  ) => boolean | Promise<boolean>;

  /**
   * 动态获取 API Key------适用于短时间内密钥会过期的情况。
   * 如果不需要动态刷新,就可以忽略这个字段。
   */
  getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;

  /**
   * 工具执行前调用。返回 `{ block: true }` 可以阻止执行。
   * 用于安全审批------"这个 rm -rf / 我不同意!"
   */
  beforeToolCall?: (
    context: {
      assistantMessage: AssistantMessage;
      toolCall: { type: "toolCall"; id: string; name: string; arguments: Record<string, unknown> };
      args: unknown;
      context: AgentContext;
    },
    signal?: AbortSignal,
  ) => Promise<{ block?: boolean; reason?: string } | undefined>;

  /**
   * 工具执行后调用。可以覆盖工具结果。
   * 比如文件保存后自动格式化、做一次 lint 检查。
   */
  afterToolCall?: (
    context: {
      assistantMessage: AssistantMessage;
      toolCall: { type: "toolCall"; id: string; name: string; arguments: Record<string, unknown> };
      args: unknown;
      result: AgentToolResult;
      isError: boolean;
      context: AgentContext;
    },
    signal?: AbortSignal,
  ) => Promise<{
    content?: TextContent[];
    details?: unknown;
    isError?: boolean;
    terminate?: boolean;
  } | undefined>;
}

为什么 convertToLlm 是必填的?

因为 AgentContext 里的消息是 AgentMessage[],LLM API 要的是 Message[]。这个转换不是"可选的优化"------不转就发不出去。放在 AgentLoopConfig 里作为必填项,强迫使用者在创建配置时就想清楚"我的 AgentMessage 怎么变成 LLM Message"。

这也是故意设计得不自动化------因为转换逻辑里可能有业务判断(哪些消息要跳过、自定义消息怎么映射),这些判断不应该由底层库替你瞎猜。

完整的 src/agent/types.ts

把上面 6 步的代码拼起来,就是完整的文件。注意 import 区域要集中放在文件顶部:

typescript 复制代码
// ─── src/agent/types.ts ───
// Agent 层的类型定义------"Agent 的员工档案"
//
// 这层在"LLM 通信层"之上,定义 Agent 循环所需的所有类型。
// 本章只定义类型骨架,不包含任何实现代码。

import type {
  Message,
  AssistantMessage,
  AssistantMessageEvent,
  ToolResultMessage,
  TextContent,
  Model,
} from "../types.js";

import type { TSchema, Static } from "@sinclair/typebox";

// ═══════════════════════════════════════════════════════════════
// 1. CustomAgentMessages ------ 声明合并的"扩展槽"
// ═══════════════════════════════════════════════════════════════

export interface CustomAgentMessages {
  // 空接口,应用层通过 declaration merging 往里加自定义消息类型
}

export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];

// ═══════════════════════════════════════════════════════════════
// 2. AgentTool ------ 会"动手"的工具
// ═══════════════════════════════════════════════════════════════

export type ToolExecutionMode = "sequential" | "parallel";

export interface AgentToolResult<TDetails = unknown> {
  content: TextContent[];
  details: TDetails;
  terminate?: boolean;
}

export type AgentToolUpdateCallback<TDetails = any> = (
  partialResult: AgentToolResult<TDetails>
) => void;

export interface AgentTool<
  TParameters extends TSchema = TSchema,
  TDetails = unknown
> {
  name: string;
  description: string;
  parameters: TParameters;
  label: string;
  prepareArguments?: (args: unknown) => Static<TParameters>;
  execute: (
    toolCallId: string,
    params: Static<TParameters>,
    signal?: AbortSignal,
    onUpdate?: AgentToolUpdateCallback<TDetails>,
  ) => Promise<AgentToolResult<TDetails>>;
  executionMode?: ToolExecutionMode;
}

// ═══════════════════════════════════════════════════════════════
// 3. AgentState ------ 运行时"仪表盘"
// ═══════════════════════════════════════════════════════════════

export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";

export interface AgentState {
  systemPrompt: string;
  model: Model;
  thinkingLevel: ThinkingLevel;
  set tools(tools: AgentTool<any>[]);
  get tools(): AgentTool<any>[];
  set messages(messages: AgentMessage[]);
  get messages(): AgentMessage[];
  readonly isStreaming: boolean;
  readonly streamingMessage?: AgentMessage;
  readonly pendingToolCalls: ReadonlySet<string>;
  readonly errorMessage?: string;
}

// ═══════════════════════════════════════════════════════════════
// 4. AgentContext ------ 发给 LLM 的"包裹"
// ═══════════════════════════════════════════════════════════════

export interface AgentContext {
  systemPrompt: string;
  messages: AgentMessage[];
  tools?: AgentTool<any>[];
}

// ═══════════════════════════════════════════════════════════════
// 5. AgentEvent ------ Agent 的"广播语言"
// ═══════════════════════════════════════════════════════════════

export type AgentEvent =
  | { type: "agent_start" }
  | { type: "agent_end"; messages: AgentMessage[] }
  | { type: "turn_start" }
  | { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
  | { 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: unknown }
  | { type: "tool_execution_update"; toolCallId: string; toolName: string; args: unknown; partialResult: unknown }
  | { type: "tool_execution_end"; toolCallId: string; toolName: string; result: ToolResultMessage; isError: boolean };

// ═══════════════════════════════════════════════════════════════
// 6. AgentLoopConfig ------ 循环的"操作手册"
// ═══════════════════════════════════════════════════════════════

export interface AgentLoopConfig {
  temperature?: number;
  maxTokens?: number;
  signal?: AbortSignal;
  model: Model;
  convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
  toolExecution?: ToolExecutionMode;
  shouldStopAfterTurn?: (context: {
    message: AssistantMessage;
    toolResults: ToolResultMessage[];
    context: AgentContext;
  }) => boolean | Promise<boolean>;
  getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
  beforeToolCall?: (
    context: {
      assistantMessage: AssistantMessage;
      toolCall: { type: "toolCall"; id: string; name: string; arguments: Record<string, unknown> };
      args: unknown;
      context: AgentContext;
    },
    signal?: AbortSignal,
  ) => Promise<{ block?: boolean; reason?: string } | undefined>;
  afterToolCall?: (
    context: {
      assistantMessage: AssistantMessage;
      toolCall: { type: "toolCall"; id: string; name: string; arguments: Record<string, unknown> };
      args: unknown;
      result: AgentToolResult;
      isError: boolean;
      context: AgentContext;
    },
    signal?: AbortSignal,
  ) => Promise<{
    content?: TextContent[];
    details?: unknown;
    isError?: boolean;
    terminate?: boolean;
  } | undefined>;
}

就这 160 行------Agent 层的全部类型骨架。

为什么 Agent 的类型看起来"什么都没有实现"

你可能觉得:写了半天,全是 interfacetype,没一个能 new 或者 await 的东西。

这是刻意的。

Pi 的 Agent 层设计有一条核心原则:"类型先于实现"(types first)。在写任何一行循环代码之前,先把数据怎么流、事件怎么发想清楚------后面写实现时你会发现,每个函数签名都已经"预言"好了。

就像一个建筑工地在打地基------没盖楼之前,地基看起来就是一片水泥。但它决定了楼能盖多高、柱子放哪里。

🔧 运行验证

类型本身不"运行",但我们可以写一个简单的测试脚本来验证:

  1. src/agent/types.ts 的类型能被正确导入
  2. AgentEvent 的 type 字段能收窄
  3. 声明合并能正常工作

更新 src/index.ts,在文件末尾加上这段测试:

typescript 复制代码
// ═══════════════════════════════════════════════════════════════
// 第 5 章测试:验证 Agent 类型系统
// ═══════════════════════════════════════════════════════════════
import type {
  AgentMessage,
  AgentEvent,
  AgentTool,
  AgentState,
  AgentContext,
  AgentLoopConfig,
  CustomAgentMessages,
} from "./agent/types.js";

// ─── 测试 1:AgentEvent 的类型收窄 ───
// 这段代码不会被真正"调用"(运行时代码被注释了),
// 但只要 TypeScript 编译通过,就说明 discriminated union 设计正确。
function 测试事件类型(event: AgentEvent): string {
  switch (event.type) {
    case "agent_start":
      return "Agent 启动了";
    case "agent_end":
      return `Agent 结束了,新增 ${event.messages.length} 条消息`;
    case "turn_start":
      return "新的一轮";
    case "turn_end":
      return `本轮结束,${event.toolResults.length} 个工具结果`;
    case "message_start":
      return `消息开始:${event.message.role ?? "未知角色"}`;
    case "message_update":
      return `消息更新中...`;
    case "message_end":
      return "消息结束";
    case "tool_execution_start":
      return `工具 ${event.toolName} 开始执行`;
    case "tool_execution_update":
      return `工具 ${event.toolName} 执行中...`;
    case "tool_execution_end":
      return event.isError
        ? `工具 ${event.toolName} 执行失败`
        : `工具 ${event.toolName} 执行成功`;
    default:
      // 这行如果编译报错,说明上面 switch 漏了事件类型------
      // TypeScript 的 exhaustiveness check 在保护你
      const _exhaustive: never = event;
      return "未知事件";
  }
}

// 调用一下让 TypeScript 验证(实际运行时不跑,因为不会真的触发事件)
console.log("事件处理器类型检查通过 ✅");

// ─── 测试 2:声明合并扩展(概念验证)───
// 模拟应用层在另一个文件里通过 declaration merging 添加自定义消息
// 这段代码验证 CustomAgentMessages 的合并机制

// 为了测试,我们在本文件里做一次"声明合并"
declare module "./agent/types.js" {
  interface CustomAgentMessages {
    /** 测试用的自定义消息------告诉 Agent "该休息了" */
    coffeeBreak: { type: "coffeeBreak"; reason: string; timestamp: number };
  }
}

// 现在 AgentMessage 应该包含 coffeeBreak 类型
// 如果我们创建一个 coffeeBreak 消息并赋值给 AgentMessage 类型变量...
//
// ⚠️ 注意:下面这段代码是概念演示,不是为了跑通
// 声明合并需要匹配的模块路径和正确的接口定义------当前章节还没实现完整的
// CustomAgentMessages 机制,直接复制粘贴会报 type 错误
// 这个示例的价值在于理解"声明合并"这个设计模式本身
const 咖啡休息: AgentMessage = {
  type: "coffeeBreak",
  reason: "已经醒了 14 个小时了",
  timestamp: Date.now(),
};

// ...TypeScript 不报错,说明声明合并生效了
console.log("声明合并验证通过 ✅");

// ─── 测试 3:打印事件类型列表 ───
// 把 AgentEvent 的所有 type 值列出来------这只是为了让你看清楚
console.log("\n📋 Agent 支持的事件类型:");
console.log("  agent_start         --- Agent 启动");
console.log("  agent_end           --- Agent 结束");
console.log("  turn_start          --- 一轮对话开始");
console.log("  turn_end            --- 一轮对话结束");
console.log("  message_start       --- 消息开始");
console.log("  message_update      --- 消息流式更新");
console.log("  message_end         --- 消息完成");
console.log("  tool_execution_start --- 工具开始执行");
console.log("  tool_execution_update --- 工具执行进度");
console.log("  tool_execution_end   --- 工具执行完成");

运行验证:

bash 复制代码
npx tsx src/index.ts

应该看到:

复制代码
...(前面的 Provider 测试输出)

事件处理器类型检查通过 ✅
声明合并验证通过 ✅

📋 Agent 支持的事件类型:
  agent_start         --- Agent 启动
  agent_end           --- Agent 结束
  turn_start          --- 一轮对话开始
  turn_end            --- 一轮对话结束
  message_start       --- 消息开始
  message_update      --- 消息流式更新
  message_end         --- 消息完成
  tool_execution_start --- 工具开始执行
  tool_execution_update --- 工具执行进度
  tool_execution_end   --- 工具执行完成

再验证 TypeScript 编译无误(这是最重要的):

bash 复制代码
npx tsc --noEmit

如果没有任何输出------恭喜,类型检查通过。

❓ 常见问题

declaration merging 是什么鬼,能不能不用它

声明合并(declaration merging)是 TypeScript 的特性:同名 interface 会自动合并属性。你可以把它想象成"累加器"------每次声明都往上加属性。

typescript 复制代码
// 文件 A
interface Box { width: number }

// 文件 B(另一个文件!)
interface Box { height: number }

// TypeScript 眼里:Box = { width: number; height: number }

在 Pi 里,CustomAgentMessages 是一个"空壳接口"。应用层通过 declare module 向它追加自定义消息类型------不需要改动库源码。

能不能不用它? 能。你可以直接写 type AgentMessage = Message | MyCustomType1 | MyCustomType2。但这样库的使用者必须改库源码才能加新类型。声明合并让库保持通用。

AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages] 为啥这么绕

因为 CustomAgentMessages 是一个接口,里面的属性名和类型都可能是未知的。

typescript 复制代码
// 应用层加了这些东西:
interface CustomAgentMessages {
  notification: { type: "notification"; text: string };
  artifact: { type: "artifact"; data: string };
}

// keyof CustomAgentMessages = "notification" | "artifact"
// CustomAgentMessages["notification"] = { type: "notification"; text: string }
// CustomAgentMessages["artifact"] = { type: "artifact"; data: string }
// CustomAgentMessages[keyof ...] = { type: "notification"; text: string } | { type: "artifact"; data: string }

// 最终:
// AgentMessage = Message | { type: "notification"; text: string } | { type: "artifact"; data: string }

为什么 AgentEvent 用 | 连了 10 种类型,而不是用一个 interface EventBase { type: string } 继承

因为 discriminated union 的自动类型收窄。如果用继承:

typescript 复制代码
// 这种写法------TypeScript 在 switch 里不会自动收窄
interface BaseEvent { type: string }
class AgentStart extends BaseEvent { type = "agent_start" }
// switch (event.type): TypeScript 不知道 AgentStart 有哪些专属字段

用联合类型:

typescript 复制代码
// switch (event.type)------每个 case 里 event 的类型自动收窄
case "tool_execution_end":
  // 这里 event.result 有智能提示,因为 TS 知道只有 tool_execution_end
  // 才有 result 字段

这是 TypeScript 最强大的类型体操之一------几行代码换来完整的类型安全和 IDE 补全。

AgentState 的属性为啥大部分只读

因为 AgentState 是给外人"看"的,不是给外人"改"的。改状态由 Agent 内部逻辑(第 7 章)通过类方法来操作------否则外面随便一改,Agent 里的状态就脏了,多线程(或者多异步流程)下就是经典的竞态 Bug。

export type ThinkingLevel = "off" | "minimal" | ... 这玩意儿有什么用

约束值范围。如果你写 thinkingLevel: "veryhigh",TypeScript 会直接报错------因为 "veryhigh" 不在联合类型里。这比运行时 if (level !== "off" && level !== "minimal" ...) 强一万倍。

📚 这一章你学到了

概念 一句话理解
AgentMessage LLM 消息 + 自定义消息的联合类型------Agent 内部的"综合档案"
CustomAgentMessages 类型扩展接口------靠声明合并让应用层在不动库源码的情况下加自定义消息
convertToLlm 翻译官------把 AgentMessage\[\](内部文件)转成 Message\[\](对外邮件)
AgentTool 会动手的工具------Tool 说明书 + execute 函数
AgentToolResult 工具执行完交的"答卷"------给 LLM 看的 content + 给 UI 看的 details
AgentToolUpdateCallback 工具执行中的"直播推流"------实时推送半成品给 UI
AgentState Agent 的仪表盘------系统提示词、模型、消息历史、流式状态、待执行工具
AgentContext 每次 LLM 调用的"输入包裹"------系统提示词 + 消息列表 + 可用工具
AgentEvent 10 种事件的 discriminated union------Agent 一生中会发出的所有信号
AgentLoopConfig 循环的操作手册------模型、转换器、工具模式、各种钩子
ThinkingLevel 推理深度------从"不想"到"往死里想"
ToolExecutionMode 工具执行模式------排队(sequential)还是同时跑(parallel)
声明合并 TypeScript 的"累加器"------同名 interface 自动合并属性
discriminated union 一个 `

🔗 对应的 Pi 源码

本章的 src/agent/types.ts 是 Pi 源码 packages/agent/src/types.ts 的等比例缩小版。具体对照:

本章代码 Pi 源码 说明
CustomAgentMessages packages/agent/src/types.ts:305-307 声明合并扩展接口
AgentMessage packages/agent/src/types.ts:314 Agent 内部消息类型
AgentTool packages/agent/src/types.ts:370-393 可执行工具定义
AgentToolResult packages/agent/src/types.ts:350-360 工具执行结果
AgentToolUpdateCallback packages/agent/src/types.ts:368 工具进度回调
AgentState packages/agent/src/types.ts:322-347 公开状态
AgentContext packages/agent/src/types.ts:397-404 循环上下文快照
AgentEvent packages/agent/src/types.ts:413-428 事件联合类型
AgentLoopConfig packages/agent/src/types.ts:140-282 循环配置(本章简化了部分钩子)
ThinkingLevel packages/agent/src/types.ts:289 推理级别
ToolExecutionMode packages/agent/src/types.ts:40 执行模式

Pi 的 AgentLoopConfig 还包含了 transformContextgetSteeringMessagesgetFollowUpMessages 等更高级的钩子------这些我们在第 8 章(消息队列)和第 15 章(上下文压缩)会逐步加上。


下一步

现在 Agent 的"骨骼"搭好了。下一章是最激动人心的部分------写循环

Agent 循环长这样:

复制代码
while (没有要停的信号) {
  发消息给 LLM → 读它的回复 → 发现它要调工具?
    → 执行工具 → 把结果告诉 LLM → 再来一轮
    → 不调工具了?→ 结束
}

听起来简单?等你亲手写出这个 while 循环,你会第一次真正理解"AI Agent 是怎么思考的"。

相关推荐
环境栈笔记1 小时前
多账号浏览器选型检查清单:Profile、权限、Session 和任务日志怎么评估
前端·人工智能·后端·自动化
Day(AKA Elin)1 小时前
【Day】MTP(Multi Token Prediction)技术学习
python·深度学习·学习·llama
神奇小汤圆1 小时前
Agent Runtime 状态机:让 AI 推理每一步都可暂停、可回放、可审计
人工智能
爱摸鱼的打工仔1 小时前
【从 ima-skills 到 WeKnora:Agent Skill 能不能直接接企业级 RAG?一次把原理讲清楚】
人工智能
老云讲算力市场1 小时前
深圳奇点点信息科技有限公司连续中标多项AI算力项目
人工智能·科技
程序员天天困1 小时前
GPT-5.6 来了!三款模型、Ultra 模式、编程能力全面解析
人工智能·chatgpt
AIGS0011 小时前
向量空间JBoltAI:本体语义如何跨越企业AI的语义鸿沟
java·大数据·人工智能·人工智能ai大模型应用
枫零NET2 小时前
跟着OpenCode学习Pi Coding Agent-06-Agent循环
学习
Lucky_luckyZzz2 小时前
明略科技·灵听工牌深度测评:四位一体闭环如何落地门店销售管理?
人工智能