Sagents.AgentUtils (Sagents v0.8.0-rc.5)

Copy Markdown

Shared utilities for Agent and SubAgent HITL (Human-in-the-Loop) support.

Provides common functions for:

  • Checking for HITL interrupts
  • Building full decisions lists
  • Extracting tool calls from chains

These utilities provide consistent HITL behavior across Agent and SubAgent implementations.

Summary

Functions

Compute the state transition for a single HITL approve/reject decision in a host's pending-tool list.

Build a full decisions list for ALL tool calls in a message.

Check if a chain has pending tool calls that require human approval.

Extract tool calls from the last assistant message in a chain.

Map an interrupt-data payload to the assigns/state changes a host (LiveView, GenServer, etc.) should merge to display the interrupt.

Functions

advance_hitl_decisions(state, index, decision_type)

@spec advance_hitl_decisions(map(), non_neg_integer(), atom()) ::
  {:resume, [map()], map()} | {:more, map()}

Compute the state transition for a single HITL approve/reject decision in a host's pending-tool list.

Reads :pending_tools and :hitl_decisions from state (treating missing keys as empty list / empty list), records decision_type for the tool at index, and returns:

  • {:resume, accumulated_decisions, changes} — all pending tools have been decided. The host should call Sagents.AgentServer.resume(agent_id, accumulated_decisions) and then merge changes (which clears :pending_tools, :interrupt_data, and :hitl_decisions).
  • {:more, changes} — tools still pending. Merge changes (which advances :pending_tools and :hitl_decisions).

Example

case AgentUtils.advance_hitl_decisions(socket.assigns, idx, :approve) do
  {:resume, decisions, changes} ->
    AgentServer.resume(agent_id, decisions)
    {:noreply, assign(socket, changes)}

  {:more, changes} ->
    {:noreply, assign(socket, changes)}
end

build_full_decisions(all_tool_calls, hitl_tool_call_ids, human_decisions, action_requests)

Build a full decisions list for ALL tool calls in a message.

Mixes human decisions (for HITL tools) with auto-approvals (for non-HITL tools). This is needed because LLMChain.execute_tool_calls_with_decisions expects a decision for EVERY tool call, not just the ones that needed approval.

Parameters

  • all_tool_calls: All tool calls from assistant message (HITL + non-HITL)
  • hitl_tool_call_ids: List of tool_call_ids that needed human approval
  • human_decisions: List of decisions from human (same order as action_requests)
  • action_requests: List of action_requests (to map decisions to tool_call_ids)

Returns

  • List of decisions matching all_tool_calls order

Example

all_tool_calls = [tc1, tc2, tc3]  # 3 total tool calls
hitl_tool_call_ids = [tc1.call_id]  # Only tc1 needed approval
action_requests = [%{tool_call_id: tc1.call_id, ...}]
human_decisions = [%{type: :approve}]  # Human approved tc1

full_decisions = build_full_decisions(all_tool_calls, hitl_tool_call_ids, human_decisions, action_requests)
# => [%{type: :approve}, %{type: :approve}, %{type: :approve}]
# First is human decision, others are auto-approved

check_for_hitl_interrupt(chain, interrupt_on)

Check if a chain has pending tool calls that require human approval.

Parameters

  • chain: LLMChain with potentially pending tool calls
  • interrupt_on: Map of tool_name => true/false/config

Returns

  • {:interrupt, interrupt_data} - Some tools need approval
  • :continue - No tools need approval or no tool calls present

Example

case AgentUtils.check_for_hitl_interrupt(chain, %{"write_file" => true}) do
  {:interrupt, interrupt_data} ->
    # Pause and request human decisions
    {:interrupt, state, interrupt_data}

  :continue ->
    # Execute tools automatically
    execute_tools(chain)
end

get_tool_calls_from_last_message(chain)

Extract tool calls from the last assistant message in a chain.

Parameters

  • chain: LLMChain

Returns

  • List of tool calls or empty list

Example

tool_calls = AgentUtils.get_tool_calls_from_last_message(chain)
# => [%ToolCall{call_id: "1", name: "write_file", ...}, ...]

interrupt_session_changes(question)

@spec interrupt_session_changes(map() | nil) :: map()

Map an interrupt-data payload to the assigns/state changes a host (LiveView, GenServer, etc.) should merge to display the interrupt.

Routes the three sagents-internal interrupt variants to their UI shapes:

  • :ask_user_question — pull the single question to the front
  • :multiple_interrupts — present as questions if all are questions, otherwise as a HITL tool batch
  • :subagent_hitl — unwrap the inner action_requests
  • any other map — treat as a generic HITL tool batch

Returns an empty map for nil, so callers can apply the result unconditionally on top of base assigns.

Returned keys

When questions are present: :pending_question, :remaining_questions, :question_responses, :pending_tools

When HITL tools are present: :pending_tools, :pending_question

Example

base = %{loading: false, agent_status: :interrupted, interrupt_data: data}
Map.merge(base, AgentUtils.interrupt_session_changes(data))