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
- Entry code: cmd/ch02/main.go
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 modelTool: executes specific tasksRetriever: retrieves informationLoader: 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) andTool(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 descriptionRun(): 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
| Dimension | ChatModel | ChatModelAgent |
| Positioning | Component | Agent |
| Core Interface | Generate()/ Stream() | Run() -> AsyncIterator[*AgentEvent] |
| Output Form | Returns message content directly | Returns event stream (messages, control actions, etc.) |
| Core Capabilities | Pure LLM invocation | Supports tools, middleware, interrupt, etc. |
| Use Cases | Simple conversational interactions | Complex agent application development |
Why do we need ChatModelAgent?
- Unified abstraction: ChatModel is just one kind of Component, while Agent is a higher-level abstraction that can compose multiple Components
- Event-driven: Agent outputs an event stream, supporting streaming responses, interrupt/resume, state transitions, and other complex scenarios
- Extensibility: ChatModelAgent can have tools, middleware, interrupt, etc. added, while ChatModel can only invoke the model
- 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() -> AgentEventoutput 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:
- Lifecycle management: Runner manages the Agent’s startup, resume, interrupt, and other states
- Checkpoint support: works with
CheckPointStoreto implement interrupt/resume (covered in later chapters) - Unified entry point: provides convenient methods like
Run()andQuery() - 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 errorevent.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:
- Use
history []Mto accumulate conversation messages (in this example,Mdefaults to*schema.AgenticMessage) - Each user input: append to history via
msgops.NewUser[M] - Call
runner.Run(ctx, msgops.NormalizeMessagesForModelInput(history))to get the event stream and consume the assistant text - 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