Sagents.Middleware.Haltable (Sagents v0.8.0-rc.9)

Copy Markdown

Adds the ability for an agent's tools to halt the agent's workflow.

Adding Sagents.Middleware.Haltable to a middleware stack expresses "this agent is haltable" — any tool the agent can invoke is then allowed to emit a :halt interrupt, which terminates the agent loop inside the framework (no further LLM call) and surfaces a user-facing message. This middleware claims those interrupts so they survive cold start and are re-surfaced to integrators on AgentServer reboot.

What a halt is

A :halt interrupt is a terminal interrupt: a tool has decided the enclosing workflow should stop, and the framework should NOT invoke the LLM again. There is no "next turn" for the orchestrator to weigh the halt against — it is a structural gate, not a persuasive one.

Contrast with Sagents.Middleware.AskUserQuestion:

:ask_user_question:halt
IntentPause for a typed responseTerminate the workflow
Resume payloadA response slotted back into the halted tool callNone
handle_resume/5 behaviorProcess the answer, continue the tool callNone — the tool call is dead
User's next messageSlotted in as a tool resultA new turn; the halted call is demoted

How resume works (and where Halt is NOT involved)

When a user sends a new free-text message after a halt, Sagents.AgentServer.handle_call({:add_message, _}) calls Sagents.State.cancel_pending_interrupts/1, which demotes every is_interrupt: true tool result in the log to an error result and clears state.interrupt_data. The agent transitions back to :idle and the new message proceeds as a fresh turn.

This means Halt.handle_resume/5 is never called for the "user moved on" leg. This middleware only handles cold-start re-surface: when an AgentServer boots from persisted state that has a surviving :halt interrupt, handle_resume/5 is called with resume_data: nil, and this middleware re-emits the interrupt so UIs can re-render the halt message.

Emitting a halt

Tool authors emit a halt via the standard {:interrupt, msg, data} return tuple, with type: :halt in the data:

def execute(args, _ctx) do
  case gate_check(args) do
    :ok ->
      {:ok, result}

    {:gate_failed, reason} ->
      {:interrupt, "Workflow halted: #{reason}",
       %{
         type: :halt,
         source_tool: "scout_outline",
         message: "Author-facing explanation of what to fix."
       }}
  end
end

Required keys in interrupt_data

  • :type — must be :halt
  • :messageString.t(), the user-facing reason for the halt
  • :source_toolString.t() identifying which tool emitted the halt (or :source if the emitter is not a tool)

The framework also fills in :tool_call_id when the halt comes from inside a tool execution (set by LangChain when it normalizes the tool function's return value).

Adoption

Add this middleware to your agent's middleware stack:

middleware: [
  # ... existing middleware ...
  Sagents.Middleware.Haltable,
  # ... rest of stack ...
]

Any tool that returns {:interrupt, _, %{type: :halt, ...}} will then produce a restorable halt interrupt.

"Halt wins" in :multiple_interrupts

When multiple parallel tools emit interrupts in the same turn and at least one is :halt, the workflow is over — there is no point asking the user to answer the sibling ask_user questions or approve the sibling HITL action requests. This middleware enforces that at cold-start time by claiming :multiple_interrupts wrappers that contain any :halt sub-interrupt. The UI layer (see Sagents.AgentUtils.interrupt_session_changes/1) enforces it at render time.

Transcript persistence

When a halt fires and the AgentServer is configured with a DisplayMessagePersistence module and a conversation_id, the halt's :message field is automatically persisted as a synthetic assistant display message in the conversation transcript. This means the halt's recommended-actions text survives:

  • the user dismissing the halt display,
  • the user sending a follow-up message (which clears :pending_halt via Sagents.State.cancel_pending_interrupts/1),
  • page reload — the message is in the persisted display-message log just like any other assistant turn.

Persistence fires once, at the original halt-emit moment. Cold-start re-surface (this middleware's handle_resume/5 re-emitting the interrupt when an AgentServer reboots from persisted state) does NOT re-persist — the transcript message is already there from the original emit.

Halts with no :message (or an empty string) are skipped. So is the case where the AgentServer has no persistence configured — no error, just a silent no-op.