Appearance
Agent 类
Agent 是 packages/agent/src/agent.ts 里的有状态封装,包住底层的 agentLoop()。
两层的关系可以这样理解:
text
agent-loop.ts 底层实现,除了局部变量基本无状态
输入 context + tools + model + stream fn + emit 回调
输出 AgentEvent 序列
agent.ts 有状态对象
持有 transcript、工具、队列、abort controller、订阅者
把 loop 事件先落进自己的 state,再依次通知监听者应用层不需要自己维护消息数组和事件顺序,直接用 Agent 即可。coding-agent 里的 AgentSession 就是 Agent 的下一层封装。
最小用法:
ts
const agent = new Agent({
initialState: { systemPrompt: "You are a helpful assistant.", model },
streamFn: models.streamSimple.bind(models),
});
agent.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
await agent.prompt("Hello!");构造选项
AgentOptions 分三类:初始状态、可赋值钩子、运行时配置。
| 选项 | 默认值 | 作用 |
|---|---|---|
initialState | 空 | 初始状态,见「初始状态」 |
streamFn | getDefaultStreamFn() | 真正发起 LLM 请求的函数,见「工具与流函数」 |
convertToLlm | 只保留 system / user / assistant / toolResult | 每次请求前把 AgentMessage[] 转成 provider 能理解的 Message[] |
transformContext | 无 | 在 convertToLlm 之前裁剪或注入上下文,收 signal |
getApiKey | 无 | 每次请求动态解析 API key,适合会过期的 OAuth token |
onPayload / onResponse / onProviderStreamEvent | 无 | 透传给 stream function 的观测/改写回调 |
beforeToolCall / afterToolCall | 无 | 工具执行前后钩子,语义见「Agent Loop」 |
finishTurn | 无 | 一轮结束后决定 continue / end |
prepareRequest | 无 | 每次 provider 请求前的状态替换,不轮询队列 |
prepareNextTurn | 无 | 下一轮开始前替换 context / model / thinking,只收 signal |
prepareNextTurnWithContext | 无 | 同上,但收 PrepareNextTurnContext;设置后优先于 prepareNextTurn |
steeringMode | "one-at-a-time" | steering 队列的出队模式 |
followUpMode | "one-at-a-time" | followUp 队列的出队模式 |
sessionId | 无 | 透传给 provider,用于 cache-aware 后端 |
thinkingBudgets | 无 | 按 thinking level 指定 token 预算 |
transport | "auto" | 透传给 stream function 的传输方式 |
maxRetryDelayMs | 无 | provider 请求重试延迟的上限 |
toolExecution | "parallel" | 一条 assistant message 里多个 tool call 的执行策略 |
构造之后它们都是公开字段,可以直接重新赋值(stream function 的字段名是 streamFunction),所以宿主能在运行前后改配置。AgentSession 就是通过重新赋值 beforeToolCall、afterToolCall、finishTurn、prepareRequest、prepareNextTurnWithContext、transformContext 注入自己的逻辑。
streamFn 在类型上必填,但运行时会回落到 getDefaultStreamFn(),也就是宿主通过 setDefaultStreamFn() 注册的函数;两者都没有时抛错。
状态
Agent 内部维护 MutableAgentState,它是 AgentState 的可变版本:pendingToolCalls 从 ReadonlySet<string> 放宽成 Set<string>,其余只读字段变成可写。对外通过 agent.state 暴露成 AgentState。
字段和含义取自 packages/agent/src/types.ts 的 AgentState 注释:
| 字段 | 类型 | 含义 |
|---|---|---|
systemPrompt | readonly string | 当前 system prompt,从 transcript 的 system 消息回放得到。只读:要修改提示词,应追加一条带 content 或 sections 的 system 消息。在 initialState 里,它用于生成开头的 system 消息。 |
model | Model<any> | 之后各轮使用的模型。 |
thinkingLevel | ThinkingLevel | 之后各轮请求的思考级别。 |
tools | AgentTool<any>[](accessor) | 可执行工具。赋值新数组时会复制顶层数组。与 transcript 中声明的工具不一致的部分,会在下一次请求前通过 system 消息告知模型。 |
messages | AgentMessage[](accessor) | 会话 transcript。赋值新数组时会复制顶层数组。transcript 里的 system 消息承载提示词和工具声明。 |
isStreaming | readonly boolean | agent 正在处理 prompt 或 continuation 时为 true。会一直保持 true,直到 await 的 agent_end 监听器结算完。 |
streamingMessage | readonly AgentMessage | undefined | 当前流式响应中的部分 assistant 消息,如果有。 |
pendingToolCalls | readonly ReadonlySet<string> | 当前正在执行的 tool call id。 |
errorMessage | readonly string | undefined | 最近一次失败或中止的 assistant turn 的错误信息,如果有。 |
AgentState 本身有一条注释解释了 tools 和 messages 为什么写成 accessor:实现可以在存下来之前先复制被赋值的数组。复制的是顶层数组,元素本身不拷贝。
初始状态
AgentInitialState 是 AgentState 去掉运行时字段(isStreaming、streamingMessage、pendingToolCalls、errorMessage)后的部分类型:
ts
interface AgentInitialState {
systemPrompt?: string;
model?: Model<any>;
thinkingLevel?: ThinkingLevel;
tools?: AgentTool<any>[];
messages?: AgentMessage[];
}createMutableAgentState() 的初始化规则:
systemPrompt和tools会合成一条开头的 system 消息:content是提示词,toolsAdded是工具声明(经过toToolDeclaration(),只保留name/description/parameters/constrainedSampling)。- 两个都为空时
createInitialSystemMessage()返回undefined,空 transcript 保持为空。 - 如果
messages本身已经以 system 消息开头,就不再插入。 - 没传
model时用内部占位对象(id: "unknown");thinkingLevel默认"off"。
修改系统提示词
这里有一个容易困惑的点:修改系统提示词不是改某个字段,而是往 transcript 里追加 system 消息。systemPrompt 是回放出来的当前值,回放规则(getCurrentSystemMessage())是:
- 后续 system 消息的
content追加到基础提示词后面 sections按名字替换,值为null表示删除某个 sectiontoolsAdded/toolsRemoved按顺序应用,得到当前工具集合
所以只替换一个 section 可以这样写:
ts
await agent.prompt([
{ role: "system", content: "", sections: { skills: "<skills>...</skills>" }, timestamp: Date.now() },
{ role: "user", content: "Continue", timestamp: Date.now() },
]);prompt 和 continue
两个入口都会启动一次 run:
prompt():从新消息开始。三种入参——字符串、单个AgentMessage、AgentMessage[]数组。字符串会包成 user 消息,第二个参数是可选的ImageContent[],图片接在 text block 后面。continue():从已有上下文继续,不新增用户消息。
约束:
- 已有
activeRun时调用prompt()/continue()会抛错。运行中要用steer()或followUp()。 continue()在 transcript 为空或只有 system 消息时抛No messages to continue from。continue()要求最后一条不是 assistant;如果最后一条恰好是 assistant,它会先 drain steering 队列,再 drain followUp 队列,两个队列都为空时才抛错。用队列消息继续时走的是runPromptMessages(),所以队列 mode(one-at-a-time/all)决定这次取出几条。
run 生命周期
两个入口最终都走 runWithLifecycle():
text
创建 ActiveRun { promise, resolve, abortController }
isStreaming = true,清空 streamingMessage 和 errorMessage
执行 executor(runPromptMessages -> runAgentLoop 或 runContinuation -> runAgentLoopContinue)
出错 -> handleRunFailure()
finally -> finishRun()ActiveRun 提供一个可等待的 promise、一个 abort controller 和一个 resolve 回调。
agent.signal返回当前 run 的 signal,没有 run 时是undefined。agent.abort()触发 abort。agent.waitForIdle()等待当前 run 和所有agent_end监听器结算完,没有 run 时立即 resolve。
handleRunFailure() 会合成一条 assistant 消息(沿用当前 model 的 api / provider / id,usage 全零,stopReason 为 error 或 aborted,errorMessage 是错误文本),并补发完整的生命周期事件:
text
message_start
message_end
turn_end
agent_end这些事件一样会经过 processEvents(),所以订阅者永远能看到完整的事件序列,不需要区分「正常结束」和「异常结束」。注意 provider 报错通常不走这条路径:请求失败会在 stream 里被编码成 stopReason: "error" 的 assistant 消息,handleRunFailure() 处理的是 loop 真正抛出的异常。
finishRun() 只重置运行时字段(isStreaming、streamingMessage、pendingToolCalls),不清 errorMessage,所以最近一次错误在 run 结束后仍然可读。
队列
PendingMessageQueue 是一个简单 FIFO,支持两种出队模式:
all:一次取走全部one-at-a-time:一次只取最旧的一条
对应两个队列,默认都是 one-at-a-time:
| 队列 | 投递时机 | 入队 API | 模式属性 |
|---|---|---|---|
| steering | 当前 assistant turn 的工具执行完成后、下一次请求前 | steer() | agent.steeringMode |
| followUp | agent 本来要停止时 | followUp() | agent.followUpMode |
配套 API:
peekQueuedMessages():预览下次会投递的消息,不消费;优先看 steering,没有才看 followUphasQueuedMessages():任一队列非空就是 trueclearSteeringQueue()/clearFollowUpQueue()/clearAllQueues():丢弃排队消息
loop 通过 createLoopConfig() 里的 getSteeringMessages 和 getFollowUpMessages 消费队列。continue() 消费过 steering 之后会给 loop 传 skipInitialSteeringPoll,让第一次 steering 轮询直接返回空数组,避免同一条消息被投递两次。
订阅与事件
ts
const unsubscribe = agent.subscribe((event, signal) => {
// 更新 UI 或持久化
});AgentEvent 是 packages/agent/src/types.ts 里的联合类型:
| 事件 | 携带数据 | 说明 |
|---|---|---|
agent_start | 无 | run 开始 |
agent_end | messages | run 的最后一个事件,messages 是本次 loop 新产生的消息 |
turn_start | 无 | 一个 turn 开始;turn = 一条 assistant message + 它产生的所有 toolResult |
turn_end | message、toolResults | turn 结束 |
message_start | message | 任何消息开始,包括 system / user / assistant / toolResult |
message_update | message、assistantMessageEvent | 只有 assistant 消息在流式输出时才有 |
message_end | message | 消息结束 |
tool_execution_start | toolCallId、toolName、args | 工具开始执行 |
tool_execution_update | toolCallId、toolName、args、partialResult | 工具通过 onUpdate 上报的部分结果 |
tool_execution_end | toolCallId、toolName、result、isError、durationMs | 工具结束;durationMs 是 execute() 的单调时钟耗时,工具没真正执行时没有这个字段 |
processEvents() 分两步:
- 按事件类型更新内部 state
- 按订阅顺序依次
await每个监听器
监听器拿到的 signal 就是当前 run 的 abort signal。监听器是 run 结算的一部分:agent_end 是最后一个事件,但 isStreaming 要等 agent_end 的所有监听器跑完、finishRun() 清空状态后才变回 false。
state 更新规则:
| 事件 | state 变化 |
|---|---|
agent_start / turn_start | 不变 |
message_start | 设置 streamingMessage |
message_update | 替换 streamingMessage |
message_end | 把消息 push 进 messages,清空 streamingMessage |
tool_execution_start | 把 id 加进 pendingToolCalls |
tool_execution_end | 从 pendingToolCalls 移除 |
turn_end | assistant 消息带 errorMessage 时记录下来 |
agent_end | 清空 streamingMessage |
注意 pendingToolCalls 每次都是新建一个 Set 再赋值,这样订阅者能通过引用变化感知到更新。
配置钩子
createLoopConfig() 把公开字段映射成 AgentLoopConfig:
convertToLlm:默认defaultConvertToLlm(),只保留system/user/assistant/toolResultreasoning:由thinkingLevel得来,"off"时传undefinedprepareNextTurn:设了prepareNextTurnWithContext就用它,并把当前 run 的signal传进去getSteeringMessages/getFollowUpMessages:从两个队列 drainmodel、toolExecution、transport、thinkingBudgets、sessionId、maxRetryDelayMs、onPayload、onResponse、onProviderStreamEvent直接透传
每次 run 开始时 createContextSnapshot() 会把 messages 和 tools 各复制一份给 loop,所以 loop 在自己的 context 里 push 消息不会直接改动 agent.state.messages,state 只在 message_end 事件里更新。
工具与流函数
AgentTool 是运行时工具接口:name、description、parameters(TypeBox schema)、execute() 必填,label、prepareArguments、outputSchema、replay、executionMode 可选。细节见「工具系统」。
packages/agent/src/stream-fn.ts 里的 setDefaultStreamFn() / getDefaultStreamFn() 让宿主把 ModelRuntime.streamSimple 注册成默认的 stream function。这样 agent 包本身不依赖 provider 目录,嵌入方也能注入自己的实现。
reset
reset() 只能在空闲时调用,效果是:
- 用
getCurrentSystemMessage()回放出 baseline system 消息,把它作为唯一的 system 消息留下 - 清空
messages的其余部分、streamingMessage、pendingToolCalls、errorMessage - 清空 steering 和 followUp 两个队列
运行中调用会抛错。
易错点
messages/tools的 setter 只复制顶层数组,不深拷贝;getter 返回的就是内部数组,agent.state.messages.push(...)会直接改 state。- 运行中不能
prompt()/continue()/reset()。 - 监听器抛错会让 run 失败,因为
processEvents()是 await 的;抛错还会中断事件向后续监听器的传播。写监听器时要自己处理异常。 - 在
agent_end监听器里await agent.waitForIdle()会死锁:run 要等监听器结算完才结束。 agent_end之后 run 未必 idle,要等agent_end监听器结算完。- 流式输出期间正在生成的消息不在
messages里,只在streamingMessage里;message_end才 push。 - 自定义消息类型需要 declaration merging 扩
CustomAgentMessages,并在convertToLlm里处理;默认的defaultConvertToLlm()会丢掉它不认识的 role。 errorMessage只在turn_end里从 assistant 消息回填,不会自动清空。setDefaultStreamFn()是全局的,所有没显式传streamFn的Agent共用它。
建议阅读顺序
packages/agent/src/types.tsAgentState/AgentInitialStateAgentEventQueueModeAgentTool
packages/agent/src/agent.tscreateMutableAgentState()prompt()/continue()/reset()runWithLifecycle()/processEvents()/handleRunFailure()PendingMessageQueuecreateLoopConfig()/createContextSnapshot()
packages/agent/test/agent.test.ts- 初始状态与 system 消息合成
reset()保留 baseline- 队列与 abort signal 的传递
packages/learn/docs/guide/agent-loop.md- 底层循环的具体行为
packages/learn/docs/guide/agent-session.md- 下一层封装怎么用这些钩子接线