Skip to content

Configure subagents

Subagents are specialized LLM workers the lead agent delegates to via the task() tool. The template ships one worked example: a researcher (web + memory, plan→search→synthesize→cite). This guide walks through adding more, trimming down, or turning the pattern off entirely.

When to use subagents

  • You have clearly separable phases in your agent's work (e.g. research, synthesize, publish).
  • You want each phase to get its own focused system prompt and tool allowlist.
  • You want each phase's tool calls audited + traced under the same session as the lead.

When not to use:

  • For a single-loop agent. Adding a subagent hop for every call just wastes turns.
  • When one delegation's output feeds the next — use sequential task() calls (or a chain) for that. For independent delegations, see task_batch below.

Single vs. batch delegation

The lead gets two delegation tools:

  • task(description, prompt, subagent_type) — one focused delegation. Unbounded output.
  • task_batch(tasks) — several independent delegations run concurrently (e.g. research three topics at once). Each tasks item is {description, prompt, subagent_type?}. Results come back in input order; an individual task's failure is reported inline and doesn't abort the batch. Concurrency is capped by subagents.max_concurrency (default 4) and each result is truncated to subagents.output_truncate chars (default 6000) so a wide fan-out can't blow the parent context. Total latency is roughly the slowest task rather than the sum.

Prefer task_batch whenever the delegations don't depend on each other.

1. Define the config

graph/subagents/config.py already defines RESEARCHER_CONFIG. To add a second role, define another SubagentConfig and register it:

python
SUMMARIZER_CONFIG = SubagentConfig(
    name="summarizer",
    description=(
        "Condenses long source text into a tight brief. "
        "Returns a ≤200-word summary; the lead decides what to do next."
    ),
    system_prompt="""You are the summarizer subagent.

Your job: given source text or URLs, return a concise brief (≤200 words):
- What the material says
- Key facts worth keeping
- Any obvious gaps or caveats

Rules:
- Keep responses focused — the lead agent is waiting on your return
  value, not a conversation.
- Answer naturally, like the lead agent — reasoning streams natively; no `<scratch_pad>` / `<output>` tags.
""",
    tools=["fetch_url", "current_time"],
    max_turns=15,
)

SUBAGENT_REGISTRY: dict[str, SubagentConfig] = {
    "researcher": RESEARCHER_CONFIG,
    "summarizer": SUMMARIZER_CONFIG,
}

2. Expose the config shape

The template's LangGraphConfig (in graph/config.py) has a researcher field. Add one for each new subagent:

python
@dataclass
class LangGraphConfig:
    # ... existing fields ...
    researcher: SubagentDef = field(default_factory=lambda: SubagentDef(
        tools=[
            "current_time",
            "web_search", "fetch_url",
            "memory_recall", "memory_list",
        ],
        max_turns=40,
    ))
    summarizer: SubagentDef = field(default_factory=lambda: SubagentDef(
        tools=["fetch_url", "current_time"],
        max_turns=15,
    ))

And update the from_yaml() subagent loop:

python
for name in ("researcher", "summarizer"):  # ← add new names
    if name in subagents:
        sub = subagents[name]
        setattr(config, name, SubagentDef(
            enabled=sub.get("enabled", True),
            tools=sub.get("tools", getattr(config, name).tools),
            max_turns=sub.get("max_turns", getattr(config, name).max_turns),
        ))

3. Add to the YAML

config/langgraph-config.yaml:

yaml
subagents:
  researcher:
    enabled: true
    tools:
      - current_time
      - web_search
      - fetch_url
      - memory_recall
      - memory_list
    max_turns: 40
  summarizer:
    enabled: true
    tools: [fetch_url, current_time]
    max_turns: 15

4. Teach the lead agent

