Skip to contents

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 SubagentStop hooks

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
#> NULL

Read 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: readonly

Only 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] 2

For 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)