这个文件是什么:上次读的 agent-loop.ts 是"发动机"——它只管跑循环,自己不记东西。这个文件是"整车"——它记住对话、管理启动和停止、运行过程中往外通报进度。它内部调用上次读过的引擎来干活,但在调用前后做大量管理工作。
What this file is: The agent-loop.ts we read last time is the "engine" — it only runs the loop and remembers nothing itself. This file is the "full vehicle" — it remembers conversations, manages start and stop, and broadcasts progress during execution. Internally it calls the engine we already read, but performs extensive management work before and after each call.
为什么要分两层:引擎是纯逻辑,不绑定任何产品形态。Agent 类是产品化的壳——不同产品可以用同一个引擎,但用不同的 Agent 配置。
Why two layers: The engine is pure logic, not tied to any product form. The Agent class is the productized shell — different products can use the same engine with different Agent configurations.
知识全景地图里的 ① 核心循环,展开看其实分两层。外层是 agent.ts(管理层,本文),内层是 agent-loop.ts(引擎,上次已读)。下图标注了本文 9 个区块各自负责的角色,以及它们连接的外部模块:
The ① Core Loop in our knowledge map actually splits into two layers when expanded. The outer layer is agent.ts (management layer, this article), the inner layer is agent-loop.ts (engine, previously read). The diagram below labels the roles of all 9 blocks in this article and the external modules they connect to:
注意箭头含义:图中 ──→ ② 上下文工程、──→ ③ 工具系统 表示"连接到 / 持有引用",不是"包含"。上下文压缩函数、工具定义等实际代码在其他文件里。agent.ts 像项目经理——手上有通讯录(知道谁负责什么),自己不干活,把引用打包交给引擎去调用。
Note on arrows: The arrows like ──→ ② Context Eng and ──→ ③ Tool System mean "connects to / holds a reference", not "contains". The actual code for context compression, tool definitions, etc. lives in other files. agent.ts is like a project manager — it has the contact list (knows who does what) but doesn't do the work itself; it packages references and hands them to the engine.
前面章节提到的 JSONL 文件(Session Log)就在右下角的 ⑨ 持久化——它是一个"订阅者",挂在 agent.ts 上。agent.ts 每收到引擎的通知,先更新自己的内存记录,然后转发给所有订阅者——其中一个订阅者负责把记录写成 JSONL 文件存到磁盘。
The JSONL file (Session Log) mentioned in earlier chapters sits at ⑨ Persistence in the bottom right — it is a "subscriber" attached to agent.ts. Each time agent.ts receives a notification from the engine, it first updates its own in-memory records, then forwards to all subscribers — one of which writes records as a JSONL file to disk.
27function defaultConvertToLlm(messages: AgentMessage[]): Message[] { 28 return messages.filter( 29 (message) => message.role === "user" 30 || message.role === "assistant" 31 || message.role === "toolResult", 32 ); 33} 34 35const EMPTY_USAGE = { input: 0, output: 0, ... }; 42const DEFAULT_MODEL = { id: "unknown", ... };
Agent 内部记录了很多种消息,但发给 AI 模型时只需要三种:用户说的(user)、AI 说的(assistant)、工具结果(toolResult)。
The Agent internally records many types of messages, but only three kinds need to be sent to the AI model: what the user said (user), what the AI said (assistant), and tool results (toolResult).
.filter() 就是过一遍列表,只留符合条件的——这里就是只留这三种角色的消息,其他的丢掉。
.filter() iterates through the list and keeps only items matching the condition — here it keeps only these three role types and discards the rest.
Pi-mono 的转换极其简单,因为它内部格式本来就和 AI 的格式很像。Claude Code 的就复杂得多。
Pi-mono's conversion is extremely simple because its internal format already closely resembles the AI's format. Claude Code's conversion is far more complex.
灰色两个常量:出错时用的空白填充,不影响理解。
The two grayed-out constants: Blank placeholders used when errors occur — not important for understanding.
55type QueueMode = "all" | "one-at-a-time"; 57type MutableAgentState = Omit<AgentState, 58 "isStreaming" | "streamingMessage" 59 | "pendingToolCalls" | "errorMessage" 60> & { ... }; 66 67function createMutableAgentState(initialState?) { 70 let tools = initialState?.tools?.slice() ?? []; 71 let messages = initialState?.messages?.slice() ?? []; 73 return { 74 systemPrompt: initialState?.systemPrompt ?? "", 75 model: initialState?.model ?? DEFAULT_MODEL, 77 get tools() { return tools; }, 80 set tools(next) { tools = next.slice(); }, 83 get messages() { return messages; }, 86 set messages(next) { messages = next.slice(); }, 89 isStreaming: false, 91 pendingToolCalls: new Set(), 93 }; 94}
Agent 运行时需要记住的东西:对话记录(messages)、可用工具列表(tools)、是否正在运行(isStreaming)、出错信息等。
Things the Agent needs to remember at runtime: message history (messages), available tool list (tools), whether it's currently running (isStreaming), error info, etc.
设计者做了一层防护:对外展示的版本是"只能看不能改"的,内部用的版本可以改。
The designers added a layer of protection: the externally exposed version is "read-only", while the internal version is mutable.
绿色行是防护的具体做法:当外部想替换整个工具列表时,代码会先把新列表复印一份(.slice() 就是复印)再存,这样外部后续怎么改自己手上的列表,都不影响 Agent 内部的存档。
The green lines show the protection mechanism: when external code tries to replace the entire tool list, the code first makes a copy (.slice() means copy) before storing it. This way, whatever the external code does to its own list afterwards won't affect the Agent's internal copy.
这个区块的实现细节不重要,只需要知道:Agent 的内部状态有保护,外部代码不能随意篡改。
The implementation details of this block aren't important — just know that the Agent's internal state is protected, and external code cannot tamper with it.
93export interface AgentOptions { 94 initialState?: ...; 96 convertToLlm?: ...; // 消息筛选message filter 98 transformContext?: ...; // 上下文压缩context compression 100 streamFn?: StreamFn; // 调 AI 的函数function to call AI 102 getApiKey?: ...; 104 onPayload?: ...; 105 onResponse?: ...; 107 beforeToolCall?: ...; // 工具执行前拦截pre-tool-call hook 110 afterToolCall?: ...; // 工具执行后处理post-tool-call hook 114 steeringMode?: QueueMode; // 方向盘队列模式steering queue mode 115 followUpMode?: QueueMode; // 追加任务队列模式follow-up queue mode 117 sessionId?: string; 118 thinkingBudgets?: ...; 119 transport?: Transport; 120 maxRetryDelayMs?: number; 121 toolExecution?: ToolExecutionMode; 122}
这是一个 interface——可以理解为一张表格模板,规定了创建 Agent 时可以填哪些配置项。所有项都是可选的(带 ?),不填就用默认值。
This is an interface — think of it as a form template that specifies what configuration options are available when creating an Agent. All fields are optional (marked with ?); if left blank, defaults are used.
绿色行是上次读引擎时见过的三个消息处理函数。
Green lines are the three message-processing functions we saw when reading the engine last time.
黄色行是上次在引擎的 executeToolCalls 里见过的两个拦截点——Claude Code 的 "Allow?" 弹窗就是通过 beforeToolCall 实现的。
Yellow lines are the two intercept points we saw in the engine's executeToolCalls — Claude Code's "Allow?" dialog is implemented via beforeToolCall.
绿色 steeringMode / followUpMode:控制下面区块 4 要讲的两个队列的行为。
Green steeringMode / followUpMode: Control the behavior of the two queues covered in Block 4 below.
createLoopConfig() 里会把它翻译成引擎需要的格式。This form is the "user-friendly version" of the engine config (AgentLoopConfig). The Agent class translates it into the engine's format in Block 8's createLoopConfig().113class PendingMessageQueue { 114 private messages: AgentMessage[] = []; 115 116 constructor(public mode: QueueMode) {} 117 118 enqueue(message: AgentMessage): void { 119 this.messages.push(message); 120 } 121 122 hasItems(): boolean { 123 return this.messages.length > 0; 124 } 125 126 drain(): AgentMessage[] { 127 if (this.mode === "all") { 128 const drained = this.messages.slice(); 129 this.messages = []; 130 return drained; 131 } 132 // one-at-a-time: 每次只取第一条take only the first message each time 133 const first = this.messages[0]; 134 if (!first) return []; 135 this.messages = this.messages.slice(1); 136 return [first]; 137 } 138 139 clear(): void { 140 this.messages = []; 141 } 142}
Agent 运行时,外部可以往这个桶里塞消息。引擎在合适的时机来取。
While the Agent is running, external code can push messages into this bucket. The engine fetches them at the right moment.
四个操作:
Four operations:
enqueue(排队)— 往桶里放一条消息hasItems — 桶里还有东西吗?drain(倒出来)— 核心方法,见下clear — 把桶清空enqueue — push one message into the buckethasItems — is anything in the bucket?drain — the core method, see belowclear — empty the bucket"all" 模式:一次把桶里所有消息全部倒出来,桶清空。适合批量场景——比如用户快速连发了 3 条修改指令,全部一次性交给 AI。
"all" mode: Pour out every message at once, emptying the bucket. Good for batch scenarios — e.g. the user fires off 3 edit instructions in quick succession; all are handed to the AI at once.
"one-at-a-time" 模式(默认值):每次只从桶里取第一条,剩下的留着下次取。这样每条消息都会触发一轮完整的 AI 回复,确保每条都被认真处理。
"one-at-a-time" mode (the default): Take only the first message each time; the rest stay for next time. This way, each message triggers a full AI response cycle, ensuring each one is properly handled.
146type ActiveRun = { 147 promise: Promise<void>; 148 resolve: () => void; 149 abortController: AbortController; 150}; 151 158export class Agent { 159 private _state: MutableAgentState; 160 private readonly listeners = new Set<...>(); 161 private readonly steeringQueue: PendingMessageQueue; 162 private readonly followUpQueue: PendingMessageQueue; 163 164 public convertToLlm: ...; 165 public transformContext?: ...; 166 public streamFn: StreamFn; 170 public beforeToolCall?: ...; 171 public afterToolCall?: ...; 172 private activeRun?: ActiveRun; 177 public toolExecution: ToolExecutionMode; 178 190 constructor(options: AgentOptions = {}) { 191 this._state = createMutableAgentState(options.initialState); 192 this.convertToLlm = options.convertToLlm 193 ?? defaultConvertToLlm; 195 this.streamFn = options.streamFn ?? streamSimple; 200 this.steeringQueue = new PendingMessageQueue( 201 options.steeringMode ?? "one-at-a-time"); 202 this.followUpQueue = new PendingMessageQueue( 203 options.followUpMode ?? "one-at-a-time"); 206 this.toolExecution = options.toolExecution 207 ?? "parallel"; 208 }
class(类)就是一张设计图纸。这里定义了"一个 Agent 由哪些零件组成"。
A class is a blueprint. This defines "what parts make up an Agent".
绿色行 159-162 — 四个核心零件:
Green lines 159-162 — four core parts:
_state — 记事本,记住所有对话内容和运行状态listeners — 订阅者名单,谁在关注 Agent 的进度steeringQueue / followUpQueue — 上面读过的两个消息排队桶_state — the notebook, remembering all conversation content and runtime statuslisteners — the subscriber roster: who's watching the Agent's progresssteeringQueue / followUpQueue — the two message queues we read above标了 private 的是内部零件,外部摸不到;标 public 的外部可以直接使用或替换。
Parts marked private are internal — outsiders can't touch them; those marked public can be accessed or replaced by external code.
黄色行 172 — activeRun:记录"当前正在跑的任务"。同一时间只能有一个任务在跑,有 activeRun 时再调 prompt() 会直接拒绝。它里面有三样东西:一个"等待完成"的凭证(promise)、一个"通知完成"的开关(resolve)、一个取消按钮(abortController)。
Yellow line 172 — activeRun: Tracks the "currently running task". Only one task can run at a time — calling prompt() when activeRun exists is immediately rejected. It contains three things: a "wait for completion" token (promise), a "notify completion" switch (resolve), and a cancel button (abortController).
constructor 是"出厂组装步骤"——把配置清单(区块 3 的 AgentOptions)里的每一项取出来,安装到对应的零件上。?? 的意思是"如果用户没提供,就用默认值"。
The constructor is the "factory assembly step" — it takes each item from the configuration checklist (Block 3's AgentOptions) and installs it onto the corresponding part. ?? means "if the user didn't provide one, use the default".
219subscribe(listener): () => void { 222 this.listeners.add(listener); 223 return () => this.listeners.delete(listener); 224} 225 229get state(): AgentState { 230 return this._state; 231} 232 252steer(message): void { 253 this.steeringQueue.enqueue(message); 254} 255 258followUp(message): void { 259 this.followUpQueue.enqueue(message); 260} 261 282hasQueuedMessages(): boolean { 283 return this.steeringQueue.hasItems() 284 || this.followUpQueue.hasItems(); 285} 286 292abort(): void { 293 this.activeRun?.abortController.abort(); 294} 295 300waitForIdle(): Promise<void> { 301 return this.activeRun?.promise 302 ?? Promise.resolve(); 303} 304 305reset(): void { 306 this._state.messages = []; 307 this._state.isStreaming = false; 311 this.clearFollowUpQueue(); 312 this.clearSteeringQueue(); 313}
外部代码想知道 Agent 的进展(AI 开始说话了、工具在执行…),就调 subscribe(),传入一个"收到通知时要执行的函数"。这种"你把一个函数交给别人,别人在合适的时机来调用它"的模式叫回调(callback)。
When external code wants to know the Agent's progress (AI started speaking, a tool is executing...), it calls subscribe(), passing in a "function to run when a notification arrives". This pattern of "you hand a function to someone else, and they call it at the right moment" is called a callback.
subscribe 返回的也是一个函数——调用它就取消订阅,不再收通知。
subscribe returns a function too — calling it unsubscribes, so you stop receiving notifications.
往上面读过的两个排队桶里塞消息。引擎在合适的时机来取:每轮结束后取 steering,准备收工时取 followUp。
Push messages into the two queues we read above. The engine fetches them at the right moments: steering messages after each turn ends, follow-up messages when the agent is about to wrap up.
abort():按下取消按钮,发出"请停下"的信号。引擎收到后在下一个安全时机停下来。就像你在 Claude Code 里按 Escape。
abort(): Press the cancel button, sending a "please stop" signal. The engine stops at the next safe point. Like pressing Escape in Claude Code.
waitForIdle():返回 activeRun 里的那个"等待完成凭证"。外部可以拿着它等——Agent 跑完后凭证自动兑现。如果 Agent 没在忙,立即通过。
waitForIdle(): Returns the "wait-for-completion" token from activeRun. External code can hold onto it and wait — it auto-resolves when the Agent finishes. If the Agent isn't busy, it resolves immediately.
清空一切:对话记录、运行状态、两个队列。相当于"开新对话"。
Clear everything: message history, runtime state, both queues. Equivalent to "start a new conversation".
313async prompt(message: AgentMessage | AgentMessage[]): Promise<void>; 314async prompt(input: string, images?: ImageContent[]): Promise<void>; 315async prompt(input, images?): Promise<void> { 317 if (this.activeRun) { 318 throw new Error( 319 "Agent is already processing. 320 Use steer() or followUp()"); 322 } 323 const messages = this.normalizePromptInput(input, images); 324 await this.runPromptMessages(messages); 325} 326 327async continue(): Promise<void> { 328 if (this.activeRun) { throw new Error(...); } 330 const lastMessage = this._state.messages.at(-1); 331 if (!lastMessage) throw new Error("No messages"); 332 333 if (lastMessage.role === "assistant") { 335 const queued = this.steeringQueue.drain(); 336 if (queued.length > 0) { 337 await this.runPromptMessages(queued, ...); 339 return; 340 } 341 const followUps = this.followUpQueue.drain(); 342 if (followUps.length > 0) { 343 await this.runPromptMessages(followUps); 345 return; 346 } 347 throw new Error("Cannot continue: nothing to do"); 348 } 350 await this.runContinuation(); 351}
灰色前两行是"重载"——同一个函数名可以接受不同类型的输入(一句话、一条消息、一批消息),内部自动识别格式。
The two grayed-out lines are "overloads" — the same function name accepts different input types (a string, a message, or a batch of messages), and internally auto-detects the format.
黄色行 317-322 是"门卫":如果 Agent 已经在忙(activeRun 存在),直接拒绝并报错——throw new Error 就是"拉响警报,停下来"。报错信息还引导你改用 steer/followUp。
Yellow lines 317-322 are the "gatekeeper": If the Agent is already busy (activeRun exists), it immediately rejects with an error — throw new Error means "sound the alarm, stop". The error message even guides you to use steer()/followUp() instead.
黄色高亮是核心逻辑,分两种情况:
The yellow highlight is the core logic, with two cases:
情况 A:上次最后说话的是 AI(assistant)
Case A: The last speaker was the AI (assistant)
AI 说完了想让它继续,但继续什么?代码去两个桶里找:先倒方向盘桶 → 有就用;没有就倒追加任务桶 → 有就用;都空 → 报错。
The AI finished speaking and you want it to continue — but continue with what? The code checks the two buckets: drain the steering bucket first — if items found, use them; if empty, drain the follow-up bucket — if items found, use them; both empty — throw an error.
情况 B:上次最后说话的是用户或工具
Case B: The last speaker was the user or a tool
说明 AI 还没来得及回就被中断了,直接从断点恢复。
This means the AI was interrupted before it could respond — resume directly from the checkpoint.
prompt() 最终调引擎的 runAgentLoop()(开新循环),continue() 调 runAgentLoopContinue()(接着跑)。这两个函数上次精读过。prompt() ultimately calls the engine's runAgentLoop() (start a new loop), while continue() calls runAgentLoopContinue() (resume the loop). We read both functions in detail last time.374private async runPromptMessages(messages, options) { 377 await this.runWithLifecycle(async (signal) => { 378 await runAgentLoop( 380 this.createContextSnapshot(), 381 this.createLoopConfig(options), 382 (event) => this.processEvents(event), 385 ); 387} 388 402private createContextSnapshot() { 405 messages: this._state.messages.slice(), 406 tools: this._state.tools.slice(), 408} 409 410private createLoopConfig(options) { 412 return { 417 beforeToolCall: this.beforeToolCall, 419 convertToLlm: this.convertToLlm, 420 transformContext: this.transformContext, 422 getSteeringMessages: async () => { 427 return this.steeringQueue.drain(); 428 }, 429 getFollowUpMessages: async () => 430 this.followUpQueue.drain(), 431 }; 432} 433 438private async runWithLifecycle(executor) { 441 if (this.activeRun) throw new Error(...); 443 const abortController = new AbortController(); 445 const promise = new Promise((resolve) => ...); 447 this.activeRun = { promise, resolve, abortController }; 450 this._state.isStreaming = true; 451 454 try { 455 await executor(abortController.signal); 456 } catch (error) { 457 await this.handleRunFailure(error, ...); 458 } finally { 459 this.finishRun(); 460 } 461} 462 463private async handleRunFailure(error, aborted) { 464 // 构造一条带错误信息的 assistant 消息build an assistant message with the error info 465 // 追加到对话记录里append it to the conversation history 466 // 发 agent_end 通知订阅者emit agent_end to notify subscribers 467} 468 480private finishRun(): void { 481 this._state.isStreaming = false; 484 this.activeRun?.resolve(); 485 this.activeRun = undefined; 486}
绿色行:传给引擎的是对话记录和工具清单的复印件。引擎拿着复印件干活,怎么改都不影响 Agent 的"正本"。
Green lines: What gets passed to the engine is a copy of the message history and tool list. The engine works with the copy — any modifications won't affect the Agent's "master copy".
黄色行:把 Agent 的配置和函数打包成引擎认识的格式。
Yellow line: Package the Agent's config and functions into the format the engine expects.
绿色行 422-430 是关键——引擎不直接摸 Agent 的队列,而是通过这两个回调来取消息。引擎每轮结束后调 getSteeringMessages 问"有方向调整吗?",准备收工时调 getFollowUpMessages 问"有追加任务吗?"
Green lines 422-430 are the key — the engine doesn't directly touch the Agent's queues. Instead, it fetches messages through these two callbacks. After each turn, the engine calls getSteeringMessages asking "any course corrections?", and when wrapping up, calls getFollowUpMessages asking "any follow-up tasks?"
所有 prompt 和 continue 都走这套固定流程,绿色行 454-460 是骨架:
Every prompt and continue goes through this fixed flow. Green lines 454-460 are the skeleton:
try:尝试把活儿交给引擎跑catch:如果出错了,记录错误到对话历史,通知订阅者finally:不管成败都要做的收工清理——标记"不忙了"、通知等待的人、清掉当前任务try: Attempt to hand the work to the enginecatch: If an error occurs, record it to conversation history and notify subscribersfinally: Cleanup that runs regardless of success or failure — mark "no longer busy", notify waiters, clear the current task灰色的 handleRunFailure 和 finishRun 是细节——出错了也要往对话记录里追加一条(这样翻记录能看到"这里出过错"),finishRun 做收工清理。
The grayed-out handleRunFailure and finishRun are details — even on error, a record is appended to conversation history (so you can see "an error happened here" when reviewing), and finishRun handles the post-run cleanup.
495private async processEvents(event) { 496 switch (event.type) { 497 498 case "message_start": 499 this._state.streamingMessage = event.message; 500 break; 501 502 case "message_update": 503 this._state.streamingMessage = event.message; 504 break; 505 506 case "message_end": 507 this._state.streamingMessage = undefined; 508 this._state.messages.push(event.message); 509 break; 510 511 case "tool_execution_start": { 512 const pending = new Set(this._state.pendingToolCalls); 513 pending.add(event.toolCallId); 514 this._state.pendingToolCalls = pending; 515 break; 516 } 517 518 case "tool_execution_end": { 519 const pending = new Set(this._state.pendingToolCalls); 520 pending.delete(event.toolCallId); 521 this._state.pendingToolCalls = pending; 522 break; 523 } 524 525 case "turn_end": 526 if (event.message.role === "assistant" 527 && event.message.errorMessage) { 528 this._state.errorMessage = event.message.errorMessage; 529 } 530 break; 531 532 case "agent_end": 533 this._state.streamingMessage = undefined; 534 break; 535 } 536 539 for (const listener of this.listeners) { 540 await listener(event, signal); 541 } 542}
第一步:更新自己的记事本(行 496-535)
Step 1: Update its own notebook (lines 496-535)
引擎跑循环时,每到一个节点就发一条通知过来。这段代码根据通知类型做不同处理(switch 就是"看是哪种情况,走对应分支"):
As the engine runs its loop, it sends a notification at each milestone. This code handles each notification type differently (switch means "check which case it is and go to the matching branch"):
第二步:转发给所有订阅者(绿色行 539-541)
Step 2: Forward to all subscribers (green lines 539-541)
遍历订阅者名单,把通知挨个转发。await 意味着每个订阅者处理完了才通知下一个——保证顺序,但也意味着一个处理很慢的订阅者会拖慢整个 Agent。
Iterate through the subscriber roster and forward the notification one by one. await means each subscriber must finish processing before the next one is notified — this guarantees order, but also means a slow subscriber can drag down the entire Agent.
一句话:Agent 类是引擎和产品之间的管理层。它管四件事——包装引擎、运行中插话、进度通知、启停善后。引擎只管跑循环。
In one sentence: The Agent class is the management layer between the engine and the product. It handles four things — wrapping the engine, mid-run message injection, progress notification, and start/stop lifecycle management. The engine just runs the loop.