AI coding

Metering Agent Retries at the Tool Boundary

Antonio Evans10 min read

A cutaway view of a mechanical relay box mounted on a workshop bench.
Key points

Key points

  1. 1Put the retry counter in the wrapper around each tool call, so every invocation increments one counter per task.
  2. 2A model client counter misses retries that happen inside a tool, in an orchestration loop, or across a restart.
  3. 3Classify each retry by cause (transport, validation, or model judgment) so the counter stays diagnostic instead of just a number.
  4. 4Give the wrapper an idempotent task identity and a retry budget to prevent doubling your counts and your side effects.

Why the model client is the wrong meter

A model client sees one thing: a chat completion request and its response. It can count how many times it sent a prompt and how many tokens came back. What it cannot see is what happened between the moment the agent decided to call a tool and the moment the tool's result re-entered context.

Those two moments are often far apart. The MCP specification describes tools as functions the model can execute, with JSON-RPC messages travelling between hosts, clients and servers (MCP specification). The protocol includes error reporting, progress tracking and cancellation as first-class utilities. Every error path is a place where a retry can be manufactured outside the model client's field of view.

Practical consequences:

  • A tool-level HTTP client that retries on a 503 generates extra calls that never appear as model requests.
  • An orchestrator that catches a timeout and re-dispatches the same tool call does so in application code, not in the model client.
  • A framework with its own internal retry policy, wrapping the same tool, adds attempts the client never observes.
  • A re-plan after a restart replays a tool call from a durable log, again invisible to the model client.

If you bill, rate limit, or debug from a client-side counter, you will be measuring the wrong population. The OpenAI-style token counters and OTel GenAI attributes are built around LLM operations, not tool invocations; the GenAI semantic conventions across OTel versions split chat spans, agent spans, and tool spans into distinct conventions (Greptime on OTel GenAI evolution). That split is a hint: tool invocation is its own unit of work.

What the tool boundary actually is

The boundary is the function in your runtime that turns a tool-call request from the model into an actual side effect and returns a result. Everything crossing that boundary is countable in one place: model-initiated calls, retries from a transport, retries from an agent loop, retries from a framework, and re-dispatches after a restart.

A useful mental model is: the wrapper is a gate. Nothing gets to the tool without passing through it, and every pass through the gate is an observable event. This is also the boundary that idempotency practice points to. As one analysis puts it, retries belong in the orchestration layer and at the tool boundary, not in model output, because the model may propose a retry but code must decide whether that attempt is a new operation (Formation on agent tool retries).

That gives you a clean decomposition:

LayerWhat it knowsWhat it misses
Model clientRequests sent, tokens used, latencyTool calls, tool retries, internal loop retries
Agent loopPlan steps, tool-call intentsTransport retries, tool-internal retries
Tool wrapperEvery dispatch, every result, every retry causeNothing relevant at this boundary
LogsNarrationReliable counts, especially after sampling or truncation

The wrapper is the only row that has full coverage of the events you actually care about.

A per-task retry counter in the wrapper

The design goal is one counter series per task. A task is a durable unit of work, usually a single user request, with a stable identifier the wrapper can attach to every dispatch. The counter increments on entry to the wrapper, not on exit, so a tool that hangs and times out still produces a count.

A minimal Python version:

from dataclasses import dataclass, field

@dataclass
class TaskMeter:
    task_id: str
    attempts: int = 0
    retries_by_cause: dict = field(default_factory=dict)

    def record(self, cause: str) -> None:
        self.attempts += 1
        self.retries_by_cause[cause] = self.retries_by_cause.get(cause, 0) + 1

def wrapped_tool(tool_name, tool_fn, meter, cause="initial"):
    meter.record(cause)
    return tool_fn()

Then call it from the loop that actually resolves the tool:

def dispatch(task, tool_name, args, tool_fn, meter):
    try:
        return wrapped_tool(tool_name, lambda: tool_fn(**args), meter)
    except TimeoutError:
        return wrapped_tool(tool_name, lambda: tool_fn(**args), meter, cause="transport_timeout")

Three rules that keep this honest:

  1. Count at entry, not on success. A retry that also fails is still a retry. Counting only successful invocations loses information about the ones that burned budget.
  2. One counter per task, not per tool. Per-tool counters are useful, but they fragment attention when the real question is "how many attempts did this task cost." Keep the per-task counter as the primary key and the per-tool counter as a dimension.
  3. Never reconstruct the counter from logs. Logs narrate; they do not count reliably. Sampling, log levels, and truncation all lose events.

The wrapper also becomes the natural place to enforce a retry budget. A turn-level cap that the wrapper refuses to exceed is the same pattern called out in production retry guidance: a turn that retries a dozen times on a misconfigured tool can burn far more than a healthy request, so a budget is not optional (Motomtech on retry budgets).

