How to write what your agent says when the lookup fails
Every agent prompt has a rule for calling the tool. Most have no rule for the call coming back empty, and the model still has to say something, so it says the most plausible thing it can compose. That is where the invented depot delay comes from.
TL;DR Make the tool outcome a typed input rather than something the model infers ($REQUIRE variable lookup: one of found, missing, offline), branch on it with a $SWITCH ON block so a forgotten failure arm is a blocking structure/non-exhaustive-switch error naming the member, and add one NEVER rule for the cause the tool did not return. The same decision written as prose IF / ELSE IF over the same typed variable leaves a member unhandled at 0 error, 0 warning, 0 info under both check and check --strict.
A customer asks where their order is. The tracking service is having a bad afternoon and returns a 503. The agent replies: Your order is currently in transit and there appears to be a slight delay at the depot. You should receive it by tomorrow. None of that came from anywhere. There is no depot in your data model, nothing said tomorrow, and now a customer has a date in writing.
This is not a hallucination in the interesting sense. It is a prompt that described one world and then asked the model to operate in a different one.
§1The rule everybody writes describes one outcome
Here is a working order-tracking agent, cut to three rules. It has a tool, it has a trigger for the tool, it has a length bound and a bit of tone. It is a perfectly ordinary prompt and I have read something close to it a hundred times.
<$CONFIG> $IMPORT tool get_order_status </$CONFIG> # Role You are an order-tracking agent for Arden Home. # Instructions - WHEN a customer asks about a delivery THEN call @[get_order_status] and read the result back. - MUST keep every reply to at most 3 sentences. - SHOULD apologize if something has gone wrong.
$ typeglish check tracking.tg ✓ 1 file - 0 error, 0 warning, 0 info $ typeglish score tracking.tg tracking.tg - B (82/100) proven errors: none tiers: base+z3 planes runtime 92 (what the model reads) · hygiene 50 (source only) facets enforceability 83 x.21 · hardness 83 x.12 · directness 97 x.08 consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 0 x.12 (hygiene) style 100 x.08 · security 100 x.08
B (82/100). There is no diagnostic for a missing world, and there should not be one: the checker cannot know which outcomes your tool has. That knowledge is the thing you are about to write down.Read the trigger rule as a specification and count the worlds it covers. Call the tool and read the result back is complete when a result exists. When the call 503s, or returns an empty array because the order number was mistyped, or hangs until the runtime gives up, the rule has no consequent and the conversation still needs a turn. The model fills the gap with the highest-probability support sentence available, which is a sentence about a depot.
An agent invents a reason when you asked it a question and gave it nothing true to answer with.
§2Name the outcomes, and let the host own them
The first move is to stop asking the model to work out what happened. Your runtime already knows: it made the call, it holds the status code and the body, it knows whether it retried. That is a fact, and a fact belongs in an input rather than in an inference.
Three members carry almost every support tool I have seen: the call answered, the call answered with nothing that matches, the call did not answer. Name them in the customer's vocabulary rather than HTTP's, because the arm bodies are prose the model reads.
$REQUIRE variable lookup: one of found, missing, offline
- no_match:: is reported as structure/undeclared-tool, Tool "no_match" is used but never imported, which is a confusing five minutes if you were not expecting it.Now the decision. There are two planes it can live on and they are not interchangeable, so this is the part worth being deliberate about. Write it as prose and the model weighs it at runtime; write it as a $SWITCH ON block and the compiler resolves it before the model sees anything. Here is the prose version, over the typed variable, with the offline member left out exactly the way a real prompt leaves it out.
<$CONFIG> $IMPORT tool get_order_status $REQUIRE variable lookup: one of found, missing, offline </$CONFIG> # Role You are an order-tracking agent for Arden Home. # Instructions - WHEN a customer asks about a delivery THEN call @[get_order_status]. - IF @{lookup} is equal to found THEN read the returned status back. - ELSE IF @{lookup} is equal to missing THEN ask for an order number again.
$ typeglish check tracking-prose.tg ✓ 1 file - 0 error, 0 warning, 0 info $ typeglish check tracking-prose.tg --strict ✓ 1 file - 0 error, 0 warning, 0 info $ typeglish score tracking-prose.tg tracking-prose.tg - C (76/100) proven errors: none tiers: base+z3 facets enforceability 50 x.21 · hardness 100 x.12 · directness 90 x.08 consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 0 x.12 (hygiene)
enforceability 50 is the only trace of anything, and it is a comment on how the rules are phrased rather than on the hole. Nothing anywhere says offline is unhandled.Move the identical decision onto the deterministic plane and the same omission is a blocking error that names the member out loud:
tracking-noarm.tg:13:1 error structure/non-exhaustive-switch @{lookup} can be offline, but no arm handles it - add a "- <member>::" row for each (or a deliberate "- otherwise::" fallback). ✗ 1 file - 1 error, 0 warning, 0 info
otherwise row swallowing it.§3Say the true thing, and bound what else can be said
Now write the arms, and be concrete in a specific way: each failure arm needs the true fact and the next action, and nothing else. Then add the rule almost nobody writes, which is the prohibition on the invented cause. Without it, say tracking is offline and invent a depot are both satisfiable at once, and only one of them is what you meant.
<$CONFIG> $IMPORT tool get_order_status $REQUIRE variable lookup: one of found, missing, offline </$CONFIG> # Role @@ role: order tracking only You are an order-tracking agent for Arden Home. # Instructions @@ lookup_first: the status is a fact from the tool, never a guess - WHEN a customer asks about a delivery THEN call @[get_order_status]. $SWITCH ON @{lookup} - found:: @@ read_back: the tool answered, so quote it and stop - MUST read the returned status back to the customer. - missing:: @@ reread: a typo is the likeliest cause and the customer can fix it - MUST ask the customer for an order number again. - offline:: @@ own_it: name the outage, own the follow-up, promise nothing else - MUST say that order tracking is offline. - MUST offer a callback. # Constraints @@ no_cause: a cause the tool did not return is a story - NEVER state a cause for a late delivery. @@ brevity: three sentences keeps a chat reply scannable - MUST keep every reply to at most 3 sentences.
0 error, 0 warning, 0 info at A (92/100). The NEVER rule is the load-bearing one: it puts the invented cause in the same slot as every other rule about explaining a delivery, so a future edit asking the agent to explain the hold-up is a provable conflict rather than a difference of opinion about tone.Then read what the model actually receives, which is the part of this technique that pays for the ceremony. The chain is compile-time, so each binding produces a prompt carrying one instruction set instead of a menu.
$ typeglish build tracking-v2.tg --vars '{"lookup":"offline"}' && cat .typeglish/dist/tracking-v2.txt ✓ built .typeglish/dist/tracking-v2.txt ← tracking-v2.tg (a80fe4ac5d8e, full) # Role You are an order-tracking agent for Arden Home. # Instructions - WHEN a customer asks about a delivery THEN call get_order_status. - MUST say that order tracking is offline. - MUST offer a callback. # Constraints - NEVER state a cause for a late delivery. - MUST keep every reply to at most 3 sentences. $ typeglish build tracking-v2.tg --vars '{"lookup":"found"}' && cat .typeglish/dist/tracking-v2.txt ✓ built .typeglish/dist/tracking-v2.txt ← tracking-v2.tg (0b4880b58c82, full) # Role You are an order-tracking agent for Arden Home. # Instructions - WHEN a customer asks about a delivery THEN call get_order_status. - MUST read the returned status back to the customer. # Constraints - NEVER state a cause for a late delivery. - MUST keep every reply to at most 3 sentences.
The build worth staring at is the third one. Leave the variable unbound and the template ships every arm as prose, which is the failure mode of this whole technique and the reason to bind it in the runtime rather than hoping.
$ typeglish build tracking-v2.tg && cat .typeglish/dist/tracking-v2.txt ✓ built .typeglish/dist/tracking-v2.txt ← tracking-v2.tg (1012ff9bf85d, full) # Role You are an order-tracking agent for Arden Home. # Instructions - WHEN a customer asks about a delivery THEN call get_order_status. If lookup is found: - MUST read the returned status back to the customer. If lookup is missing: - MUST ask the customer for an order number again. If lookup is offline: - MUST say that order tracking is offline. - MUST offer a callback. # Constraints - NEVER state a cause for a late delivery. - MUST keep every reply to at most 3 sentences.
§4Pin the wording before somebody softens it
The last line of defence is a deterministic assert, because the prohibition in §3 is exactly the kind of rule that gets quietly relaxed when a QA scorecard says the agent sounded curt during an outage. A negative lookahead over the words your agent has no business using is cheap and it does not need a judge.
$TEST offline_invents_nothing - input:: Where is my order 88213? - expect:: - matches /^(?!.*(depot|courier|tomorrow)).*$/ - at most 3 sentences $ typeglish check tracking-v3.tg ✓ 1 file - 0 error, 0 warning, 0 info $ typeglish test tracking-v3.tg --dry ✓ tracking-v3.tg coverage: 0/6 rules exercised · offline_invents_nothing - "Where is my order 88213?" (not run) ✓ matches /^(?!.*(depot|courier|tomorrow)).*$/ ✓ at most 3 sentences ✓ 1 prompt - 0 failed
--dry is fully offline, so the two ticks mean the asserts are well formed and nothing more, and 0/6 is the suite telling you this case is not attributed to any of the six rules. A live run needs ANTHROPIC_API_KEY, and the case is worth running against the offline build specifically, since that is the artifact where the invented sentence lives.§5The four lines, in order
Compressed to something you can apply to a prompt you already have, in the order I would do it:
- List the outcomes your tool really has. Not the HTTP codes, the outcomes a customer can tell apart. Three is usual: answered, answered with nothing, did not answer. If a fourth genuinely behaves differently for the customer (a partial result, a permission denial), it earns a member.
- Declare it as a closed domain and make the host bind it.
$REQUIRE variable lookup: one of found, missing, offline, single-word English keys. The detection is the runtime's job and always was; the prompt's job is the wording. - Write one arm per member in a
$SWITCH ONblock. Each arm gets the true fact and the next action. Resist the- otherwise::row: on a fully covered domain it is a blocking dead arm anyway, and its whole purpose is to absorb the member you add next year without telling you. - Add the prohibition, and one assert.
NEVER state a cause for a late deliveryis what stops the depot. Thematchesassert is what stops the prohibition being softened by somebody who was not in the incident review.
Four lines of ceremony for the outcome, one for the prohibition, one test. What you get is a prompt where the failure path is a compiler obligation instead of an intention, and where the day the tracking service falls over your agent says the boring true thing. Boring is the goal. Nobody has ever escalated a ticket because the agent was insufficiently imaginative about a 503.
§6Common questions
- What should my AI agent do when a tool call fails?
- Say the specific true thing and stop. Three lines cover almost every failure a support agent meets: name what happened in the terms the customer cares about (tracking is offline, the order number does not match), do the one thing that moves it forward (offer a callback, ask for the number again), and never state a cause the tool did not return. The third line is the one everybody leaves out, and it is the one that stops an invented depot delay becoming a promise your team has to honour. Written as
NEVER state a cause for a late delivery, it is a rule in the same slot as every other rule about that action, so a later edit asking the agent to explain the delay is a provable conflict rather than a tone change. - Why does my agent make up a reason when the API is down?
- Because the prompt asked it a question it had to answer and gave it nothing true to answer with. A rule like
WHEN a customer asks about a delivery THEN call get_order_status and read the result backdescribes exactly one world, the one where a result comes back. In every other world the model still has to produce a reply, and the most plausible-sounding reply is a generic logistics sentence. Nothing incheckcatches that: the happy-path-only file is0 error, 0 warning, 0 infoatB (82/100). The fix is not a longer instruction, it is naming the other outcomes so there is a rule to follow in each of them. - How do I make sure I have not forgotten an error case in a system prompt?
- Type the outcome as a closed domain and branch on it with a
$SWITCH ONblock, and forgetting becomes a compile error rather than a discovery. With$REQUIRE variable lookup: one of found, missing, offline, a switch missing the third arm isstructure/non-exhaustive-switchand the message names the member: @{lookup} can be offline, but no arm handles it. The same decision written as proseIFandELSE IFover the same typed variable, with the same member unhandled, is0 error, 0 warning, 0 infounder bothcheckandcheck --strict. Choosing the plane is choosing whether the omission is enforced. - Should the failure path live in the prompt or in the runtime?
- The detection belongs to the runtime and the wording belongs to the prompt. Your host already knows whether the call returned a row, returned nothing, or timed out, so make it pass that in as a typed value rather than asking the model to infer it from a tool result it may have misread. Then the compiler resolves the branch at build time and the model is handed one instruction: the
offlinebinding builds toa80fe4ac5d8ecarrying only the offline rules, thefoundbinding to0b4880b58c82carrying only the read-back rule. The unbound template build at1012ff9bf85dis the shape to avoid in production, because it ships all three arms as prose and tells the model three things at once.
Getting the tool wired up correctly is the step before any of this, and it has its own set of ways to come loose: how to connect an agent tool to a real API covers $TOOL against $IMPORT tool, the - request:: binding, and the dangling references the checker blocks. A failure arm is only worth writing once you are sure the call is being made at all.