Skip to content

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空初始状态,见「初始状态」
streamFngetDefaultStreamFn()真正发起 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 注释:

字段类型含义
systemPromptreadonly string当前 system prompt,从 transcript 的 system 消息回放得到。只读:要修改提示词,应追加一条带 content 或 sections 的 system 消息。在 initialState 里,它用于生成开头的 system 消息。
modelModel<any>之后各轮使用的模型。
thinkingLevelThinkingLevel之后各轮请求的思考级别。
toolsAgentTool<any>[](accessor)可执行工具。赋值新数组时会复制顶层数组。与 transcript 中声明的工具不一致的部分,会在下一次请求前通过 system 消息告知模型。
messagesAgentMessage[](accessor)会话 transcript。赋值新数组时会复制顶层数组。transcript 里的 system 消息承载提示词和工具声明。
isStreamingreadonly booleanagent 正在处理 prompt 或 continuation 时为 true。会一直保持 true,直到 await 的 agent_end 监听器结算完。
streamingMessagereadonly AgentMessage | undefined当前流式响应中的部分 assistant 消息,如果有。
pendingToolCallsreadonly ReadonlySet<string>当前正在执行的 tool call id。
errorMessagereadonly 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 表示删除某个 section
  • toolsAdded / 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
followUpagent 本来要停止时followUp()agent.followUpMode

配套 API:

  • peekQueuedMessages():预览下次会投递的消息,不消费;优先看 steering,没有才看 followUp
  • hasQueuedMessages():任一队列非空就是 true
  • clearSteeringQueue() / 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_endmessagesrun 的最后一个事件,messages 是本次 loop 新产生的消息
turn_start无一个 turn 开始;turn = 一条 assistant message + 它产生的所有 toolResult
turn_endmessage、toolResultsturn 结束
message_startmessage任何消息开始,包括 system / user / assistant / toolResult
message_updatemessage、assistantMessageEvent只有 assistant 消息在流式输出时才有
message_endmessage消息结束
tool_execution_starttoolCallId、toolName、args工具开始执行
tool_execution_updatetoolCallId、toolName、args、partialResult工具通过 onUpdate 上报的部分结果
tool_execution_endtoolCallId、toolName、result、isError、durationMs工具结束;durationMs 是 execute() 的单调时钟耗时,工具没真正执行时没有这个字段

processEvents() 分两步:

  1. 按事件类型更新内部 state
  2. 按订阅顺序依次 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_endassistant 消息带 errorMessage 时记录下来
agent_end清空 streamingMessage

注意 pendingToolCalls 每次都是新建一个 Set 再赋值,这样订阅者能通过引用变化感知到更新。

配置钩子 ​

createLoopConfig() 把公开字段映射成 AgentLoopConfig:

  • convertToLlm:默认 defaultConvertToLlm(),只保留 system / user / assistant / toolResult
  • reasoning:由 thinkingLevel 得来,"off" 时传 undefined
  • prepareNextTurn:设了 prepareNextTurnWithContext 就用它,并把当前 run 的 signal 传进去
  • getSteeringMessages / getFollowUpMessages:从两个队列 drain
  • model、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 共用它。

建议阅读顺序 ​

  1. packages/agent/src/types.ts

    • AgentState / AgentInitialState
    • AgentEvent
    • QueueMode
    • AgentTool
  2. packages/agent/src/agent.ts

    • createMutableAgentState()
    • prompt() / continue() / reset()
    • runWithLifecycle() / processEvents() / handleRunFailure()
    • PendingMessageQueue
    • createLoopConfig() / createContextSnapshot()
  3. packages/agent/test/agent.test.ts

    • 初始状态与 system 消息合成
    • reset() 保留 baseline
    • 队列与 abort signal 的传递
  4. packages/learn/docs/guide/agent-loop.md

    • 底层循环的具体行为
  5. packages/learn/docs/guide/agent-session.md

    • 下一层封装怎么用这些钩子接线