Eino Study Notes - MuxiaoWFSkip to main content
This page was machine-translated and may contain errors or omissions. / 本页面为机器翻译,可能存在错漏。

Eino Study Notes

Eino Study Notes — Getting Started

Mon Jul 20 2026
5439 words · 43 minutes

This note was generated with AI assistance, but the order for reading the code should be fine (all examples use the official Eino examples; personally I felt the official tutorials were a bit hard to understand).

Additionally, possibly due to the fast update pace, some methods in the code have already been marked as deprecated, but the examples still haven’t been updated to the latest methods — please map them to the current ones yourself.

Eino Framework Study Notes

📂 Source repository: github.com/cloudwego/eino-examples

📖 Series docs: Getting Started | Agentic Advanced | Appendix One: Flow | Appendix Two: Components | Appendix Three: Tools


Quick Overview: Two Call Paths & Core Cheat Sheet

Before diving into details, keep a master map in your head. Eino has only two call paths:

sequenceDiagram
    actor U as User code
    participant CM as ChatModel
    participant A as Agent
    participant R as Runner
    participant I as Iterator

    Note over U,I: === Lightweight path: use ChatModel directly (Getting Started §2) ===
    U->>CM: Generate(messages) / Stream(messages)
    CM-->>U: *schema.Message / StreamReader

    Note over U,I: === Full path: Agent + Runner (Getting Started §3~§5) ===
    U->>CM: ① NewChatModel(config)
    CM-->>U: model
    U->>A: ② NewChatModelAgent(model, instruction, tools)
    A-->>U: agent
    U->>R: ③ NewRunner(agent, streaming, checkpoint)
    R-->>U: runner
    U->>R: ④ Run(input) / Query(input, checkpointID)
    R-->>I: AsyncIterator[*AgentEvent]
    loop Consume events
        I->>I: Next() → (event, ok)
    end

    Note over U,I: === Agentic path: Responses API (Agentic Advanced) ===
    U->>CM: ① New(agenticModel)
    CM-->>U: model
    U->>A: ② NewTypedChatModelAgent[*AgenticMessage](model, ...)
    A-->>U: agent
    U->>R: ③ NewTypedRunner[*AgenticMessage](agent, ...)
    R-->>U: runner
    U->>R: ④ Run(AgenticMessage input)
    R-->>I: Iterator
    loop Consume events
        I->>I: TypedGetMessage(event) → msg.String()
    end

📇 Full cheat-sheet cards (after reading this you only need to remember these)

Create chain: NewChatModel → NewChatModelAgent → NewRunner → Run
(model) (agent) (runner) (message)
Two iterations: Agent uses Next() → (event, ok) —— !ok ends
Stream uses Recv() → (chunk, err) —— io.EOF ends
Agent manages capability: {Name, Instruction, ToolsConfig, Model}
Runner manages execution: {Agent, EnableStreaming, CheckPointStore}
Three ways to create Tool: 90% use InferTool(function + struct tag)
use NewTool(schema + function) when constraints needed
use struct implementing interface when stateful
Three elements of interrupt/resume: CheckPointStore + CheckPointID + Resume
Core packages (by usage frequency):
github.com/cloudwego/eino/adk ← Agent, Runner, Message
github.com/cloudwego/eino/schema ← UserMessage, SystemMessage
github.com/cloudwego/eino/components/tool/utils ← InferTool, NewTool
github.com/cloudwego/eino-ext/components/model/openai ← ChatModel

⚡ Key Concept: What is an Iterator (event iterator)?

In all Eino examples, runner.Run() and runner.Query() return immediately with an AsyncIterator[*AgentEvent] (Iterator for short), and then you consume the events one by one via Next(). This is the most core data consumption pattern in Eino.

Why an Iterator?

Agent execution is asynchronous: a model call may take anywhere from a few seconds to tens of seconds, and may go through multiple stages like “thinking → call Tool → rethink → output text”. If Run() waited until everything finished before returning, your program would freeze.

The Iterator solves this problem — it immediately returns an “event pipeline”, the Agent generates events in the background and pushes them into the pipeline, and you pull them out and process them one by one in the foreground. Analogy:

Runner.Run() returning an Iterator = you get a "walkie-talkie"
The Agent works in the background, saying something through the walkie-talkie every now and then
You in the foreground: Next() → receive a message → Next() → receive the next one → ...
Next() returns (event, false) = the walkie-talkie loses signal = the Agent is done

The Nature of the Iterator

type AsyncIterator[T any] struct {
// internally has a channel connecting the "producer" (Agent goroutine) and the "consumer" (your code)
}
func (iter *AsyncIterator[T]) Next() (item T, ok bool) {
// blocks waiting for the next event
// ok=true: got a new event
// ok=false: the pipeline is closed, Agent execution finished
}

Comparison with Go channels

Go channelAsyncIterator
Createmake(chan T)Returned by Runner.Run() / Runner.Query()
Sendch <- itemAgent internally calls gen.Send(event)
Receiveitem := <-chevent, ok := iter.Next()
Closeclose(ch)Agent internally calls gen.Close()
Close detectionitem, ok := <-chevent, ok := iter.Next() — also ok=false

🧠 In one sentence: The Iterator is an “event stream” wrapping a channel — the Agent writes events into it in the background, and you read them one by one in the foreground with Next(). When you finish reading (ok=false), the Agent is done.


I. Hello World: The Four-Step Assembly Line

📁 Code: adk/helloworld/helloworld.go

Why is it designed this way?

Eino splits an AI conversation application into four independent parts, created one by one like an assembly line, and finally assembled and run together. The benefit of this split is: each part can be replaced individually (e.g., swap the model, swap the Agent type) without affecting the others.

The four-step assembly line (memory mnemonic: “model → agent → runner → message”)

Step 1 Create ChatModel → Step 2 Create Agent → Step 3 Create Runner → Step 4 Send message and run
("mouth", responsible for calling the LLM) ("brain", encapsulates business logic) ("engine", manages execution) (what the user says)

Example code (with comments)

