Stop recursive delegation between AI agents
Two agents that call each other can loop. Planner asks Budget to price a trip, Budget asks Planner for the itinerary, and Planner asks Budget again. Each agent works on its own; together they bounce, and every bounce costs model calls. The A2A delegation guard in the DVARA AI governance platform refuses the first hop that loops back or goes too deep, before the peer is called and before the hop is billed.
The guard is part of the A2A Gateway from 1.8.3, and it is
on by default in enforce mode. It checks message:send and message:stream.
Task operations such as tasks:get are not a new delegation, so the guard does
not check them. Lines on this page that start "Since 1.8.4" describe behaviour
that changed in 1.8.4.
What does the guard check?
Every hop carries a delegation chain: the list of agents, and the skill each was asked for, that led to this call. The gateway writes the chain, signs it, and hands it to the agent it calls. That agent sends it back on its own onward calls. The guard reads the chain before it forwards the next hop and applies three rules, in this order.
| Rule | Refuses | Error |
|---|---|---|
| Cycle | A target that is already in the chain | 409 a2a_delegation_cycle |
| Depth | A hop that would make the chain longer than max-depth (default 8) | 409 a2a_delegation_depth_exceeded |
| Chain required | A hop with no chain from an API key bound to an agent, when the workspace requires a chain (off by default) | 403 a2a_delegation_chain_required |
A chain that was changed, has expired, or belongs to another workspace or
session fails closed with 403 a2a_delegation_chain_invalid. That refusal
happens in observe mode too.
Parallel calls are not recursion. Planner can call Budget and Weather at the
same time. Each branch carries its own chain, planner → budget and
planner → weather, so neither branch sees the other and both are allowed.
How does the guard stop the Planner and Budget loop?
Workspace acme-travel runs Planner (planner) and Budget (budget).
A user asks Planner for "a 3-day Lisbon trip under €1,500". The table shows each
hop, the chain it arrives with, and the answer under each cycle rule.
| Hop | Call | Chain on arrival | Default rule agent-skill | Strict rule agent |
|---|---|---|---|---|
| 1 | user → Planner plan | none (a new chain) | allowed | allowed |
| 2 | Planner → Budget price | planner | allowed | allowed |
| 3 | Budget → Planner itinerary | planner → budget | allowed: Planner is asked for something new | refused: Planner is already in the chain |
| 4 | Planner → Budget price | planner → budget → planner | refused: Budget was already asked to price | never sent |
Under the default agent-skill rule, asking the same agent for a different job
stays legal ("draft a plan", then "check the plan"). Repeating the same job in one
chain is refused. The stricter agent rule refuses any return to an agent.
Depth. With max-depth: 4, the chain Planner → Budget → FX → Tax → Audit is
refused at the fifth agent with a2a_delegation_depth_exceeded, even though no
agent repeats.
What does the refused agent receive?
Budget sends hop 3 with the chain it was given. Replace <dvara-host>,
<budget-api-key> and <chain-from-the-inbound-call>:
curl --silent --include https://<dvara-host>/a2a/planner/message:send \
--header 'Authorization: Bearer <budget-api-key>' \
--header 'Content-Type: application/json' \
--header 'X-Session-Id: trip-42' \
--header 'X-DVARA-Delegation: <chain-from-the-inbound-call>' \
--data '{
"jsonrpc": "2.0",
"id": "hop-3",
"method": "message/send",
"params": {
"metadata": { "skill": "itinerary" },
"message": {
"role": "user",
"parts": [{ "kind": "text", "text": "Send the day-by-day itinerary for trip-42." }]
}
}
}'
With the agent rule, the gateway answers without calling Planner:
HTTP/1.1 409 Conflict
{
"jsonrpc": "2.0",
"id": "hop-3",
"error": {
"code": "a2a_delegation_cycle",
"message": "Delegation cycle: agent planner is already in chain planner → budget.",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}
}
Under the default rule, the refusal comes one hop later, and the message names
the repeated job: Delegation cycle: budget::price is already in chain planner → budget → planner.
A refused hop never reaches its peer, is never billed, and does not count toward
the workspace's monthly A2A hop cap. The status is 409, not 429, because a
cycle fails the same way on every retry.
How do you verify what the guard did?
Each refusal leaves three pieces of evidence you can check:
- Audit. The A2A audit trail records one event named after the code, such as
A2A_DELEGATION_CYCLE, with the agents in the chain, the depth, the rule, the session and the trace id. It never records the chain token itself. - Hop view. The hop appears in Flightdeck's A2A hop view with its chain, its depth and the error code. Since 1.8.4, a hop refused for an invalid chain shows "chain invalid" instead of a chain, because the chain could not be trusted.
- Metrics.
dvara_a2a_delegation_refused_totalcounts refusals by workspace, reason (cycle,depth_exceeded,chain_required,chain_invalid,binding_unavailable) and mode. The mode says what happened:enforcefor every refused hop, andobserveonly for a would-refuse that let the hop through. Since 1.8.4 that includes an invalid chain in anobserveworkspace, which is refused and so counted asenforce; before, it was counted asobserve.dvara_a2a_delegation_depthshows how deep real chains go, which helps you pickmax-depth.
Which settings control the guard?
Each workspace can set these four keys. A key the workspace leaves unset uses the gateway's fleet default, which is the value in the table unless your operator changed it.
| Key | Values | Default | What it does |
|---|---|---|---|
a2a.delegation.mode | off, observe, enforce | enforce | enforce refuses. observe audits what it would refuse and lets the hop through. off does not run the guard and forwards no chain. |
a2a.delegation.cycle-rule | agent-skill, agent | agent-skill | agent-skill refuses a repeat of the same agent and skill. agent refuses any repeat of an agent. |
a2a.delegation.max-depth | 1–32 | 8 | The longest chain allowed. |
a2a.delegation.require-chain-for-agents | true, false | false | When true, a key bound to an agent must send the chain. A user's or an app's key is never refused for it. |
Set them in Flightdeck: in the Console, open the workspace and use the A2A
delegation section; in the Portal, a workspace admin uses Agents → A2A
Delegation. GitOps export and import carry them. A change reaches every
gateway with the next configuration bundle, with no restart.
The gateway reads these values in any case, so Observe or agent_skill in a
GitOps file works. Since 1.8.4 the Console, the Portal and the import store the
canonical form (observe, agent-skill, true), and both forms show a value
stored in another case. Before, such a value showed as "inherit default", and
the next Console save of the workspace cleared it.
A workspace that wants to measure first can start like this:
a2a.delegation.mode: "observe"
a2a.delegation.cycle-rule: "agent-skill"
a2a.delegation.max-depth: "8"
a2a.delegation.require-chain-for-agents: "false"
Run observe for a week. Read the would-refuse audit events (they carry
observed: true) and the depth metric. Then switch mode to enforce. A chain
that fails verification is still refused in observe mode, because a bad chain
fails closed.
A chain lives for 15 minutes from its first hop (a2a.delegation.chain-ttl, a
fleet setting). It is never extended, so a hop that returns an expired chain is
refused with a2a_delegation_chain_invalid.
How is the chain signed?
The gateway signs each chain with HMAC-SHA256. An agent can carry the chain but cannot change it: a changed chain fails the check. Every gateway pod must hold the same key, so any pod can verify a chain another pod signed.
| Property | Environment variable | Default |
|---|---|---|
dvara.a2a.delegation.signing-secret | DVARA_A2A_DELEGATION_SIGNING_SECRET | Derived from the audit HMAC secret |
The derived default needs no new setup, because the pods already share the audit secret. That also means the derived key is only as secret as the audit secret, so use a long random value for it. Set the delegation secret when you want to rotate the delegation key on its own schedule. After a rotation, chains signed with the old key are refused, and agents start new chains on their next root hop.
If neither secret is set, the gateway cannot sign. It logs an error at startup
and runs the guard as off. It never forwards an unsigned chain.
Since 1.8.4, a secret shorter than 32 bytes, or a placeholder value that DVARA
publishes (such as the audit secret's shipped development default), gives no key
either, because anyone could compute it and forge chains. On a production
profile the gateway then refuses to start. On a development profile (dev,
test, ci, local or default) it logs a warning and runs the guard as off.
A guard that cannot run is easy to see. GET /actuator/gateway-status carries an
a2aDelegation block with active and reason, and a warning that the Flightdeck
Console dashboard shows. The gauge dvara_a2a_delegation_guard_active{reason} is
1 or 0, with reason configured, audit_derived, no_secret or
unsafe_secret.
Since 1.8.4 the chain token is never written to the gateway's logs, including the copy inside the A2A message metadata. Log lines name the agents and the depth only.
Which agent is calling?
The gateway does not trust a header in which an agent names itself, because any caller could claim any name. It uses the API key instead.
Bind each agent's API key to its agent with Acts as agent on the API Keys page in the Console or the Portal. Then:
- A2A policy
source:rules match the agent, not the key's name. require-chain-for-agentscan tell that a call with no chain came from an agent that dropped the header.- A bound key cannot use another agent's chain. That hop is refused with
a2a_delegation_chain_invalid.
Since 1.8.4, if the gateway cannot read a key's binding, it no longer treats the
key as "not an agent". A workspace with require-chain-for-agents: true refuses
the hop with 503 a2a_delegation_binding_unavailable. In any other workspace
the hop goes on, the gateway logs a warning, and
dvara_a2a_delegation_binding_lookup_failed_total counts it by workspace.
Since 1.8.4, when an agent with a bound key starts a chain itself, the chain
starts with that agent. If Planner calls Budget first, the chain Budget receives
is planner → budget, so Budget → Planner is refused on the first bounce under
the agent rule. That first entry counts toward max-depth.
Since 1.8.4, a chain names the calling agent only when the key is bound to that agent. A hop from an unbound key keeps its cycle and depth protection, but A2A policy, hops and audit see the key's name, not an agent. Before 1.8.4, an unbound key that brought a valid chain was taken for the chain's last agent. To give an agent its identity, bind its key with Acts as agent. The gateway also takes the chain out of every reply it returns, so a peer that echoes the message never hands the chain back to its caller.
What must an agent builder do?
- Forward two headers on every outbound A2A call through DVARA:
X-DVARA-DelegationandX-Session-Id, unchanged, the same way you forwardtraceparent. Treat the chain as opaque: do not parse or change it. The chain also arrives in the message asmetadata["dvara.delegation"], so an SDK that copies message metadata but not headers still returns it. - Treat
a2a_delegation_cycleanda2a_delegation_depth_exceededas final. Do not retry. Answer your caller with what you have, or do the work yourself. In the example, Budget prices the draft it already has. - Use one API key per agent, bound to that agent. It gives the agent its
identity in policy and audit, and it is required for
require-chain-for-agents. - Keep each skill name fixed. Send the same
metadata.skillfor the same kind of work. An agent that builds names on the fly (price,price2,price-retry) is caught only bymax-depth, not by the default cycle rule.
When a hop arrives with no X-Session-Id, the gateway sets one: from the A2A
message's contextId when there is one, otherwise a new id. It returns it in the
X-Session-Id response header. A caller-supplied session id always wins. A hop
that sends a chain and a different X-Session-Id is refused as
a2a_delegation_chain_invalid, because the chain belongs to another session.
Which error codes can the guard return?
The first two are final. The last one is the only one that a retry may fix.
| Code | HTTP | When | What to do |
|---|---|---|---|
a2a_delegation_cycle | 409 | The target is already in the chain, by the workspace's rule | Do not retry. Answer with what you have. |
a2a_delegation_depth_exceeded | 409 | The hop would make the chain longer than max-depth | Do not retry. Answer with what you have, or raise max-depth if the depth is legitimate. |
a2a_delegation_chain_required | 403 | A key bound to an agent sent no chain, and the workspace requires one | Fix the agent so it forwards X-DVARA-Delegation. |
a2a_delegation_chain_invalid | 403 | The chain was changed, expired, came from another workspace or session, was signed with another key, or a bound key sent another agent's chain | Forward the chain unchanged, with the same session id. After an expiry or a key rotation, start a new chain. |
a2a_delegation_binding_unavailable | 503 | Since 1.8.4: the gateway could not read which agent the API key acts as, and the workspace requires a chain from agents | Retry. This one may pass on a retry. |
What still stops a loop the guard cannot see?
The guard is the first line. These controls stay on as backstops:
- the session loop detector, for agents that drop the chain but keep a session;
- the session kill switch;
- the workspace's monthly A2A hop cap; and
- A2A policies and human approvals.
What are the known limits?
- The loop detector backstop is per pod. The guard itself works on every
pod, because any pod can verify the chain. But when an agent drops the chain,
the loop detector sees only the hops that reach its own pod, and it refuses
later than it would on one pod. For workspaces whose agents may drop headers,
set
require-chain-for-agents: trueand bind each agent's key. - The default rule trusts the skill name. The skill comes from the caller,
in
metadata.skill. An agent that loops while it changes the skill name (price,price2,price3), or sends no skill, passes theagent-skillrule and is stopped only bymax-depth. If your agents build skill names on the fly, use theagentrule and a lowermax-depth. - Finish an upgrade from 1.8.3 before you rely on the guard. 1.8.4 signs a
new chain format that keeps each hop's agent and skill apart. A 1.8.4 pod
still accepts a chain a 1.8.3 pod signed until it expires (15 minutes). A
1.8.3 pod refuses the new format as
a2a_delegation_chain_invalid, so a mixed fleet refuses hops that cross versions. - An agent id cannot contain
::. The old chain format split each hop at::, so an id such asfx::v2could be misread. Since 1.8.4 the Console, the Portal and a GitOps import refuse a new agent id with::asA2A_AGENT_ID_INVALID, naming the id and suggesting-,_or.. An agent that already has such an id keeps it.
Where to go next
- Govern peer-agent calls with the A2A Gateway for registration, policy, PII and approvals on each hop.
- Stop runaway agents and approve risky actions for the kill switch and the other agent controls.
- Manage A2A governance in Flightdeck to inspect hops and their chains.