TypeScriptADK-TS
Runtime

Invocation Lifecycle

Complete lifecycle from user query to response completion

An invocation represents everything that happens in response to a single user query, from initial receipt until the agent finishes processing.

Step-by-Step Breakdown

  1. User Input: The user sends a query (for example, "What's the capital of France?").
  2. Runner Starts: Runner.runAsync begins. It loads the relevant Session via SessionService, appends the user query as the first Event in the history, and prepares an InvocationContext (ctx).
  3. Agent Execution: The Runner calls agent.runAsync(ctx) on the designated agent (for example, a LlmAgent).
  4. LLM Call (Example): The agent determines it needs external information and prepares a request for the LLM. Suppose the LLM decides to call MyTool.
  5. Yield FunctionCall Event: The agent receives the FunctionCall from the LLM and wraps it in an Event (e.g., author: agent.name, content.parts: [{ functionCall: { name: 'MyTool', args: { ... } } }]), then yields it.
  6. Agent Pauses: The agent's async generator pauses immediately after the yield.
  7. Runner Processes: The Runner receives the FunctionCall event, records it in the session via SessionService, and emits it upstream to the user interface.
  8. Agent Resumes: The Runner advances the generator (implicit via next()), and the agent resumes execution.
  9. Tool Execution: The runtime executes the requested tool on behalf of the agent (implementation calls tool.runAsync(...)) and routes the result back into the agent's flow.
  10. Tool Returns Result: MyTool completes and returns a result (for example, { result: 'Paris' }).
  11. Yield FunctionResponse Event: The agent wraps the tool result in an Event containing a FunctionResponse part (for example, content.parts: [{ functionResponse: { name: 'MyTool', response: { ... } } }]). If applicable, the event can also include actions such as stateDelta or artifactDelta.
  12. Agent Pauses: The agent pauses again after yielding the FunctionResponse.
  13. Runner Processes: The Runner receives the FunctionResponse, applies any actions.stateDelta/actions.artifactDelta through SessionService, persists the event, and emits it upstream.
  14. Agent Resumes: The agent resumes, now with the tool result available and any state/artifact changes committed.
  15. Final LLM Call (Example): The agent sends the tool result to the LLM to generate a natural language answer.
  16. Yield Final Text Event: The agent wraps the final text into an Event (for example, content.parts: [{ text: 'The capital of France is Paris.' }]) and yields it.
  17. Agent Pauses: The agent pauses again.
  18. Runner Processes: The Runner persists the final text event via SessionService and emits it to the user. This event is typically marked as isFinalResponse().
  19. Agent Resumes & Finishes: The agent resumes and, having no more events to yield for this invocation, its runAsync generator completes.
  20. Runner Completes: The Runner detects that the agent's generator is exhausted and finishes the loop for this invocation.

Important Runtime Behaviors

Understanding a few key aspects of how the ADK TypeScript Runtime handles state, streaming, and asynchronous execution will help you build predictable and efficient agents.

State Updates & Commitment Timing

  • The rule: When your code (in an agent, tool, or callback) modifies session state, the change is locally visible within the current invocation immediately, but it is only guaranteed to be persisted after you yield a non-partial Event that includes the corresponding stateDelta in its actions. The Runner persists that event via SessionService.appendEvent(...), which applies the stateDelta to the session.
  • Implication: Code that runs after resuming from a yield can reliably assume that the state changes signaled in the yielded event have been committed.

TypeScript example (agent logic):

import { Event, EventActions } from "@iqai/adk";
import type { InvocationContext } from "@iqai/adk";

async function* runAsyncImpl(ctx: InvocationContext) {
  // 1) Stage a state change locally and construct an event that carries the delta
  ctx.session.state["status"] = "processing"; // local visibility (dirty until committed)
  const event1 = new Event({
    invocationId: ctx.invocationId,
    author: ctx.agent.name,
    actions: new EventActions({ stateDelta: { status: "processing" } }),
    content: { parts: [{ text: "Starting work..." }] },
    partial: false,
  });

  // 2) Yield event with the delta
  yield event1;
  // --- PAUSE --- Runner persists event1 and applies stateDelta

  // 3) Resume execution – state is now guaranteed committed
  const current = ctx.session.state["status"]; // "processing"
  // ... continue
}