package main
import (
"context"
"fmt"
"log"
"os"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"github.com/cloudwego/eino-ext/components/model/openai"
)
func main() {
// [Fixed boilerplate] the "pass" for all Eino calls, threaded throughout the entire call chain
ctx := context.Background()
// ─── Step 1: Create ChatModel ("mouth") ───
// ChatModel = the component that communicates with the LLM, abstracting away vendor differences
model, err := openai.NewChatModel(ctx, &openai.ChatModelConfig{
APIKey: os.Getenv("OPENAI_API_KEY"),
Model: os.Getenv("OPENAI_MODEL"),
BaseURL: os.Getenv("OPENAI_BASE_URL"),
})
if err != nil {
log.Fatal(err)
}
// ─── Step 2: Create Agent ("brain") ───
agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "hello_agent",
Description: "A friendly greeting assistant",
Instruction: "You are a friendly assistant. Please respond warmly.",
Model: model, // ← inject the model from step 1
})
if err != nil {
log.Fatal(err)
}
// ─── Step 3: Create Runner ("engine") ───
runner := adk.NewRunner(ctx, adk.RunnerConfig{
Agent: agent,
EnableStreaming: true,
})
// ─── Step 4: Assemble messages and run ───
input := []adk.Message{
schema.UserMessage("Hello, please introduce yourself."),
}
// runner.Run() returns an event iterator; consume events one by one
events := runner.Run(ctx, input)
for {
event, ok := events.Next()
if !ok {
break
}
if event.Err != nil {
log.Printf("Error: %v", event.Err)
break
}
if msg, err := event.Output.MessageOutput.GetMessage(); err == nil {
fmt.Printf("Agent: %s\n", msg.Content)
}
}
}

💡 Key understanding

ConceptIn one sentenceAnalogy
context.Background()The “pass” for all calls, fixed boilerplateID card, shown every time you do something
ChatModelThe component responsible for communicating with the LLMDatabase driver (abstracts MySQL/PG differences)
AgentEncapsulates the business logic of “how to use the model”Business logic layer
RunnerManages the Agent’s execution lifecycleCar engine (you only turn the wheel, not the internals)
events.Next()Consume the next event, returns false when no more eventsBuffet conveyor belt, take plate by plate

🧠 Memory trick

Remember the creation process as one sentence: “Use a certain model (ChatModel), build an agent (Agent), hand it to the engine (Runner) to run (Run)”

NewChatModel → NewChatModelAgent → NewRunner → Run
(model) (agent) (runner) (message)

Common import paths:

"github.com/cloudwego/eino-ext/components/model/openai" // OpenAI-compatible model; more supported code not listed — refer directly to the official docs
"github.com/cloudwego/eino/adk" // Agent, Runner, Message
"github.com/cloudwego/eino/schema" // UserMessage, SystemMessage, etc.

💡 Type alias note: adk.Message is just *schema.Message (a Go type alias). Writing []adk.Message{ schema.UserMessage(...) } and []*schema.Message{ schema.UserMessage(...) } is equivalent. Both forms appear in the docs — when you see them, just know they’re the same thing.

📇 Cheat-sheet cards for this section

NewChatModel → NewChatModelAgent → NewRunner → Run
event, ok := iter.Next(); if !ok { break }
msg, err := event.Output.MessageOutput.GetMessage()

II. Using ChatModel Directly (without an Agent)

📁 Code: quickstart/chat/main.go, components/prompt/chat_prompt/chat_prompt.go

When not to use an Agent?

If you only need a one-off Q&A (no multi-turn dialogue, tool calls, or interrupt/resume), using ChatModel directly is lighter weight.

Two output modes

ModeMethodEffectUse case
One-shotmodel.Generate(ctx, messages)Wait for full generation then return *schema.MessageBatch processing, non-real-time scenarios
Streamingmodel.Stream(ctx, messages)Return *schema.StreamReader, receive chunk by chunkChat, real-time display

Example code

2.1 Building messages with a template

import (
"github.com/cloudwego/eino/components/prompt"
"github.com/cloudwego/eino/schema"
)
// Create template: use {variable name} as placeholders
func createTemplate() prompt.ChatTemplate {
return prompt.FromMessages(schema.FString,
schema.SystemMessage("You are a {role}. Answer in a {style} tone."),
schema.MessagesPlaceholder("chat_history", true),
schema.UserMessage("Question: {question}"),
)
}
// Generate actual messages from the template
func createMessages() []*schema.Message { // as mentioned above, using adk.Message here is the same
template := createTemplate()
messages, err := template.Format(context.Background(), map[string]any{
"role": "programmer cheerleader",
"style": "positive, warm and professional",
"question": "My code keeps throwing errors, what should I do?",
"chat_history": []*schema.Message{
schema.UserMessage("Hello"),
schema.AssistantMessage("Hey! Keep it up!", nil),
},
})
// ... handle err
return messages
}

💡 components/prompt/chat_prompt/chat_prompt.go shows a more complete template usage.

2.2 One-shot generation (Generate)

result, err := model.Generate(ctx, messages)
if err != nil {
log.Fatal(err)
}
fmt.Println(result.Content)

2.3 Streaming generation (Stream) — focus on mastering this

streamReader, err := model.Stream(ctx, messages)
if err != nil {
log.Fatal(err)
}
defer streamReader.Close() // ⚠️ Remember to close when done!
for {
chunk, err := streamReader.Recv()
if err == io.EOF { break }
if err != nil {
log.Fatal(err)
}
fmt.Print(chunk.Content)
}

💡 What is StreamReader? *schema.StreamReader[*schema.Message] returned by model.Stream() is similar to the Iterator — the model generates chunks in the background, and you consume them chunk by chunk via Recv(). But there are key differences:

  • Iterator (used by Agent): Next() returns (event, bool), !ok means the Agent finished execution
  • StreamReader (used by ChatModel): Recv() returns (chunk, error), io.EOF means the stream ended
  • defer Close() is mandatory: StreamReader holds an HTTP connection underneath; not calling Close causes connection leak / goroutine leak

⚠️ The most easily confused part of this section

Eino has two iteration modes, with different end conditions:

