Chapter 2: ChatModelAgent, Runner, AgentEvent (Console Multi-Turn)

Goal of this chapter: introduce ADK execution abstractions (Agent + Runner) and implement a multi-turn conversation in a Console program.

Code Location

Prerequisites

Same as Chapter 1: you need a configured and available ChatModel (OpenAI or Ark).

Running

In the examples/quickstart/chatwitheino directory:

go run ./cmd/ch02

After the prompt appears, enter your questions (empty line to exit):

you> Hi, explain what an Agent is in Eino?
...
you> Summarize that in one sentence
...

Key Concepts

From Component to Agent

In Chapter 1 we learned about Components — the replaceable, composable capability units in Eino:

  • ChatModel: calls a large language model
  • Tool: executes specific tasks
  • Retriever: retrieves information
  • Loader: loads data

The relationship between Component and Agent:

  • Components alone don’t form a complete AI application: they are capability units that need to be organized, orchestrated, and executed
  • An Agent is a complete AI application: it encapsulates complete business logic and can run directly
  • Agents use Components internally: most importantly ChatModel (conversation) and Tool (execution)

Why do we need Agent?

With Components alone, you would need to handle:

  • Managing conversation history
  • Orchestrating the call flow (when to call the model, when to call tools)
  • Handling streaming output
  • Implementing interrupt and resume

What does Agent provide?

  • A complete runtime framework: unified execution management via Runner
  • Standardized event stream output: Run() -> AsyncIterator[*AgentEvent], supporting streaming, interrupt, and resume
  • Extensibility: tools, middleware, interrupt, and more can be added
  • Ready to use: create an Agent and run it directly, no need to worry about internal details

This chapter’s example:

ChatModelAgent is the simplest Agent — it only uses a ChatModel internally, but already possesses the complete Agent capability framework. Later chapters will demonstrate how to add Tool and other capabilities.

Agent Interface

Agent is the core interface in ADK, defining the basic behavior of an intelligent agent:

type Agent interface {
    Name(ctx context.Context) string
    Description(ctx context.Context) string
    
    // Run executes the Agent and returns an event stream
    Run(ctx context.Context, input *AgentInput, options ...AgentRunOption) *AsyncIterator[*AgentEvent]
}

Interface responsibilities:

  • Name() / Description(): identify the Agent’s name and description
  • Run(): the core method to execute the Agent, accepting input messages and returning an event stream

Design philosophy:

  • Unified abstraction: all Agents (ChatModelAgent, WorkflowAgent, SupervisorAgent, etc.) implement this interface
  • Event-driven: execution is output through an event stream (AsyncIterator[*AgentEvent]), supporting streaming responses
  • Extensibility: when adding tools, middleware, interrupt, etc., the interface remains unchanged

ChatModelAgent

ChatModelAgent is an implementation of the Agent interface, built on top of ChatModel:

agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
    Name:        "Ch02ChatModelAgent",
    Description: "A minimal ChatModelAgent with in-memory multi-turn history.",
    Instruction: instruction,
    Model:       cm,
})

ChatModel vs ChatModelAgent: the essential difference

DimensionChatModelChatModelAgent
PositioningComponentAgent
Core Interface
Generate()
/
Stream()
Run() -> AsyncIterator[*AgentEvent]
Output FormReturns message content directlyReturns event stream (messages, control actions, etc.)
Core CapabilitiesPure LLM invocationSupports tools, middleware, interrupt, etc.
Use CasesSimple conversational interactionsComplex agent application development

Why do we need ChatModelAgent?

  1. Unified abstraction: ChatModel is just one kind of Component, while Agent is a higher-level abstraction that can compose multiple Components
  2. Event-driven: Agent outputs an event stream, supporting streaming responses, interrupt/resume, state transitions, and other complex scenarios
  3. Extensibility: ChatModelAgent can have tools, middleware, interrupt, etc. added, while ChatModel can only invoke the model
  4. Orchestration-friendly: Agents can be uniformly managed by Runner, supporting checkpoint, resume, and other runtime capabilities

In simple terms:

  • ChatModel = “The component responsible for communicating with the LLM, abstracting away differences between model providers (OpenAI, Ark, Claude, etc.)”
  • ChatModelAgent = “An agent built on top of the model that can call the model but can also do much more”

Analogy:

  • ChatModel is like a “database driver”: responsible for communicating with the database, abstracting away MySQL/PostgreSQL differences
  • ChatModelAgent is like a “business logic layer”: built on top of the database driver, but also contains business rules, transaction management, etc.

Characteristics:

  • Encapsulates ChatModel invocation logic
  • Provides a unified Run() -> AgentEvent output form
  • Can have tools, middleware, and other capabilities added later

Runner

Runner is the entry point for executing an Agent, responsible for managing the Agent’s lifecycle:

type Runner struct {
    a Agent  // The Agent to execute
    enableStreaming bool
    store CheckPointStore  // State storage for interrupt/resume
}

Why do we need Runner?

