Skip to content

Multi-agent delegation

In Genkit, multi-agent systems split work between specialized agents and an orchestrator. The orchestrator decides which specialist should handle each part of the request, then synthesizes a final answer.

Use this pattern when separate capabilities benefit from separate prompts, tools, state, or evaluation. A single agent with several tools is usually simpler when one prompt can coordinate the whole task. Multiple agents are useful when specialists need different instructions, different model settings, durable specialist memory, or independently inspectable artifacts.

The experimental middleware package github.com/firebase/genkit/go/plugins/middleware/exp provides Agents for delegation. It injects one delegation tool per sub-agent (named delegate_to_<agentName> by default) and appends a <sub-agents> listing to the orchestrator’s system prompt. Attach it with ai.WithUse inside the agent’s inline prompt.

import (
"github.com/firebase/genkit/go/ai"
aix "github.com/firebase/genkit/go/ai/exp"
"github.com/firebase/genkit/go/ai/exp/localstore"
"github.com/firebase/genkit/go/genkit"
genkitx "github.com/firebase/genkit/go/genkit/exp"
middlewarex "github.com/firebase/genkit/go/plugins/middleware/exp"
"github.com/firebase/genkit/go/plugins/googlegenai"
)

The snippets below share one Genkit instance and one store for the orchestrator:

g := genkit.Init(ctx,
genkit.WithExperimental(), // Required: the exp constructors panic without it.
genkit.WithPlugins(&googlegenai.GoogleAI{}),
)
store := localstore.NewInMemorySessionStore[any]()
researcher := genkitx.DefineAgent(g, "researcher",
aix.InlinePrompt{
ai.WithModelName("googleai/gemini-flash-latest"),
ai.WithSystem("Research the user request and write concise findings."),
ai.WithUse(&middlewarex.Artifacts{}),
},
aix.WithDescription[any]("Finds facts and produces sourced research notes."),
)
coder := genkitx.DefineAgent(g, "coder",
aix.InlinePrompt{
ai.WithModelName("googleai/gemini-flash-latest"),
ai.WithSystem("Write clear Go code unless the user asks for another language."),
ai.WithUse(&middlewarex.Artifacts{}),
},
aix.WithDescription[any]("Writes, debugs, and explains code. Use for programming tasks."),
)
coordinator := genkitx.DefineAgent(g, "coordinator",
aix.InlinePrompt{
ai.WithModelName("googleai/gemini-flash-latest"),
ai.WithSystem("Delegate to specialists, inspect their results, then answer the user."),
ai.WithUse(
&middlewarex.Agents{
Agents: []aix.AgentRef{researcher.Ref(), coder.Ref()},
HistoryLength: 4,
MaxDelegations: 5,
ArtifactStrategy: middlewarex.ArtifactStrategySession,
},
&middlewarex.Artifacts{Readonly: true},
),
},
aix.WithSessionStore(store),
)

Reference a sub-agent by name (aix.AgentRef{Name: "researcher"}) or capture it from an agent value with agent.Ref(), which carries the agent’s description into the system listing. Descriptions matter because they become the delegation tool descriptions the orchestrator model sees, so keep them concrete.

The middleware resolves sub-agents through the Genkit instance seeded on the turn context, which genkitx.DefineAgent (and genkit.Generate) set automatically. Attach it to the orchestrator agent. Delegation composes: a sub-agent that carries its own Agents middleware delegates further, so orchestrations nest without extra wiring.

Middleware is per-agent. A delegation tool runs the sub-agent as its own invocation, so middleware attached with ai.WithUse on the orchestrator’s inline prompt wraps only the orchestrator’s model calls, never a sub-agent’s. Attach cross-cutting middleware such as redaction or logging to every agent that should have it. Doing so does not double-apply: the two agents’ generate calls are separate.

Token usage is per-agent too. Each sub-agent records its model calls in its own session, so the orchestrator’s Usage fields count only its own calls. A Budget middleware on the orchestrator is the exception: while the run lasts, it also counts the calls of the in-process sub-agents the run starts, so delegation cannot spend around the cap. See Middleware.

The orchestrator agent in the basic-agents sample is this arrangement running: it delegates to two client-managed sub-agents and reads their work back through session artifacts.

The Agents middleware is configured through struct fields:

  • Agents lists the sub-agents available for delegation, by name or via agent.Ref(). At least one is required.
  • ToolPrefix controls generated tool names. A nil value defaults to delegate_to (tools become delegate_to_<agent>); a pointer to the empty string uses bare agent names. A non-empty prefix also namespaces the shared tools described below, so two Agents instances in one generate call need distinct, explicit prefixes.
  • MaxDelegations caps delegation calls in one orchestrator generate call. 0 means unlimited. Background launches and continuations spend the same budget.
  • HistoryLength sets how many recent conversation messages are forwarded to a sub-agent. 0 forwards only the task description.
  • ArtifactStrategy controls how sub-agent artifacts surface, ArtifactStrategyInline (default) or ArtifactStrategySession.
  • Async lets the orchestrator launch a sub-agent in the background and collect its result later. See Delegate in the background.
  • MaxWaitSeconds bounds how long one wait_for_background_tasks call blocks, whatever timeout the model asks for. 0 means no bound.

