Subagents¶
The adk_fluent._subagents package is adk-fluent’s dynamic specialist-spawning
mechanism. It mirrors Claude Agent SDK’s Task tool pattern: a parent LLM keeps
control of the conversation and, when it needs focused work done, calls a
task(role, prompt) tool that dispatches a fresh specialist, waits for the
answer, and folds the result back into the parent’s context window.
A subagent is not a long-running teammate — it is a short-lived worker with its own instruction, its own toolset, and a disposable context. The parent never sees the specialist’s scratchpad, only its final output.
Why subagents?¶
Static sub-agents (.sub_agent()) commit the parent to a fixed topology at
build time. Subagents invert that: the parent carries a registry of roles
and decides at runtime which specialist to invoke and with what brief. That
gives you three properties you cannot get from a static topology:
Context isolation — the specialist burns its own tokens and returns a short summary, so the parent’s context stays lean.
Dynamic routing — the parent LLM picks the role based on the actual task, not a hand-wired predicate.
Parallel scaling — the same registry can be invoked concurrently from multiple turns without any topology duplication.
Quick start¶
from adk_fluent import (
Agent,
FakeSubagentRunner,
H,
SubagentSpec,
)
registry = H.subagent_registry(
[
H.subagent_spec(
role="researcher",
instruction="Find three authoritative papers and summarise them.",
description="Deep research specialist",
),
H.subagent_spec(
role="reviewer",
instruction="Critique the draft for factual errors.",
description="Technical critic",
),
]
)
task = H.task_tool(registry, FakeSubagentRunner())
coordinator = (
Agent("coordinator", "gemini-2.5-flash")
.instruct("Coordinate specialists. Use the `task` tool to delegate.")
.tool(task)
)
import {
Agent,
FakeSubagentRunner,
H,
SubagentSpec,
} from "adk-fluent-ts";
const registry = H.subagentRegistry([
H.subagentSpec({
role: "researcher",
instruction: "Find three authoritative papers and summarise them.",
description: "Deep research specialist",
}),
H.subagentSpec({
role: "reviewer",
instruction: "Critique the draft for factual errors.",
description: "Technical critic",
}),
]);
const task = H.taskTool(registry, new FakeSubagentRunner());
const coordinator = new Agent("coordinator", "gemini-2.5-flash")
.instruct("Coordinate specialists. Use the `task` tool to delegate.")
.tool(task);
The generated task callable has a docstring that enumerates every registered
role, so the parent LLM gets an accurate menu when it decides who to call.
Core types¶
SubagentSpec¶
A frozen dataclass describing one specialist. Fields:
Field |
Purpose |
|---|---|
|
Unique identifier the parent uses to invoke it. |
|
System instruction handed to the specialist at spawn time. |
|
One-line summary shown in the parent’s tool docstring. |
|
Optional model override (defaults to the runner’s default). |
|
Tuple of tool names the specialist is allowed to use. |
|
Optional permission mode (see permissions). |
|
Optional token ceiling for the specialist. |
|
Free-form dict for runner-specific extensions. |
Specs are immutable; the registry rejects empty roles and empty instructions up front.
SubagentRegistry¶
An ordered catalogue of specs with explicit mutation semantics:
.register(spec)— add a new role, raising on duplicates..replace(spec)— overwrite an existing role (or add it)..unregister(role)— silent no-op if absent..get(role)/.require(role)— lookup, returningNoneor raising..roles()— insertion-ordered role list..roster()— human-readable catalogue that the task tool embeds in its docstring so the parent LLM can pick a specialist.
SubagentResult¶
What runners return. Carries role, output, usage, artifacts,
metadata, and an optional error. Two conveniences:
.is_error—Trueif theerrorfield is set..to_tool_output()— formats as[role] outputor[role:error] reasonfor return from the task tool.
SubagentRunner¶
The runtime contract. A @runtime_checkable Protocol with one method:
def run(
self,
spec: SubagentSpec,
prompt: str,
context: dict[str, Any] | None = None,
) -> SubagentResult: ...
Runners are deliberately not coupled to ADK — you can wire in a local
Runner, an A2A endpoint, or canned responses in tests. The ship-included
FakeSubagentRunner is a deterministic runner that:
Defaults to echoing the prompt.
Accepts a custom
respondercallable.Accepts
error_for_roleto simulate failures per role.Records every invocation in
.callsfor assertions.Catches responder exceptions and surfaces them as error results.
make_task_tool¶
task = make_task_tool(
registry,
runner,
*,
context_provider=lambda: {"turn": ctx.turn},
tool_name="task",
)
Returns a callable with signature task(role: str, prompt: str) -> str. The
generated function:
Looks up the spec and returns a structured error for unknown roles (with the list of known roles).
Calls
context_provider()once per invocation and threads the result into the runner.Catches runner exceptions and converts them into
[role:error] …strings.Has its
__doc__rewritten at build time to list every registered role so the parent LLM sees an accurate menu.
Use tool_name= to expose the tool under a different identifier — handy when
you want multiple dispatch tools in the same agent.
H namespace sugar¶
H.subagent_spec(role, instruction, ...)
H.subagent_registry(specs)
H.task_tool(registry, runner, *, context_provider=None, tool_name="task")
These mirror the underlying classes 1:1 but keep imports short when you stay inside the fluent surface.
Testing¶
FakeSubagentRunner is the canonical test double. Because make_task_tool
returns a plain callable, you can unit-test dispatch without touching ADK:
registry = H.subagent_registry([H.subagent_spec("r", "instruction")])
runner = FakeSubagentRunner(responder=lambda spec, prompt, ctx: "ok")
task = H.task_tool(registry, runner)
assert task("r", "hi") == "[r] ok"
assert task("missing", "hi").startswith("Error: unknown subagent role")
Design notes¶
Specs are frozen dataclasses — you can hash them, diff them, and share them across threads without worrying about accidental mutation.
The registry’s
roster()is intentionally plain text: the parent LLM reads a tool docstring, not a schema, and Markdown rendering is the responsibility of whoever embeds the menu.Runners are synchronous. If your backend is async, wrap it in a runner that drives the event loop; the task tool never awaits.
The parent LLM is always in charge. A subagent cannot transfer control sideways or spawn its own subagents unless its own toolset includes a task tool — which you control.