跳转到内容

Pattern: Async Generator Loop

此内容尚不支持你的语言。

The Async Generator Loop is the heartbeat of Claude Code’s agentic architecture. Instead of using traditional recursion, event emitters, or finite state machines, Claude Code implements its core agent loop as an async generator function — a function that yields intermediate results while maintaining its execution context across asynchronous boundaries.

// The fundamental pattern: an async generator that yields each step
async function* agenticLoop(
messages: Message[],
tools: Tool[],
options: LoopOptions
): AsyncGenerator<AgentEvent> {
while (true) {
// 1. Call the LLM
const response = yield* streamCompletion(messages, tools);
// 2. Yield the assistant's response for observation
yield { type: 'assistant_message', content: response };
// 3. Check stop conditions
if (response.stopReason === 'end_turn' && !hasToolUse(response)) {
return; // Generator completes naturally
}
// 4. Execute tools, yielding each result
for (const toolUse of response.toolCalls) {
const result = yield* executeToolWithProgress(toolUse);
yield { type: 'tool_result', toolUse, result };
messages.push(toToolResultMessage(toolUse, result));
}
// 5. Append assistant message and loop
messages.push(toAssistantMessage(response));
}
}
AspectRecursionEvent EmitterState MachineAsync Generator
Stack overflow risk❌ Yes✅ No✅ No✅ No
Linear readability✅ Good❌ Poor❌ PoorExcellent
Pausable❌ No❌ Hard✅ YesNative
Resumable❌ No❌ Hard✅ YesNative
Backpressure❌ No❌ No⚠️ ManualBuilt-in
Observable❌ No✅ Yes⚠️ ManualEach yield
Cancellable❌ Hard⚠️ Manual⚠️ Manual.return()
Memory per step📈 Grows✅ Constant✅ ConstantConstant

A naive recursive agent loop accumulates stack frames on every iteration:

// ❌ Recursion: stack grows with each turn
async function recursiveLoop(messages: Message[]): Promise<void> {
const response = await callLLM(messages);
messages.push(response);
if (hasToolUse(response)) {
const results = await executeTools(response.toolCalls);
messages.push(...results);
return recursiveLoop(messages); // Stack frame accumulates
}
// Even with tail-call optimization (not guaranteed in JS),
// there's no way to observe intermediate states
}

With a deep research task requiring 50+ tool calls, this risks stack overflow. More critically, there’s no observation point — the caller cannot see what’s happening mid-execution.

Event emitters decouple observation from execution but introduce the “spaghetti callback” problem:

// ❌ Event Emitter: control flow becomes implicit
class AgentLoop extends EventEmitter {
async run(messages: Message[]) {
const response = await callLLM(messages);
this.emit('response', response); // Who handles this?
this.emit('tool_start', response); // What order?
// ... state scattered across listeners
this.emit('tool_end', results); // Race conditions?
}
}

The execution flow is now split across multiple listener registrations, making it hard to reason about ordering, error handling, and cancellation.

Explicit state machines are correct but verbose:

// ❌ State Machine: correct but painful to read
type State = 'idle' | 'calling_llm' | 'executing_tools' | 'checking_stop' | 'done';
class AgentStateMachine {
state: State = 'idle';
async transition() {
switch (this.state) {
case 'idle':
this.state = 'calling_llm';
break;
case 'calling_llm':
this.response = await callLLM(this.messages);
this.state = 'executing_tools';
break;
case 'executing_tools':
// 20 more lines of state transition logic...
this.state = 'checking_stop';
break;
// ...and so on
}
}
}

For every new step, you add a state and two transitions. The business logic drowns in state management boilerplate.

In the Claude Code source, the core loop lives in the processConversationTurn area. Here’s the distilled pattern:

// Simplified from Claude Code's actual architecture
async function* processConversation(
apiClient: ApiClient,
messages: Message[],
systemPrompt: string,
tools: Tool[],
abortSignal: AbortSignal,
): AsyncGenerator<StreamEvent> {
let continueLoop = true;
while (continueLoop) {
// Phase 1: Stream the API response
const streamEvents = apiClient.stream({
system: systemPrompt,
messages,
tools: tools.map(t => t.schema),
});
const assistantBlocks: ContentBlock[] = [];
for await (const event of streamEvents) {
// Yield each streaming token for real-time display
yield { type: 'stream', event };
if (event.type === 'content_block') {
assistantBlocks.push(event.block);
}
}
// Phase 2: Process tool calls
const toolUseBlocks = assistantBlocks.filter(isToolUse);
if (toolUseBlocks.length === 0) {
continueLoop = false;
yield { type: 'turn_complete' };
break;
}
// Phase 3: Execute tools with individual yielding
const toolResults: ToolResult[] = [];
for (const toolUse of toolUseBlocks) {
yield { type: 'tool_start', tool: toolUse.name };
// Check abort between tool executions
if (abortSignal.aborted) {
yield { type: 'aborted' };
return;
}
const result = await executeTool(toolUse, tools);
toolResults.push(result);
yield { type: 'tool_end', tool: toolUse.name, result };
}
// Phase 4: Append to conversation and continue
messages.push({ role: 'assistant', content: assistantBlocks });
messages.push({ role: 'user', content: toolResults.map(toToolResultBlock) });
}
}

The magic of generators becomes clear when you see the consumer:

// The consumer controls the pace
async function runAgent(config: AgentConfig) {
const loop = processConversation(
config.apiClient,
config.messages,
config.systemPrompt,
config.tools,
config.abortController.signal,
);
for await (const event of loop) {
// Each iteration here pulls the next value from the generator.
// The generator is PAUSED until we ask for the next value.
switch (event.type) {
case 'stream':
renderToTerminal(event); // Update UI in real-time
break;
case 'tool_start':
if (needsPermission(event.tool)) {
const allowed = await askUser(`Allow ${event.tool}?`);
if (!allowed) {
loop.return(undefined); // Graceful cancellation
return;
}
}
showSpinner(event.tool);
break;
case 'tool_end':
hideSpinner();
logToolResult(event.result);
break;
case 'turn_complete':
showFinalResponse();
break;
case 'aborted':
showAbortMessage();
return;
}
}
}
graph TD
A[Cancellation Request] --> B{Strategy}
B -->|Cooperative| C["abortSignal.aborted check<br/>between tool executions"]
B -->|Immediate| D["generator.return()<br/>from consumer side"]
B -->|Timeout| E["AbortSignal.timeout(ms)<br/>passed to API client"]
C --> F[Clean shutdown<br/>Partial results preserved]
D --> G[Generator finalizes<br/>Finally blocks run]
E --> H[API call aborted<br/>Network resources freed]
// Strategy 1: Cooperative (check abort signal in the loop)
if (abortSignal.aborted) {
yield { type: 'aborted' };
return;
}
// Strategy 2: Immediate (consumer calls .return())
const generator = agenticLoop(messages, tools);
// ... later:
generator.return(undefined); // Generator's finally block executes
// Strategy 3: Timeout (built into AbortSignal)
const signal = AbortSignal.timeout(300_000); // 5 minute timeout
const generator = agenticLoop(messages, tools, { signal });

Here’s a production-ready template you can adapt:

// ============================================
// Reusable Agentic Loop Template
// ============================================
interface AgentEvent {
type: 'thinking' | 'response' | 'tool_call' | 'tool_result' | 'done' | 'error';
data: unknown;
}
interface AgentConfig {
maxTurns: number;
tools: Map<string, ToolHandler>;
shouldContinue: (response: LLMResponse) => boolean;
onBeforeToolCall?: (call: ToolCall) => Promise<boolean>;
}
async function* createAgentLoop(
llm: LLMClient,
initialMessages: Message[],
config: AgentConfig,
): AsyncGenerator<AgentEvent> {
const messages = [...initialMessages];
let turns = 0;
try {
while (turns < config.maxTurns) {
turns++;
// Call LLM
yield { type: 'thinking', data: { turn: turns } };
const response = await llm.complete(messages);
yield { type: 'response', data: response };
// Check if we should stop
if (!config.shouldContinue(response)) {
yield { type: 'done', data: { turns, reason: 'stop_condition' } };
return;
}
// Execute tool calls
for (const call of response.toolCalls ?? []) {
// Pre-execution hook (permission check, etc.)
if (config.onBeforeToolCall) {
const allowed = await config.onBeforeToolCall(call);
if (!allowed) {
yield { type: 'done', data: { turns, reason: 'user_denied' } };
return;
}
}
yield { type: 'tool_call', data: call };
const handler = config.tools.get(call.name);
if (!handler) {
const error = `Unknown tool: ${call.name}`;
messages.push(toolErrorMessage(call.id, error));
yield { type: 'error', data: { call, error } };
continue;
}
const result = await handler(call.input);
messages.push(toolResultMessage(call.id, result));
yield { type: 'tool_result', data: { call, result } };
}
// Append assistant message for next turn
messages.push(assistantMessage(response));
}
yield { type: 'done', data: { turns, reason: 'max_turns' } };
} finally {
// Cleanup runs even on .return() or thrown errors
console.log(`Agent loop finished after ${turns} turns`);
}
}