History is forwarded only to client-managed sub-agents (those without a session store). A server-managed sub-agent owns its server-side session, so it receives only the task description.

Every delegation tool takes a task and an optional name, a short label the middleware echoes on the result and on background-task reports next to the task ID. It is a reading aid for a model juggling several delegations, not an identifier.

An orchestrator that delegates and waits is blocked for as long as the sub-agent runs. Set Async: true to let it keep working instead. Every delegation tool then takes a background flag that starts the sub-agent through its detach support and returns a task ID at once, and three shared tools give the orchestrator one control per thing it can do with a launched task.

ToolInputReturns
delegate_to_<agent>task, name, background: trueresponse describing the launch, taskId, status: "pending"
check_background_taskstaskIdsOne report per task: taskId, agent, status, and response, artifacts, or error.
wait_for_background_taskstaskIds, timeoutSeconds, waitFor: "all" | "first"The same reports, plus timedOut.
abort_background_taskstaskIdsThe same reports, each task where the stop left it.

It is the sub-agent that needs a session store here, one whose store supports detach: background work is tracked by a pending snapshot, so a sub-agent without one can only be delegated to synchronously, and the launch is refused with a hint to retry that way. The orchestrator itself needs no store for this.

logAnalyst := genkitx.DefineAgent(g, "log_analyst",
aix.InlinePrompt{
ai.WithModelName("googleai/gemini-flash-latest"),
ai.WithSystem("Scan the logs of the service you are given and report the failure signature."),
ai.WithTools(queryLogs),
},
aix.WithSessionStore(localstore.NewInMemorySessionStore[any]()),
aix.WithDescription[any]("Scans service logs and reports the failure signature. Slow: a scan takes tens of seconds."),
)
commander := genkitx.DefineAgent(g, "commander",
aix.InlinePrompt{
ai.WithModelName("googleai/gemini-flash-latest"),
ai.WithSystem("Run the incident. Start every investigation in the background, post a status update at once, then wait for the results."),
ai.WithTools(postStatus),
// Launch, post, wait, and post again are all tool rounds.
ai.WithMaxTurns(15),
ai.WithUse(&middlewarex.Agents{
Agents: []aix.AgentRef{logAnalyst.Ref()},
Async: true,
MaxDelegations: 6,
}),
},
aix.WithSessionStore(store),
)

The orchestrator launches with {"task": "...", "background": true}, calls other tools while the sub-agent runs, and collects the result later. wait_for_background_tasks takes an optional timeoutSeconds, so a slow task becomes an interim answer instead of a blocked turn: zero waits until the tasks settle, and a wait that runs out returns the current statuses with timedOut set. waitFor: "first" turns the join into a race that returns as soon as any listed task settles while the rest keep running. Set MaxWaitSeconds to put an upper bound on every wait, zero included, so a sub-agent that keeps running cannot hold the orchestrator’s turn open: at the bound the tool reports timedOut, as it does at the model’s own timeout. An abort is safe on any task and never blocks: a task that had already finished is left alone and reports its result, and a live one reports aborting while it saves its progress. It settles as a resumable aborted, or as completed when the turn in flight finished anyway, and the wait tool can collect either.

The middleware keeps no task registry. A task ID is <agent>:<snapshotId>, and it rides in the tool result, so the orchestrator’s own conversation is the registry: a re-instantiated orchestrator collects with nothing but the IDs in its history. Reports key on the snapshot. A pending task reports its status alone; a completed one carries the sub-agent’s last message and its artifacts; a failed, aborted, or expired one carries an explanatory error; a completed run whose finish reason carries no answer, such as an interrupt, reports as failed with the reason. A task ID the store cannot find reports “delegate again”.

Two prompting details matter. The middleware explains how background delegation works, but not when to use it, so tell the orchestrator which delegations to background and when to post interim updates. And raise ai.WithMaxTurns: launching, posting, waiting, and posting again are all tool rounds, so an orchestrator that collects in the background needs more room than one that blocks on each delegation. The commander agent in the basic-agents sample is the worked example: an incident commander that starts two slow investigators in the background, posts its first update while they run, and folds their answers in as they settle.

