Sagents.Middleware.HumanInTheLoop (Sagents v0.8.0-rc.4)

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

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 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 :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}

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:

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

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

interrupt_config()

@type interrupt_config() :: %{allowed_decisions: [atom()]}

interrupt_data()

@type interrupt_data() :: %{
  action_requests: [action_request()],
  review_configs: %{required(String.t()) => interrupt_config()},
  hitl_tool_call_ids: [String.t()]
}

interrupt_on_config()

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

Functions

check_for_interrupt(state, config)

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

Returns

  • {:interrupt, interrupt_data} - If tools need approval
  • :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.

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

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