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" or "Since 1.8.5"
describe behaviour that changed in that release.
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 |
| Session hop budget | Since 1.8.5: a hop past the most hops one session may forward, when the workspace sets a budget (off by default) | 409 a2a_delegation_hop_budget_exceeded |
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.
Since 1.8.5 this holds for the session loop detector too. Planner can ask Budget
to price five options at once, and each call carries the same chain. The
detector counts calls to the same agent and skill from the same chain as one
call, so the fifth is no longer refused as a loop. Real recursion changes the
chain on every round, so a Planner, Budget, Planner loop is still refused at the
same hop as before (hop 7). An agent that sends the same call over and over from
one chain is now stopped by the session's hop rate (60 a minute,
429 a2a_session_rate_limited). That refusal does not kill the session.
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.
Since 1.8.5 the hop view also marks a hop that
observemode let through but would have refused. The hop table, the hop detail and the session and conversation timelines show "would refuse: " and the reason (cycle,depth_exceededorchain_required). The Would refuse only filter lists just those hops. Before, such a hop looked like a clean one. The Flightdeck shows no hop rows, so it has nothing to mark. -
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 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. |
a2a.delegation.preset | standard, hardened | standard | Since 1.8.5. hardened turns on the stricter posture described below. standard leaves every key as set. |
a2a.delegation.max-hops-per-session | 0–1,000,000 | 0 (no limit) | Since 1.8.5. The most hops one session may forward. 0 turns the budget off. |
Set them in Flightdeck: in the Flightdeck, open the workspace and use the A2A
delegation section; in the Flightdeck, 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 Flightdeck, the Flightdeck 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 Flightdeck 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.
Use the hardened preset
Since 1.8.5 a workspace can choose the hardened preset. The fleet can make it
the default with a2a.delegation.preset. With the default settings, an agent
whose key is bound can leave its chain out. The gateway then starts a new chain
at depth 2, with a new 15-minute lifetime, so the agent resets its own history.
The hardened preset closes that:
- a key bound to an agent must send its chain, whatever
require-chain-for-agentssays; - any repeat of an agent is a cycle (the
agentrule), whatevercycle-rulesays; and - the session hop budget is on, at 200 hops unless the workspace sets its own
max-hops-per-session.
mode and max-depth still apply. So you can run hardened with observe
first and read what it would refuse. The preset is opt-in. The defaults do not
change in 1.8.5.
A bound agent must also be able to start work of its own. Mark its key May
start chains on the API Keys page, beside Acts as agent, in the Flightdeck or
the Flightdeck, on create or on the key's row. In GitOps, set
mayStartChains: true on the key's agent binding. A hop with no chain from such
a key starts a new chain that begins with its agent, so the agent rule still
catches the first bounce. It is not a way round a bad chain: a chain that was
changed, has expired or belongs to another session is still refused. Give an
agent two keys: one that may start chains, for its own work, and one that may
not, for the calls it makes on a caller's behalf. The change is audited as
API_KEY_AGENT_BOUND, with may_start_chains.
Limit the hops one session may forward
Since 1.8.5 a2a.delegation.max-hops-per-session sets the most hops one
session may forward, on every tier. It is a safety limit, not billing: the
monthly hop cap and its tiers do not change. It is off by default.
The count is shared by the gateways in one cluster, so hops spread over
gateways count together. Only a hop forwarded to a peer counts. A hop refused
later, or held for approval and then denied, gives its place back. Past the
limit a hop is refused with 409 a2a_delegation_hop_budget_exceeded.
The gateway waits up to 100 ms for the count
(a2a.delegation.hop-budget-timeout, a fleet setting). If it cannot read the
count in time, a hardened workspace refuses the hop with
503 a2a_delegation_hop_budget_unavailable. Any other workspace lets the hop
through, logs a warning and counts it in
dvara_a2a_hop_budget_unavailable_total.
When the settings cannot be read
Since 1.8.5 a failed read of a workspace's delegation settings no longer falls
back to the weaker fleet defaults. The gateway uses the settings it last read
for that workspace. If it has none, it refuses the hop with
503 a2a_delegation_settings_unavailable, before the hop is forwarded or
counted. Each hop also reads its settings once, so a change made during a hop
cannot reset or drop its chain.
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 never forwards an
unsigned chain. Since 1.8.5 a gateway on a production profile then refuses to
start, unless a2a.delegation.mode is off. On a development profile it logs
an error at startup and runs the guard as off.
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
Flightdeck dashboard shows. Since 1.8.5 the block also has configuredMode, the
mode the fleet asked for, and effectiveMode, the mode that runs. When they
differ, the warning names both. 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 Flightdeck or the Flightdeck. 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. The same holds fora2a_delegation_hop_budget_exceeded. The gateway refuses the call; your agent owns the recovery. Make no further onward call for that task, so the user's call still ends normally. - 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 409 codes are final. Only the 503 codes may pass on a retry.
| 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_hop_budget_exceeded | 409 | Since 1.8.5: the session has forwarded as many hops as max-hops-per-session allows | Do not retry. Answer with what you have, or start a new session. |
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. |
a2a_delegation_settings_unavailable | 503 | Since 1.8.5: the gateway could not read the workspace's delegation settings and has none from an earlier read | Retry. |
a2a_delegation_hop_budget_unavailable | 503 | Since 1.8.5: under the hardened preset, the gateway could not read the session's hop count in time | 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 needs the gateways in one cluster. Since 1.8.5
the session loop detector sees a session's hops on every gateway in the
cluster, and refuses a loop at the same hop as one gateway does. Gateways in
separate clusters each see only their own hops. For workspaces whose agents
may drop headers, use the
hardenedpreset, or setrequire-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 Flightdeck, the Flightdeck 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.