// ─── Mode A: Agent Runner event iteration ───
// Next() returns (event, bool), !ok means end
for {
event, ok := iter.Next()
if !ok { break }
}
// ─── Mode B: ChatModel Stream streaming iteration ───
// Recv() returns (chunk, error), io.EOF means end
defer streamReader.Close()
for {
chunk, err := streamReader.Recv()
if err == io.EOF { break }
}

Memory trick: Agent uses Next + ok, Stream uses Recv + EOF.

Common import paths (new in this section):

"github.com/cloudwego/eino/components/prompt" // ChatTemplate, FromMessages

📇 Cheat-sheet cards for this section

One-shot: model.Generate(ctx, messages) → (*Message, error)
Streaming: model.Stream(ctx, messages) → (*StreamReader, error)
defer close → Recv() loop → io.EOF ends
Template: prompt.FromMessages(schema.FString, ...messages)
template.Format(ctx, map[string]any{...})

III. ChatModelAgent Basics: Tool + Agent + Runner

📁 Code: adk/intro/chatmodel/chatmodel.go

📖 Theory: quickstart/chatwitheino/docs/ch02_chatmodel_agent_runner_console

🆕 New packages overview for this section (just remember the names, details later):

"github.com/cloudwego/eino/components/tool" // BaseTool, InvokableTool
"github.com/cloudwego/eino/components/tool/utils" // InferTool, NewTool
"github.com/cloudwego/eino/compose" // ToolsNodeConfig (Tool config container)

The compose package will be covered in depth in section seven — here you only need to know that compose.ToolsNodeConfig is the “config shell” used when registering Tools.

3.1 Why do you need an Agent instead of using ChatModel directly?

DimensionChatModel (component)ChatModelAgent (agent)
PositionSingle capability unitComplete AI application
OutputGenerate() / Stream() return message directlyRun() returns an event stream which can include tool calls, interrupts, etc.
Multi-turn dialogueNeed to manage history yourselfHandled internally by the Agent
Tool calls❌ Not supported✅ Configured via ToolsConfig
Interrupt/resume❌ Not supported✅ Via CheckPointStore
Use caseSimple one-off Q&AComplex agent applications

3.2 Three ways to create a Tool

📁 Supplementary code: quickstart/todoagent/main.go

Eino provides three ways to create a Tool:

MethodApproachSuitable scenario
Method 1: InferToolWrite a plain function, auto-infer schema from struct tagsMost common, 90% of scenarios
Method 2: NewToolManually write schema.ToolInfo, pass to utils.NewToolWhen precise control over parameter constraints is needed
Method 3: struct implements interfacestruct implements Info() + InvokableRun()Tool logic is complex, needs to be stateful

Method 1: InferTool (auto-inference, most common) ⭐

import (
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/components/tool/utils"
)
// ① Define input struct (describe params with json tag + jsonschema tag)
type BookSearchInput struct {
Genre string `json:"genre" jsonschema_description:"book genre"`
MaxPages int `json:"max_pages" jsonschema_description:"maximum page count"`
}
type BookSearchOutput struct {
Books []string `json:"books"`
}
// ② Write business function + utils.InferTool auto-wraps it
func NewBookSearchTool() tool.InvokableTool {
t, _ := utils.InferTool(
"search_book",
"Search books by preferences",
func(ctx context.Context, input *BookSearchInput) (*BookSearchOutput, error) {
return &BookSearchOutput{Books: []string{"《The Three-Body Problem》"}}, nil
},
)
return t
}

Method 2: NewTool (manually define schema)

Used when you need more precise control over parameter descriptions, adding enum constraints, etc.:

func getAddTodoTool() tool.InvokableTool {
info := &schema.ToolInfo{
Name: "add_todo",
Desc: "Add a todo item",
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"content": {
Desc: "The content of the todo item",
Type: schema.String,
Required: true,
},
"deadline": {
Desc: "The deadline of the todo item, in unix timestamp",
Type: schema.Integer,
},
}),
}
return utils.NewTool(info, AddTodoFunc)
}
type TodoAddParams struct {
Content string `json:"content"`
Deadline *int64 `json:"deadline,omitempty"`
}
func AddTodoFunc(_ context.Context, params *TodoAddParams) (string, error) {
return `{"msg": "add todo success"}`, nil
}

Method 3: struct implements interface (most flexible)

When the Tool needs to be stateful, or the logic is very complex, implement the interface directly:

type ListTodoTool struct{}
func (lt *ListTodoTool) Info(_ context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: "list_todo",
Desc: "List all todo items",
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"finished": {
Desc: "filter todo items if finished",
Type: schema.Boolean,
},
}),
}, nil
}
func (lt *ListTodoTool) InvokableRun(_ context.Context, argumentsInJSON string, _ ...tool.Option) (string, error) {
return `{"todos": [...]}`, nil
}

🧠 How to choose: 90% of scenarios are covered by InferTool; use NewTool when you need to manually constrain parameter types / enum values; use the struct approach when the Tool itself is stateful (e.g., has a database connection). All three registration methods into the Agent are exactly the same — just drop them into the ToolsConfig.Tools array.

3.3 Assemble the full application (Tool + streaming)

ℹ️ Focus of this section: only shows the main flow of Tool + Agent + Runner + Query. Interrupt/resume (Resume) is covered separately in section five.

package main
import (
"context"
"fmt"
"log"
"os"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
"github.com/cloudwego/eino-ext/components/model/openai"
)
func main() {
ctx := context.Background()
// ─── Create model ───
model, err := openai.NewChatModel(ctx, &openai.ChatModelConfig{
APIKey: os.Getenv("OPENAI_API_KEY"),
Model: os.Getenv("OPENAI_MODEL"),
BaseURL: os.Getenv("OPENAI_BASE_URL"),
})
if err != nil {
log.Fatal(err)
}
// ─── Create Agent (configure Tool) ───
agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "BookRecommender",
Description: "An agent that recommends books",
Instruction: "You are a book expert. Use search_book tool to find books.",
Model: model,
ToolsConfig: adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{
NewBookSearchTool(),
},
},
},
})
if err != nil {
log.Fatal(err)
}
// ─── Create Runner (configure streaming) ───
runner := adk.NewRunner(ctx, adk.RunnerConfig{
Agent: agent,
EnableStreaming: true,
})
// ─── Run ───
iter := runner.Query(ctx, "Recommend a sci-fi novel")
for {
event, ok := iter.Next()
if !ok { break }
if event.Err != nil {
log.Fatal(event.Err)
}
fmt.Printf("Event: %+v\n", event)
}
}

