Sagents.SubAgent (Sagents v0.10.1)

Copy Markdown

A runnable, pausable, and resumable agent execution context.

Core Philosophy

"The SubAgent struct HOLDS the LLMChain."

This is the key insight that makes pause/resume trivial:

  • The chain persists in the SubAgent struct between pause and resume
  • The chain remembers all messages, tool calls, and state
  • Pause = stop executing, save the SubAgent struct
  • Resume = continue the SAME chain with decisions
  • No reconstruction needed

The SubAgent Struct

The SubAgent holds:

  • The LLMChain - THE KEY FIELD - manages the entire conversation
  • Status tracking (idle, running, interrupted, completed, error)
  • Interrupt data when paused (which tools need approval)
  • Error when failed
  • Metadata (id, parent_agent_id, created_at)

How It Works

  1. Initialization: Create LLMChain with initial messages, tools, and model
  2. Execution: Run LLMChain in a loop until completion or interrupt
  3. Conversation: All messages live in the chain - it's all there
  4. Results: Extract from the final message in the chain

Key Design Principles

  1. Chain Persistence = Simple Resume

    • Chain is already paused at the right spot
    • Resume just continues the chain with decisions
    • The chain remembers everything
  2. Direct Chain Management

    • SubAgent.execute runs LLMChain.run directly
    • No delegation to Agent
    • Full control over the execution loop
  3. HITL = Pause/Resume

    • Interrupt → Chain pauses after LLM returns tool calls
    • Save interrupt data (which tools, what arguments)
    • Resume → Apply decisions, execute tools, continue chain
    • Can have multiple pause/resume cycles naturally

SubAgent Execution Flow

Creating a SubAgent

# From configuration
subagent = SubAgent.new_from_config(
  parent_agent_id: "main-agent",
  instructions: "Research renewable energy",
  agent_config: agent_from_registry,
  parent_state: parent_state
)

Executing

case SubAgent.execute(subagent) do
  {:ok, completed_subagent} ->
    # Extract result
    result = SubAgent.extract_result(completed_subagent)

  {:interrupt, interrupted_subagent} ->
    # Needs human approval
    # interrupted_subagent.interrupt_data contains action requests

  {:error, error_subagent} ->
    # Execution failed
    # error_subagent.error contains the error
end

Resuming After Interrupt

case SubAgent.resume(interrupted_subagent, decisions) do
  {:ok, completed_subagent} -> # Completed
  {:interrupt, interrupted_subagent} -> # Another interrupt
  {:error, error_subagent} -> # Failed
end

Multiple Interrupts

The beauty of this design: multiple interrupts just repeat the pause/resume:

# First execution
{:interrupt, subagent1} = SubAgent.execute(subagent0)
# chain paused at: [user, assistant_with_tool_call_1]

# First resume
{:interrupt, subagent2} = SubAgent.resume(subagent1, [decision1])
# chain paused at: [user, assistant_1, tool_result_1, assistant_with_tool_call_2]

# Second resume
{:ok, subagent3} = SubAgent.resume(subagent2, [decision2])
# chain completed

Summary

Functions

Build an agent map of subagents from configurations.

Build a registry of subagents, raising on error.

Build descriptions map for subagents.

Check if SubAgent can be executed.

Check if SubAgent can be resumed.

Compose the child agent's base_system_prompt from a Config.

Execute the SubAgent.

Extract the module from any middleware format.

Extract result from completed SubAgent.

Check if a middleware spec refers to the SubAgent middleware.

Check if SubAgent is in a terminal state.

Create a SubAgent from compiled agent (pre-built).

Create a SubAgent from configuration (dynamic subagent).

Resume the SubAgent after HITL interrupt.

Build a middleware stack for subagents, filtering out blocked middleware.

Returns the universal framing prompt prepended to every task-style sub-agent's system prompt.

Types

t()

@type t() :: %Sagents.SubAgent{
  chain: LangChain.Chains.LLMChain.t() | nil,
  created_at: DateTime.t() | nil,
  error: term() | nil,
  id: String.t() | nil,
  interrupt_data: map() | nil,
  interrupt_on: map() | nil,
  max_runs: term(),
  parent_agent_id: String.t() | nil,
  require_tool_success: boolean(),
  status: :idle | :running | :interrupted | :completed | :error,
  until_tool: String.t() | [String.t()] | nil
}

Functions

build_agent_map(configs, default_model, default_middleware \\ [])

Build an agent map of subagents from configurations.

build_agent_map!(configs, default_model, default_middleware \\ [])

Build a registry of subagents, raising on error.

build_descriptions(configs)

Build descriptions map for subagents.

can_execute?(sub_agent)

Check if SubAgent can be executed.

Only SubAgents with status :idle can be executed.

can_resume?(sub_agent)

Check if SubAgent can be resumed.

Only SubAgents with status :interrupted can be resumed.

compose_child_system_prompt(cfg)

Compose the child agent's base_system_prompt from a Config.

