Sagents.Middleware.HumanInTheLoop (Sagents v0.17.1)
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
Recovery
An approved tool can be interrupted by the agent process stopping (a crash,
a deploy, a lost node) after it has started and before its result is
recorded. :recovery says what the next boot does about it:
:report_unknown(default) - Record a result telling the model the call was started and its outcome is unknown, so the model can check before trying again. The call is never re-run on its own.:reexecute- Run the approved calls again, with the same decisions. Only for tools that are safe to repeat, for example because they are idempotent bytool_call_id(available ascontext.tool_call_id).
A batch is re-run only when every call in it that was started is
:reexecute. A tool call that does not need approval but was in the same
batch counts as :report_unknown.
interrupt_on = %{
"set_thermostat" => %{allowed_decisions: [:approve, :reject], recovery: :reexecute},
"send_payment" => true
}Calls that cannot run
A human is only asked about calls that would actually run. Before
interrupting, each gated call's arguments are parsed by its own tool, with
the context the tool would run with: its :parse_args parser, or the
required-parameter check when it has none (see LangChain.Function). A
call naming a tool the agent does not have is refused the same way.
A refused call is not put to a human. It is answered with the tool's message
as an error result, the same answer it would get if it ran, so the model can
correct it. When other calls still need approval, the refused ones are held
in the interrupt data under :pre_decided and answered when the batch is
resumed. They are never executed, even if their arguments would parse by
then. When no call needs approval, the batch runs at once.
The parser runs again when an approved call executes, and only that run's result reaches the tool body. An approval can wait a long time, and data can change in between. A parser therefore runs more than once for one call, and must only read, never write.
A tool that wants a no-op call ("nothing would change") kept away from the human refuses it from its parser.
Durability
An interrupt survives the agent process. When approval is needed, the
conversation gets a tool message holding one placeholder result per pending
tool call, each carrying this interrupt's data, and it is persisted with the
state. A later boot rebuilds the interrupt from it and comes up
:interrupted.
When approval is given, the placeholders are marked as running, with the decisions and the recovery policy, and that is persisted before any tool runs. If the agent stops before the run's result is persisted, the next boot applies the recovery policy instead of asking again or dropping the calls.
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 one decision per action request. A decision
answers an action request either by naming its :tool_call_id, or by
position when no decision names one (see Sagents.AgentUtils.pair_decisions/2).
Naming the id is the safer form for a UI that lets the user decide tools
in any order: Sagents.AgentUtils.advance_hitl_decisions/3 builds decisions
this way.
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}Any decision may also carry the :tool_call_id of the action request it
answers. Either every decision carries one or none does.
Complete Resume Example
Positional decisions 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)Decisions keyed by :tool_call_id may come in any order:
decisions = [
%{type: :reject, tool_call_id: req3.tool_call_id},
%{type: :approve, tool_call_id: req1.tool_call_id},
%{type: :edit, tool_call_id: req2.tool_call_id, arguments: %{"path" => "other.txt"}}
]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
Types
The decision recorded for a gated call whose arguments its tool refused before any human was asked. See "Calls that cannot run" in the moduledoc.
Functions
Append the placeholder tool message for an approval interrupt to chain.
Check if the current state requires an interrupt for human approval.
Handle resume for HITL interrupts.
The decisions to run again for an approval that was running when the agent
stopped and whose tools allow it, or :none.
Conditionally append HumanInTheLoop middleware to a middleware list.
The decisions for a batch none of whose calls is waiting on a human: the
pre-decided rejection for each settled call, and :approve for the rest.
One decision per call, in tool_calls order, ready for
LangChain.Chains.LLMChain.execute_tool_calls_with_decisions/3.
Validate human decisions against tool calls.
Remove the placeholder tool message add_approval_placeholders/2 appended,
so the chain ends with the assistant message whose tool calls await their
decisions. The real results take the placeholders' place when the decisions
are run. A chain that does not end in approval placeholders is returned
unchanged.
The state to persist before approved tool calls start running, or :none.
Types
@type interrupt_config() :: %{ :allowed_decisions => [atom()], optional(:recovery) => :report_unknown | :reexecute }
@type interrupt_data() :: %{ :action_requests => [action_request()], :review_configs => %{required(String.t()) => interrupt_config()}, :hitl_tool_call_ids => [String.t()], optional(:pre_decided) => %{required(String.t()) => pre_decided_rejection()} }
@type interrupt_on_config() :: %{ optional(String.t()) => boolean() | interrupt_config() }
@type pre_decided_rejection() :: %{type: :reject, message: String.t(), is_error: true}
The decision recorded for a gated call whose arguments its tool refused before any human was asked. See "Calls that cannot run" in the moduledoc.
Functions
@spec add_approval_placeholders(LangChain.Chains.LLMChain.t(), interrupt_data()) :: LangChain.Chains.LLMChain.t()
Append the placeholder tool message for an approval interrupt to chain.
Called by Sagents.Mode.Steps.check_pre_tool_hitl/2 when it interrupts. The
message answers every tool call of the last assistant message with a
placeholder result carrying interrupt_data, so the interrupt is persisted
with the conversation and a later boot can rebuild it. The tool calls are
held as a batch, so all of them get one, not only the gated ones. A call in
interrupt_data.pre_decided gets its refusal as the placeholder text.
@spec check_for_interrupt(Sagents.State.t(), map(), keyword()) :: {:interrupt, interrupt_data()} | {:settle, [LangChain.Message.ToolCall.t()], %{required(String.t()) => pre_decided_rejection()}} | :continue
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 mapopts- Options::parse_arguments- A function taking a gatedLangChain.Message.ToolCalland returning{:ok, parsed_arguments}or{:error, message}, normally&LangChain.Chains.LLMChain.parse_tool_call_arguments(chain, &1). A gated call it refuses is settled with the message rather than put to a human. Without it, every gated call is put to a human.
Returns
{:interrupt, interrupt_data}- If tools need approval. Gated calls that were refused are ininterrupt_data.pre_decided.{:settle, tool_calls, pre_decided}- No call needs approval, but some gated calls were refused.tool_callsis the whole batch andpre_decidedmaps each refused call's id to its rejection. The caller must run the batch with these decisions instead of executing it normally (seepre_decided_decisions/2).: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.
The decisions to run again for an approval that was running when the agent
stopped and whose tools allow it, or :none.
Reads the :in_flight mark that resume_checkpoint/3 records. A boot that
restores such an interrupt resumes it with these decisions before it
announces any status.
@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
@spec pre_decided_decisions([LangChain.Message.ToolCall.t()], %{ required(String.t()) => pre_decided_rejection() }) :: [map()]
The decisions for a batch none of whose calls is waiting on a human: the
pre-decided rejection for each settled call, and :approve for the rest.
One decision per call, in tool_calls order, ready for
LangChain.Chains.LLMChain.execute_tool_calls_with_decisions/3.
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
@spec remove_approval_placeholders(LangChain.Chains.LLMChain.t()) :: LangChain.Chains.LLMChain.t()
Remove the placeholder tool message add_approval_placeholders/2 appended,
so the chain ends with the assistant message whose tool calls await their
decisions. The real results take the placeholders' place when the decisions
are run. A chain that does not end in approval placeholders is returned
unchanged.
@spec resume_checkpoint(Sagents.State.t(), term(), [Sagents.MiddlewareEntry.t()]) :: {:ok, Sagents.State.t()} | :none
The state to persist before approved tool calls start running, or :none.
Marks each placeholder of the pending approval as running: its interrupt data
gains :in_flight, which records the decisions, what each call is about to
do (:started, or :rejected when a human rejected it or it was settled
before the interrupt), and the batch's recovery policy. A boot that finds the marks applies the policy (see
"Recovery" in the moduledoc).
Returns :none when there is nothing to mark: no HumanInTheLoop entry in
middleware, a pending interrupt that is not an approval, decisions that do
not validate (the resume fails before any tool runs), or a conversation that
does not end in this approval's placeholders.