Make Every Tool Response Declare Its Own Outcome
An autonomous consumer of a tool never sees the tool’s internals. It sees the value that comes back, and it reasons over that value as fact. This is the whole premise of tool use: the system asks, the tool answers, the answer becomes ground truth for whatever happens next. Hiding inside that premise is a failure mode that costs nothing to create and is expensive to detect. If two different things that can happen inside a tool produce the same-looking answer, the consumer cannot tell them apart. It will pick one reading and act on it, with full confidence, and it will sometimes pick wrong. The defense is not a smarter model or a more careful prompt. It is a discipline at the tool boundary: every response carries an explicit statement of what actually happened, and the consumer branches on that statement rather than on the shape of the data.
Shape is not meaning
A model-driven system branches on what a tool returns. Hand it a list of results and it summarizes them. Hand it an empty list and it reports that nothing was found. That behavior is exactly what you want, right up until the empty list means something other than “nothing exists.” A source that is unreachable, a credential that has expired, a query that timed out, a response that was silently truncated by a size limit: each of these can arrive at the consumer looking identical to a clean query that legitimately found nothing. The payload is the same. The meaning is opposite. And an empty answer is only safe to act on when it means a confirmed absence, never when it means the tool failed to look.
This is not a quirk of search tools. It is a property of any tool whose output shape does not uniquely determine what happened inside it. Emptiness is the sharpest case, because an empty collection is both a valid answer and the natural residue of a dozen kinds of failure, but the same trap appears anywhere a real outcome and a failure collapse into one representation. A counter that returns zero on error and zero as a legitimate total. A lookup that returns null for “absent” and null for “never reached.” A bulk operation that reports done when it actually processed part of its input. In every case the consumer is handed a value it will read as fact, and the value is not carrying enough information to be read correctly.
This survives into production because the consuming system is doing exactly what it was told: use the tool result, and an empty result is a result. The payload offers no reason to doubt it and no field to check it against, so the system emits a firm claim of absence it was never equipped to make.
The adapter is where meaning gets attached
The layer that carries this is a thin adapter between the raw tool and the consumer, and its single job is to convert an ambiguous response into an unambiguous one before anything downstream sees it. It catches whatever the underlying source actually did, classifies it, and attaches an explicit label describing the outcome. The consumer never receives raw output. It receives normalized output that states its own meaning.
Being code is the whole point of putting the distinction here. The adapter runs the same way on every call, it cannot be talked out of its behavior by an unusual input, and it applies its rules uniformly across every invocation and every tool it fronts. A guarantee you want to hold on every invocation has to live in the part of the system that behaves the same on every invocation, and a nondeterministic consumer is not that part.
The adapter also concentrates the discipline in one reviewable place. When the rule for distinguishing a real absence from a failed reach lives in the tool boundary, there is a single artifact to audit, test, and correct. When it lives in the prose of a system prompt, spread across whatever instructions happen to govern each consumer, there is no such artifact and no way to be sure the rule holds everywhere it needs to.
A mandatory, typed outcome is the contract
The core of the adapter is a single field present on every response that names the outcome class. Four classes cover the space cleanly, and each one maps to a distinct consumer behavior.
The classes divide along two questions: did the tool make contact with its source, and if so, did it return everything the source held. Success is contact plus a complete match: the consumer uses what came back and moves on. A confirmed empty is contact plus a verified nothing, an answer the consumer is licensed to act on and must not retry, since the source has already spoken. Partial is contact with an answer that was cut off, whether by a size ceiling, a timeout that landed mid-response, or an error partway through the stream; the consumer may use what arrived but is barred from treating it as the whole picture. An access failure is the absence of contact entirely, which licenses no conclusion about the source’s contents at all: the consumer withholds judgment, runs the retry policy, and reports the failure upward once retries are spent. Two questions, four answers, and every real outcome lands in exactly one.
Two properties make this field a contract rather than a suggestion. It has to be mandatory, and it has to be typed. Optional is the same failure you are trying to eliminate, one level up: an optional field gets populated by the tools whose authors remembered it and skipped by the ones who did not, so half the surface disambiguates and the other half quietly falls back to the very ambiguity the field exists to kill. Make it required and there is no half. If the field can be absent, its absence is itself a fifth outcome with no label, and the original ambiguity has simply moved up one level to live in the schema. Typed matters for a related reason. Free text reopens the same hole from the other direction: two engineers labeling the identical condition will write it two ways, casing and spelling and word choice drifting, and a consumer that switches on the string ignores every spelling it was not coded for. A closed enum with a fixed set of members forces every response into a state the consumer already handles and turns an unrecognized one into a loud error instead of a silent fall-through.
The status is the required spine of the response, but a complete envelope carries more than the class label. A partial is only useful if it also names what it did cover and what it dropped, so the consumer can see the shape of the gap rather than guess at it. An empty or a success is worth more when it records the extent of what was examined and the authority the query ran under, because a downstream step can then tell whether the answer covers the ground its own decision depends on. Design those fields deliberately. They are what turn a bare class label into a result a consumer can act on precisely instead of merely branching on. The one that is non-negotiable is the outcome class itself, because without it none of the others can be interpreted.
Classifying raw signals into outcomes
The adapter earns its place by mapping raw, ambiguous signals onto that closed set of outcomes, and the mapping rules are where most of the real work sits. The instinct to read a transport-level success as a semantic success is exactly the instinct that produces the bug.
A transport-level success carrying an empty body is a true empty: contact was made, the query ran against real contents, and the honest result was zero matches. That is meaningful and the adapter labels it empty. A response that fails at the transport level is an access failure regardless of what its body contains, because the source rejected or could not process the request. Server-side failures are typically transient and worth retrying; client-side failures are typically permanent, with the well-known exceptions such as rate limiting and request timeouts that are transient despite their category. A timeout that returns no status at all is also an access failure, and the one case the adapter has to label from its own knowledge that a call went out and nothing came back, since there is no response to read a class off of. Truncation is the subtlest: the source answered, the transport succeeded, and the body looks like a valid result, but a length cap or a mid-response error means part of the answer is missing. That is a partial, and detecting it usually requires the adapter to know something the raw response does not volunteer, such as a total count that exceeds what was returned or a truncation marker the source sets.
The discipline in all of this is that the adapter decides the outcome class from evidence it can verify, not from the surface shape of the payload. An empty body is not proof of a real absence. A populated body is not proof of a complete answer. The adapter’s job is to close the gap between what a response looks like and what it means.
Retry belongs to the adapter, not the prompt
Once the outcome class exists, retry becomes a mechanical decision, and it belongs in the adapter for the same reason classification does. A confirmed empty never retries. It is settled: the source already evaluated the query and reported no match, and asking again while nothing has changed can only return the same verdict, now with latency and quota spent to re-learn what was already known. An access failure may retry, but only when it is the transient kind, and only up to a bounded limit with backoff between attempts. A permanent access failure, an authorization rejection or a malformed request, will fail the same way every time, so spending the retry budget on it is pure waste and the adapter should surface it immediately.
The reason to put this in the adapter rather than the prompt is consistency. Retry policy expressed as an instruction to the model is applied differently across sessions, tools, and moments of attention, which is precisely how a system ends up retrying confirmed absences and giving up on transient failures, the exact inversion of what you want. Expressed as adapter code keyed off the status field, it is applied identically everywhere. And when a retryable failure exhausts its budget, the adapter has one more obligation that is easy to miss: it must return an access failure, not an empty. A tool that retries three times and then hands back an empty list has manufactured the original bug at the end of its own retry loop, converting a known failure into an indistinguishable absence.
An invisible failure must still leave a trace
The most expensive property of this class of bug is that it leaves no wreckage. A confident false absence throws nothing. Every component reports success, the output is well formed, and by every signal the run behaved. The only artifact is a decision made downstream on a false premise, and when someone tries to trace that decision back days later, there is nothing in the record pointing at the moment it went wrong.
The adapter is the one place positioned to fix this, because it is the one place that knows a failure occurred at the instant it occurred. Every access failure and every truncation should write a structured log entry at that moment, capturing what call was made, against what target, what class of failure it was, and what the retry policy did about it. Reconstructing this after the fact from the consumer’s output is impossible, precisely because the output was designed to look like a success. Built in from the start, the trace turns a multi-day forensic reconstruction into a query. This is the difference between a system whose silent failures are merely rare and one whose silent failures are also diagnosable.
The consumer has to spend the outcome it was given
An adapter that attaches an impeccable status field accomplishes nothing if the consumer ignores it. The contract has two sides, and the second is that the consumer actually branches on the class it was handed, all four of them, with none left to a default. A success is used. A confirmed empty is reported as a real absence and nothing more. A partial is passed along with its incompleteness intact, never dressed up as a whole answer. An access failure yields no claim about what exists; it triggers the retry policy and, failing that, an honest report that the question went unanswered. The failure mode is the branch nobody wrote. An instruction that says “use the tool result to answer” without saying what to do on access failure leaves a gap, and the model fills that gap with its default, which is to treat the empty payload as a real absence and answer with confidence. The unhandled branch is where the bug walks back in.
The same obligation runs all the way to whatever surface a person or another system finally reads. The class has to survive to the point of consumption, and the words at that surface have to match it, so that an outcome the adapter marked as unverified is never rendered in the voice of a confirmed one. A rendering layer that collapses every class back into a single confident nothing has quietly discarded everything the adapter worked to preserve, and the bug the adapter prevented at the tool boundary reappears at the last inch before the reader.
What the discipline costs, and when a tool can skip it
None of this is free. Every tool now needs an adapter, a response schema, and a classification path, and a large toolset multiplies that cost. Maintaining a mandatory typed status across dozens of integrations is real engineering work, and it is tempting to treat it as ceremony for tools that seem to behave. The temptation is worth resisting selectively rather than wholesale. The cost is justified precisely when a consumer will infer meaning from the shape of a payload, which is nearly always the case for a model-driven consumer that reads results as fact.
There are boundaries where the full apparatus is genuinely unnecessary. A tool that cannot fail in a way that mimics a valid answer does not need disambiguation it will never use. A tool whose failure raises a typed error the consumer is structurally unable to ignore already carries its outcome unambiguously, and adding a second envelope over it changes nothing. The test is not how reliable the tool feels. It is whether there exists any pair of distinct real outcomes that the consumer would see as the same value. Where that pair exists, the adapter is load-bearing. Where it provably does not, the adapter is overhead, and honest engineering means being able to tell the two situations apart rather than applying the pattern by reflex or skipping it by reflex.
Disambiguation is a property of the boundary
The through-line is that ambiguity is not the model’s problem to reason its way out of. It is a defect in the interface the model was handed, and defects in an interface are fixed at the interface. A tool’s output should uniquely determine what happened, and wherever it does not, a deterministic layer at the boundary must make it so, by attaching an explicit outcome, mapping raw signals onto it honestly, governing retry from it, logging the failures it hides, and refusing to let two distinct outcomes arrive at the consumer as the same value. When every layer holds that line, a confident false absence has nowhere left to form. That is what makes a system trustworthy at the moments it is leaned on hardest: not tools that never fail, but a failure that can never be mistaken for an answer.
