Current section
Files
Jump to
Current section
Files
lib/sagents/middleware/sub_agent.ex
defmodule Sagents.Middleware.SubAgent do
@moduledoc """
Middleware 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 of `SubAgent.Config` or `SubAgent.Compiled` configurations
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.
## 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:
1. **SubAgent middleware is ALWAYS excluded** - This prevents recursive subagent
nesting which could lead to resource exhaustion. You cannot override this.
2. **Blocked middleware is excluded** - Any modules listed in `:block_middleware`
are filtered out before passing to the subagent.
### 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
]}
## 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 result
## Architecture
Main Agent
│
├─ task("research task", "researcher")
│ │
│ └─ SubAgent (as SubAgentServer process)
│ ├─ Fresh conversation
│ ├─ Specialized tools
│ ├─ LLM executes
│ └─ Returns final message only
│
└─ Receives result, continues
## HITL 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 completes
"""
@behaviour Sagents.Middleware
require Logger
alias Sagents.SubAgent
alias Sagents.SubAgentServer
alias Sagents.SubAgentsDynamicSupervisor
alias LangChain.Function
## Middleware Callbacks
@impl true
def init(opts) do
# Extract configuration
subagents = Keyword.get(opts, :subagents, [])
agent_id = Keyword.fetch!(opts, :agent_id)
model = Keyword.fetch!(opts, :model)
middleware = Keyword.get(opts, :middleware, [])
block_middleware = Keyword.get(opts, :block_middleware, [])
# Validate block_middleware entries (warn about potential issues)
validate_block_middleware(block_middleware, middleware)
# Build agent lookup map from subagent configs
# Returns {:ok, %{"researcher" => agent_struct, "coder" => agent_struct}}
# This is just a MAP for looking up which Agent to use, NOT a process Registry
case SubAgent.build_agent_map(subagents, model, middleware) do
{:ok, agent_map} ->
# Build descriptions map for tool schema
descriptions = SubAgent.build_descriptions(subagents)
# Build until_tool map from subagent configs that have until_tool set
until_tool_map =
subagents
|> Enum.filter(fn config ->
is_struct(config, SubAgent.Config) and config.until_tool != nil
end)
|> Map.new(fn config -> {config.name, config.until_tool} end)
# Add "general-purpose" entry for dynamic subagent creation
# This special marker enables runtime tool inheritance
agent_map_with_general = Map.put(agent_map, "general-purpose", :dynamic)
descriptions_with_general =
Map.put(
descriptions,
"general-purpose",
"General-purpose subagent for complex, multi-step tasks. " <>
"Inherits all tools and middleware from parent agent. " <>
"Use when you need to delegate independent work that can run in isolation."
)
config = %{
agent_map: agent_map_with_general,
descriptions: descriptions_with_general,
agent_id: agent_id,
model: model,
block_middleware: block_middleware,
until_tool_map: until_tool_map
}
{:ok, config}
{:error, reason} ->
{:error, "Failed to build subagent lookup map: #{reason}"}
end
end
@impl true
def system_prompt(_config) do
"""
## SubAgent Delegation
You have access to a `task` tool for delegating work to specialized SubAgents.
**Use SubAgents when:**
- Task is complex and multi-step
- Task can be fully delegated in isolation
- You only care about the final result
- Heavy context/token usage would benefit from isolation
**Do NOT use SubAgents when:**
- Task is trivial (single tool call)
- You need to see intermediate reasoning
- Task requires iterative back-and-forth
SubAgents have their own conversation context and will work independently
to complete the task. You will receive only their final result.
"""
end
@impl true
def tools(config) do
[build_task_tool(config)]
end
## Private Functions - Tool Building
defp build_task_tool(config) do
# Get list of available subagent names from the lookup map
subagent_names = config.agent_map |> Map.keys()
# Build description with available subagents
description = build_task_description(config.descriptions)
Function.new!(%{
name: "task",
description: description,
display_text: "Running task",
parameters_schema: %{
type: "object",
required: ["instructions", "subagent_type"],
properties: %{
"instructions" => %{
type: "string",
description:
"Detailed instructions for what the SubAgent should accomplish. Be specific about the task, expected output, and any context needed."
},
"subagent_type" => %{
type: "string",
enum: subagent_names,
description: "Which specialized SubAgent to use for this task"
},
"system_prompt" => %{
type: "string",
description:
"Optional custom system prompt to define how the SubAgent should behave. " <>
"Only applicable for 'general-purpose' type. Defines role, capabilities, and constraints. " <>
"If omitted, a default general-purpose prompt will be used."
}
}
},
function: fn args, context ->
execute_task(args, context, config)
end,
# Allow multiple SubAgents to run in parallel
async: true
})
end
defp build_task_description(descriptions) do
base = "Delegate a task to a specialized SubAgent.\n\nAvailable SubAgents:\n"
subagent_list =
descriptions
|> Enum.map(fn {name, desc} -> "- #{name}: #{desc}" end)
|> Enum.join("\n")
base <> subagent_list
end
## Private Functions - Task Execution
defp execute_task(args, context, config) do
instructions = Map.fetch!(args, "instructions")
subagent_type = Map.fetch!(args, "subagent_type")
# Check if we're resuming an existing SubAgent
case get_resume_context(context) do
{:resume, sub_agent_id} ->
# Resume existing SubAgent with decisions
resume_subagent(sub_agent_id, context)
:new ->
# Start new SubAgent (pass full args for system_prompt support)
start_subagent(instructions, subagent_type, args, context, config)
end
end
defp get_resume_context(context) do
# Check if this is a resume operation
# The context will contain resume info from Agent.resume
case Map.get(context, :resume_info) do
%{sub_agent_id: sub_agent_id} ->
{:resume, sub_agent_id}
_ ->
:new
end
end
@doc """
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.
- `subagent_type` - The name/type of SubAgent 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
- `"subagent_type"` (required) - Same as subagent_type 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 subagent_type -> Agent struct
- `:descriptions` - Map of subagent_type -> description string
- `:agent_id` - Parent agent ID
- `:model` - Model configuration
## 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",
"subagent_type" => "researcher"
}
# 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
end
## Notes
- 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
"""
@spec start_subagent(String.t(), String.t(), map(), map(), map()) ::
{:ok, String.t()}
| {:ok, String.t(), term()}
| {:interrupt, map()}
| {:error, String.t()}
def start_subagent(instructions, subagent_type, args, context, config) do
Logger.debug("Starting SubAgent: #{subagent_type}")
# Get agent from lookup map
case Map.fetch(config.agent_map, subagent_type) do
{:ok, :dynamic} ->
# Handle "general-purpose" dynamic subagent with tool inheritance
start_dynamic_subagent(instructions, args, context, config)
{:ok, agent_config} ->
# Look up until_tool configuration for this subagent type
until_tool_map = Map.get(config, :until_tool_map, %{})
until_tool = Map.get(until_tool_map, subagent_type)
# Create SubAgent struct from pre-configured agent
# Check if it's a Compiled struct (with initial_messages) or just an Agent
subagent =
case agent_config do
%SubAgent.Compiled{} = compiled ->
# Use new_from_compiled to include initial_messages
SubAgent.new_from_compiled(
parent_agent_id: config.agent_id,
instructions: instructions,
compiled_agent: compiled.agent,
initial_messages: compiled.initial_messages || [],
until_tool: until_tool
)
agent ->
# Regular Agent struct from Config
SubAgent.new_from_config(
parent_agent_id: config.agent_id,
instructions: instructions,
agent_config: agent,
until_tool: until_tool
)
end
# Get supervisor for this parent agent
# Uses existing Sagents.Registry for process lookup
supervisor_name = SubAgentsDynamicSupervisor.get_name(config.agent_id)
# Spawn SubAgentServer under supervision
# SubAgentServer will register itself in Sagents.Registry
child_spec = %{
id: subagent.id,
start: {SubAgentServer, :start_link, [[subagent: subagent]]},
# Don't restart on crash
restart: :temporary
}
try do
case DynamicSupervisor.start_child(supervisor_name, child_spec) do
{:ok, _pid} ->
# Execute SubAgent synchronously (blocks until complete or interrupt)
execute_subagent(subagent.id, subagent_type)
{:error, reason} ->
{:error, "Failed to start SubAgent: #{inspect(reason)}"}
end
catch
:exit, reason ->
{:error, "Failed to start SubAgent: #{inspect(reason)}"}
end
:error ->
{:error, "Unknown SubAgent type: #{subagent_type}"}
end
end
## Starting Dynamic SubAgent (general-purpose with tool inheritance)
defp start_dynamic_subagent(instructions, args, context, config) do
Logger.debug("Starting dynamic general-purpose SubAgent")
# Extract parent capabilities from context (set by Agent.build_chain)
parent_middleware = Map.get(context, :parent_middleware, [])
# Get optional custom system prompt, or use default
system_prompt = Map.get(args, "system_prompt", default_general_purpose_prompt())
# Validate system prompt
case validate_system_prompt(system_prompt) do
:ok ->
# Filter middleware using block list from config
filtered_middleware =
SubAgent.subagent_middleware_stack(
parent_middleware,
[],
block_middleware: Map.get(config, :block_middleware, [])
)
# Convert MiddlewareEntry structs back to raw middleware specs
# parent_middleware contains initialized MiddlewareEntry structs, but Agent.new!
# expects raw middleware specs (module or {module, opts} tuples)
raw_middleware_specs = Sagents.MiddlewareEntry.to_raw_specs(filtered_middleware)
# Build Agent struct with inherited middleware capabilities
# Do NOT pass parent_tools - let filtered_middleware provide tools naturally
# This ensures SubAgent "task" tool is not inherited after filtering out SubAgent middleware
agent_config =
Sagents.Agent.new!(
%{
model: config.model,
base_system_prompt: system_prompt,
middleware: raw_middleware_specs
},
replace_default_middleware: true,
interrupt_on: nil
)
# Create SubAgent struct
subagent =
SubAgent.new_from_config(
parent_agent_id: config.agent_id,
instructions: instructions,
agent_config: agent_config
)
# Get supervisor and start SubAgent (same as pre-configured)
supervisor_name = SubAgentsDynamicSupervisor.get_name(config.agent_id)
child_spec = %{
id: subagent.id,
start: {SubAgentServer, :start_link, [[subagent: subagent]]},
restart: :temporary
}
try do
case DynamicSupervisor.start_child(supervisor_name, child_spec) do
{:ok, _pid} ->
execute_subagent(subagent.id, "general-purpose")
{:error, reason} ->
{:error, "Failed to start dynamic SubAgent: #{inspect(reason)}"}
end
catch
:exit, reason ->
{:error, "Failed to start dynamic SubAgent: #{inspect(reason)}"}
end
{:error, reason} ->
{:error, "Invalid system_prompt: #{reason}"}
end
end
defp default_general_purpose_prompt() do
"""
You are a general-purpose assistant SubAgent. You have access to tools. Focus on completing the specific task you've been given.
Return a clear, concise result suitable for the parent agent to use.
"""
end
defp validate_system_prompt(system_prompt) when is_binary(system_prompt) do
cond do
String.length(system_prompt) == 0 ->
{:error, "system_prompt cannot be empty"}
String.length(system_prompt) > 10_000 ->
{:error, "system_prompt too long (max 10,000 characters)"}
contains_potential_injection?(system_prompt) ->
{:error, "system_prompt contains potentially unsafe content"}
true ->
:ok
end
end
defp validate_system_prompt(_), do: {:error, "system_prompt must be a string"}
# Validate block_middleware entries and log warnings for potential issues
# Note: We only validate that entries are atoms and loaded modules.
# We don't check if they're in the parent middleware stack because that
# information isn't available at init time - the actual parent middleware
# is passed via context when creating subagents at runtime.
defp validate_block_middleware(block_list, _middleware) when is_list(block_list) do
for module <- block_list do
cond do
not is_atom(module) ->
Logger.warning(
"[SubAgent] block_middleware entry #{inspect(module)} is not a module atom"
)
not Code.ensure_loaded?(module) ->
Logger.warning("[SubAgent] block_middleware module #{inspect(module)} is not loaded")
true ->
:ok
end
end
:ok
end
defp validate_block_middleware(block_list, _parent_middleware) do
Logger.warning("[SubAgent] block_middleware must be a list, got: #{inspect(block_list)}")
:ok
end
# Basic safety check for prompt injection patterns
defp contains_potential_injection?(text) do
# Check for common prompt injection patterns
dangerous_patterns = [
~r/ignore\s+(all\s+)?previous\s+instructions/i,
~r/disregard\s+(all\s+)?previous\s+instructions/i,
~r/forget\s+(all\s+)?previous\s+instructions/i,
~r/new\s+instructions:/i,
~r/system\s*:\s*you\s+are\s+now/i
]
Enum.any?(dangerous_patterns, fn pattern ->
Regex.match?(pattern, text)
end)
end
defp execute_subagent(sub_agent_id, subagent_type) do
Logger.debug("Executing SubAgent: #{sub_agent_id}")
case SubAgentServer.execute(sub_agent_id) do
{:ok, final_result} ->
Logger.debug("SubAgent #{sub_agent_id} completed")
SubAgentServer.stop(sub_agent_id)
{:ok, final_result}
{:ok, final_result, extra} ->
# SubAgent completed with extra data (e.g., until_tool result)
Logger.debug("SubAgent #{sub_agent_id} completed with extra data")
{:ok, final_result, extra}
{:interrupt, interrupt_data} ->
Logger.info("SubAgent '#{subagent_type}' interrupted for HITL")
# Return 3-tuple that LangChain.execute_tool_call recognizes
# Keep alive — needs resume later
{:interrupt, "'#{subagent_type}' requires human approval.",
%{
type: :subagent_hitl,
sub_agent_id: sub_agent_id,
subagent_type: subagent_type,
interrupt_data: interrupt_data
}}
{:error, reason} ->
Logger.error("SubAgent #{sub_agent_id} failed: #{inspect(reason)}")
SubAgentServer.stop(sub_agent_id)
{:error, "SubAgent execution failed: #{inspect(reason)}"}
end
end
## Resuming Existing SubAgent
defp resume_subagent(sub_agent_id, context) do
Logger.debug("Resuming SubAgent: #{sub_agent_id}")
decisions = Map.get(context.resume_info, :decisions, [])
subagent_type = Map.get(context.resume_info, :subagent_type, "unknown")
case SubAgentServer.resume(sub_agent_id, decisions) do
{:ok, final_result} ->
Logger.debug("SubAgent #{sub_agent_id} completed after resume")
SubAgentServer.stop(sub_agent_id)
{:ok, final_result}
{:ok, final_result, extra} ->
# SubAgent completed with extra data after approval
Logger.debug("SubAgent #{sub_agent_id} completed after resume with extra data")
{:ok, final_result, extra}
{:interrupt, interrupt_data} ->
Logger.info("SubAgent '#{subagent_type}' interrupted again")
# Return 3-tuple that LangChain.execute_tool_call recognizes
# Keep alive — needs resume later
{:interrupt, "'#{subagent_type}' requires human approval.",
%{
type: :subagent_hitl,
sub_agent_id: sub_agent_id,
subagent_type: subagent_type,
interrupt_data: interrupt_data
}}
{:error, reason} ->
Logger.error("SubAgent #{sub_agent_id} resume failed: #{inspect(reason)}")
SubAgentServer.stop(sub_agent_id)
{:error, "SubAgent resume failed: #{inspect(reason)}"}
end
end
end