Back to Cherry Studio

Agent Loop

docs/references/ai/agent-loop.md

2.0.06.1 KB
Original Source

Agent Loop

What it is

Agent (src/main/ai/runtime/aiSdk/Agent.ts) wraps @cherrystudio/ai-core's createAgent(...).stream() (built on the AI SDK's ToolLoopAgent) with a composeHooks pipeline that folds N independent hook contributors (per-feature plugins, AiService analytics, internal observers) into a single AgentLoopHooks object with deterministic ordering, then bridges one streaming pass to a ReadableStream<UIMessageChunk> with a stable id for the first emitted message.

The stream is single-pass: Agent.stream runs the AI SDK stream exactly once and pipes it through. There is no mid-stream message injection — steering a chat turn is handled upstream by abort-and-restart (see Stream Manager).

Agent does not know about topics, IPC, persistence, or multi-model fan-out. Those concerns live in the stream manager — see Stream Manager.

API

ts
const agent = new Agent({
  providerId, providerSettings, modelId,
  plugins, tools, system, options,
  hookParts,          // RequestFeature contributions
  messageId           // stable id for the first emitted UIMessage
})

const stream: ReadableStream<UIMessageChunk> = agent.stream(initialMessages, signal)
// or (non-streaming; input is { prompt } | { messages })
const result = await agent.generate({ messages }, signal)

// internal observers can also register on the agent:
const dispose = agent.on('onStepFinish', step => { … })

stream() and generate() share the underlying agent — only the AI SDK call differs. Future runToCompletion() / toTool() are placeholders; they don't ship in this PR.

Hooks model

ts
interface AgentLoopHooks {
  onStart?: () => Promise<void> | void
  prepareStep?: PrepareStepFunction             // chained
  onStepFinish?: (step) => Promise<void> | void // void-fan-out
  onToolExecutionStart?: (event) => Promise<void> | void
  onToolExecutionEnd?: (event) => Promise<void> | void
  onFinish?: () => Promise<void> | void
  onAbort?: () => Promise<void> | void
  onError?: (ctx) => 'retry' | 'abort'
}

Hook contributions come from three sources, all folded by composeHooks:

  1. Internal observers (Agent.on(key, fn)) — attachUsageObserver (injects message-metadata chunks carrying token usage).
  2. Feature contributions (hookParts param) — each RequestFeature's contributeHooks(scope) (see Params Pipeline).
  3. Caller hooksAiService adds the analytics hook only (usage is accumulated via onStepFinish, then flushed idempotently from onFinish, onAbort, or onError). It does not contribute a root-span/trace lifecycle hook — the OTel root span is owned by AiStreamManager.runExecutionLoop.

Composition rules per hook key:

keyrule
onStart, onFinish, onAbort, onStepFinish, onToolExecutionStart/EndchainVoid — sequential for-loop await; per-hook throws logged and swallowed, chain continues
prepareStepchained — each invocation receives the previous return value
onErrorevery handler invoked sequentially; any 'retry' makes the result 'retry'; default abort

All void hooks share the same chainVoid helper in composeHooks.ts — there is no Promise.allSettled / parallel path.

Tool execution events (onToolExecutionStart/End) are emitted by a wrapper around each tool's execute. No released AI SDK version brackets a single tool's execution: v6 exposes call-level (experimental_onToolCallStart) and input-level (onInputStart / onInputDelta / onInputAvailable) hooks, but nothing around execute itself — so we wrap. A future SDK version may add Agent-level execution hooks with the same shape, at which point the wrapper is removed and hook signatures stay stable.

Steering

There is no in-loop steering. Agent.stream makes a single AI SDK pass and never folds a mid-flight follow-up into the running turn — doing so mutated in-flight history and had no clean turn boundary. A new chat submission to a live topic is handled one level up by the stream manager: the dispatcher aborts the running turn, waits for it to persist as paused, and starts a fresh one — see Stream Manager → Steering.

Agent-session runtimes are different: they queue their own follow-ups on the session's pendingTurns and interrupt between turns rather than restarting — see Agent Session Runtime.

Error and abort

  • signal.aborted is honoured throughout stream() and generate(). Aborted streams settle cleanly with the accumulated chunks already broadcast, including when the SDK rejects result metadata while unwinding the aborted stream. Clean cancellation calls onAbort (not onError) so per-run resources and analytics can finalize.
  • Thrown errors are caught and routed through onError. Returning 'retry' is reserved for a future implementation — today the loop logs and aborts.
  • Trusted local tools can return a structured terminal failure (terminal: true, retryable: false). A generic process-local provenance marker prevents matching JSON from MCP or provider-executed tools from controlling the loop. Wrappers such as deferred tool_invoke pass the same object reference through, so Agent Core never parses tool names or wrapper payloads. The feature stops at that step boundary and Agent converts the finish into an error.
  • The effective step-cap condition records when it actually returns true; Agent converts only that outcome into an explicit error. This avoids treating an approval pause as cap exhaustion. If a queued steer and the cap both trigger on one step, the clean steer yield takes precedence.
  • The writer is settled exactly once via the then/catch of the internal IIFE — listeners never see a half-closed stream.

Where to read more

  • Code: src/main/ai/runtime/aiSdk/
  • Tests: src/main/ai/runtime/aiSdk/loop/__tests__/agentLoop.test.ts
  • Stream manager integration: Stream Manager
  • Hook contributors: Params Pipeline