For complex tasks, deputy supports a lead agent that delegates work to specialised sub-agents. Each sub-agent has its own tools, prompt, and (optionally) model. The lead agent decides when to delegate and synthesises the results.
When to Use Multi-Agent
Multi-agent orchestration is useful when:
- Different parts of a task need different tools (e.g., code analysis vs. data analysis)
- You want to isolate sub-tasks with their own system prompts
- Sub-agents should run with restricted permissions
- You need audit trails per sub-agent via
SubagentStophooks
For simpler tasks, a single Agent with the right tools
is usually enough.
Defining Sub-Agents
Use agent_definition() to describe a sub-agent:
library(deputy)
code_reviewer <- agent_definition(
name = "code_reviewer",
description = "Reviews R code for best practices and potential issues",
prompt = "You are an expert R developer. Review code for correctness,
style, and potential bugs. Be specific and actionable.",
tools = tools_file()
)
data_analyst <- agent_definition(
name = "data_analyst",
description = "Analyses data files and produces statistical summaries",
prompt = "You are a data analyst. Read data files and provide clear,
concise statistical summaries with key insights.",
tools = tools_data()
)Fields:
| Field | Description |
|---|---|
name |
Unique routing key: lowercase letter first, then letters, numbers,
_, or -
|
description |
What this agent does (shown to the lead LLM) |
prompt |
System prompt for the sub-agent |
tools |
Tools available to the sub-agent |
model |
"inherit" (default) or a specific model name |
skills |
Optional list of skills to load |
disallowed_tools |
Additional tool names that the child must not use |
memory |
Optional context appended to the child’s system prompt |
mcp_servers |
Optional MCP servers loaded for the child |
initial_prompt |
Optional text prepended to delegated tasks |
max_requests |
Optional per-child request limit |
permission_mode |
Optional mode that preserves or narrows the lead policy |
Delegated Permission Boundaries
A sub-agent cannot gain authority that its lead does not have. Deputy
intersects the requested child mode with the lead’s capabilities, write
root, tool gates, and permission callback. disallowed_tools
adds further denials.
The allowed child-mode transitions use the same authority order as
Agent$set_permission_mode():
| Lead mode | Allowed child modes |
|---|---|
"readonly" |
"readonly" |
"standard" |
"standard", "readonly"
|
"plan" |
"plan", "readonly"
|
"full" |
"full", "standard", "plan",
"readonly"
|
For example, a readonly reviewer can be declared beneath a standard lead without gaining file reads, web access, or tools that the lead explicitly denied:
reviewer <- agent_definition(
name = "reviewer",
description = "Reviews files without changing them",
prompt = "Review the requested files and report findings.",
tools = tools_file(),
permission_mode = "readonly",
disallowed_tools = "read_csv",
max_requests = 5
)Creating a LeadAgent
LeadAgent extends Agent with a built-in
delegate_to_agent tool:
lead <- LeadAgent$new(
chat = ellmer::chat_anthropic(),
sub_agents = list(code_reviewer, data_analyst),
system_prompt = "You coordinate analysis tasks. Delegate to the
appropriate specialist and synthesise their findings."
)
lead$available_sub_agents()
#> [1] "code_reviewer" "data_analyst"Names are trimmed and converted to lowercase by
agent_definition(). A LeadAgent rejects
duplicate normalized names at construction and registration. Its
sub_agent_defs field returns a list of read-only S7
AgentDefinition values; use
register_sub_agent() so the registry and lead prompt stay
synchronized. agent_definition() and
AgentDefinition() are the same S7 constructor. To revise a
definition, edit a plain property record and construct a new value:
library(deputy)
reviewer <- agent_definition("Reviewer", "Reviews text", "Read carefully.")
fields <- S7::props(reviewer)
fields$name <- "limited-reviewer"
fields$max_requests <- 2L
limited_reviewer <- do.call(agent_definition, fields)
limited_reviewer$name
#> [1] "limited-reviewer"
limited_reviewer$max_requests
#> [1] 2
# The original definition still has no request limit.
reviewer$max_requests
#> NULLRead properties with $, S7::prop(), or
@. Definitions compose original ellmer tools and read-only
Skill values. Executable tools nested in either value retain their
closures, services, and caller-owned state; frozen configuration does
not freeze tool state. S7::props() retains these objects,
so use the YAML helpers and explicit registries below when you need a
portable definition.
Running a Delegation Task
When the lead agent decides to delegate, it calls
delegate_to_agent internally. The sub-agent runs to
completion and returns its result to the lead:
library(deputy)
code_reviewer <- agent_definition(
name = "code_reviewer",
description = "Reviews R code and suggests improvements",
prompt = "You are an R code reviewer. Be concise.",
tools = tools_file()
)
chat <- ellmer::chat_anthropic(model = "claude-sonnet-4-20250514")
lead <- LeadAgent$new(
chat = chat,
sub_agents = list(code_reviewer)
)
result <- lead$run_sync(
"Ask the code reviewer to look at the DESCRIPTION file and
summarise what this package does."
)
cat(result$response)Direct delegation participates in the lead run’s usage limits. Each
child inherits the lead’s remaining UsageLimits, and its
usage is aggregated into result$usage and enforced by the
lead after delegation. Concurrent direct children reserve allocations
before launch. Background children, transitive child trees, and
cross-run global limits remain outside this contract.
Stateless fan-out
For independent perspectives on supplied text, the host can select responders directly. Each definition gets a fresh conversation with its own prompt and at most one model request. The lead makes no model request for orchestration. Definitions with tools, skills, or MCP servers are rejected before dispatch.
lead <- LeadAgent$new(
ellmer::chat_openai(model = "gpt-5.6-luna"),
sub_agents = list(
agent_definition("benefits", "Find benefits", "Explain the strongest benefits."),
agent_definition("risks", "Find risks", "Explain the strongest risks.")
)
)
batch <- lead$parallel_delegate(
c(benefits = "Review a four-day working week.",
risks = "Review a four-day working week."),
max_active = 2,
usage_limits = UsageLimits(max_requests = 2),
run_context = list(workflow = "decision-review")
)
batch$status
batch$results$benefits$response
batch$run$usage
lead$list_subagents()Names are normalized and must select each registered definition at most once. The result preserves input order and has five fields:
| Field | Meaning |
|---|---|
mode |
"stateless"; other modes are currently rejected |
results |
Named list of child AgentResult objects, or
NULL
|
errors |
Named list of captured conditions, or NULL
|
status |
Named vector: completed, failed,
stopped, or not_started
|
run |
Aggregate AgentResult with usage, events, and batch
stop reason |
A failed responder does not discard successful siblings. Check
status before synthesis; a stopped child may have partial
text. Child records include unique agent, session, run, and delegation
identifiers linked to the batch run, and fire the usual
SubagentStart and SubagentStop hooks.
$last_run() retains the aggregate on normal return. The
lead’s conversation is unchanged; the host explicitly supplies selected
responses to any later synthesis run.
Limits and cancellation
max_active bounds simultaneous responders. Work runs in
waves: the next wave starts after all current responders settle. Each
wave reserves request slots before dispatch and divides the remaining
token and cost ceilings among its responders. A definition can narrow
its request allowance to zero. Failed dispatch attempts count as
requests; missing cost information stops queued work when a cost ceiling
is configured.
Request limits prevent excess dispatches. Token and cost ceilings use
observed usage, so a wave can exceed them by up to one response per
active responder. Unused allocations are released after settlement. All
observed child usage is included in the aggregate, including failed or
interrupted runs. The default on_exceed = "stop" returns
partial outcomes. on_exceed = "error" raises the usual
typed limit condition after cleanup; completed child records remain
inspectable through $list_subagents() and
$get_subagent_results().
For Shiny or another asynchronous host, use
$parallel_delegate_async() and consume its promise.
$interrupt() prevents queued work from starting and asks
active responders to stop at the next supported provider boundary. The
batch settles after its active responders drain. The lead rejects
overlapping runs until cleanup finishes. Tool-using background workers
and persistent agent scheduling are separate future work.
Opposing perspectives and synthesis
For a complete opposing-perspective workflow, run the installed standalone example after configuring your OpenAI credentials:
source(system.file("examples", "standalone", "09-debate.R", package = "deputy"))It prints a Markdown comparison, requires both heads to complete, and then uses a separate one-request moderator. Its bundled prompt can also be reused with any Agent:
agent$load_skill(system.file("skills", "debate", package = "deputy"))Loading this prompt does not start independent responders. The host supplies arguments or orchestrates fan-out explicitly.
Monitoring with SubagentStop Hooks
Use a SubagentStop hook to log or inspect sub-agent
results:
hook_monitor <- HookMatcher(
event = "SubagentStop",
callback = function(agent_name, task, result, context) {
cli::cli_alert_info("Sub-agent {agent_name} finished")
cli::cli_alert("Status: {context$status}")
NULL
}
)
lead$add_hook(hook_monitor)Portable definitions
AgentDefinitions can live as .yaml or .yml
files in .deputy/agents/. A file contains data, while the
host chooses the actual tools and skills it can reference. Reading files
does not run R code, load a Skill, contact an MCP server, or create an
Agent.
Here is a complete version 1 definition:
version: 1
name: reviewer
description: Reviews local text for gaps
prompt: |
Read the supplied text using read_file.
Report unsupported claims and missing evidence concisely.
tools: [read_file]
model: inherit
skills: []
disallowed_tools: [write_file, run_bash]
memory: []
mcp_servers: []
initial_prompt: Keep the review grounded in the supplied text.
max_requests: 2
permission_mode: readonlyOnly version, name,
description, and prompt are required. All
other fields use agent_definition() defaults when omitted.
The format covers the entire constructor surface:
| Field | Type and meaning |
|---|---|
version |
Required numeric format version, currently 1
|
name |
Required routing name; canonicalized to lowercase |
description |
Required nonempty description shown to the lead |
prompt |
Required nonempty prompt; | preserves multiline
text |
tools |
Tool registry keys; defaults to []
|
model |
Model string, default inherit
|
skills |
Skill registry keys; defaults to []
|
disallowed_tools |
Tool names denied to the subagent, or null
|
memory |
Sequence of memory strings, or null
|
mcp_servers |
MCP server names for the existing delegation lifecycle, or
null
|
initial_prompt |
Initial task prefix, or null
|
max_requests |
Non-negative integer request limit, or null
|
permission_mode |
standard, readonly, plan,
full, or null
|
The lead still controls the authority ceiling. A file cannot give a
subagent more permission than its lead, nor supply credentials, host
settings, or nested subagents. Tool and skill references are exact,
case-sensitive keys like read_file or
concise_review; they do not name R expressions, package
exports, or filesystem paths. A host can map a key to an approved Skill
path. Loading that Skill happens when the subagent is instantiated.
Unknown fields, unsupported versions, missing references, duplicate
keys, and !expr tags are errors. Quote YAML strings like
"yes" and "123" to prevent YAML’s automatic
type conversion. Use [] for empty sequences and
null for absent optional fields. Single strings are also
accepted for one-element sequences. Files are limited to 1 MiB.
Read and delegate
The package ships the YAML above as a working example. This chunk reads it without model credentials:
library(deputy)
tool_registry <- list(read_file = tool_read_file)
definition_dir <- system.file("examples", "agent-definitions", package = "deputy")
definitions <- agent_definitions(definition_dir, tools = tool_registry)
names(definitions)
#> [1] "reviewer"
definitions$reviewer$max_requests
#> [1] 2For a project, use
agent_definitions(tools = tool_registry) to discover
.deputy/agents/. Discovery is nonrecursive and ordered by
filename. A missing or empty directory returns an empty list. Duplicate
canonical names and invalid files stop discovery rather than silently
dropping a definition.
lead <- LeadAgent$new(
chat = ellmer::chat_openai(model = "gpt-5.6-luna"),
sub_agents = definitions,
permissions = Permissions(mode = "standard", file_write = FALSE),
usage_limits = UsageLimits(max_requests = 6)
)
result <- lead$run_sync("Delegate a review of input.txt to reviewer.")
lead$list_subagents()Write and round-trip
Pass the same registries when writing and reading. The writer finds
registry keys by exact object identity; it refuses unmapped tools/skills
and ambiguous aliases. Existing files require
overwrite = TRUE. Comments, YAML formatting, and names
attached to R lists or character sequences are not preserved; object
order and registry identity are preserved.
The writer completes a temporary file in the destination directory
before installing it. With overwrite = TRUE, it renames the
completed file into place. Otherwise it creates a hard link, which fails
atomically if another writer has already created the destination. This
requires filesystem support for hard links. A failed write or
installation leaves an existing definition intact.
path <- tempfile(fileext = ".yaml")
agent_definition_write(definitions$reviewer, path, tools = tool_registry)
restored <- agent_definition_read(path, tools = tool_registry)
identical(restored, definitions$reviewer)
#> [1] TRUE
unlink(path)