Sagents.SubAgent (Sagents v0.8.0-rc.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,
  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.

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 %{}.
  • :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)
  • :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 %{}.
  • :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 failed

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.

The SubAgent middleware itself is ALWAYS filtered out to prevent recursive subagent nesting, regardless of whether it appears in the block list.

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: only SubAgent middleware is 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.