Cherry Studio Agent Loop 深度解析:AI SDK 单遍流式 Agent 的 Hook 编排、Steering 与错误语义
【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio
Cherry Studio 将 AI SDK 的ToolLoopAgent(经由@cherrystudio/ai-core的createAgent(...).stream())封装为一个单遍(single-pass)流式执行循环——Agent,并通过composeHooks将 N 个相互独立的 hook 贡献方(各功能插件、AiService 分析、内部观察者)折叠成具有确定性顺序的单一AgentLoopHooks对象。本文讲解Agent的 API 形态、hooks 折叠规则、Steering 边界、错误与中止语义,并结合仓库源码说明其底层实现与测试验证方式。
Agent 是什么
Agent(src/main/ai/runtime/aiSdk/Agent.ts)是 Cherry Studio 在 AI SDK 之上构建的流式 agent 循环封装。它把底层createAgent(...).stream()(基于 AI SDK 的ToolLoopAgent)与composeHooks管线组合起来,最终向调用方暴露一个ReadableStream<UIMessageChunk>,并且保证第一个发出的消息块携带稳定的messageId。
关键设计约束是:流是单遍的。Agent.stream只运行一次 AI SDK 流并直接透传,不存在流中途注入消息的机制——在同一轮对话内做 steering 需要在上层完成:排队一个 steer、在 step 边界让出(yield)、再链式接续一个 continuation(见 Stream Manager → Steering)。
同时Agent对话题(topic)、IPC、持久化、多模型 fan-out 一无所知,这些关注点全部位于 stream manager 层(见 Stream Manager)。这种职责切分保证了Agent是一个聚焦、可独立测试的纯运行循环。
从源码看职责边界
从 Agent.ts 看,Agent类的状态极简:一个observers表(按 hook key 存放观察者函数列表)与一个currentWriter(当前进行中的流写入器)。构造时立即执行attachUsageObserver(this)挂载用量观察者。类上既没有 topic 引用也没有持久化依赖,印证了文档对职责边界的描述。
API
Agent的构造参数与调用方式如下(完整类型见 loop/types.ts):
const agent = new Agent({ providerId, providerSettings, modelId, plugins, tools, system, options, hookParts, // RequestFeature 贡献的 hooks messageId // 首条 UIMessage 的稳定 id }) const stream: ReadableStream<UIMessageChunk> = agent.stream(initialMessages, signal) // 或非流式;输入为 { prompt } | { messages } const result = await agent.generate({ messages }, signal) // 内部观察者也可注册到 agent 上: const dispose = agent.on('onStepFinish', step => { … })stream()与generate()共享同一个底层 agent,只是调用 AI SDK 的方式不同:stream()调用aiAgent.stream({ messages, abortSignal })并通过toUIMessageStream转成 UI 流;generate()则调用aiAgent.generate(...)返回{ text, usage }(见 Agent.ts)。runToCompletion()/toTool()不属于当前 API。
参数细节
结合 AgentLoopParams 与 AgentOptions,构造参数可分为几组:
- 模型定位:
providerId(AppProviderSettingsMap的键)、providerSettings、modelId,以及用于错误归一化的errorContext; - 稳定身份:
messageId——AI SDK 的generateMessageId回调只在第一次生成消息 id 时返回该值,后续用crypto.randomUUID()(见 Agent.ts); - 扩展点:
plugins(AI 插件数组)、wrapModel(在 agent 使用模型之前包一层,例如 ai-retry 的重试/回退包装)、hookParts(独立 hook 贡献方); - 运行时:
tools(ToolSet)、system(系统提示词)、options、mediaCapabilities/toolResultMediaCapabilities(模型与工具结果可承载的媒体形态,不支持的媒体在转换前被剥离)。
AgentOptions覆盖 AI SDK 的CallSettings(maxOutputTokens、temperature、topP、topK、presencePenalty、frequencyPenalty、stopSequences、seed、maxRetries、timeout、headers)与 agent 特定项(toolChoice、activeTools、providerOptions、context、repairToolCall、download、stopWhen、telemetry)。注意stopWhen默认使用 AI SDK 的默认值stepCountIs(20)。
在真实调用中,AiService.ts 会传入errorContext(真实 provider/model id)、messageId、plugins: [...plugins, usagePlugin]、wrapModel,并把分析 hook 与runtimeTimingSink钩子一起放进hookParts。
Hooks 模型
AgentLoopHooks定义了循环生命周期中的全部可挂载点(loop/types.ts):
interface AgentLoopHooks { onStart?: () => Promise<void> | void prepareStep?: PrepareStepFunction // 链式 onStepFinish?: (step) => Promise<void> | void // void 扇出 onToolExecutionStart?: (event) => Promise<void> | void onToolExecutionEnd?: (event) => Promise<void> | void onFinish?: () => Promise<void> | void onAbort?: () => Promise<void> | void onError?: (ctx) => 'retry' | 'abort' }其中工具执行事件带有明确的载荷:ToolExecutionStartEvent包含callId、toolName、input、messages;ToolExecutionEndEvent额外包含durationMs(仅统计工具execute的墙钟耗时,不含 hook 延迟)与toolOutput(tool-result或tool-error)。事件形状刻意对齐 AI SDK v7 的experimental_onToolExecutionStart/End命名,便于升级时直接替换包装器。
hook 贡献的三个来源
所有 hook 贡献最终都由composeHooks折叠:
- 内部观察者(
Agent.on(key, fn))——典型代表attachUsageObserver:在每个 step 结束时把累计用量写成message-metadata块注入流; - 功能贡献(
hookParts参数)——每个RequestFeature的contributeHooks(scope)(见 Params Pipeline); - 调用方 hooks——
AiService只追加分析 hook:用量在onStepFinish累计,并在onFinish/onAbort/onError中幂等落盘;它不贡献根 span/链路生命周期 hook——OTel 根 span 由AiStreamManager.runExecutionLoop持有(见 Observability)。
从 composedHooks 的实现看,折叠顺序是:先按 observer 注册顺序展开this.observers中每个 key 的函数列表,再追加params.hookParts,最后整体交给composeHooks。因此内部观察者总是先于调用方 hookParts 执行。
折叠规则
composeHooks(params/composeHooks.ts)对每个 key 采用不同的组合策略:
| key | 规则 |
|---|---|
onStart、onFinish、onAbort、onStepFinish、onToolExecutionStart/End | chainVoid—— 顺序 for 循环逐个await;单个 hook 抛错只记 warn 日志并被吞掉,链条继续 |
prepareStep | 链式 —— 每次调用接收上一次的返回值 |
onError | chainOnError—— 所有处理器依次执行;任一返回'retry'则结果为'retry';默认abort |
所有 void 类 hook 共用chainVoid这一个辅助函数,没有Promise.allSettled/ 并行路径。chainOnError中对抛出异常的处理器采取隔离策略:它不参与决策,但链条继续,保证每个处理器都被调用(与chainVoid一致)。
chainPrepareStep的语义值得特别注意(源码注释有详细说明):只有messages会在链式调用间向后传递,下游 hook 能看到上游 hook 对消息的修改;其他返回键(model、system、toolChoice 等)不会传给下一个 hook 的入参,而是以"后写者胜"的方式浅合并进最终结果。因此一个下游 hook 无法观察上游 hook 对非messages键的覆盖,但任何键的最后写入者仍然决定 SDK 实际消费的结果。
工具执行事件的来源:包装器
工具执行事件(onToolExecutionStart/End)由每个工具execute外层包装器发出(loop/hookRunner.ts)。已发布的 AI SDK 版本没有单独包裹某个工具execute的钩子:v6 暴露的是调用级(experimental_onToolCallStart)和输入级(onInputStart/onInputDelta/onInputAvailable)钩子,唯独没有围绕execute本身的钩子——所以 Cherry Studio 自己包装。未来 SDK 若提供同形 Agent 级执行钩子,将移除包装器而 hook 签名保持稳定(这正是事件形状对齐 v7 命名的原因)。
实现上,包装器在工具有execute函数时才生效:先发onToolExecutionStart,用performance.now()记录起点,try/catch中执行原始execute,最后无论成功或失败都发onToolExecutionEnd(带durationMs与tool-output或tool-error载荷)。源码注释提醒:AI SDK v6 允许execute返回AsyncIterable用于初步结果,当前没有工具使用该能力,否则 end hook 会过早触发。
Steering:不在循环内注入
Agent内部没有 steering。Agent.stream只做一次 AI SDK 遍,绝不会把飞行中的后续请求折进正在运行的这一轮——那样会改动进行中的历史记录,且没有干净的 turn 边界。
对活动话题发起新提交时,处理位置在上层 stream manager:它持久化并排队 steer,当前 step 循环干净地让出(yield),然后由 continuation 应答排队的行——详见 Stream Manager → Steering。
Agent-session 运行时则不同:带redirect的驱动可以在运行时原生安全点注入后续请求;否则宿主把请求排到pendingTurns等待下一轮——见 Agent Session Runtime → Live Follow-up。
从源码佐证:Agent.stream的循环体只是读取uiStream并把块依次写入 writer(Agent.ts),没有任何将新消息折入当前步骤的逻辑;prepareStep中出现的消息改写仅来自 hook 链(如routeToolResultMedia对工具结果媒体的路由),而非 steering。
错误与中止语义
Agent.stream/Agent.generate对错误与取消做了精细处理:
信号中止:signal.aborted在stream()与generate()全程被尊重。被中止的流以"已广播的累计块"干净收尾——包括 SDK 在展开被中止流时拒绝返回结果元数据的情况。干净取消调用onAbort(而非onError),使每轮资源和分析数据得以收尾。在stream()中,中止路径会调用settleWriter()干净关闭 writer;commitFinish还会在 signal abort 时通过outputController.terminate()关闭 readable 侧,阻止被背压的 finish 块在之后被投递。
错误路由:抛出的错误被捕获并路由到onError。返回'retry'为未来实现保留——当前循环只是记日志并中止(Agent.ts 有// TODO: retry logic注释)。调用级重试/回退位于更下一层的模型包装器(见 Model Retry & Fallback)。
可信本地工具的终端失败:受信任的本地工具可以返回结构化终端失败(terminal: true、retryable: false)。一个进程内 provenance 标记(WeakSet brand,见 localToolTerminalOutcome.ts)防止来自 MCP 或 provider 执行工具的、形状匹配的 JSON 控制循环——标记不会出现在线上数据形态中。包装器(如延迟tool_invoke)直接透传同一对象引用,因此 Agent Core 从不解析工具名或包装器载荷。该特性在 step 边界停止,Agent把这次完成转换为错误(ToolLoopTerminalError)。
step 上限条件:有效的 step 上限条件记录它实际返回true的时刻(trackStopCondition把状态记在 WeakMap 上,并记录触发时的具体 step,见 toolLoopTermination.ts);Agent只把该结果转换为显式错误,避免把审批暂停误判为上限耗尽。如果同一个 step 上排队 steer 与上限同时触发,干净的 steer 让出优先(AI SDK 用Promise.all评估所有 stop 条件,因此steer-yield命中时不返回 cap 错误)。
writer 恰好 settle 一次:通过内部 IIFE 的then/catch保证 writer 只 settle 一次——监听者永远看不到半关闭的流。
错误归一化:MissingFinishReasonError(i18nKeymissing_finish_reason)在流结束未给出 finish reason 时生成(Agent.ts),并携带 provider/model 上下文。
finish 块的两阶段提交
Agent.stream中有一个值得注意的实现细节:看起来"成功"的finish标记会被暂存(pendingFinish),直到循环结果被分类后才提交(Agent.ts)。原因在于:cap 触发或终端工具停止必须以错误形态进入持久化,而不是短暂地以成功完成。commitFinish仅在真正的 enqueue 边界把终态转为成功,避免被背压的 finish 写入被中止打断后仍发布成功标记。另外onFinish是"仅成功"的:只有流干净排空时才触发,错误/中止路径走onError/onAbort;失败轮次的分析数据经由onStepFinish累计、从onError落盘。
用量观察者
attachUsageObserver(observers/usage.ts)是内部观察者的典型样本:它在onStart重置累计器,在每个onStepFinish将step.usage合并进累计值,然后通过agent.write发出一个携带完整MessageStats快照的message-metadata块。源码注释解释了为什么每步都发完整累计快照:AI SDK 会深度合并message-metadata到累计消息中,嵌套键只能被覆盖、不能被清除,因此全量快照能保证每个 step 的每个桶都是权威的。它还区分了inputTokens是否被报告,避免用仅含输出 token 的totalTokens作为错误的压缩锚点。
测试与验证
Agent循环的测试集中在 loop/tests/agentLoop.test.ts(990 行,mock 掉@cherrystudio/ai-core的createAgent),配套还有 hookRunner.test.ts(工具执行包装器)与 toolLoopTermination.test.ts(终端工具失败与 cap 判定)。composeHooks的折叠语义(含chainPrepareStep的 threading 行为)由 params/tests/composeHooks.test.ts 覆盖。
测试通过注入预置的UIMessageChunk序列与steps、finishReason,验证流块转发、错误投影(tool-input-error/tool-output-error/error三类 chunk 的 FIFO 错误消费)、取消路径、终端工具失败转错误、cap 触发的边界行为等。
关键要点速览
Agent是单遍流式循环:一次 AI SDK 流,无中途注入;steering 在 stream manager 层完成;- 三种 hook 来源(内部观察者、功能贡献、调用方)由
composeHooks以确定性顺序折叠:内部观察者先行; - void hook 全部走
chainVoid(顺序、吞错、无并行);prepareStep只把messages在链间传递;onError是"任一 retry 即 retry"; - 工具执行钩子由包装器补齐(AI SDK v6 缺
execute级钩子),事件形状对齐 v7; - 干净取消走
onAbort,错误走onError;'retry'当前仅保留接口;调用级重试在模型包装器; - 可信本地工具可用带进程内 provenance 的结构化失败终止循环,防 MCP/provider 伪造;
- cap 与 steer 同 step 触发时 steer 优先;writer 恰好 settle 一次,监听者看不到半关闭流。
延伸阅读
- 代码:src/main/ai/runtime/aiSdk/(Agent、loop/hookRunner、loop/toolLoopTermination、observers/usage、params/composeHooks)
- 测试:agentLoop.test.ts、hookRunner.test.ts、composeHooks.test.ts
- 调用方:AiService.ts
- 上层集成:Stream Manager、Agent Session Runtime
- Hook 贡献方:Params Pipeline
- 调用级重试:Model Retry & Fallback
【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考