Composition rule:

  • Header = system_prompt_override (if set) else the built-in boilerplate.
  • Body = instructions (if set) else system_prompt (legacy) else "".

The header and body are joined with a blank line. Middleware-contributed prompt fragments are appended later by the normal Sagents.Agent compile path — this function does not handle those.

execute(subagent, opts \\ [])

Execute the SubAgent.

Runs the LLMChain until:

  • Natural completion (no more tool calls)
  • HITL interrupt (tool needs approval)
  • Error

Returns updated SubAgent struct with new status.

Options

  • :callbacks - Map of LLMChain callbacks (e.g., %{on_message_processed: fn...})

Examples

case SubAgent.execute(subagent) do
  {:ok, completed_subagent} ->
    result = SubAgent.extract_result(completed_subagent)

  {:interrupt, interrupted_subagent} ->
    # interrupted_subagent.interrupt_data contains action_requests

  {:error, error_subagent} ->
    # error_subagent.error contains the error
end

# With callbacks for real-time message broadcasting
callbacks = %{
  on_message_processed: fn _chain, message ->
    broadcast_message(message)
  end
}
SubAgent.execute(subagent, callbacks: callbacks)

extract_middleware_module(module)

@spec extract_middleware_module(any()) :: module() | nil

Extract the module from any middleware format.

Returns the middleware module regardless of whether the input is:

  • A raw module atom
  • A {module, opts} tuple
  • A MiddlewareEntry struct

Returns nil for unrecognized formats.

extract_result(sub_agent)

Extract result from completed SubAgent.

For completed SubAgents, extracts the final message content as a string. This is the default extraction - middleware or custom logic can provide different extraction.

An until_tool run terminates the moment the target tool's result lands, so its final message is a tool message and the result the caller wants is that tool's content — not assistant prose. Those runs extract the matched tool result directly; every other completion still goes through LangChain.Utils.ChainResult.to_string/1. A tool result flagged is_error is returned as {:error, content} so a failed submission does not read as a successful one.

Returns {:ok, string} on success or {:error, reason} on failure.

Examples

{:ok, completed_subagent} = SubAgent.execute(subagent)
{:ok, result} = SubAgent.extract_result(completed_subagent)
# => {:ok, "Research complete: Solar energy has shown..."}

is_subagent_middleware?(middleware)

@spec is_subagent_middleware?(any()) :: boolean()

Check if a middleware spec refers to the SubAgent middleware.

Handles all middleware formats: raw modules, tuples, and MiddlewareEntry structs.

is_terminal?(sub_agent)

Check if SubAgent is in a terminal state.

Terminal states are :completed and :error.

new_from_compiled(opts)

Create a SubAgent from compiled agent (pre-built).

A compiled subagent is pre-defined by the application with complete control over configuration.

Options

  • :parent_agent_id - Parent's agent ID (required)
  • :instructions - Task description (required)
  • :compiled_agent - Pre-built Agent struct (required)
  • :initial_messages - Optional initial message sequence (default: [])
  • :parent_tool_context - Parent's tool_context map to inherit (optional). Static, caller-supplied data (e.g., user_id, feature flags) that tools access as flat top-level keys on context. Defaults to %{}.
  • :parent_metadata - Parent's state metadata map to inherit (optional). Dynamic, middleware-managed data (e.g., conversation_title) that tools access via context.state.metadata. Defaults to %{}.
  • :parent_runtime - Parent's state.runtime map to inherit (optional). Process-local middleware state such as captured ProcessContext snapshots. Inherited so sub-agents continue to see the parent's propagated tenant / OTel / Sentry context. Defaults to %{}.
  • :scope - Scope struct to inherit from the parent (optional). Propagated to the SubAgent's custom_context.scope so sub-agent tools and persistence callbacks see the same tenant context as the parent. Defaults to compiled_agent.scope.

Examples

subagent = SubAgent.new_from_compiled(
  parent_agent_id: "main-agent",
  instructions: "Extract structured data",
  compiled_agent: data_extractor_agent,
  initial_messages: [prep_message],
  parent_tool_context: %{user_id: 42},
  parent_metadata: %{"conversation_title" => "Extraction"}
)

new_from_config(opts)

Create a SubAgent from configuration (dynamic subagent).

The main agent configures a subagent through the task tool by providing:

  • Instructions (becomes user message)
  • Agent configuration (Agent struct)

