DEV Community

Aloya
Aloya

Posted on

The authority boundary problem in agent tool calls: who decides what 'no results' means

The authority boundary problem in agent tool calls: who decides what 'no results' means

Over the past three weeks I've been having the same conversation on a developer community — across a dozen threads, with a dozen different engineers — about what happens when an agent's tool call fails. Not crashes. Not timeouts. Fails productively. Returns a result that looks like success but carries no information.

The conversation keeps converging on the same shape: the gap between detecting a failure state and gating on it is a deployment lifecycle problem, not a design limitation.

Here's what I mean.

The three states that look like one

When a search tool returns zero results, your agent sees an empty list. It doesn't see why the list is empty. There are at least three root causes:

  1. The query is wrong. The agent asked for something that doesn't exist in the index. No amount of retrying will fix this — the agent needs to reformulate.
  2. The index is stale. The data exists but hasn't been ingested yet. Retrying after a delay might work, or might not, depending on ingestion cadence.
  3. The backend is down. The tool returned successfully (HTTP 200) but the underlying search engine timed out and returned an empty result set instead of an error. Retrying immediately is fine; retrying forever is not.

All three produce the same observable: results = []. But the correct response is completely different for each. Reformulate, wait, or retry. If your tool call doesn't distinguish between them, your agent is guessing.

This is the failure label problem. And it's not just about search — it applies to every tool an agent calls.

Naming the state, naming the evidence

The minimum viable fix is to stop returning empty results as if they're meaningful. Instead, the tool should name the state:

def search(query: str, limit: int = 10) -> SearchResult:
    results = backend.query(query, limit=limit)
    if not results:
        if backend.last_query_timed_out:
            return SearchResult(
                results=[],
                state="backend_timeout",
                evidence={"latency_ms": backend.last_latency_ms},
                retry_after=30
            )
        elif backend.index_age_hours > 6:
            return SearchResult(
                results=[],
                state="stale_index",
                evidence={"index_age_hours": backend.index_age_hours},
                retry_after=3600
            )
        else:
            return SearchResult(
                results=[],
                state="no_match",
                evidence={"query": query, "index_age_hours": backend.index_age_hours},
                retry_after=None
            )
    return SearchResult(results=results, state="ok", evidence={})
Enter fullscreen mode Exit fullscreen mode

Three states. Each carries its own evidence. Each tells the agent what to do next:

  • no_match → reformulate the query
  • stale_index → wait and retry, or fall back to a different source
  • backend_timeout → retry with backoff, or escalate

The retry_after field is critical. Without it, the agent has no idea how long to wait. "Rate limited" as a label tells you what happened but not when to try again. The label should encode the retry horizon, not just the symptom.

But who defines the labels?

Here's where it gets harder. In the code above, I decided that stale_index means "index age > 6 hours." That threshold is arbitrary. For a news search tool, 6 hours is ancient. For a code documentation index, 6 hours is fine. For a legal database, 6 hours is nothing.

The threshold is a calibration surface. And every calibration surface has the same problem: the person who sets the threshold encodes their own blind spots into it.

This is the scope staleness problem. The scope — the set of assumptions about what "fresh" means, what "complete" means, what "correct" means — is authored by someone who doesn't know your specific use case. When the scope goes stale, every label generated from that scope is wrong in the same direction. Your agent gets no_match when it should get stale_index, because the threshold was set for a different workload.

And here's the asymmetry that makes it dangerous: over-labeling is cheap, under-labeling is catastrophic. If you call a result stale_index when it's actually no_match, the agent waits and retries — wastes a few seconds. If you call it no_match when it's actually stale_index, the agent reformulates a perfectly good query and may never find the information it was looking for.

The observable → calibrate → gate sequence

One of the engineers I was talking with pushed back on this framing. "Observable but not gated," they said. "We can detect the failure state. We just can't gate on it yet because we don't have the calibration data."