3.4 Config ownership quick reference

Remember one sentence: The Agent manages “what capabilities it has”, the Runner manages “how to execute”.

ConfigWhere to set itEffect
InstructionChatModelAgentConfigSystem prompt, defines the Agent’s “persona”
ToolsConfigChatModelAgentConfigRegister the tools available to the Agent
EnableStreaming: trueRunnerConfigStreaming output (typewriter effect)
CheckPointStoreRunnerConfigSave execution state, support interrupt/resume (section five)

3.5 Underlying view: an Agent is essentially an interface

📁 Code: adk/intro/custom/myagent.go

adk.NewChatModelAgent internally implements the adk.Agent interface:

type Agent interface {
Name(ctx) string
Description(ctx) string
Run(ctx, input, ...options) *AsyncIterator[*AgentEvent]
}

myagent.go shows the minimal implementation — three key points:

  • NewAsyncIteratorPair = build an event pipeline; gen manages sending, iter manages receiving
  • A goroutine is spawned because Run() must return iter immediately, with events generated asynchronously in the background
  • gen.Close() must be called, otherwise the consumer’s Next() will block forever

Common import paths (new in this section):

"github.com/cloudwego/eino/components/tool" // BaseTool, InvokableTool, Option
"github.com/cloudwego/eino/components/tool/utils" // InferTool, NewTool
"github.com/cloudwego/eino/compose" // ToolsNodeConfig

📇 Cheat-sheet cards for this section

Agent.{Name, Instruction, ToolsConfig, Model}
Runner.{Agent, EnableStreaming, CheckPointStore}
Three ways to create Tool: InferTool (90%) / NewTool / struct interface
Registration is the same for all: ToolsConfig.Tools = []tool.BaseTool{ toolfuction...}
Note: if you forget what a specific struct needs to contain, just look at the source interface definition

IV. Middleware: Adding Interceptors to the Agent

📁 Code:

  • quickstart/chatwitheino/helpers/middleware.go - SafeToolMiddleware, custom new middleware
  • quickstart/chatwitheino/helpers/retry.go - automatic error retry
  • adk/middlewares/dynamictool/toolsearch/ — dynamic tool retrieval middleware
  • adk/middlewares/skill/main.go — skill loading middleware

📖 Theory: quickstart/chatwitheino/docs/ch05_middleware

4.1 Why do you need Middleware?

Two common problems:

Problem 1: A Tool error crashes the whole conversation

[tool call] read_file(file_path: "nonexistent.txt")
Error: open nonexistent.txt: no such file or directory
// 💥 Conversation interrupted directly

Problem 2: Model API rate limiting causes failure

Error: rate limit exceeded (429)
// 💥 Conversation interrupted

The behavior you want: when a Tool errors, hand the error to the LLM to self-heal; when the model is rate-limited, retry automatically. This is what Middleware solves — an interceptor for the Agent, inserting custom logic before and after the call.

4.2 The nature of Middleware: the onion model

Request → A.Wrap → B.Wrap → C.Wrap → actual execution → C returns → B returns → A returns → Response
↑ ↑
Outermost intercepts first Innermost touches the actual result first

4.3 Core scenario 1: SafeToolMiddleware (custom new middleware: Tool error to string)

// Define a struct to carry the middleware logic. Embed a base middleware to reuse its default behavior.
type safeToolMiddleware struct {
*adk.BaseChatModelAgentMiddleware
}
// Intercept synchronous tool calls; the method signature is defined in BaseChatModelAgentMiddleware
func (m *safeToolMiddleware) WrapInvokableToolCall(
_ context.Context,
endpoint adk.InvokableToolCallEndpoint,
_ *adk.ToolContext,
) (adk.InvokableToolCallEndpoint, error) {
return func(ctx context.Context, args string, opts ...tool.Option) (string, error) {
// intercept the raw endpoint's result and err
result, err := endpoint(ctx, args, opts...)
if err != nil {
// ⚠️ Interrupt errors must continue propagating, cannot be swallowed
if _, ok := compose.IsInterruptRerunError(err); ok {
return "", err
}
// normal error → convert to string so the LLM can see it
return fmt.Sprintf("[tool error] %v", err), nil
}
// no error, just return the raw result
return result, nil
}, nil
}
// You can also intercept other calls like WrapStreamableToolCall for streaming tools, etc. — see the official examples yourself

💡 Key distinction: compose.IsInterruptRerunError is a special error thrown by interrupt/resume (section five); it must continue propagating upward and cannot be converted to a string.

4.4 Core scenario 2: ModelRetryConfig (automatic model call retry)

The original IsRetryAble was marked deprecated; now you should use ShouldRetry instead

Some middleware can be specified directly when defining the agent, such as ModelRetryConfig

ModelRetryConfig: &adk.ModelRetryConfig{
MaxRetries: 5,
ShouldRetry: func(ctx context.Context, retryCtx *adk.TypedRetryContext[M]) *adk.TypedRetryDecision[M] {
// get the error message
err := retryCtx.Error
// decide whether to retry
if err != nil && (strings.Contains(err.Error(), "429") || strings.Contains(err.Error(), "Too Many Requests")) {
// return a retry decision
return &adk.TypedRetryDecision[M]{
Retry: true,
// here you can also modify the input or options when retrying, etc.
}
}
// no retry needed, return nil or Retry: false
return nil
},
},

4.5 Core scenario 3: Dynamic tool retrieval (ToolSearch Middleware)

Eino also pre-defines some middleware, such as agentsmd, dynamictool/toolsearch, etc., which can be called directly. A few common ones are introduced below.