Every delegation to a server-managed sub-agent leaves a continuable handle behind. A synchronous result names the run’s last committed snapshot as taskId with its settled status, and background reports for failed, aborted, and expired tasks name the same handle. The shared continue_task tool spends it:

  • A failed or aborted task continues from its last saved progress. With no instructions the committed turn is re-attempted as it stood; with instructions the retry is steered by a fresh user message.
  • A completed task accepts follow-up instructions inside the sub-agent’s own session, so pressing on never repeats finished work. It is refused without them, since an empty input would re-run the finished turn.
  • An expired task, whose worker died, is fenced with an abort and continued from its parent snapshot, the last one committed before the launch. A launch that never committed a turn has nothing saved, and the tool says to delegate again.
  • A task that stopped on an interrupt is refused as a dead end, since continuing it would mean answering the interrupt. The orchestrator delegates a more self-contained task instead.

With Async set, continue_task also takes background: true and returns a fresh task ID in the same session. A client-managed delegation settles inline, carries no taskId, and is redone by delegating again. The tool registers only when some configured sub-agent may be server-managed, so an all-client-managed configuration gets no dead tool. A continuation spends a MaxDelegations slot; a refusal that names a retry which can succeed refunds it.

A sub-agent can run in another service. Any agent that a Genkit app serves over HTTP, in Go, JavaScript, or Dart, becomes a sub-agent once genkitx.DefineRemoteAgent registers it, and the orchestrator code is the same as for a local agent, background delegation included:

researcher := genkitx.DefineRemoteAgent(g, "researcher", "https://research.internal/agents/researcher",
aix.WithDescription[any]("Researches one question and answers in a few sentences."),
aix.WithAgentMetadata(&aix.AgentMetadata{
StateManagement: aix.AgentStateManagementServer,
Abortable: true,
}),
)
orchestrator := genkitx.DefineAgent(g, "orchestrator",
aix.InlinePrompt{
ai.WithModelName("googleai/gemini-flash-latest"),
ai.WithSystem("Give each part of a question to the researcher in the background, then combine the answers."),
ai.WithUse(&middlewarex.Agents{
Agents: []aix.AgentRef{researcher.Ref()},
Async: true,
}),
ai.WithMaxTurns(10),
},
aix.WithSessionStore(store),
)

The metadata tells the middleware what the remote server supports. StateManagement: aix.AgentStateManagementServer means the remote agent keeps its own session, so it gets no forwarded history. Abortable: true declares that its store can stop background work, which background delegation needs. Without metadata the middleware forwards no history and learns from the agent’s answer whether it can run in the background. The remote server needs a session store for background tasks and continue_task, the same as a local sub-agent.

A remote sub-agent’s failures reach the orchestrator with their status names, so continue_task and the background-task tools classify them the same way as a local agent’s. Its token usage stays in the remote session, and a Budget on the orchestrator does not see the calls it makes in its own process. See Call a remote agent for authentication and the other options. go/samples/basic-agents-remote is this arrangement running.

The Artifacts middleware gives a model read_artifact and write_artifact tools over the active session’s artifacts, and injects an <artifacts> listing into the system prompt each turn. Set Readonly: true to provide only read_artifact.

With ArtifactStrategySession, a sub-agent’s artifacts are merged into the parent session and kept out of the tool result. They are namespaced by invocation, <agent>_<snapshotId prefix>/<name> for a run with a snapshot behind it and <agent>_<n>/<name> otherwise, so a later check of the same task overwrites its earlier merge rather than duplicating it. Pair the strategy with &middlewarex.Artifacts{Readonly: true} on the orchestrator so it can inspect delegated work through read_artifact before answering. The default ArtifactStrategyInline instead includes artifact content in the delegation tool result and also merges it into the session.

Artifacts live on the active agent session, so the Artifacts tools only have an effect inside an agent invocation. With no active session they degrade gracefully: the listing is empty and the tools report that.

A sub-agent failure is returned to the orchestrator as the delegation tool’s output, with the task ID to continue it, rather than propagated as a top-level error to the original client. A sub-agent interrupt is reported the same way but cannot be continued: there is no stateful sub-agent runtime to answer it from. Write orchestrator instructions that say how to handle a delegated failure, such as continuing the task, choosing another specialist, or asking the user for clarification.

Task handles are scoped to the conversation. check_background_tasks, wait_for_background_tasks, abort_background_tasks, and continue_task accept a task ID only when this generate call started the task, or when a delegation or continue_task result in the history carries the ID. Any other ID reports as unknown, “was not started in this conversation”, and an abort leaves it alone. Text that reaches the orchestrator model, such as a sub-agent’s answer or a retrieved document, therefore cannot steer these tools at another conversation’s task. A history trimmed or compacted past a launch result loses that task’s handle. The sub-agent’s own companion actions and routes are not scoped this way, so in a multi-tenant deployment treat snapshot IDs as capability-like secrets wherever those are exposed.

Using the middlewares through ai.WithUse needs no plugin. Register &middlewarex.Middleware{} only to make them resolvable by name, for example in the Developer UI.

g := genkit.Init(ctx,
genkit.WithExperimental(),
genkit.WithPlugins(&googlegenai.GoogleAI{}, &middlewarex.Middleware{}),
)