Although Agent provides a Run() method, calling it directly lacks many runtime capabilities:

  1. Lifecycle management: Runner manages the Agent’s startup, resume, interrupt, and other states
  2. Checkpoint support: works with CheckPointStore to implement interrupt/resume (covered in later chapters)
  3. Unified entry point: provides convenient methods like Run() and Query()
  4. Event stream encapsulation: converts the Agent’s event stream into a consumable AsyncIterator[*TypedAgentEvent[M]]

Usage:

runner := adk.NewTypedRunner[M](adk.TypedRunnerConfig[M]{
    Agent:           agent,
    EnableStreaming: true,
})

// Method 1: pass a message list
events := runner.Run(ctx, history)

// Method 2: convenience method, pass a single query string
events := runner.Query(ctx, "Hello")

AgentEvent

AgentEvent is the event unit returned by Runner:

type AgentEvent struct {
    AgentName string
    RunPath   []RunStep

    Output *AgentOutput  // Output content
    Action *AgentAction  // Control action
    Err    error         // Execution error
}

Main fields:

  • event.Err: execution error
  • event.Output.MessageOutput: message or message stream (streaming)
  • event.Action: interrupt/transfer/exit and other control actions (used in later chapters)

AsyncIterator: Consuming the Event Stream

Runner.Run() returns *AsyncIterator[*AgentEvent], a non-blocking streaming iterator.

Why use AsyncIterator instead of returning results directly?

Because Agent execution is streaming: the model generates replies token by token, with tool calls interspersed. If we waited for everything to complete before returning, users would have to wait much longer. AsyncIterator lets you consume each event in real time.

Consumption pattern:

// events is *AsyncIterator[*AgentEvent], returned by runner.Run()
events := runner.Run(ctx, history)

for {
    event, ok := events.Next()  // Get next event, blocks until an event is available or stream ends
    if !ok {
        break  // Iterator closed, all events consumed
    }
    if event.Err != nil {
        // Handle error
    }
    if event.Output != nil && event.Output.MessageOutput != nil {
        // Handle message output (may be streaming)
    }
}

Note: each runner.Run() creates a new iterator; it cannot be reused after consumption.

Multi-Turn Conversation Implementation

This chapter implements simple multi-turn conversation: user input → model reply → user continues → …

Implementation approach:

Without tools, ChatModelAgent only performs one model invocation per Run() call. Multi-turn conversation is achieved by maintaining history on the caller side:

  1. Use history []M to accumulate conversation messages (in this example, M defaults to *schema.AgenticMessage)
  2. Each user input: append to history via msgops.NewUser[M]
  3. Call runner.Run(ctx, msgops.NormalizeMessagesForModelInput(history)) to get the event stream and consume the assistant text
  4. Append the assistant text back to history via msgops.NewAssistant[M], then enter the next turn

Key code snippet (Note: this is a simplified snippet that cannot run directly; see cmd/ch02/main.go for the full code):

func runTyped[M adk.MessageType](ctx context.Context, instruction string) {
    agent, err := adk.NewTypedChatModelAgent[M](ctx, &adk.TypedChatModelAgentConfig[M]{
        Name:        "Ch02Agent",
        Instruction: instruction,
        Model:       cm,
    })
    if err != nil {
        log.Fatal(err)
    }

    runner := adk.NewTypedRunner[M](adk.TypedRunnerConfig[M]{
        Agent:           agent,
        EnableStreaming: true,
    })

    history := make([]M, 0, 16)

    for {
        line := readUserInput()
        if line == "" {
            break
        }

        history = append(history, msgops.NewUser[M](line))
        events := runner.Run(ctx, msgops.NormalizeMessagesForModelInput(history))
        result, err := helpers.PrintAndCollect[M](events, helpers.PrintOptions{})
        if err != nil {
            log.Fatal(err)
        }
        history = append(history, msgops.NewAssistant[M](result.AssistantText, nil))
    }
}

Flow diagram:

┌─────────────────────────────────────────┐
│  Initialize history = []                 │
└─────────────────────────────────────────┘
                   ↓
        ┌──────────────────────┐
        │  User inputs message  │
        └──────────────────────┘
                   ↓
        ┌──────────────────────┐
        │  Append to history    │
        └──────────────────────┘
                   ↓
        ┌──────────────────────┐
        │  runner.Run(history)  │
        └──────────────────────┘
                   ↓
        ┌──────────────────────┐
        │  Consume event stream │
        └──────────────────────┘
                   ↓
        ┌──────────────────────┐
        │  Append AssistantMsg  │
        └──────────────────────┘
                   ↓
              (loop continues)

Chapter Summary

  • Agent interface: defines the basic behavior of an intelligent agent; the core is Run() -> AsyncIterator[*AgentEvent]
  • ChatModelAgent: an Agent implementation based on ChatModel, providing a unified execution abstraction
  • Runner: the execution entry point for Agents, managing lifecycle, checkpoint, event streams, and other runtime capabilities
  • AgentEvent: an event-driven output unit supporting streaming responses and control actions
  • Multi-turn conversation: implemented by maintaining history on the caller side; each Run() completes one conversation turn