The lead's task() tool docstring is how the LLM learns what subagents exist. It's generated automatically from SUBAGENT_REGISTRY, but the lead also needs to know when to delegate. Add that guidance to your persona file, config/SOUL.md (read into the system prompt by graph/prompts.py::build_system_prompt — you don't edit prompts.py):

markdown
Available subagents (invoke via the `task` tool):
- `researcher` — gathers + synthesizes background on a topic, returns a sourced brief
- `summarizer` — condenses long source text into a ≤200-word brief

Delegate to researcher when a user asks an open-ended "find out about X"
question. Handle short factual queries yourself.

No-fork path: a subagent can also be shipped as a plugin (register_subagent) — added to SUBAGENT_REGISTRY at load with no edit to graph/subagents/config.py or graph/config.py.

5. Turn subagents off entirely

If your agent is simple enough that subagents are pure overhead, flip include_subagents=False when the graph is built. In server/agent_init.py::_init_langgraph_agent:

python
_graph = create_agent_graph(
    _graph_config,
    knowledge_store=knowledge_store,  # keep the bundled store wired up
    include_subagents=False,           # ← skip the task() tool and subagent machinery
)

This drops the task() tool from the lead's toolset. No runtime hit.

The adversarial-research roles

Beyond researcher, the template ships three roles that exist specifically to make a research report honest — used by the deep-researchworkflow (ADR 0011). They're separate agents on purpose: no agent should grade its own homework.

  • antagonist — adversarial reviewer. Steelmans the strongest opposing case, attacks weak/unsupported claims, and uses its own web_search/fetch_url to hunt disconfirming evidence. Outputs an "Opposition & weaknesses" memo.
  • verifier — independent claim-checker. Extracts the load-bearing factual claims and labels each supported/unsupported/uncertain against sources.
  • synthesizer — writes the final balanced report, folding the antagonist's opposition into a "Counterpoints & caveats" section, dropping anything the verifier didn't support, and only earning a high Confidence if the opposition was answered.

They're ordinary SubagentConfigs in the registry — reuse, retune, or drop them like any other; the deep-research recipe just wires them into a DAG.

Background jobs (run detached)

A delegation can run detached so it never blocks the turn: task(run_in_background=true) (or task_batch) fires the subagent, returns a bg-… job id immediately, and the result is drained back into the conversation when it finishes — with a live card on the console's background-jobs surface (ADR 0050). Durable (survives restart), concurrency-capped (BACKGROUND_MAX_CONCURRENCY), and cancellable.

Completion is a push, not mail waiting for pickup (ADR 0070). When a job finishes, the server resumes the origin session with a nudge turn: the pending <task-notification> drains into that turn and the agent briefs the operator right where the work was requested (config background.auto_resume, on by default; false restores pull-only delivery on the session's next manual turn — the ADR 0050 Activity idle-wake then covers autonomous reaction instead). A substantial report (> ~800 chars) is also indexed into the knowledge store at completion, keyed to the origin session (source_type: background_report, trust tier 2 — agent-derived), so only a 3 000-char excerpt rides in-context while the full text stays searchable via memory_recall and openable from the console report card (GET /api/background/{job_id}). Worker transcripts are disposable: background:* sessions are excluded from session-summary persistence, the <prior_sessions> digest, and retirement harvest — the jobs DB is the system of record. Jobs spawned from an incognito thread inherit the no-memory-trail contract: no auto-resume, no indexing; the report still drains normally.

Reuse it from your own code — BackgroundManager.spawn_work(...). Not every long job is an LLM turn. A deterministic pipeline — media transcription, a bulk import, a crawl — shouldn't spend a model turn on itself. spawn_work runs a plain coroutine through the same durable registry + concurrency cap + background.* event stream + drain-on-next-turn + idle-wake as a background subagent, with no LLM turn:

python
from runtime.state import STATE

mgr = STATE.background_mgr  # None when the background subsystem is disabled → run inline instead
if mgr is not None:
    job_id = await mgr.spawn_work(
        origin_session=session_id,
        kind="ingest",                     # short label — shows on the job card
        description="Ingest that video",
        detail=source,                     # recorded on the job row
        work=lambda: do_the_work(source),  # an async () -> result-string callable
    )

The knowledge_ingest tool is the first consumer — it detaches any slow URL/media ingest this way so a 20-minute transcription never freezes the chat. Reach for spawn_work whenever a tool or plugin has a long deterministic operation that shouldn't block the turn (vs. run_in_background, which is for detaching an LLM subagent turn).

From a plugin, use the consumption SDK instead of STATE. graph.sdk.spawn_background(prompt, *, subagent_type, origin_session, label=None) spawns a detached subagent job through the same pipeline — the report card, KB indexing, and the push-resume nudge all land for free — and graph.sdk.background_status(task_id) reads the job row ({status, description, report?}) so a plugin dashboard can show progress between launch and the nudge. See Plugins ▸ Tapping core deeper.

What you get for free

Every subagent call:

  • Runs inside the same trace_session context as the lead → nested Langfuse span.
  • Inherits the same session_id → audit-log entries from the subagent's tools land alongside the lead's.
  • Emits the same autonomous.cost.* events on terminal completion.
  • Is rate-limited by max_turns (hard stop — avoids runaway recursion).

Neither task nor task_batch is ever in a subagent's tool allowlist (subagents only get the tools named in their tools: list), so subagents can't spawn further subagents. This is intentional; one level of delegation is almost always enough.

Part of the protoLabs autonomous development studio.