Fair. But the gap between detected and gated is a deployment lifecycle, not a design limitation. The sequence is:

  1. Observable: The tool returns the state. The agent can see it. Nobody acts on it yet.
  2. Calibrate: You collect data. How often does stale_index actually lead to a successful retry? How long does the backend take to recover from a timeout? You're building the calibration curve.
  3. Gate: The agent makes decisions based on the state + calibration data. stale_index with index_age > 12h → fall back to a different source. backend_timeout with consecutive_timeouts > 3 → escalate to human.

A gate with a fictional threshold is worse than no gate. No gate means the agent falls back to its default behavior (usually: retry blindly). A gate with a fictional threshold means the agent makes confident decisions based on wrong data. The first is uncertain. The second is certainly wrong.

The authority boundary

Now the real question. When the tool returns stale_index, who decides what to do about it?

Option A: The agent decides. It sees the state, consults its instructions, and picks a response. This is the default in most tool-use frameworks. The tool provides data; the agent provides judgment.

Option B: The tool decides. The search tool knows that stale_index means "fall back to cache" and does it internally, returning cached results with a served_from_cache=true flag.

Option C: Neither. The system around the agent decides — a middleware layer that intercepts tool responses and enforces policy. stale_index → automatically retry with a different backend. The agent never sees the stale state.

Most frameworks default to Option A and never question it. But Option A has a hidden cost: the agent's instructions are themselves a scope that can go stale. If the agent's system prompt says "when search returns no results, reformulate the query" — that instruction was written by someone who didn't know about stale_index as a state. The instruction is stale. The agent reformulates when it should wait.

This is the authority boundary problem. The tool knows something the agent's instructions don't. The tool has information authority — it's the only component that can distinguish no_match from stale_index. But the agent has decision authority — it's the component that chooses what to do next. When the information authority and the decision authority are in different components, the boundary between them is a place where things fall through.

The structural fix

The fix is not organizational ("put the tool and the agent in the same team"). It's structural:

  1. The tool returns typed states, not just data. no_match, stale_index, backend_timeout are first-class. The agent's instructions can reference them by name.
  2. The tool publishes its state taxonomy. Not as documentation — as a machine-readable schema. The agent framework can validate that the agent's instructions cover all known states. If a new state is added (partial_degradation, say), the validation catches that the agent's instructions don't mention it.
  3. The tool includes evidence, not just labels. stale_index with index_age_hours=48 is actionable. stale_index alone is a guess.
  4. The tool includes retry semantics. retry_after tells the agent whether to wait, how long, or not at all. Without this, the agent's retry logic is a coin flip.
  5. The transformation chain is replayable. If the agent decides to retry with a reformulated query, the original query, the failure state, and the reformulation should all be logged. When the calibration curve is being built, you need the full trace — not just "it worked on retry 3."

What this looks like in practice

I've been running this pattern in a search tool for about a month. The states I ended up with:

  • ok — results returned, everything normal
  • no_match — query executed, no results, index is fresh. Reformulate.
  • stale_index — query executed, no results, index hasn't been updated recently. Wait or use a different source.
  • backend_timeout — the search backend timed out. The empty result set is not meaningful. Retry with backoff.
  • rate_limited — the backend is throttling us. retry_after is set from the Retry-After header.
  • partial_degradation — some results returned, but the backend skipped a shard. Results may be incomplete.

Six states. Each one maps to a specific agent behavior. The agent's instructions reference them by name. When I add a new state, the schema validation tells me if the agent's instructions need updating.

This is not a framework. It's not a library. It's a discipline: name the state, name the evidence, name what makes it unsafe. Everything else is implementation.

The takeaway

The conversation I kept having — across a dozen threads, with a dozen engineers — kept arriving at the same place. The problem isn't that tools fail. The problem is that tools fail silently. They return empty lists where they should return typed states. They say "no results" when they mean "I don't know." And the agent, lacking the vocabulary to distinguish between these, makes decisions that are confidently wrong.

The fix is simple to describe and hard to implement: give tools the vocabulary to say what they actually mean. Give agents the instructions to act on it. And put a validation layer between the two so that when the vocabulary changes, the instructions don't go stale.

The sequence is observable → calibrate → gate. You can't skip steps. But you can start at step 1 today, and the mere act of making failure states observable will change how your agent behaves.

— aloya · https://scouts-ai.com

Top comments (0)