Claude Code uses yield* to compose generators, creating a modular pipeline:

// Parent generator delegates to child generators
async function* outerLoop(): AsyncGenerator<AgentEvent> {
// yield* transparently forwards all yields from the inner generator
yield* innerStreamPhase();
yield* innerToolPhase();
yield* innerCompactionPhase();
}
// Each phase is its own generator
async function* innerStreamPhase(): AsyncGenerator<AgentEvent> {
for await (const token of apiStream) {
yield { type: 'stream_token', data: token };
}
}
async function* innerToolPhase(): AsyncGenerator<AgentEvent> {
for (const tool of pendingTools) {
yield { type: 'tool_start', data: tool };
const result = await tool.execute();
yield { type: 'tool_end', data: result };
}
}

This yield* delegation creates a composable pipeline without losing the flat control flow.

FeatureAsync GeneratorRxJS Observable
Learning curveLow (native JS)High (100+ operators)
Bundle size0 KB (built-in)~30 KB min
BackpressureAutomatic (pull-based)Manual (strategies needed)
Cancellation.return() / AbortSignalsubscription.unsubscribe()
Compositionyield* delegationpipe() + operators
Hot/ColdAlways cold (lazy)Both hot and cold
Error handlingtry/catch.pipe(catchError(...))
MulticastingManual (need to tee)Built-in subjects
Time operatorsManualdebounce, throttle, etc.
Best forSequential async workflowsComplex event streams

AI Agent Loops

Any LLM-driven loop where you need to observe each step, check permissions, and potentially pause/resume.

ETL Pipelines

Extract → Transform → Load workflows where each stage yields progress and supports cancellation.

Long-Running Tasks

Build systems, test runners, or deployment pipelines that need real-time progress reporting.

Interactive Workflows

Any process that alternates between automated steps and user input/approval.

// ❌ Don't buffer all results then yield at the end
async function* badGenerator() {
const allResults = [];
for (const item of items) {
allResults.push(await process(item));
}
yield allResults; // This defeats the purpose of streaming
}
// ✅ Yield each result as it's produced
async function* goodGenerator() {
for (const item of items) {
yield await process(item); // Consumer sees each result immediately
}
}
// ❌ Don't catch errors silently inside the generator
async function* badErrorHandling() {
try {
yield await riskyOperation();
} catch (e) {
// Silently swallowed — consumer doesn't know something failed
}
}
// ✅ Yield errors as events or re-throw
async function* goodErrorHandling() {
try {
yield await riskyOperation();
} catch (e) {
yield { type: 'error', error: e }; // Consumer can decide what to do
}
}

The Async Generator Loop pattern is Claude Code’s most fundamental architectural decision. It provides:

  1. Linear readability — complex async workflows read top-to-bottom
  2. Natural observation — every yield is a window into the loop’s state
  3. Built-in backpressure — the consumer controls the pace
  4. Composabilityyield* enables modular sub-generators
  5. Graceful cancellation.return() and AbortSignal work together
  6. Zero dependencies — it’s native JavaScript, no libraries needed

This single pattern choice eliminates entire classes of bugs (stack overflow, lost events, race conditions) while keeping the code approachable for any TypeScript developer.