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.
347 lines
12 KiB
Markdown
347 lines
12 KiB
Markdown
---
|
|
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
|
|
|
|
- Entry code: [cmd/ch07/main.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch07/main.go)
|
|
|
|
## 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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
# Set the project root directory
|
|
export PROJECT_ROOT=/path/to/your/project
|
|
|
|
go run ./cmd/ch07
|
|
```
|
|
|
|
Output example:
|
|
|
|
```text
|
|
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:**
|
|
|
|
```go
|
|
// 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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
runner := adk.NewRunner(ctx, adk.RunnerConfig{
|
|
Agent: agent,
|
|
EnableStreaming: true,
|
|
CheckPointStore: adkstore.NewInMemoryStore(), // In-memory storage
|
|
})
|
|
```
|
|
|
|
### 2. Configure Agent with ApprovalMiddleware
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
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
|