📁 adk/middlewares/dynamictool/toolsearch/

Pain point: Too many Tools will blow up the context window. ToolSearch first puts all Tools into a “tool library”, and searches/filters when calling:

toolSearchMiddleware, _ := toolsearch.New(ctx, &toolsearch.Config{
DynamicTools: allDynamicTools,
})
agent, _ := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "tool_search_agent",
Model: chatModel,
Handlers: []adk.ChatModelAgentMiddleware{
toolSearchMiddleware, // ← not ToolsConfig!
},
})

🧠 Difference from section three’s ToolsConfig: Section three writes Tools into ToolsConfig.Tools — all sent to the LLM. ToolSearch searches/filters first, only sending the relevant ones to the LLM.

4.6 Core scenario 4: Skill Middleware (dynamically load skills)

📁 adk/middlewares/skill/main.go

Create the Skill backend

skillBackend, err := skill.NewBackendFromFilesystem(ctx, &skill.BackendFromFilesystemConfig{
Backend: be,
BaseDir: skillsDir, // specify the root directory to scan
})

Create and inject the Skill middleware

sm, err := skill.NewMiddleware(ctx, &skill.Config{
Backend: skillBackend,
})

Then at runtime, dynamically load “skills” (predefined prompt + tool combinations) from the filesystem

// filesystem middleware → Skill middleware (order matters!)
agent, _ := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "LogAnalysisAgent",
Model: cm,
Handlers: []adk.ChatModelAgentMiddleware{
fsm, // file read/write capability (outer layer of onion model, wraps first, returns last)
sm, // Skill loading capability (inner layer of onion model, wraps later, returns first)
},
})

4.7 Middleware panorama quick reference

Middleware typeWhat pain point it solvesSourceConfig location
SafeToolMiddlewareTool error crashes conversation → convert to string for LLM self-healImplement yourselfHandlers
ModelRetryConfigModel API rate limiting → automatic retryadk.ModelRetryConfig (built-in)ChatModelAgentConfig
ToolSearchToo many Tools blow up context → dynamic retrieval injectiontoolsearch.New()Handlers
SkillDynamically load skills at runtime → read from filesystemskill.NewMiddleware()Handlers
filesystemAgent needs to read/write filesystemfilesystem.New()Handlers

🧠 Model: security checkpoint

User request → [Check A: check ID] → [Check B: X-ray] → [Check C: metal detector] → board
User receives ← [Check A: stamp] ← [Check B: tag] ← [Check C: weigh] ← luggage out

📇 Cheat-sheet cards for this section

SafeToolMiddleware = error to string (except InterruptRerunError)
ModelRetryConfig = rate-limit automatic retry (built-in config item)
ToolSearch = dynamic tool pool search (replaces ToolsConfig)
Skill = load skills from filesystem
Common pattern: embed BaseChatModelAgentMiddleware → override one hook → put into Handlers

V. Interrupt & Resume

📁 Code:

  • adk/intro/chatmodel/ — Tool interrupt
  • adk/cancel/graceful-exit/ — external cancellation
  • adk/human-in-the-loop/ — full human-in-the-loop example set

📖 Memory theory: quickstart/chatwitheino/docs/ch03_memory_session_jsonl

📖 Tool theory: quickstart/chatwitheino/docs/ch04_tool_backend_filesystem

5.1 Interrupt mechanism overview

This is the core advanced feature of the Eino ADK. The whole mechanism is based on a simple state machine:

stateDiagram-v2
    [*] --> Running: runner.Query/Run(checkpointID)
    Running --> Interrupted: compose.Interrupt()
    Running --> Cancelled: Ctrl-C → cancelFn()
    Running --> Completed: normal end
    Running --> HITL_Interrupted: compose.Interrupt()

    Interrupted --> Resuming: runner.Resume(checkpointID, WithToolOptions)
    Cancelled --> ResumingNew: new runner.Resume(checkpointID)
    HITL_Interrupted --> ResumingHITL: runner.ResumeWithParams(checkpointID, params)

    Resuming --> Running
    ResumingNew --> Running
    ResumingHITL --> Running
    Completed --> [*]

The three trigger methods compared:

