TypeScriptADK-TS

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:

  1. Runner receives user query and initiates agent processing
  2. Agent executes logic until it has something to report (response, tool call, state change)
  3. Event yielded by agent as part of async generator
  4. Runner processes event, commits changes via services, forwards event upstream
  5. Agent resumes execution after Runner completes event processing
  6. 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 Event with the relevant content and actions, then yield it to the Runner
  • Pause: Execution pauses immediately after the yield statement 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.state reflecting 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