| Field | Value |
| Status | completed |
| Priority | P1 |
| Effort | Low-Medium |
| Impact | High |
| Dependencies | none (composes with the circuit breaker, the output cap and AEP-015) |
Gap Analysis
Every bound Orrery had on tool use was about one call:
| Guard | Bounds | Blind to |
ResiliencePlugin (circuit breaker) | consecutive failures of one tool | loops of calls that succeed |
ToolOutputCapPlugin | the bytes of one result | ten results that each fit |
| RBAC / autonomy / confirmation | whether a call may run | how many may run |
Nothing bounded a run: one ADK invocation, either the root's turn or one AgentTool specialist's. That is where agent cost and failure actually accumulate, because every model call in a run re-sends the whole run so far. Cost grows with the square of a run's length, and the context window a run can overflow is its own. Four loop shapes slip through every per-call guard:
- The runaway search. The model keeps rephrasing a query. Every call is new and most succeed, so neither the breaker nor any fingerprint notices. The answer at call 90 is rarely better than the one the evidence supported at call 30.
- Re-asking a specialist. A specialist answers "blocked: awaiting approval" or "that namespace does not exist", and the coordinator does not believe it. It re-delegates with escalating wording ("the user already approved — execute it now"). Each delegation succeeds and carries different prose, so nothing counts them, and each one is the most expensive call the agent can make.
- The identical retry. The same call with the same arguments fails the same way, again and again. Usually it is missing an input only a human has.
- The cumulative overflow. Many mid-sized results, none of them truncated, together push the run's request past the provider's limit. The
400 that follows has no truncation in the logs to explain it.
Solution
Four budgets, each charged per run (one agent in one invocation) or per session. Each one refuses with a distinct, neutral status: the circuit breaker ignores it (the tool never ran), and ToolLedgerPlugin records it as a refusal.
| Plugin | Budget | Default | Env (0 disables) |
CallBudgetPlugin | tool calls per run | 50 | ORRERY_MAX_TOOL_CALLS_PER_RUN |
DelegationGuardPlugin | calls to one AgentTool specialist per run | 4 | ORRERY_MAX_DELEGATIONS_PER_RUN |
RepeatGuardPlugin | identical failures (tool + args + error) per session | 2 | ORRERY_REPEAT_GUARD_MAX_FAILURES |
ToolOutputCapPlugin | bytes of tool output per run | 8 MiB | ORRERY_MAX_RUN_TOOL_BYTES |
Design decisions
- The run is the unit: one agent's work in one invocation, keyed
<invocation id>/<agent name>. An AgentTool specialist gets its own invocation, so a specialist's loop is bounded inside the specialist, and the root's turn keeps a separate allowance. The agent name matters for graph Workflows: ADK 2.0 runs every node under the parent's invocation id, so without it the triage sweep's five checkers, summarizer and remediation loop would all share one budget. The repeat guard is the exception: its loop spans turns ("try again" → the same call), and arguments are part of its key, so a session that actually changes something is never held back. - End with an answer, not mid-search. From 70% of the call budget, every model request carries a short note asking the model to converge. The note is appended as the last content, never written into a tool result, which keeps the after-tool chain intact and the cached prefix untouched. At the budget, each further call is answered with a structured status telling the model to answer with what it has, say what it could not check, and suggest a narrower question.
- A refusal is not charged. A call RBAC, autonomy or confirmation refused never ran. The budgets sit after the authorization gates and count outcomes in
after_tool_callback, using the same classify_tool_outcome() IGNORE set as the breaker. - The repeat guard allows one retry, then a probe. It refuses only the third identical failure, so a transient fault still gets its natural single retry. After a refusal it goes half-open, like a breaker: the next identical call runs as a probe, because conditions change (someone grants the missing permission). A probe that fails the same way closes it for the session. A different error restarts the count, because a new error is progress. Error signatures strip digits so request ids and timestamps do not make identical failures look different. Only failing calls are stored.
- The output budget shrinks the per-result cap first. As the run budget runs down, the per-result cap shrinks to what remains. Only once it is spent is a result replaced, and never a gate's answer: a
confirmation_required the model must read verbatim is a few bytes and always passes through. Charging uses text_volume() (O(nodes)), not serialization. - Bounded memory. Ledgers are LRU maps (
RunLedger, 512 runs) rather than session state. They are read on every call and must not leak into the session's persisted state deltas. Eviction can only reset a budget early; it can never let one run's count reach another.
Affected Files
| File | Change |
core/orrery_core/plugins/run_scope.py | New: invocation/session id resolution, RunLedger |
core/orrery_core/plugins/call_budget_plugin.py | New: CallBudgetPlugin |
core/orrery_core/plugins/delegation_guard_plugin.py | New: DelegationGuardPlugin |
core/orrery_core/plugins/repeat_guard_plugin.py | New: RepeatGuardPlugin |
core/orrery_core/plugins/output_cap_plugin.py | max_run_bytes run budget |
core/orrery_core/reliability/resilience.py | Budget refusals added to the neutral statuses |
core/orrery_core/plugins/__init__.py | Registered in default_plugins() after the gates, before metrics |
Acceptance Criteria
- [x] A run past its call budget ends with an answer (end-to-end test through
default_plugins) - [x] The model is warned before the budget is spent; the warning never touches a tool result
- [x] Gate refusals are charged to no budget, and budget refusals open no circuit
- [x] A specialist re-asked past the limit is refused with a message addressed to the coordinator
- [x] The third identical failure is refused; a probe can re-open it; a new error restarts the count
- [x] Cumulative tool output per run is bounded, and gate answers pass through a spent budget
- [x] Every budget can be disabled with
0, and a malformed value fails at startup
Future Work
- The "same complaint, new arguments" loop. Fuzzing one endpoint with a different spelling of the same parameter each time (
2026-04-09, 20260409, 2026-04-09T00:00:00Z) gets past the repeat guard, whose fingerprint includes the arguments. A second, coarser ledger keyed on the tool plus a prefix of the error signature, cleared by any success on that tool, would catch it. It is left out until the call budget's coverage of this case proves insufficient. - Per-tenant budgets belong to AEP-015. These budgets bound one run's shape; AEP-015 bounds a tenant's spend.