Skip to main content
Version: Latest (1.9.x dev)

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" 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.

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

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.

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.
  • 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 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.

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.

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.

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 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-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.
  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 first two are final. The last one is the only one that a retry may fix.

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_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.

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: 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 Console, the Portal 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​