Sagents.SubAgent (Sagents v0.11.1)
Copy MarkdownA 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
- Initialization: Create LLMChain with initial messages, tools, and model
- Execution: Run LLMChain in a loop until completion or interrupt
- Conversation: All messages live in the chain - it's all there
- Results: Extract from the final message in the chain
Key Design Principles
Chain Persistence = Simple Resume
- Chain is already paused at the right spot
- Resume just continues the chain with decisions
- The chain remembers everything
Direct Chain Management
- SubAgent.execute runs LLMChain.run directly
- No delegation to Agent
- Full control over the execution loop
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
endResuming After Interrupt
case SubAgent.resume(interrupted_subagent, decisions) do
{:ok, completed_subagent} -> # Completed
{:interrupt, interrupted_subagent} -> # Another interrupt
{:error, error_subagent} -> # Failed
endMultiple 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
@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: integer() | nil, parent_agent_id: String.t() | nil, require_tool_success: boolean(), status: :idle | :running | :interrupted | :completed | :error, suppress_debug_events: boolean(), until_tool: String.t() | [String.t()] | nil }
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.
Only SubAgents with status :idle can be executed.
Check if SubAgent can be resumed.
Only SubAgents with status :interrupted can be resumed.
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) elsesystem_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 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 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 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..."}
Check if a middleware spec refers to the SubAgent middleware.
Handles all middleware formats: raw modules, tuples, and MiddlewareEntry structs.
Check if SubAgent is in a terminal state.
Terminal states are :completed and :error.
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 viacontext.state.metadata. Defaults to%{}.:parent_runtime- Parent'sstate.runtimemap to inherit (optional). Process-local middleware state such as capturedProcessContextsnapshots. 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'scustom_context.scopeso sub-agent tools and persistence callbacks see the same tenant context as the parent. Defaults tocompiled_agent.scope.:suppress_debug_events- When true, none of this sub-agent's events are published on the parent's:debugchannel (optional). Seenew_from_config/1. Defaults tofalse.
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"}
)
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 viacontext.state.metadata. Defaults to%{}.:parent_runtime- Parent'sstate.runtimemap to inherit (optional). Process-local middleware state such as capturedProcessContextsnapshots. 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'scustom_context.scopeso sub-agent tools and persistence callbacks see the same tenant context as the parent. Defaults toagent_config.scope.:suppress_debug_events- When true, none of this sub-agent's events are published on the parent's:debugchannel (optional). Use it for a sub-agent whose inner messages must not leave the sub-agent. The parent still receives the run's outcome through the normal return value; only the debug fan-out is silenced. Defaults tofalse.
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 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 :interrupteddecisions- List of decision maps from human revieweropts- 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; theSubAgentcarries the reason in its:errorfield{: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
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:
Sagents.Middleware.SubAgent, to prevent recursive subagent nesting.Sagents.Middleware.AskUserQuestion, because a sub-agent has no user to ask.compose_child_system_prompt/1already tells every task sub-agent "You cannot ask the user questions. There is no user in this conversation.", so inheriting theask_usertool handed the sub-agent a capability the prompt then had to police.
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]
)
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.