Options

  • :parent_agent_id - Parent's agent ID (required)
  • :instructions - Task description (required)
  • :agent_config - Agent struct with tools, model, middleware (required)
  • :initial_messages - Optional list of %LangChain.Message{} structs to slot between the system messages and the user instruction. Use this to inject established context (e.g. a <references> synthetic preamble) for the sub-agent's first turn. Defaults to [].
  • :parent_tool_context - Parent's tool_context map to inherit (optional). Static, caller-supplied data (e.g., user_id, feature flags) that tools access as flat top-level keys on context. Defaults to %{}.
  • :parent_metadata - Parent's state metadata map to inherit (optional). Dynamic, middleware-managed data (e.g., conversation_title) that tools access via context.state.metadata. Defaults to %{}.
  • :parent_runtime - Parent's state.runtime map to inherit (optional). Process-local middleware state such as captured ProcessContext snapshots. Inherited so sub-agents continue to see the parent's propagated tenant / OTel / Sentry context. Defaults to %{}.
  • :scope - Scope struct to inherit from the parent (optional). Propagated to the SubAgent's custom_context.scope so sub-agent tools and persistence callbacks see the same tenant context as the parent. Defaults to agent_config.scope.

Examples

subagent = SubAgent.new_from_config(
  parent_agent_id: "main-agent",
  instructions: "Research renewable energy impacts",
  agent_config: agent_struct,
  parent_tool_context: %{user_id: 42, tenant: "acme"},
  parent_metadata: %{"conversation_title" => "Research"},
  scope: %MyApp.Accounts.Scope{user: user}
)

resume(subagent, decisions, opts \\ [])

Resume the SubAgent after HITL interrupt.

Takes human decisions and continues execution from where it left off. This is the magic - the agent state is already paused at the right spot.

Parameters

  • subagent - SubAgent with status :interrupted
  • decisions - List of decision maps from human reviewer
  • opts - Optional keyword list with:
    • :callbacks - Map of LLMChain callbacks (e.g., %{on_message_processed: fn...})

Returns

  • {:ok, completed_subagent} - Execution completed
  • {:interrupt, interrupted_subagent} - Another interrupt (multiple HITL tools)
  • {:error, error_subagent} - Resume ran but failed; the SubAgent carries the reason in its :error field
  • {:error, {:unsupported_interrupt, :tool_raised}} - Interrupted on a shape this function cannot apply decisions to (see below)
  • {:error, {:invalid_status, status, :expected_interrupted}} - Not interrupted

Supported interrupt shapes

Only the pre-tool HITL interrupt is resumable. That shape carries :action_requests and :hitl_tool_call_ids, and names tool calls that have not run yet, so the decisions can be applied by executing them.

An interrupt raised from inside a tool body (is_interrupt: true on a ToolResult, surfaced by check_tool_interrupts/2) is not resumable here and returns {:error, {:unsupported_interrupt, :tool_raised}}. Its tool has already executed, so resolving it means replacing the recorded tool result rather than executing a pending call — the job of the middleware that owns the interrupt type, working on Sagents.State via State.replace_tool_result/3. A sub-agent runs a bare chain with no handle_resume/5 dispatch, so it has no owner to hand the interrupt to.

Callers resolve these at the parent instead: Sagents.Middleware.SubAgent turns the error into a tool result for the parent chain and stops the sub-agent process.

Examples

decisions = [
  %{type: :approve},
  %{type: :edit, arguments: %{"path" => "safe.txt"}}
]

case SubAgent.resume(interrupted_subagent, decisions) do
  {:ok, completed_subagent} ->
    result = SubAgent.extract_result(completed_subagent)

  {:interrupt, interrupted_again} ->
    # Another interrupt - repeat the process

  {:error, error_subagent} ->
    # Handle error
end

subagent_middleware_stack(default_middleware, additional_middleware \\ [], opts \\ [])

@spec subagent_middleware_stack(list(), list(), keyword()) :: list()

Build a middleware stack for subagents, filtering out blocked middleware.

Two middleware modules are excluded from inheritance by default, without needing to appear in :block_middleware:

Only inherited middleware is filtered. Middleware passed explicitly as additional_middleware (a SubAgent.Config's own :middleware list) is always kept, so a sub-agent that genuinely needs one of these can still opt back in.

This function handles middleware in all formats:

  • Raw module atoms (e.g., Sagents.Middleware.SubAgent)
  • Raw tuples (e.g., {Sagents.Middleware.SubAgent, opts})
  • Initialized MiddlewareEntry structs (from agent.middleware)

Options

  • :block_middleware - List of middleware modules to exclude from inheritance. These modules will not be passed to general-purpose subagents. Defaults to [].

Examples

# Default behavior: SubAgent and AskUserQuestion middleware are filtered
subagent_middleware_stack(parent_middleware)

# Block additional middleware from being inherited
subagent_middleware_stack(parent_middleware, [],
  block_middleware: [ConversationTitle, Summarization]
)

# Add additional middleware while blocking others
subagent_middleware_stack(parent_middleware, [CustomMiddleware],
  block_middleware: [ConversationTitle]
)

task_subagent_boilerplate()

Returns the universal framing prompt prepended to every task-style sub-agent's system prompt.

Encodes the "no user, complete-or-fail, no clarifying questions" contract so that Sagents.SubAgent.Task authors can focus their instructions/0 on the substantive procedure. Host compilers combine this with the task's instructions/0 to form the child agent's system prompt; it can be replaced per-sub-agent via Sagents.SubAgent.Config's system_prompt_override.