TypeScript example (tool logic using ToolContext):

import type { ToolContext } from "@iqai/adk";

export async function runAsync(params: any, toolCtx: ToolContext) {
  toolCtx.state["progress"] = "started"; // local visibility during this invocation
  toolCtx.actions.stateDelta["progress"] = "started"; // ensure it’s committed with the functionResponse event
  return { ok: true };
}

Notes:

  • Use CallbackContext.state/ToolContext.state for delta-aware state updates. The runtime will merge local changes and the provided stateDelta when a non-partial event is persisted.
  • Persisted state updates occur only on non-partial events. Partial (streaming) events are forwarded but not committed.

"Dirty Reads" of Session State

  • Definition: While commitment happens after the yield of a non-partial event, code running later within the same invocation (before the commit) can often see local, uncommitted changes. This is a “dirty read.”
  • Benefit: Lets multiple parts of your logic within a single invocation (e.g., callbacks and tools) coordinate via state without waiting for a full yield/commit cycle.
  • Caveat: Don’t rely on dirty reads for critical transitions. If the invocation fails before the committing event is yielded and processed, the uncommitted change is lost. For critical updates, ensure you attach a stateDelta to a non-partial event that will be processed by the Runner.

TypeScript example (callback ➜ tool within the same invocation):

// beforeAgent callback
async function beforeAgentCallback(cbCtx: CallbackContext) {
  cbCtx.state["field_1"] = "value_1"; // staged locally
  cbCtx.eventActions.stateDelta["field_1"] = "value_1"; // will commit when the next non-partial event is persisted
}

// later, during the same invocation, inside a tool
async function runAsync(_params: any, toolCtx: ToolContext) {
  const val = toolCtx.state["field_1"]; // likely "value_1" (dirty read)
  // ... do work
  return { value: val };
}

Streaming vs. Non-Streaming Output (partial)

This concerns how LLM responses are handled, especially when using streaming generation APIs.

  • Streaming: The LLM generates its response in chunks. The framework yields multiple Event objects for one conceptual response. Most of these have partial: true.
  • The Runner forwards partial events immediately to the UI but does not persist them or apply actions (e.g., stateDelta).
  • Eventually, a final event for that response is yielded with partial: false (or implicitly as the turn’s completion). The Runner persists only this final event and applies any actions.stateDelta/artifactDelta.
  • Non-Streaming: The LLM returns the whole response at once; the framework yields a single non-partial event, which the Runner processes fully.
  • Why it matters: Ensures state changes are applied atomically and only once based on the complete response, while still allowing progressive UI updates.

Conceptual Runner loop:

for await (const event of agent.runAsync(invocationContext)) {
  if (!event.partial) {
    await sessionService.appendEvent(session, event); // applies stateDelta/artifactDelta
  }
  yield event; // always forward upstream
}

Async is Primary (runAsync)

  • Core design: The ADK TS Runtime is asynchronous and uses async generators to handle concurrent operations without blocking.
  • Main entry point: Runner.runAsync returns an AsyncGenerator<Event> for each invocation.
  • Synchronous convenience (run): A sync wrapper exists primarily for local testing. Internally it drives runAsync and buffers yielded events; prefer runAsync in production.
  • Callbacks/tools: You can implement tools and callbacks as async functions. Synchronous code works but can block the Node.js event loop. For long-running or CPU-bound work, consider using Worker Threads, child processes, or external queues/services to avoid blocking.

Understanding these behaviors will help you write robust ADK TS applications and debug issues related to state consistency, streaming updates, and asynchronous execution.

How is this guide?