Sagents.Middleware.HumanInTheLoop (Sagents v0.8.0-rc.3)
Copy MarkdownMiddleware that enables human oversight and intervention in agent workflows.
HumanInTheLoop (HITL) allows pausing agent execution before executing sensitive or critical tool operations, providing humans with the ability to approve, edit, or reject tool calls.
Configuration
The middleware is configured with an interrupt_on map that specifies which
tools should trigger human approval:
# Simple boolean configuration
interrupt_on = %{
"write_file" => true, # Enable with default decisions
"delete_file" => true,
"read_file" => false # No interruption
}
# Advanced configuration with custom decisions
interrupt_on = %{
"write_file" => %{
allowed_decisions: [:approve, :edit, :reject]
},
"delete_file" => %{
allowed_decisions: [:approve, :reject] # No edit option
}
}Decision Types
:approve- Execute the tool with original arguments:edit- Execute the tool with modified arguments:reject- Skip tool execution entirely
Usage
# Create agent with HITL middleware
{:ok, agent} = Agent.new(
model: model,
interrupt_on: %{
"write_file" => true,
"delete_file" => true
}
)
# Execute - will return interrupt if tool needs approval
state = State.new!(%{messages: [...]})
result = Agent.execute(agent, state)
case result do
{:ok, state} ->
# Normal completion
handle_response(state)
{:interrupt, state, interrupt_data} ->
# Human approval needed
decisions = get_human_decisions(interrupt_data)
{:ok, final_state} = Agent.resume(agent, state, decisions)
endInterrupt Structure
When a tool requires approval, execution returns an interrupt tuple with detailed information about the tools requiring approval.
Structure
{:interrupt, state, %{
action_requests: [action_request, ...],
review_configs: %{tool_name => config, ...}
}}Action Requests
Each action request contains complete information about the tool call:
%{
tool_call_id: "call_123", # Unique identifier for this tool call
tool_name: "write_file", # Name of the tool being called
arguments: %{...} # Arguments that would be passed to the tool
}Review Configs
A map of tool names to their approval configuration. More efficient than the Python library's array-based approach, providing O(1) lookup:
%{
"write_file" => %{
allowed_decisions: [:approve, :edit, :reject]
},
"delete_file" => %{
allowed_decisions: [:approve, :reject] # Edit not allowed
}
}Complete Example
{:interrupt, state, %{
action_requests: [
%{
tool_call_id: "call_123",
tool_name: "write_file",
arguments: %{"path" => "file.txt", "content" => "data"}
},
%{
tool_call_id: "call_456",
tool_name: "delete_file",
arguments: %{"path" => "old.txt"}
}
],
review_configs: %{
"write_file" => %{allowed_decisions: [:approve, :edit, :reject]},
"delete_file" => %{allowed_decisions: [:approve, :reject]}
}
}}Resume Structure
Resume execution by providing decisions that correspond to each action request in order.
Decision Types
:approve- Execute the tool with its original arguments:edit- Execute the tool with modified arguments (requires:argumentsfield):reject- Skip tool execution, provide rejection message to agent
Decision Format
Each decision is a map with a required :type field:
# Approve decision
%{type: :approve}
# Edit decision (must include :arguments with the modified parameters)
%{type: :edit, arguments: %{"path" => "modified.txt", "content" => "new data"}}
# Reject decision
%{type: :reject}Complete Resume Example
The decisions list must match the order and count of action_requests:
# Given interrupt with 3 action requests
{:interrupt, state, %{action_requests: [req1, req2, req3], ...}}
# Provide corresponding decisions
decisions = [
%{type: :approve}, # Approve req1
%{type: :edit, arguments: %{"path" => "other.txt"}}, # Edit req2's arguments
%{type: :reject} # Reject req3
]
# Resume execution
{:ok, final_state} = Agent.resume(agent, state, decisions)Position in Middleware Stack
HITL must be the last middleware in the stack. This is required because
during handle_resume, HITL executes ALL tool calls from the interrupted
assistant message (auto-approving non-HITL tools). If an auto-approved tool
produces its own interrupt (e.g., ask_user), HITL detects this and hands off
via {:cont} so the owning middleware can claim it. Since the handle_resume
middleware cycle is single-pass, only middleware that comes AFTER HITL in the
stack will see the handoff. Placing interrupt-producing middleware before HITL
ensures they get a chance to claim interrupts discovered during HITL's tool
execution.
HumanInTheLoop.maybe_append/2 enforces this by always appending to the end
of the middleware list.
Default stack position:
- TodoList
- FileSystem
- SubAgent
- Summarization
- PatchToolCalls
- AskUserQuestion (or other interrupt-producing middleware)
- HumanInTheLoop ← Always last
Summary
Functions
Check if the current state requires an interrupt for human approval.
Handle resume for HITL interrupts.
Conditionally append HumanInTheLoop middleware to a middleware list.
Validate human decisions against tool calls.
Types
@type decision() :: %{ :type => :approve | :edit | :reject, optional(:arguments) => map() }
@type interrupt_config() :: %{allowed_decisions: [atom()]}
@type interrupt_data() :: %{ action_requests: [action_request()], review_configs: %{required(String.t()) => interrupt_config()}, hitl_tool_call_ids: [String.t()] }
@type interrupt_on_config() :: %{ optional(String.t()) => boolean() | interrupt_config() }
Functions
Check if the current state requires an interrupt for human approval.
This can be called before tool execution to determine if we need to pause and wait for human decisions.
Parameters
state- The current agent stateconfig- Middleware configuration with interrupt_on map
Returns
{:interrupt, interrupt_data}- If tools need approval:continue- If no approval needed
Handle resume for HITL interrupts.
Claims interrupts where state.interrupt_data has :action_requests.
Validates decisions, executes approved tools via LLMChain, and returns
the updated state with tool results added.
@spec maybe_append(list(), interrupt_on_config() | nil) :: list()
Conditionally append HumanInTheLoop middleware to a middleware list.
This is a convenience function for building middleware stacks. It only adds
the HumanInTheLoop middleware if the interrupt_on configuration is provided
and contains at least one tool configuration.
Parameters
middleware- The existing middleware listinterrupt_on- Map of tool names to interrupt configuration, ornil
Returns
The middleware list, either unchanged or with HumanInTheLoop appended.
Examples
# With interrupt configuration - adds HumanInTheLoop
middleware = [TodoList, FileSystem]
|> HumanInTheLoop.maybe_append(%{"write_file" => true})
# => [TodoList, FileSystem, {HumanInTheLoop, [interrupt_on: %{...}]}]
# Without configuration - returns unchanged
middleware = [TodoList, FileSystem]
|> HumanInTheLoop.maybe_append(nil)
# => [TodoList, FileSystem]
# Empty map - returns unchanged
middleware = [TodoList, FileSystem]
|> HumanInTheLoop.maybe_append(%{})
# => [TodoList, FileSystem]Usage in Factory
defp build_middleware(interrupt_on) do
[
Sagents.Middleware.TodoList,
Sagents.Middleware.FileSystem,
Sagents.Middleware.SubAgent,
Sagents.Middleware.Summarization,
Sagents.Middleware.PatchToolCalls
]
|> Sagents.Middleware.HumanInTheLoop.maybe_append(interrupt_on)
end
Validate human decisions against tool calls.
This is called by Agent.resume/3 to validate decisions before executing tools. The actual tool execution happens in LLMChain.execute_tool_calls_with_decisions/3.
Parameters
state- The state at the point of interruptiondecisions- List of decision mapsconfig- Middleware configuration
Returns
{:ok, state}- Decisions are valid (state unchanged){:error, reason}- Invalid decisions