Event Loop Pattern
The cooperative async generator pattern that powers ADK-TS runtime
At the heart of ADK-TS Runtime is an Event Loop—a cooperative pattern where the Runner and your execution logic (agents, tools, callbacks) take turns exchanging information through Events. This pattern enables predictable, manageable asynchronous workflows while maintaining clear separation of concerns.
Prerequisite Knowledge
It's helpful to understand the Key Components (Runner, Events, Services, Session, Invocation) before diving into how they interact here.
The Basic Cycle
Every user interaction follows the same predictable pattern:
- Runner receives user query and initiates agent processing
- Agent executes logic until it has something to report (response, tool call, state change)
- Event yielded by agent as part of async generator
- Runner processes event, commits changes via services, forwards event upstream
- Agent resumes execution after Runner completes event processing
- Cycle repeats until agent has no more events for the current query
The key point: agent execution pauses at the yield statement. The Runner takes over, processes the event, and only then does the agent resume.
Execution Pauses at Yield
This pause-and-resume behavior is fundamental to understanding ADK-TS. When your code reaches a yield statement, execution stops immediately. The Runner processes the event completely (persisting changes, calling services) before your code resumes on the next line.
The Two Players: Runner and Execution Logic
Understanding the distinct roles helps clarify what each side is responsible for.
What the Runner Does
The Runner acts as the central coordinator for a single user invocation. It orchestrates the entire flow:
- Initiation: Receives the user's query and appends it to the session history via
SessionService - Kick-off: Calls the agent's async generator to start execution
- Event Loop: Waits for each event the agent yields, processes it, then signals the agent to resume
- Service Coordination: Uses services (SessionService, ArtifactService, MemoryService) to persist changes from
event.actions - Upstream Forwarding: Yields processed events to the calling application or UI for rendering
The Runner's core responsibility is orchestrating this cycle. It doesn't execute your business logic—it manages the flow of events and ensures state consistency.
Example: Runner's Event Processing Loop
Here's what happens inside the Runner's main loop:
async function* runAsync(
userId: string,
sessionId: string,
newMessage: Content,
): AsyncGenerator<Event> {
// 1. Get or create session
const session = await sessionService.getSession(appName, userId, sessionId);
// 2. Create invocation context
const invocationContext = new InvocationContext({
/* ... */
});
// 3. Append new message to session (if provided)
if (newMessage) {
await appendNewMessageToSession(session, newMessage, invocationContext);
}
// 4. Find the agent to run for this session
const agentToRun = findAgentToRun(session, rootAgent);
// 5. Get agent's async generator
const agentGenerator = agentToRun.runAsync(invocationContext);
// 6. Process events from agent
for await (const event of agentGenerator) {
// 7. For non-partial events, commit changes to session and memory
if (!event.partial) {
await sessionService.appendEvent(session, event);
if (memoryService) {
await memoryService.addSessionToMemory(session);
}
}
// 8. Yield event for upstream processing (e.g., UI rendering)
yield event;
}
}What Your Execution Logic Does
Your execution logic is responsible for the actual computation and decision-making. The key interaction points are:
- Execute: Run logic based on the current
InvocationContext, with access to the latest session state - Yield: Create an
Eventwith the relevant content and actions, then yield it to the Runner - Pause: Execution pauses immediately after the
yieldstatement while the Runner processes the event - Resume: Only after the Runner commits changes does execution continue from the line after
yield - See Updated State: Access the updated
ctx.session.statereflecting all committed changes from the yielded event
The key insight is that your code pauses at the yield statement. The Runner processes the event, persists changes, and only then does your code resume with the updated state available.
Example: Agent Yielding an Event with State Changes
Here's how an agent constructs and yields an event with state updates:
// 1. Determine a change or output is needed, construct the event
// Example: Updating state
const updateData = { field_1: "value_2" };
const eventWithStateChange = new Event({
author: this.name,
actions: new EventActions({ stateDelta: updateData }),
content: { parts: [{ text: "State updated." }] },
// ... other event fields ...
});
// 2. Yield the event to the Runner for processing & commit
yield eventWithStateChange;
// <<<<<<<<<<<< EXECUTION PAUSES HERE >>>>>>>>>>>>
// <<<<<<<<<<<< RUNNER PROCESSES & COMMITS THE EVENT >>>>>>>>>>>>
// 3. Resume execution ONLY after Runner is done processing the above event.
// Now, the state committed by the Runner is reliably reflected.
// Subsequent code can safely assume the change from the yielded event happened.
const val = ctx.session.state["field_1"];
// here `val` is guaranteed to be "value_2" (assuming Runner committed successfully)
console.log(`Resumed execution. Value of field_1 is now: ${val}`);
// ... subsequent code continues ...
// Maybe yield another event later...Event Types and Processing
Different events serve different purposes in the event loop. Understanding them helps you know what to yield in different situations.
Content Events
Content events carry messages between user and agent. They are the primary way information flows through the conversation.
User Messages
const userEvent = new Event({
invocationId: "inv_123",
author: "user",
content: {
parts: [{ text: "Hello, how are you?" }],
},
});Agent Responses
const agentEvent = new Event({
invocationId: "inv_123",
author: "my_agent",
content: {
parts: [{ text: "I'm doing well, thank you!" }],
},
});Function Call Events
Function call events represent tool invocations. When an agent decides to use a tool, it yields a function call event, the tool executes, and results are wrapped in a function response event.
Function Call
const functionCallEvent = new Event({
invocationId: "inv_123",
author: "my_agent",
content: {
parts: [
{
functionCall: {
name: "search_web",
args: { query: "latest AI news" },
},
},
],
},
});Function Response
const functionResponseEvent = new Event({
invocationId: "inv_123",
author: "my_agent",
content: {
parts: [{
functionResponse: {
name: "search_web",
response: { results: [...] }
}
}]
}
});State Change Events
State change events update the session's persistent state. They use the EventActions structure to communicate stateDelta that the Runner will merge into the session state.
import { EventActions, Event } from "@iqai/adk";
const stateChangeEvent = new Event({
invocationId: "inv_123",
author: "my_agent",
actions: new EventActions({
stateDelta: {
user_preference: "dark_mode",
session_count: 5,
},
}),
});Advanced Topics
Once you understand the basic cycle and event types, these advanced topics explain how specific patterns work.
Async Generator Pattern
The async generator pattern is central to ADK-TS. Both agents and the Runner use async generators to yield control back and forth.
How Agents Implement It
Agents implement the async generator pattern in their runAsyncImpl method. This allows them to yield events one at a time, pause, and resume after the Runner processes each event.
Agent Example
import { BaseAgent, InvocationContext, Event } from "@iqai/adk";
class CustomAgent extends BaseAgent {
protected async *runAsyncImpl(
ctx: InvocationContext,
): AsyncGenerator<Event, void, unknown> {
// Step 1: Analyze user input
yield new Event({
invocationId: ctx.invocationId,
author: this.name,
content: { parts: [{ text: "Let me think about this..." }] },
});
// Step 2: Perform some computation
const result = await this.processUserQuery(ctx.userContent);
// Step 3: Yield final response
yield new Event({
invocationId: ctx.invocationId,
author: this.name,
content: { parts: [{ text: result }] },
});
}
}How the Runner Processes Events
The Runner iterates through each event the agent yields and handles it according to whether it's partial or complete. Partial events stream immediately, while complete events are persisted to the session.
Runner Processing Example
// Simplified Runner event processing
for await (const event of agent.runAsync(invocationContext)) {
// 1. Validate event
if (!event.partial) {
// 2. Persist to session
await this.sessionService.appendEvent(session, event);
// 3. Apply state changes
if (event.actions?.stateDelta) {
// Apply state changes through service
}
// 4. Handle artifacts
if (event.actions?.artifactDelta) {
// Process artifact changes
}
}
// 5. Forward upstream
yield event;
}Processing Guarantees and Streaming
The Runtime provides strong guarantees about how events are processed.
Ordering & Atomicity
Events are always processed in the exact order they are yielded. State changes are applied atomically per event, and no race conditions can occur between event processing.
State Consistency
Agent execution pauses until the Runner completes event processing. State changes are committed to services before the agent resumes. After resuming, the agent sees the committed state reflected in ctx.session.state.
Error Handling
Exceptions during event processing are propagated to agents. Partial state changes are rolled back on failure. Services provide graceful degradation for failures, ensuring the system remains resilient.
Streaming and Partial Events
Partial events enable real-time streaming responses to users without waiting for the complete response to be ready. They are not persisted to the session, allowing for faster feedback.
When to Use Partial Events
Partial events are ideal for:
- Streaming LLM responses token-by-token
- Showing real-time progress updates
- Providing immediate feedback while processing
Example: Streaming Response
Here's how an agent can stream a response using partial events:
// Stream partial updates (not persisted)
yield new Event({
invocationId: ctx.invocationId,
author: this.name,
content: { parts: [{ text: "Thinking" }] },
partial: true,
});
yield new Event({
invocationId: ctx.invocationId,
author: this.name,
content: { parts: [{ text: "Thinking..." }] },
partial: true,
});
// Final response (persisted to session)
yield new Event({
invocationId: ctx.invocationId,
author: this.name,
content: { parts: [{ text: "Here's my complete response" }] },
partial: false,
});Next Steps
🎯 Runner & Event Processing
Learn how the Runner coordinates event processing and service interactions
📋 Invocation Lifecycle
Walk through a complete user request from start to finish
⚙️ Runtime Configuration
Configure streaming, speech, artifacts, and execution limits
⏮️ Session Rewind
Restore sessions to previous states for debugging or undo