Classifying retries by cause

A counter that only says "five attempts" is not actionable. A counter that says "five attempts: one transport timeout, three validation errors, one model re-plan" tells you what to fix. Classification happens where the wrapper sees the failure, because the wrapper is where the exception, status code, or error payload actually lands.

A practical taxonomy:

CauseSignal at the wrapperWhat it usually means
Transport timeoutTimeout exception, no responseUnknown outcome; a retry may duplicate the effect
Server error, retryable5xx or protocol-level retryable errorSafe to retry if the operation is idempotent
Rate limit or throttle429 or a retry-after headerWait then retry; do not count as a behavior problem
Validation failure4xx, schema error, bad argumentsRetrying unchanged will fail again; the agent needs to fix the request
Model judgmentThe model requested another tool call after a successful oneNot evidence the first call failed; may be a re-plan
Restart recoveryReplay from a durable logRecovering an existing operation, not a fresh intent

The Azure retry storm guidance is emphatic about the failure classification problem: retrying a client-side validation error that will keep failing worsens the problem rather than solving it, and clients should limit both the number of retries and the duration (Azure retry storm antipattern). The wrapper is where this distinction becomes a number you can look at.

The most under-counted class is "model judgment." A model can look at a valid result, decide it is inadequate, and ask for another call. That request is a new operation, not a retry of a failed one, but as a matter of behavior it is an extra tool call that costs money and moves the state of the world. Counting it separately, and never lumping it in with transport retries, keeps both numbers meaningful.

Avoiding double counting

The obvious risk with a wrapper is that you count the same attempt twice. This usually happens in one of three ways.

First, nested wrappers. If a framework already wraps tools in its own retry logic and you wrap again, one dispatch becomes two increments. Decide once which layer owns the counter and make it the outermost boundary.

Second, retries that cross the wrapper boundary. If code above the wrapper catches a failure and re-dispatches the plan, the wrapper sees a brand new dispatch and cannot tell it apart from a genuine new call. That is fine from a counting standpoint as long as the task ID is stable, but the classification will read "initial" unless the caller passes a cause. Make the cause an explicit parameter, as in the code above.

Third, replay after restart. A durable execution log might replay a tool call that already committed, and the wrapper would count it again as a fresh attempt unless it recognizes the task ID and operation key. The idempotency literature is blunt about this: a fresh tool-call identifier is not a stable business-operation identifier, so attach the key outside model-controlled arguments (Formation on tool retries and idempotency). The same identity that makes the effect safe makes the count correct.

A useful policy summary:

If the task_id and operation_key match a completed dispatch:
    record "recovered" and return the cached result
Else:
    record the dispatch with a cause and execute

That single rule de-duplicates both the side effect and the counter increment.

Worked example: 1 task, 5 attempts

Suppose a task asks an agent to file an expense report. The agent calls create_expense with a receipt. The tool's HTTP client has a 10-second timeout. On attempt one, the request reaches the server and the server commits the record, but the response never makes it back before the timeout.

Here is how the numbers look under three different metering strategies:

AttemptModel client viewLog viewWrapper view (per task)
1. Initial dispatch1 request, timeout errorOne line, "error"attempts=1, cause=transport_timeout
2. Framework retry, same call(no new request seen)One line, "retrying"attempts=2, cause=transport_timeout
3. Model re-plans with a corrected argument, calls again1 requestOne line, "tool"attempts=3, cause=model_replan
4. Tool-internal retry after a 429(no new request seen)Sampled awayattempts=4, cause=rate_limit
5. Success(response recorded)One lineattempts=5, cause=model_replan

The model client sees two requests. The logs, depending on sampling, see between zero and five lines. The wrapper sees five attempts on one task, with a cause distribution of one transport timeout, one transport timeout from a framework retry, two model re-plans, and one rate limit. Only the wrapper view supports a decision like "our expense tool times out on 40 percent of first attempts; fix the server side, not the retry policy."

This example also shows why per-task counting matters more than per-tool counting. Per-tool, you would know that create_expense was called five times across a batch. Per-task, you know that one expense report was nearly filed three times and involves two model re-plans. Without the wrapper, you would have no way to tell them apart.

Comparing with log-based metering

Log-based metering is the default approach for teams already shipping to an observability stack: emit a log line at each retry, then run a query that counts matching lines. It has real strengths. It requires no new abstraction. It captures arbitrary context for free. It works with vendored SDKs you cannot wrap.

It also fails in specific, predictable ways.

DimensionWrapper counterLog-based count
CompletenessEvery dispatch, by constructionOnly events that were logged
ConsistencySame key path every timeDepends on wording and log level
Sampling riskNone if counters are first-classHigh when logs are sampled or truncated
BackfillRequires replaying from durable stateQuery historical lines
Cost of ownershipOne wrapper to maintainOngoing query maintenance and drift
Debug contextCause plus task IDFree-form, often richer