Method 1: Tool interrupt (essentially Method 3)Method 2: External cancellationMethod 3: Human-in-the-Loop
Trigger sourceTool calls `compose.Interrupt“System signal Ctrl-CTool calls compose.Interrupt
RunnerSame runnerNew runnerSame runner
Resume APIrunner.Resume + WithToolOptionsNew runner.Resumerunner.ResumeWithParams
Typical scenarioInsufficient info, wait for user to supplementLong-running, user wants to abortSensitive operation approval, parameter review

Common point: all rely on CheckPointStore + CheckPointID.

🆕 New packages overview for this section:

"github.com/cloudwego/eino/compose" // CheckPointStore (interrupt state storage interface)
// CheckPointStore has multiple implementations, e.g.:
// store.NewInMemoryStore() ← in-memory implementation (for dev)
// Section five only cares about the interrupt API; the underlying principle of CheckPointStore is in section seven

5.2 Method 1: Interrupt inside Tool (actively pause and wait for input)

User inputs "recommend a book"
runner := adk.NewRunner(ctx, adk.RunnerConfig{
Agent: a,
CheckPointStore: store.NewInMemoryStore(), // runner sets CheckPointStore to store the subsequent CheckPointID
})
runner.Query(ctx, "recommend a book", WithCheckPointID("1")) pass CheckPointID; subsequent resume uses this
Agent analysis: not enough info → Tool returns Interrupt("What genre of books do you like?") Note: in the example code NewInterruptAndRerunErr is marked enabled
Receive Interrupted event in the event stream → pause, wait for user input
runner.Resume(ctx, "1", WithToolOptions(WithNewInput("sci-fi"))) resume via CheckPointID
Agent continues → re-calls Tool with "sci-fi" → returns result

Note: since this is already outdated + essentially the same as Method 3, you can directly look at how Method 3 interrupts.

5.3 Method 2: External cancellation (Ctrl-C graceful exit)

📁 adk/cancel/graceful-exit/main.go

Design intent: When the user presses Ctrl-C, instead of killing the process and wasting the work — safely pause, automatically save progress, then resume from the breakpoint later.

User presses Ctrl-C
cancelFn(CancelAfterChatModel, WithRecursive, 30s timeout)
├─→ CancelAfterChatModel: wait for current ChatModel call to finish before stopping
├─→ WithRecursive: recursively propagate to child Agents
└─→ Timeout fallback: if no safe point reached within 30s → force CancelImmediate
CheckPointStore auto-saves state → new Runner.Resume resumes

Creating the cancellation

// Create cancel options and the external trigger function
cancelOpt, cancelFn := adk.WithCancel()
// Start Agent run, bound to a breakpoint ID
iter := runner.Run(ctx, input, cancelOpt, adk.WithCheckPointID(checkpointID))
// Register OS signal listener: SIGINT(Ctrl-C), SIGTERM
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
fmt.Printf("\nReceived OS signal: %v, initiating graceful Agent cancellation\n", sig)
// Initiate graceful cancellation: wait for ChatModel safe point, recursively propagate to child Agents, 30s timeout fallback
handle, contributed := cancelFn(
adk.WithAgentCancelMode(adk.CancelAfterChatModel),
adk.WithRecursive(),
adk.WithAgentCancelTimeout(30*time.Second),
)
fmt.Printf("Was cancellation request successfully submitted: %v\n", contributed)
// Block waiting for cancellation to complete (returns after breakpoint is persisted)
if waitErr := handle.Wait(); waitErr != nil {
fmt.Printf("Graceful cancellation error: %v\n", waitErr)
} else {
fmt.Println("Graceful cancellation done, breakpoint saved")
}
}()

Resuming from cancellation

// Use the same CheckPointStore, create a new Runner to resume
resumeRunner := adk.NewRunner(ctx, adk.RunnerConfig{
Agent: agent,
EnableStreaming: true,
CheckPointStore: cpStore,
})
resumeIter, err := resumeRunner.Resume(ctx, checkpointID)
if err != nil {
log.Fatalf("Breakpoint resume failed: %v", err)
}
drainEvents(resumeIter)

5.4 Method 3: Human-in-the-Loop collaboration (ResumeWithParams)

📁 adk/human-in-the-loop/1_approval/ ~ 8_supervisor-plan-execute/

Match the interrupt point precisely via interruptID, and inject any type of data:

// ① Start
iter := runner.Query(ctx, query, adk.WithCheckPointID("1"))
// ② Detect interrupt, extract interruptID
interruptCtx := lastEvent.Action.Interrupted.InterruptContexts[0]
interruptID := interruptCtx.ID
// ③ After user decision, pass it back via ResumeWithParams
iter, _ = runner.ResumeWithParams(ctx, "1", &adk.ResumeParams{
Targets: map[string]any{
interruptID: modifiedInfo,
},
})

The interrupting side:

func FollowUp(ctx context.Context, input *FollowUpToolInput) (string, error) {
// First entry (trigger interrupt)
wasInterrupted, _, storedState := tool.GetInterruptState[*FollowUpState](ctx)
if !wasInterrupted {
// Prepare the info to show the user (e.g., a list of questions)
info := &FollowUpInfo{Questions: input.Questions}
// Prepare the state to save (so we know what we're doing when resuming)
state := &FollowUpState{Questions: input.Questions}
// Trigger the interrupt! Send info to the frontend to display, save the state, then return immediately
return "", tool.StatefulInterrupt(ctx, info, state)
}
// Second entry
isResumeTarget, hasData, resumeData := tool.GetResumeContext[*FollowUpInfo](ctx)
if !isResumeTarget {
// If the system wakes this function but finds it's not the current target to resume
// then take out the previously saved state and keep interrupting to wait
info := &FollowUpInfo{Questions: storedState.Questions}
return "", tool.StatefulInterrupt(ctx, info, storedState)
}
// Woken up, but no answer yet (defensive re-entry)
if !hasData || resumeData.UserAnswer == "" {
return "", fmt.Errorf("tool resumed without a user answer")
}
// Successfully resumed, got the answer (normal exit)
return resumeData.UserAnswer, nil
}

Four interaction modes (differ only in info type and user action):

ModeInfo carried at interruptUser actionExample directory
ApprovalApprovalResultY/N approve or reject1_approval, 5_supervisor
Review/editReviewEditInfoModify params / approve / reject2_review-and-edit
Feedback loopFeedbackInfoInput feedback3_feedback-loop
Follow-upFollowUpInfoAnswer questions one by one4_follow-up, 7_deep-agents

💡 ResumeWithParams vs old Resume: ResumeWithParams is the general API; Resume + WithToolOptions is a special case of it.

📇 Cheat-sheet cards for this section

Three elements of interrupt/resume: CheckPointStore (stores state) + CheckPointID (identifier) + Resume (resume)
Method 1: compose.Interrupt → runner.Resume(id, WithToolOptions)
Method 2: Ctrl-C → cancelFn → new runner.Resume(id)
Method 3: compose.Interrupt → runner.ResumeWithParams(id, {Targets: {interruptID: data}})

VI. Advanced Topics

6.1 Callbacks & Tracing (observability)

📁 adk/common/trace/coze_loop.go

traceCloseFn, startSpanFn := trace.AppendCozeLoopCallbackIfConfigured(ctx)
defer traceCloseFn(ctx)
ctx, endSpanFn := startSpanFn(ctx, "MyTask", "user query")
// ... runner.Query(ctx, query) ...
endSpanFn(ctx, lastMessage)

6.2 Workflow Agent: Orchestrating Collaboration of Multiple Agents

📁 adk/intro/workflow/loop/, parallel/, sequential/

Three built-in workflow Agents:

TypeModeUse case
LoopAgentMain Agent generates → reviewer critiques → redo if unsatisfiedTasks needing repeated refinement
ParallelAgentMultiple sub-Agents receive the same input simultaneously, each executesCollect info from multiple data sources at once
SequentialAgentSub-Agents execute one by one, previous output → next inputStep-by-step task pipeline
// LoopAgent
a, _ := adk.NewLoopAgent(ctx, &adk.LoopAgentConfig{
SubAgents: []adk.Agent{mainAgent, critiqueAgent},
MaxIterations: 5,
})
// ParallelAgent
a, _ := adk.NewParallelAgent(ctx, &adk.ParallelAgentConfig{
SubAgents: []adk.Agent{stockAgent, newsAgent, socialAgent},
})
// SequentialAgent
a, _ := adk.NewSequentialAgent(ctx, &adk.SequentialAgentConfig{
SubAgents: []adk.Agent{planAgent, writerAgent},
})

6.3 Multi-Agent: Supervisor Pattern

📁 adk/multiagent/supervisor/, layered-supervisor/, plan-execute-replan/

Supervisor: a “manager” Agent analyzes the user request and decides which “expert” sub-Agent to dispatch to.

sv, _ := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "supervisor",
Instruction: "You are a supervisor managing a research_agent and math_agent.",
Model: m,
})
supervisorAgent, _ := supervisor.New(ctx, &supervisor.Config{
Supervisor: sv,
SubAgents: []adk.Agent{searchAgent, mathAgent},
})

Layered Supervisor: a sub-Agent can itself be a Supervisor, forming a tree-shaped dispatch.

Plan-Execute-Replan: Planner makes a plan → Executor executes step by step → Replanner checks and revises the plan.

entryAgent, _ := planexecute.New(ctx, &planexecute.Config{
Planner: planAgent,
Executor: executeAgent,
Replanner: replanAgent,
MaxIterations: 20,
})

🧠 Comparison of the three multi-Agent modes:

  • Workflow Agent (6.2): orchestration is fixed (sequential / parallel / loop)
  • Supervisor (6.3): orchestration is dynamic, decided by the LLM who to dispatch to
  • Plan-Execute-Replan: suitable for complex tasks requiring multi-step planning where the plan may change midway

6.4 Compose Orchestration API Overview

From highest to lowest abstraction level:

ADK (Agent+Runner) > Chain > Graph > Workflow
High-level, out of the box Low-level, fine control over every node and data flow
APICharacteristicsSuitable scenario
WorkflowField mapping + auto dependency inferencePure-function data pipeline
GraphManually AddEdge to build graph, supports StateLLM call flow, stateful branching
ChainChained Append nodesChatModel → ToolsNode linear flow
BatchBatch parallel processing of N inputsBulk document review, etc.

Graph basic usage

g := compose.NewGraph[map[string]any, *schema.Message]()
_ = g.AddChatTemplateNode("prompt", pt)
_ = g.AddChatModelNode("model", chatModel)
_ = g.AddEdge(compose.START, "prompt")
_ = g.AddEdge("prompt", "model")
_ = g.AddEdge("model", compose.END)
runner, _ := g.Compile(ctx)
result, _ := runner.Invoke(ctx, input)

Chain basic usage

chain := compose.NewChain[map[string]any, string]()
chain.
AppendLambda(preprocess).
AppendBranch(compose.NewChainBranch(cond).AddLambda("b1", fn1).AddLambda("b2", fn2)).
AppendLambda(postprocess)
runner, _ := chain.Compile(ctx)

📇 Cheat-sheet cards for this section

Workflow Agent: LoopAgent / ParallelAgent / SequentialAgent
Supervisor: supervisor.New({Supervisor, SubAgents})
Compose orchestration: Graph(AddEdge) / Chain(Append) / Workflow(MapFields)

VII. Full Architecture Layered Diagram

graph TD
    Q["What do you want to do?"] --> Q1{"Simple one-off Q&A?"}
    Q1 -->|Yes| CM["ChatModel.Generate/Stream
§2"] Q1 -->|No| Q2{"Standard Agent
Q&A + tools + memory?"} Q2 -->|Yes| ADK["ChatModelAgent + Runner
§3"] Q2 -->|No| Q3{"Custom ReAct loop?"} Q3 -->|Yes| REACT["react.NewAgent
appendix1 §1"] Q3 -->|No| Q4{"Multi-Agent collaboration?"} Q4 -->|Dispatch pattern| SUP["ADK Supervisor
§6.3
or Host MultiAgent
appendix1 §6"] Q4 -->|Pipeline| PE["Plan-Execute
appendix1 §7"] Q4 -->|Complex state graph| CUSTOM["Custom Graph+State
appendix1 §9"] Q4 -->|No| Q5{"Fine control over every node?"} Q5 -->|Yes| GRAPH["Compose Graph/Chain
§6.4"] Q5 -->|No| CHECK["Back to ADK path"]

📇 Full cheat-sheet cards (complete version)

┌─ Create chain ─────────────────────────────────────────────┐
│ NewChatModel → NewChatModelAgent → NewRunner → Run │
│ (model) (agent) (runner) (message) │
├─ Two iterations ───────────────────────────────────────────┤
│ Agent: for { event, ok := iter.Next(); if !ok {break}}│
│ Stream: for { chunk, err := sr.Recv(); io.EOF → break}│
├─ Responsibility separation ───────────────────────────────────────────┤
│ Agent manages capability:{Name, Instruction, ToolsConfig, Model} │
│ Runner manages execution:{Agent, EnableStreaming, CheckPointStore}│
├─ Tool creation ──────────────────────────────────────────┤
│ InferTool (90%) / NewTool / struct interface │
├─ Middleware ────────────────────────────────────────┤
│ SafeTool / ModelRetry / ToolSearch / Skill │
├─ Interrupt/resume ──────────────────────────────────────────┤
│ CheckPointStore + CheckPointID + Resume │
│ Three methods: Tool interrupt / Ctrl-C cancel / Human-in-the-Loop │
├─ Orchestration levels ──────────────────────────────────────────┤
│ ADK(Agent+Runner) > Chain > Graph > Workflow │
├─ Agentic path ([Agentic Advanced](./agentic))──────────────────────────┤
│ Just add Typed[*AgenticMessage] to all types │
└──────────────────────────────────────────────────────┘

Common import paths (full summary):

// Model
"github.com/cloudwego/eino-ext/components/model/openai" // OpenAI-compatible ChatModel
// ADK core
"github.com/cloudwego/eino/adk" // Agent, Runner, Message, Middleware
"github.com/cloudwego/eino/schema" // UserMessage, SystemMessage, ToolInfo
// Tool
"github.com/cloudwego/eino/components/tool" // BaseTool, InvokableTool
"github.com/cloudwego/eino/components/tool/utils" // InferTool, NewTool
// Orchestration
"github.com/cloudwego/eino/compose" // Graph, Chain, Workflow, ToolsNodeConfig
// Middleware extension
"github.com/cloudwego/eino/adk/middlewares/dynamictool/toolsearch" // ToolSearch
"github.com/cloudwego/eino/adk/middlewares/skill" // Skill
"github.com/cloudwego/eino/adk/middlewares/filesystem" // filesystem
// Prompt
"github.com/cloudwego/eino/components/prompt" // ChatTemplate, FromMessages

⚠️ Common Gotchas

When developing Eino applications, the following pitfalls are the easiest to fall into — each is shown with a “bad code vs good code” comparison:

Pitfall 1: Forgetting defer streamReader.Close() → goroutine leak

// ❌ Bad: forgot to Close, HTTP connection never released, goroutine leak
sr, _ := model.Stream(ctx, messages)
for { chunk, err := sr.Recv(); ... }
// ✅ Good: defer Close ensures it closes no matter how you exit
sr, _ := model.Stream(ctx, messages)
defer sr.Close()
for { chunk, err := sr.Recv(); ... }

Pitfall 2: Custom Agent forgets gen.Close()Next() blocks forever

// ❌ Bad: forgot Close, consumer's Next() waits for false forever
func (m *MyAgent) Run(...) *AsyncIterator[*AgentEvent] {
iter, gen := adk.NewAsyncIteratorPair[*AgentEvent]()
go func() {
gen.Send(&AgentEvent{...})
// forgot gen.Close()!!!
}()
return iter
}
// ✅ Good: defer + recover ensures Close is always called
func (m *MyAgent) Run(...) *AsyncIterator[*AgentEvent] {
iter, gen := adk.NewAsyncIteratorPair[*AgentEvent]()
go func() {
defer func() {
if e := recover() { gen.Send(&AgentEvent{Err: ...}) }
gen.Close() // ← must be called!
}()
gen.Send(&AgentEvent{...})
}()
return iter
}

Pitfall 3: SafeToolMiddleware mistakenly swallows interrupt error

// ❌ Bad: all errors converted to string, including interrupt errors
func wrap(endpoint) endpoint {
return func(ctx, args) (string, error) {
result, err := endpoint(ctx, args)
if err != nil { return fmt.Sprintf("[error] %v", err), nil }
return result, nil
}
}
// ✅ Good: distinguish interrupt errors from normal errors
func wrap(endpoint) endpoint {
return func(ctx, args) (string, error) {
result, err := endpoint(ctx, args)
if err != nil {
if _, ok := compose.IsInterruptRerunError(err); ok {
return "", err // ← interrupt error must propagate!
}
return fmt.Sprintf("[tool error] %v", err), nil
}
return result, nil
}
}

Pitfall 4: CheckPointID inconsistent on Resume

// ❌ Bad: Query and Resume use different IDs — Runner can't find the previous save point
iter := runner.Query(ctx, "help me recommend", adk.WithCheckPointID("session-1"))
// ... interrupt ...
iter, _ = runner.Resume(ctx, "session-2") // ← ID inconsistent! Should be "session-1"
// ✅ Good: same ID
iter := runner.Query(ctx, "help me recommend", adk.WithCheckPointID("session-1"))
// ... interrupt ...
iter, _ = runner.Resume(ctx, "session-1") // ← consistent!

Pitfall 5: New Runner on Resume without passing the same CheckPointStore

// ❌ Bad: new Runner didn't use the same CheckPointStore — state lost
runner1 := adk.NewRunner(ctx, adk.RunnerConfig{Agent: agent}) // default empty Store
iter := runner1.Query(ctx, "query", adk.WithCheckPointID("1"))
// ... Ctrl-C cancel ...
runner2 := adk.NewRunner(ctx, adk.RunnerConfig{Agent: agent}) // another empty Store
iter, _ = runner2.Resume(ctx, "1") // ← can't find checkpoint!
// ✅ Good: share the same CheckPointStore
store := store.NewInMemoryStore()
runner1 := adk.NewRunner(ctx, adk.RunnerConfig{Agent: agent, CheckPointStore: store})
runner1.Query(...)
// ...
runner2 := adk.NewRunner(ctx, adk.RunnerConfig{Agent: agent, CheckPointStore: store})
runner2.Resume(ctx, "1") // ← found!

Pitfall 6: Server-side tool (web_search) expects function_tool_result

// ❌ Bad: waiting for web_search's function_tool_result
for _, block := range msg.ContentBlocks {
if block.Type == schema.ContentBlockTypeFunctionToolResult {
// web_search will never produce this!
}
}
// ✅ Good: understand server_tool_call executes on the vendor side, no corresponding function_tool_result
for _, block := range msg.ContentBlocks {
if block.Type == schema.ContentBlockTypeServerToolCall {
// the model has already consumed the search results — you'll only see the subsequent reasoning/text block
fmt.Println("Server-side search complete:", block.ServerToolCall.Name)
}
}

🧠 Summary: The six pitfalls above cover 90% of Eino development problems. Remember three principles:

  1. Open must close (StreamReader → defer Close, AsyncIterator → gen.Close)
  2. Interrupt must pass through (InterruptRerunError cannot be swallowed by Middleware)
  3. Resume must align (CheckPointStore + CheckPointID must be consistent across Runners)

📂 Source repository: github.com/cloudwego/eino-examples

📖 Keep reading:


Thanks for reading! Follow me if you'd like~

Eino Study Notes

Mon Jul 20 2026
5439 words · 43 minutes
Cover
Sample track
Sample artist
Cover
Sample track
Sample artist
0:00 / 0:00