Skip to main content
Version: 1.8.0

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.

Where the guard runs

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.

RuleRefusesError
CycleA target that is already in the chain409 a2a_delegation_cycle
DepthA hop that would make the chain longer than max-depth (default 8)409 a2a_delegation_depth_exceeded
Chain requiredA 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 budgetSince 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.

HopCallChain on arrivalDefault rule agent-skillStrict rule agent
1user → Planner plannone (a new chain)allowedallowed
2Planner → Budget priceplannerallowedallowed
3Budget → Planner itineraryplanner → budgetallowed: Planner is asked for something newrefused: Planner is already in the chain
4Planner → Budget priceplanner → budget → plannerrefused: Budget was already asked to pricenever 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 observe mode 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_exceeded or chain_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_total counts refusals by workspace, reason (cycle, depth_exceeded, chain_required, chain_invalid, binding_unavailable) and mode. The mode says what happened: enforce for every refused hop, and observe only for a would-refuse that let the hop through. Since 1.8.4 that includes an invalid chain in an observe workspace, which is refused and so counted as enforce; before, it was counted as observe. dvara_a2a_delegation_depth shows how deep real chains go, which helps you pick max-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.

KeyValuesDefaultWhat it does
a2a.delegation.modeoff, observe, enforceenforceenforce 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-ruleagent-skill, agentagent-skillagent-skill refuses a repeat of the same agent and skill. agent refuses any repeat of an agent.
a2a.delegation.max-depth1–328The longest chain allowed.
a2a.delegation.require-chain-for-agentstrue, falsefalseWhen 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.presetstandard, hardenedstandardSince 1.8.5. hardened turns on the stricter posture described below. standard leaves every key as set.
a2a.delegation.max-hops-per-session0–1,000,0000 (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-agents says;
  • any repeat of an agent is a cycle (the agent rule), whatever cycle-rule says; 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.

PropertyEnvironment variableDefault
dvara.a2a.delegation.signing-secretDVARA_A2A_DELEGATION_SIGNING_SECRETDerived 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-agents can 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?​

  1. Forward two headers on every outbound A2A call through DVARA: X-DVARA-Delegation and X-Session-Id, unchanged, the same way you forward traceparent. Treat the chain as opaque: do not parse or change it. The chain also arrives in the message as metadata["dvara.delegation"], so an SDK that copies message metadata but not headers still returns it.
  2. Treat a2a_delegation_cycle and a2a_delegation_depth_exceeded as 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 for a2a_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.
  3. 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.
  4. Keep each skill name fixed. Send the same metadata.skill for the same kind of work. An agent that builds names on the fly (price, price2, price-retry) is caught only by max-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.

CodeHTTPWhenWhat to do
a2a_delegation_cycle409The target is already in the chain, by the workspace's ruleDo not retry. Answer with what you have.
a2a_delegation_depth_exceeded409The hop would make the chain longer than max-depthDo not retry. Answer with what you have, or raise max-depth if the depth is legitimate.
a2a_delegation_chain_required403A key bound to an agent sent no chain, and the workspace requires oneFix the agent so it forwards X-DVARA-Delegation.
a2a_delegation_chain_invalid403The chain was changed, expired, came from another workspace or session, was signed with another key, or a bound key sent another agent's chainForward the chain unchanged, with the same session id. After an expiry or a key rotation, start a new chain.
a2a_delegation_hop_budget_exceeded409Since 1.8.5: the session has forwarded as many hops as max-hops-per-session allowsDo not retry. Answer with what you have, or start a new session.
a2a_delegation_binding_unavailable503Since 1.8.4: the gateway could not read which agent the API key acts as, and the workspace requires a chain from agentsRetry. This one may pass on a retry.
a2a_delegation_settings_unavailable503Since 1.8.5: the gateway could not read the workspace's delegation settings and has none from an earlier readRetry.
a2a_delegation_hop_budget_unavailable503Since 1.8.5: under the hardened preset, the gateway could not read the session's hop count in timeRetry.

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 hardened preset, or set require-chain-for-agents: true and 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 the agent-skill rule and is stopped only by max-depth. If your agents build skill names on the fly, use the agent rule and a lower max-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 as fx::v2 could be misread. Since 1.8.4 the Flightdeck, the Flightdeck and a GitOps import refuse a new agent id with :: as A2A_AGENT_ID_INVALID, naming the id and suggesting -, _ or .. An agent that already has such an id keeps it.

Where to go next​