The last two rows are where the choice actually lives. If your team already has a mature searchable log pipeline and treats retries as a secondary signal, log-based counting has a lower operating cost. If retries drive money, rate limits, or support load, the wrapper counter is the primary instrument and logs are the annotation.

OpenTelemetry is a useful middle path. The GenAI semantic conventions define a execute_tool span, and the agent span conventions distinguish tool execution from agent invocation, which means a correctly instrumented tool boundary already produces a per-attempt telemetry signal that a collector can sum (semantic conventions for GenAI agent spans). The caveat is maturity: the agent and tool conventions remain in Development status, so the attribute names and span shapes can change (Greptime on OTel GenAI maturity). Build the counter in code first; map it to semantic conventions second.

For teams running many tools through the model context protocol, the same choice appears at the protocol level. MCP servers expose tools, resources and prompts as distinct features, and the client is the party that sees both the request and the result (MCP specification). If you build a client-side wrapper around every tool the host exposes, you get one counter for all of them, whether the tool is local or remote, and whether the remote server has its own retry logic or not.

Where conflicts and extensions show up

Multi-agent systems introduce a coordination problem that a per-task counter exposes but does not solve. One practitioner running nine agents against a single API quota described a chain of 60 or more failed requests in a single incident when every agent retried on the same schedule, producing a second wave of throttling and a third, burning through a shared hourly limit in roughly 90 seconds (Dresher on rate limiting multi-agent systems). The wrapper counter is what makes that wave visible; it is not what prevents it. Traffic-light throttling, priority tiers, and jittered backoff are the prevention.

On the authorization side, per-task counters align naturally with per-user or per-project identity. The MCP authorization tutorial notes that authorization is the mechanism for per-user usage tracking and rate limiting, and that this is only meaningful when the identity is preserved through the tool call (MCP authorization guide). A wrapper that carries the task ID and the calling identity together is set up for both.

Finally, the idempotency contract that makes retries safe is the same one that makes counts trustworthy. Analysts writing on the topic keep returning to the same construction: derive the key from the orchestrator's durable step, not from model output, and keep it stable across transport retries, re-plans, and restarts (tianpan.co on the orchestration contract). If your counter is keyed the same way, the number and the side effect stay in agreement.

When your tasks call many kinds of tools, and the tools call many kinds of models, having one consistent place to instrument helps. The natural next step for teams already wiring metering into a multi-model stack is a single inference path across text, image, video, audio and embedding models through one OpenAI-compatible key, with prepaid pricing in US dollars, which is what Quanta Inference provides.

FAQ

FAQ

Why not just count retries in the model client and be done with it?

Because the model client only sees chat completion requests. Tool-internal retries, framework retries, orchestrator re-dispatches and restart replays all happen after the client's view ends, so the client counter undercounts by exactly the population you wanted to monitor.

Do I need one counter per tool as well as per task?

Keep the per-task counter as the primary series, because it answers the money question ("how many attempts did this task cost"). Add a per-tool dimension for diagnosis, since a specific misbehaving tool is much easier to spot once you can see which tool supplied which attempt within a task.

How do I stop counting a single retry twice?

Decide which layer owns the counter and make it the outermost boundary. Then have every internal retry path pass its cause as an explicit parameter, and de-duplicate by task ID plus operation key so that a replay after restart is recorded as "recovered" rather than as a fresh attempt.

What counts as a retry versus a new operation?

A retry is another attempt at the same logical operation. A new operation has a different business intent, even when the arguments look identical. Use an orchestrator-derived key built from the task, the logical step, the tool name and the business scope, and treat any change in business scope as a new operation rather than as a retry (Formation on agent tool retries).

Is log-based metering ever the right choice?

Yes, when logs are already the system of record for a mature observability pipeline and retries are a secondary signal. Once retries drive spend, rate limits, or support tickets, the wrapper counter should be the primary instrument, with logs used only for the surrounding narrative context.

Sources

Sources

  1. Specification
  2. How OpenTelemetry Traces LLM Calls, Agent Reasoning, and MCP ...
  3. Agent Tool Call Retries: Idempotency and Recovery
  4. AI Agent Tool Retries: Idempotency Without Duplicate Orders
  5. Retry Storm Antipattern - Azure Architecture Center | Microsoft Learn
  6. Semantic Conventions for GenAI agent and framework spans - GitHub
  7. 9 AI Agents, One API Quota, The Rate Limiting Problem ...
  8. Understanding Authorization in MCP
  9. Agent Idempotency Is an Orchestration Contract, Not a Tool ...
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles