You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

12 KiB

title
Chapter 7: Interrupt/Resume

The goal of this chapter is to understand the Interrupt/Resume mechanism and implement a Tool approval flow that lets users confirm before sensitive operations.

Code Location

Prerequisites

Same as Chapter 1: you need to configure an available ChatModel (OpenAI or Ark). Additionally, you need to set PROJECT_ROOT as in Chapter 4:

export PROJECT_ROOT=/path/to/eino  # Eino core library root directory (defaults to the current directory if not set)

Running

In the examples/quickstart/chatwitheino directory, run:

# Set the project root directory
export PROJECT_ROOT=/path/to/your/project

go run ./cmd/ch07

Output example:

you> Please execute the command echo hello

Warning: Approval Required
Tool: execute
Arguments: {"command":"echo hello"}

Approve this action? (y/n): y
[tool result] hello

hello

From Auto-Execution to Human Approval: Why We Need Interrupt

In the previous chapters, the Agent automatically executed all Tool calls, but in certain scenarios this is dangerous:

Risks of auto-execution:

  • Deleting files: Accidentally deleting important data
  • Sending emails: Sending incorrect content
  • Executing commands: Running dangerous operations
  • Modifying configuration: Breaking system settings

The role of Interrupt:

  • Interrupt is the Agent's pause mechanism: Pauses before critical operations, waiting for user confirmation
  • Interrupt can carry information: Shows the user the operation about to be executed
  • Interrupt is resumable: Continues execution after user confirmation, returns an error if rejected

Simple analogy:

  • Auto-execution = "autopilot" (fully trusting the system)
  • Interrupt = "manual override" (critical decisions are made by humans)

Key Concepts

Interrupt Mechanism

Interrupt is the core mechanism in Eino for implementing human-agent collaboration.

Core idea: Pause before executing critical operations, and continue after user confirmation.

A Tool execution that requires approval is split into two phases:

  1. First call (trigger interrupt): The Tool saves the current arguments, then returns an interrupt signal. The Runner pauses execution and returns an Interrupt event to the caller.
  2. Resume after user approval: The Runner calls the Tool again. This time the Tool detects it was "previously interrupted", reads the user's approval result, and executes (or rejects).

Simplified pseudocode:

func myTool(ctx, args):
    if first call:
        save args
        return interrupt signal  // Runner pauses, shows approval prompt
    else:  // Second call after Resume
        if user approved:
            return execute(saved args)
        else:
            return "Operation rejected by user"

Full code with key field explanations:

// Trigger an interrupt in a Tool
func myTool(ctx context.Context, args string) (string, error) {
    // wasInterrupted: whether this is the second call after Resume (false on first call, true after Resume)
    // storedArgs: arguments saved via StatefulInterrupt during the first call, retrievable after Resume
    wasInterrupted, _, storedArgs := tool.GetInterruptState[string](ctx)

    if !wasInterrupted {
        // First call: trigger interrupt, saving args for use after Resume
        return "", tool.StatefulInterrupt(ctx, &ApprovalInfo{
            ToolName:        "my_tool",
            ArgumentsInJSON: args,
        }, args)  // Third argument is the state to save (retrievable via storedArgs after Resume)
    }

    // Second call after Resume: read user's approval result
    // isTarget: whether this Resume targets the current Tool (each Resume targets only one Tool)
    // hasData:  whether the Resume carries approval result data
    // data:     the user's approval result
    isTarget, hasData, data := tool.GetResumeContext[*ApprovalResult](ctx)
    if isTarget && hasData {
        if data.Approved {
            return doSomething(storedArgs)  // Execute actual operation using saved arguments
        }
        return "Operation rejected by user", nil
    }

    // Other cases (isTarget=false means this Resume targets a different Tool): re-interrupt
    return "", tool.StatefulInterrupt(ctx, &ApprovalInfo{
        ToolName:        "my_tool",
        ArgumentsInJSON: storedArgs,
    }, storedArgs)
}

ApprovalMiddleware

ApprovalMiddleware is a general-purpose approval middleware that can intercept specific Tool calls:

type approvalMiddleware struct {
    *adk.BaseChatModelAgentMiddleware
}

func (m *approvalMiddleware) WrapInvokableToolCall(
    _ context.Context,
    endpoint adk.InvokableToolCallEndpoint,
    tCtx *adk.ToolContext,
) (adk.InvokableToolCallEndpoint, error) {
    // Only intercept Tools that require approval
    if tCtx.Name != "execute" {
        return endpoint, nil
    }

    return func(ctx context.Context, args string, opts ...tool.Option) (string, error) {
        wasInterrupted, _, storedArgs := tool.GetInterruptState[string](ctx)

        if !wasInterrupted {
            return "", tool.StatefulInterrupt(ctx, &commontool.ApprovalInfo{
                ToolName:        tCtx.Name,
                ArgumentsInJSON: args,
            }, args)
        }

        isTarget, hasData, data := tool.GetResumeContext[*commontool.ApprovalResult](ctx)
        if isTarget && hasData {
            if data.Approved {
                return endpoint(ctx, storedArgs, opts...)
            }
            if data.DisapproveReason != nil {
                return fmt.Sprintf("tool '%s' disapproved: %s", tCtx.Name, *data.DisapproveReason), nil
            }
            return fmt.Sprintf("tool '%s' disapproved", tCtx.Name), nil
        }

        // Re-interrupt
        return "", tool.StatefulInterrupt(ctx, &commontool.ApprovalInfo{
            ToolName:        tCtx.Name,
            ArgumentsInJSON: storedArgs,
        }, storedArgs)
    }, nil
}

func (m *approvalMiddleware) WrapStreamableToolCall(
    _ context.Context,
    endpoint adk.StreamableToolCallEndpoint,
    tCtx *adk.ToolContext,
) (adk.StreamableToolCallEndpoint, error) {
    // If the agent is configured with StreamingShell, execute uses streaming calls,
    // so this method must be implemented to intercept it
    if tCtx.Name != "execute" {
        return endpoint, nil
    }
    return func(ctx context.Context, args string, opts ...tool.Option) (*schema.StreamReader[string], error) {
        wasInterrupted, _, storedArgs := tool.GetInterruptState[string](ctx)
        if !wasInterrupted {
            return nil, tool.StatefulInterrupt(ctx, &commontool.ApprovalInfo{
                ToolName:        tCtx.Name,
                ArgumentsInJSON: args,
            }, args)
        }

        isTarget, hasData, data := tool.GetResumeContext[*commontool.ApprovalResult](ctx)
        if isTarget && hasData {
            if data.Approved {
                return endpoint(ctx, storedArgs, opts...)
            }
            if data.DisapproveReason != nil {
                return singleChunkReader(fmt.Sprintf("tool '%s' disapproved: %s", tCtx.Name, *data.DisapproveReason)), nil
            }
            return singleChunkReader(fmt.Sprintf("tool '%s' disapproved", tCtx.Name)), nil
        }

        isTarget, _, _ = tool.GetResumeContext[any](ctx)
        if !isTarget {
            return nil, tool.StatefulInterrupt(ctx, &commontool.ApprovalInfo{
                ToolName:        tCtx.Name,
                ArgumentsInJSON: storedArgs,
            }, storedArgs)
        }

        return endpoint(ctx, storedArgs, opts...)
    }, nil
}

CheckPointStore

CheckPointStore is the key component for implementing interrupt recovery:

type CheckPointStore interface {
    // Save a checkpoint
    Put(ctx context.Context, key string, checkpoint *Checkpoint) error

    // Get a checkpoint
    Get(ctx context.Context, key string) (*Checkpoint, error)
}

Why do we need CheckPointStore?

  • Save state on interrupt: Tool arguments, execution position, etc.
  • Load state on resume: Continue execution from the interrupt point
  • Support cross-process recovery: Can resume even after process restart

Interrupt/Resume Implementation

1. Configure Runner with CheckPointStore

runner := adk.NewRunner(ctx, adk.RunnerConfig{
    Agent:           agent,
    EnableStreaming: true,
    CheckPointStore: adkstore.NewInMemoryStore(),  // In-memory storage
})

2. Configure Agent with ApprovalMiddleware

agent, err := deep.New(ctx, &deep.Config{
    // ... other configuration
    Handlers: []adk.ChatModelAgentMiddleware{
        &approvalMiddleware{},  // Add approval middleware
        &safeToolMiddleware{},  // Convert Tool errors to strings (interrupt errors continue to propagate upward)
    },
})

3. Handle Interrupt Events

checkPointID := sessionID

events := runner.Run(ctx, history, adk.WithCheckPointID(checkPointID))
content, interruptInfo, err := printAndCollectAssistantFromEvents(events)
if err != nil {
    return err
}

if interruptInfo != nil {
    // Note: it's recommended to use the same stdin reader for both "user input" and "approval y/n"
    // to avoid approval input being treated as the next round's you> message
    content, err = handleInterrupt(ctx, runner, checkPointID, interruptInfo, reader)
    if err != nil {
        return err
    }
}

_ = session.Append(schema.AssistantMessage(content, nil))

Interrupt/Resume Execution Flow

+------------------------------------------+
|  User: Execute command echo hello         |
+------------------------------------------+
                   |
        +------------------------+
        |  Agent analyzes intent  |
        |  Decides to call        |
        |  execute                |
        +------------------------+
                   |
        +------------------------+
        |  ApprovalMiddleware     |
        |  Intercepts Tool call   |
        +------------------------+
                   |
        +------------------------+
        |  Trigger Interrupt      |
        |  Save state to Store    |
        +------------------------+
                   |
        +------------------------+
        |  Return Interrupt event |
        |  Wait for user approval |
        +------------------------+
                   |
        +------------------------+
        |  User inputs y/n        |
        +------------------------+
                   |
        +------------------------+
        |  runner.ResumeWith...   |
        |  Resume execution       |
        +------------------------+
                   |
        +------------------------+
        |  Execute command        |
        |  or return rejection    |
        +------------------------+

Chapter Summary

  • Interrupt: The Agent's pause mechanism — pauses before critical operations to wait for confirmation
  • Resume: Resumes execution — continues after user confirmation, or returns an error if rejected
  • ApprovalMiddleware: A general-purpose approval middleware that intercepts specific Tool calls
  • CheckPointStore: Saves interrupt state, supporting cross-process recovery
  • Human-agent collaboration: Critical decisions are confirmed by humans, improving safety

Further Thinking

Other Interrupt scenarios:

  • Multi-option approval: User selects one of multiple options
  • Parameter completion: User provides missing parameters
  • Conditional branching: User decides the execution path

Approval strategies:

  • Allowlist: Only require approval for sensitive operations
  • Blocklist: Require approval for all operations, except safe ones
  • Dynamic rules: Decide whether to require approval based on argument content