Skip to main content

Agent A Asks Agent B, Agent B Asks Agent A: How to Prevent Recursive Delegation

· 12 min read

Agent A asks Agent B to do a task. Agent B needs something from Agent A, so it asks. Agent A, to answer, asks Agent B again. How do you stop that recursion?

Carry a signed delegation chain with the call: a record of which agents it has already passed through. Check it before calling the next agent. With strict agent matching and enforcement turned on, a return to an agent already in the chain stops there. In DVARA, you can also allow a return for a different skill. The choice matters, as does requiring agents to pass the chain along: a signature prevents edits, but it cannot stop an agent from leaving the chain out.

Updated October 2, 2026 for DVARA 1.8.5.

The problem: two good agents make one loop​

Take two agents in a travel workspace. Planner builds trip plans and Budget prices them.

A user asks Planner for "a 3-day Lisbon trip under €1,500". Planner asks Budget to price its draft. Budget needs the day-by-day itinerary, so it asks Planner. To build the itinerary, Planner needs a price, so it asks Budget again.

Each agent is asking for information it needs. The problem is that neither knows the other is waiting on it. Every trip around that loop adds work and model calls to your bill.

Adding agents just makes the loop harder to see. Planner → Budget → FX → Tax → Planner has the same problem, with more steps before anyone returns to Planner.

Why the usual fixes fall short​

Timeouts​

A timeout limits how long a loop runs, but the work before it expires has already happened. At one hop every two seconds, a 60-second timeout leaves room for roughly 30 hops.

Loop detectors and rate limits​

A rate limit stops calls after they reach a threshold. A session loop detector looks for repeated patterns. Both let some work through before acting. For example, a detector that waits for three repetitions of a short cycle handles Planner and Budget like this:

HopCallSkillResult
1user → Plannerplanallowed
2Planner → Budgetpriceallowed
3Budget → Planneritineraryallowed
4Planner → Budgetpriceallowed
5Budget → Planneritineraryallowed
6Planner → Budgetpriceallowed
7Budget → Planneritineraryrefused

Six hops go through before the seventh is refused. The detector also needs to know which calls belong together. DVARA looks for X-Session-Id first, then the session in the delegation chain, then the message's contextId. Losing the header alone is therefore recoverable. Lose all three, and DVARA has to create a new session id; it can no longer connect the call to the earlier work.

A per-session list of agents​

You could record every agent a session has touched and refuse a repeat. But that refuses valid work. Planner may call Budget and Weather at the same time, and later call Budget again for a different trip in the same session. One shared list mixes those branches and refuses calls that are fine.

Letting agents name themselves​

If the check relies on a header in which the caller names itself, any caller can claim any name, or leave out the part of the chain that would get it refused.

The general answer, rule by rule​

A gateway, message bus or orchestrator can make the decision before forwarding each call. It needs a few things from the caller and a few rules of its own:

  1. Put the chain on the call. Each call carries the path that led to it: planner → budget. The agent that receives it passes it on with its own onward calls, the way it already forwards a trace id.
  2. Refuse a cycle. If the target is already in the chain, refuse the call. You can choose how strict to be. "Same agent" refuses any return to an agent. "Same agent and same job" lets Planner be asked for a draft and later for a check, but refuses the same request twice.
  3. Set a depth limit. Decide how many agents a task should need. Refuse the next hop when it exceeds that depth, even if every agent or skill name is different.
  4. Let the router own and sign the chain. The component that forwards the call writes the chain and signs it. An agent can carry the chain but cannot change it. A chain that was changed, has expired, or belongs to another workspace or session is refused. Fail closed: a bad chain is never treated as "no chain".
  5. Keep parallel branches separate. Check planner → budget against its own chain and planner → weather against its own. A session detector running behind that check must respect those branches too.
  6. Expire the chain. Count its lifetime from the first call, without renewing it at each hop. Require the chain on onward calls so an agent cannot reset its depth or age by omitting it. Use a separate session hop budget to limit total work across branches.
  7. Tell the caller to stop this delegation. Return 409 Conflict with a cycle error code. The agent needs a handler for it: finish with the information available, return an incomplete result, or ask a person for help. Retrying the same delegation will fail again.

Keeping the chain on the call means another gateway with the same signing key can check it without fetching its history. That check is stateless. Counting calls across a session is a separate job, and multiple gateways need shared accounting to enforce one session limit.

How DVARA does it​

The DVARA A2A Gateway has checked delegation chains since 1.8.3. Version 1.8.5 adds a hardened preset and a session hop budget, and fixes how the session detector handles parallel sibling calls.

These are Enterprise platform capabilities. You can use them without a licence in Development for non-production work within 3 workspaces and 100,000 governed calls a month. Production rights and support require a licence. The DVARA Open Source distribution does not include the A2A Gateway.

With a usable signing key and the guard enabled, the gateway signs a delegation chain and sends it with each hop, in a header called X-DVARA-Delegation. The agent passes it back to the gateway on its next outgoing call, together with its session id. Before the gateway forwards a hop, the guard checks the chain for a cycle and for depth. A refused hop never reaches the peer, is never billed, and does not count toward the workspace's monthly hop cap.

Here is the same Planner and Budget example in enforce mode, with each agent preserving its chain:

HopCallChain on arrivalDefault rule (same agent and skill)Strict rule (same agent)
1user → Planner plannoneallowedallowed
2Planner → Budget priceplannerallowedallowed
3Budget → Planner itineraryplanner → budgetallowedrefused
4Planner → Budget priceplanner → budget → plannerrefusednever sent

With the strict rule, Budget gets this on hop 3, and Planner is never called:

409 a2a_delegation_cycle
Delegation cycle: agent planner is already in chain planner → budget.

Budget now has a choice to make in its error handler. It can price the draft it already has, or return an incomplete result if the draft is not enough. It must not retry a2a_delegation_cycle. DVARA stops the call at hop 3; Budget still has to decide how to answer Planner.

The default rule, same agent and same skill, lets Budget ask Planner for the itinerary once, because that is a new job. It refuses the repeat on hop 4. The default depth limit is 8, and each workspace picks its own rule and depth.

Enable the hardened preset in 1.8.5​

If you want to stop the first return to an agent, choose a2a.delegation.preset: hardened for the fleet or workspace. This is opt-in. It selects the agent rule, requires chains from agent-bound keys with the root-key exception below, and sets a 200-hop session budget unless you specify another value. You still choose the mode separately: observe records what would be refused; enforce refuses it.

Existing installations keep the standard defaults: mode: enforce, cycle-rule: agent-skill, max-depth: 8, and require-chain-for-agents: false, all under a2a.delegation. The session safety budget is off under that preset. a2a.delegation.chain-ttl defaults to 15 minutes from the root call. The delegation guard guide covers the full configuration.

Check that the gateway can sign chains​

Set DVARA_A2A_DELEGATION_SIGNING_SECRET to a private random secret of at least 32 bytes, shared by the gateway pods. If it is unset, DVARA derives a delegation key from a usable DVARA_AUDIT_HMAC_SECRET. Published placeholder secrets are not usable signing keys.

Check gateway status before testing a loop. In 1.8.5, a production profile refuses to start without a signing secret if the fleet asks for an active guard. A development profile can start with the guard effectively off. Gateway status shows the configured and effective fleet modes; check those alongside your workspace's mode. An enforce setting is no help if the pod cannot sign chains.

Separate root keys from onward-agent keys​

Bind each agent's API key to that agent. When chains are required and the guard enforces them, an ordinary agent-bound key without a chain receives 403 a2a_delegation_chain_required.

Some calls have to start somewhere. A key marked “may start chains” can start new work without an incoming chain; DVARA puts its bound agent at the beginning of the new chain. Unbound application keys can start roots too. Keep those credentials at trusted entry points and give agents separate keys for onward calls. Otherwise, an agent can use its root permission to start again.

Under the standard defaults, dropping the token from both the header and message metadata starts a fresh chain. DVARA also accepts the token in message.metadata["dvara.delegation"], so dropping only the header does not reset the chain when that metadata remains. The session detector still provides a backstop when calls remain correlated.

Observe the effect before enforcing​

Start in observe mode. Review would-refuse events, the Console's hop markers, and the depth metric before switching to enforce. In observe mode, cycle, depth, missing-chain and hop-budget violations are recorded without blocking the call. Invalid signatures still fail closed, and other controls such as session loop detection remain active.

The API-key binding identifies the caller. Possessing a chain does not establish an agent's identity. DVARA removes dvara.delegation metadata from replies, including nested values and escaped JSON keys in streams, and keeps the token out of its delegation logs. It does not scrub arbitrary text in which a peer has copied the token. Agents need to treat it as sensitive too.

Bound session work separately from billing​

In 1.8.5, a2a.delegation.max-hops-per-session limits how many hops a session forwards. It is separate from chain depth and from the workspace's monthly billing allowance. Under the standard preset, 0 means no safety limit. Under hardened, 0 selects the preset's 200-hop limit; an explicit positive value replaces it.

Once the budget is used up, enforce mode returns 409 a2a_delegation_hop_budget_exceeded before the next call reaches the peer. Handle that as a stop for this work, not a reason to open another session. If DVARA cannot read the count, hardened enforcement returns 503 a2a_delegation_hop_budget_unavailable. Standard mode lets that call through uncounted and records a warning and metric. In observe, exceeding the budget is recorded but does not block the call.

If you run several gateway pods, make sure they share the same accounting cluster. The local fallback only counts calls on its own pod. Separate clusters, including those split by a partition, each count their own traffic. Cluster-backed counts also expire after an hour idle. These are limits on session work, not a permanent spending cap.

Do not rely on the monthly A2A allowance to stop a runaway session. An unlimited allowance or billable overage permits more calls, and an unavailable usage count does not stop them either. Set the session safety budget for that purpose.

What stays as a backstop​

The delegation guard works alongside session loop detection, the session kill switch, billing controls, A2A policies and configured human-approval rules. In 1.8.5, identical sibling hops carrying the same verified parent chain count once for repetition and cycle detection. Each still counts toward the session rate limit and any enabled safety budget. A rate refusal uses 429 a2a_session_rate_limited and does not kill the session.

That sibling handling depends on a verified chain. Calls without one still go through the session detector's heuristic checks. Across multiple pods, shared loop history depends on the accounting cluster; a local fallback sees only the calls on that pod.

One choice deserves particular care: the default agent-skill rule trusts the skill name the caller sends. Changing that name avoids a cycle match, although a preserved chain will still hit its depth limit. If a return to the same agent should always stop, use strict matching. Keep onward calls on keys that require a chain, and preserve their session context, so those checks have the history they need.

Try it​

Stop recursive delegation between AI agents covers the settings, error codes, and what an agent builder must forward. Govern peer-agent calls with the A2A Gateway covers registration, policy, PII and approvals on each hop.