Sagents.Middleware.SubAgent (Sagents v0.11.0)
Copy MarkdownMiddleware for delegating tasks to specialized SubAgents.
Provides a task tool that allows the main agent to delegate complex,
multi-step work to specialized SubAgents. SubAgents run in isolated
contexts with their own conversation history, providing token efficiency
and clean separation of concerns.
Features
- Dynamic SubAgents: Create SubAgents from configuration at runtime
- Pre-compiled SubAgents: Use pre-built Agent instances
- HITL Propagation: SubAgent interrupts automatically propagate to parent
- Token Efficiency: Parent only sees final result, not SubAgent's internal work
- Process Isolation: SubAgents run as supervised processes
Configuration Options
The middleware accepts these options:
:subagents- List ofSubAgent.ConfigorSubAgent.Compiledconfigurations for pre-defined subagents. Defaults to[].:model- The chat model for dynamic subagents. Required.:middleware- Additional middleware to add to subagents. Defaults to[].:block_middleware- List of middleware modules to exclude from general-purpose subagent inheritance. Defaults to[]. See "Middleware Filtering" below.:include_task_list- Whether to render the## Available Taskssection (one bullet per configured sub-agent plusgeneral-purpose) into the middleware's system prompt. Defaults totrue.Set to
falsewhen the integrating application supplies the task menu another way. For example, a/commandsflow that injects only the relevant task entry on demand to keep the base context lean and reduce the chance of the model picking the wrong task. Thetasktool'stask_nameenum still constrains valid values regardless.
Configuration Example
middleware = [
{SubAgent, [
model: model,
subagents: [
SubAgent.Config.new!(%{
name: "researcher",
description: "Research topics using internet search",
system_prompt: "You are an expert researcher...",
tools: [internet_search_tool]
}),
SubAgent.Compiled.new!(%{
name: "coder",
description: "Write code for specific tasks",
agent: pre_built_coder_agent
})
],
block_middleware: [ConversationTitle, Summarization]
]}
]Middleware Filtering
When a general-purpose subagent is created, it inherits the parent agent's middleware stack with certain exclusions:
SubAgent middleware is ALWAYS excluded - This prevents recursive subagent nesting which could lead to resource exhaustion. You cannot override this.
AskUserQuestion middleware is excluded - A subagent has no user to ask. Its
:ask_user_questioninterrupt is raised from inside a tool body, so it returns to the parent wrapped as:subagent_hitlwith the question buried where the question-rendering path does not look. The task-subagent system prompt already states "You cannot ask the user questions. There is no user in this conversation.", so inheriting theask_usertool handed the subagent a capability the prompt then had to police.Blocked middleware is excluded - Any modules listed in
:block_middlewareare filtered out before passing to the subagent.
Exclusions apply to inheritance only. A pre-configured subagent that names
one of these in its own :middleware list still receives it.
Example: Blocking Unnecessary Middleware
Some middleware is inappropriate for short-lived subagents:
{SubAgent, [
model: model,
subagents: [],
# These middleware modules won't be inherited by general-purpose subagents
block_middleware: [
Sagents.Middleware.ConversationTitle, # Subagents don't need titles
Sagents.Middleware.Summarization # Short tasks don't need summarization
]
]}Pre-configured Subagents
The :block_middleware option only affects general-purpose subagents created
dynamically via the task tool. Pre-configured subagents (defined in :subagents)
use their own explicitly defined middleware and are NOT affected by this option.
{SubAgent, [
subagents: [
# This subagent defines its own middleware - block_middleware doesn't apply
SubAgent.Config.new!(%{
name: "researcher",
middleware: [ConversationTitle] # Explicitly included
})
],
block_middleware: [ConversationTitle] # Only affects general-purpose
]}Suppressing Debug Events
A sub-agent's events are published on the parent's :debug channel, and
they carry content: the initial messages, every inner LLM message live, and
the whole inner chain on failure or cancellation. For a sub-agent that exists
as a confidentiality boundary — it reads sensitive data and is meant to return
only a narrow result — set :suppress_debug_events on its config:
{SubAgent, [
model: model,
subagents: [
SubAgent.Config.new!(%{
name: "pii-extractor",
description: "Extract structured fields from a sensitive document",
tools: [extract_tool],
suppress_debug_events: true
})
]
]}Nothing about that sub-agent's run reaches the parent's :debug channel. The
parent still receives the outcome as the task tool result; only the observer
fan-out is silenced. Defaults to false, so existing consumers are unaffected.
The setting is per sub-agent type and all-or-nothing. It does not apply to the
general-purpose sub-agent, which is created dynamically rather than
declared in :subagents and so has no config to carry the flag.
Usage Example
# Main agent decides to delegate work
"I need to research renewable energy. I'll use the researcher SubAgent."
→ Calls: task("Research renewable energy impacts", "researcher")
# SubAgent executes independently
# If SubAgent hits HITL interrupt (e.g., internet_search needs approval):
# 1. SubAgent pauses
# 2. Interrupt propagates to parent
# 3. User sees: "SubAgent 'researcher' needs approval for 'internet_search'"
# 4. User approves
# 5. Parent resumes, which resumes SubAgent
# 6. SubAgent completes and returns resultArchitecture
Main Agent
│
├─ task("research task", "researcher")
│ │
│ └─ SubAgent (as SubAgentServer process)
│ ├─ Fresh conversation
│ ├─ Specialized tools
│ ├─ LLM executes
│ └─ Returns final message only
│
└─ Receives result, continuesHITL Interrupt Flow
1. SubAgent hits HITL interrupt
2. SubAgentServer.execute() returns {:interrupt, interrupt_data}
3. Task tool receives interrupt
4. Task tool returns {:interrupt, enhanced_data} to parent
5. Parent agent propagates to AgentServer
6. User approves
7. Parent agent resumes
8. Task tool calls SubAgentServer.resume(decisions)
9. SubAgent continues and completesHalts are propagated, not wrapped
The flow above applies to interrupts that can be resolved: a HITL
approval, a question. A :halt is different. It declares the workflow
over, so there is nothing to approve and nothing to resume.
When a sub-agent tool emits %{type: :halt} (or a :multiple_interrupts
batch containing one, since "halt wins"), the halt is propagated to the parent
as a halt rather than wrapped in :subagent_hitl, and the sub-agent
process is stopped. This is what lets the parent's existing halt machinery
apply: the loop terminates without another LLM call,
Sagents.Middleware.Haltable claims the interrupt for cold-start restore,
and Sagents.AgentUtils.interrupt_session_changes/1 surfaces the halt's
author-facing :message.
The propagated halt keeps the emitting tool's :source_tool and gains a
:source_task naming the sub-agent that ran it. It deliberately carries no
:sub_agent_id, because a restorable interrupt must be pure data and the
sub-agent process does not survive a reboot.
Summary
Functions
Handle resume for SubAgent interrupts.
Starts and executes a new SubAgent to delegate work.
Functions
Handle resume for SubAgent interrupts.
Claims interrupts where state.interrupt_data has type: :subagent_hitl.
Delegates to SubAgentServer.resume and handles completion, re-interrupt,
and error cases. Also handles type: :multiple_interrupts by processing
the first interrupt and queuing the rest.
@spec start_subagent( String.t(), String.t(), args :: map(), context :: map(), config :: map() ) :: {:ok, String.t()} | {:ok, String.t(), term()} | {:interrupt, map()} | {:error, String.t()}
Starts and executes a new SubAgent to delegate work.
This function allows custom tools and middleware to spawn SubAgents for
delegating complex, multi-step tasks, similar to how the built-in task tool
works. The SubAgent runs as an isolated, supervised process with its own
conversation context.
Parameters
instructions- Detailed instructions for what the SubAgent should accomplish. Be specific about the task, expected output, and any context needed.task_name- The name of the task to use. Must match a configured SubAgent name (from middleware init) or "general-purpose" for dynamic SubAgents.args- Full arguments map containing:"instructions"(required) - Same as instructions parameter"task_name"(required) - Same as task_name parameter"system_prompt"(optional) - Custom system prompt for general-purpose SubAgents
context- Tool execution context map containing::agent_id- Parent agent ID:state- Parent agent state:parent_middleware- Parent middleware list (for general-purpose SubAgents):resume_info- Resume information if continuing interrupted SubAgent
config- Middleware configuration map containing::agent_map- Map of task_name -> Agent struct:descriptions- Map of task_name -> description string:agent_id- Parent agent ID:model- Model configuration:initial_messages(optional) - List of%LangChain.Message{}structs to slot between the sub-agent's system messages and the user instruction. Use this to inject per-call reference content (e.g. a<references>synthetic preamble) as established context for the sub-agent's first turn. Empty list or omitted key means no augmentation. Supported on all task types: for Compiled subagents, these are appended after the registeredCompiled.initial_messages.
Raises
ArgumentErrorifconfig[:initial_messages]is supplied but is not a list of%LangChain.Message{}structs.
Returns
{:ok, result}- SubAgent completed successfully, returns final message content{:interrupt, interrupt_data}- SubAgent hit HITL interrupt, needs approval{:error, reason}- Failed to start or execute SubAgent
Example
Using from a custom tool function:
def my_research_tool_function(args, context) do
# Build config from middleware state
subagent_config = %{
agent_map: context.subagent_map,
descriptions: context.subagent_descriptions,
agent_id: context.agent_id,
model: context.model
}
# Prepare arguments
task_args = %{
"instructions" => "Research quantum computing developments",
"task_name" => "research"
}
# Start SubAgent
case SubAgent.start_subagent(
"Research quantum computing developments",
"researcher",
task_args,
context,
subagent_config
) do
{:ok, result} ->
{:ok, "Research complete: " <> result}
{:interrupt, interrupt_data} ->
# Propagate interrupt to parent
{:interrupt, interrupt_data}
{:error, reason} ->
{:error, "Failed to research: " <> reason}
end
endNotes
- SubAgents run in isolated process contexts with their own conversation history
- Parent only sees final result, not intermediate reasoning (token efficient)
- HITL interrupts from SubAgents automatically propagate to parent
- For "general-purpose" type, tools and middleware are inherited from parent
- SubAgents are supervised and cleaned up automatically