Sagents.Middleware.HumanInTheLoop (Sagents v0.17.1)

Copy Markdown

Middleware 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 by tool_call_id (available as context.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)
end

Interrupt 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 :arguments field)
  • :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:

  1. TodoList
  2. FileSystem
  3. SubAgent
  4. Summarization
  5. PatchToolCalls
  6. AskUserQuestion (or other interrupt-producing middleware)
  7. 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

action_request()

@type action_request() :: %{
  tool_call_id: String.t(),
  tool_name: String.t(),
  arguments: map()
}

decision()

@type decision() :: %{
  :type => :approve | :edit | :reject,
  optional(:arguments) => map(),
  optional(:tool_call_id) => String.t()
}

interrupt_config()

@type interrupt_config() :: %{
  :allowed_decisions => [atom()],
  optional(:recovery) => :report_unknown | :reexecute
}

interrupt_data()

@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()}
}

interrupt_on_config()

@type interrupt_on_config() :: %{
  optional(String.t()) => boolean() | interrupt_config()
}

pre_decided_rejection()

@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

add_approval_placeholders(chain, interrupt_data)

@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.

check_for_interrupt(state, config, opts \\ [])

@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 state
  • config - Middleware configuration with interrupt_on map
  • opts - Options:
    • :parse_arguments - A function taking a gated LangChain.Message.ToolCall and 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 in interrupt_data.pre_decided.
  • {:settle, tool_calls, pre_decided} - No call needs approval, but some gated calls were refused. tool_calls is the whole batch and pre_decided maps each refused call's id to its rejection. The caller must run the batch with these decisions instead of executing it normally (see pre_decided_decisions/2).
  • :continue - If no approval needed

handle_resume(agent, state, decisions, config, opts)

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.

in_flight_decisions(arg1)

@spec in_flight_decisions(map() | nil) :: {:ok, [decision()]} | :none

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.

maybe_append(middleware, interrupt_on)

@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 list
  • interrupt_on - Map of tool names to interrupt configuration, or nil

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

pre_decided_decisions(tool_calls, pre_decided)

@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.

process_decisions(state, decisions, config)

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 interruption
  • decisions - List of decision maps
  • config - Middleware configuration

Returns

  • {:ok, state} - Decisions are valid (state unchanged)
  • {:error, reason} - Invalid decisions

remove_approval_placeholders(chain)

@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.

resume_checkpoint(state, decisions, middleware)

@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.