Refusal as planning hint
what you'll learn · Why typed refusals — slugs like `no_state_root`, not prose — are the part of a tool surface that decides whether an agent can plan.
A 500 tells an agent something went wrong. A typed refusal tells it what to do next. The cost of getting this right is a vocabulary; the payoff is an agent that doesn't bluff.
The first agent loop I built against a real backtest engine got
two-thirds of its calls right and the last third silently empty.
book.recent_provenance was supposed to return the decisions made
this morning. In CI it returned []. The agent — having no reason to
suspect otherwise — wrote a note that began “no recent activity in
the book.” That sentence was wrong. The book wasn’t empty; the book
didn’t exist. The CI environment had no BookState configured.
The tool had hit os.environ.get(...) for the parquet root, got
None, fell through to a default, and returned the natural shape: an
empty list.
This post is about what that empty list cost, and what the fix looks like once you commit to it everywhere.
The shape of a tool surface
Most internal APIs end up at one of two failure modes:
- Return what you have. Missing input → empty result. Empty token bucket → empty page. The caller is responsible for telling the difference between “nothing happened” and “I have nothing to say.”
- Raise an exception. Missing input → 500. The caller learns that something went wrong; the caller does not learn what to do next. Logs get a stack trace. The retry loop runs anyway.
Either path is fine for a deterministic caller — a frontend that will read the docs, an operator who will check the dashboard. Both are catastrophic for an agent. An agent is trying to decide what to do next using only the tool’s output. An empty list closes a branch in its reasoning that should have stayed open. A 500 forces it to parse a message string for hints. Either way you get an agent that either bluffs or floods the human’s inbox.
The third option — the one the Model Context Protocol’s tools/call
shape was designed for — is typed refusal. The tool returns a
structured error with a reason slug the agent can branch on:
{
"isError": true,
"_meta": { "reason": "no_state_root" },
"content": [{ "type": "text", "text": "ALPHAKERNEL_BOOK_STATE_ROOT is unset — ..." }]
}
no_state_root isn’t a HTTP code or a stack-trace nickname. It’s a
verb: I refuse to serve this call because the precondition the
contract names is absent. The agent’s planner can match on the
slug, treat it as a known state, and route around it — try a
different tool, escalate to the human, defer the task — without
parsing prose.
What this costs
You pay for typed refusal in vocabulary. Every refusal branch has to
get a name and stick with it. no_state_root is one. The bitemporal
floor is another:
as_of_in_future— the agent asked for a feature panel at a future timestamp. The floor refuses because ADR-0016 forbids reading data that hasn’t happened.since_in_future— same shape for the windowed attribution call.bad_window— the agent’ssince > until. Could be a logic bug in the planner.unknown_slug— the agent asked for a research note that isn’t in the manifest, almost always because it hallucinated the slug. The ADR-0015 strict-citation policy refuses the call rather than returning a placeholder.not_implemented— the tool’s contract is published but the body isn’t wired yet. The agent should not retry.
Once you write that list down, two things become obvious. The first
is that you’d been carrying this vocabulary in your head anyway —
every “but what if X is wrong?” question your code reviewer asked
was an unnamed refusal slug. The second is that the list is short.
Eleven tools across the surface I’ve been writing about have around
forty refusal reasons between them, most reused: bad_limit,
bad_strategy, missing_slug. A planner that can branch on forty
slugs is not a planner that needs to do prose parsing.
Publishing the vocabulary
The mistake is keeping the vocabulary in the handler bodies. If the
slugs only appear inside raise ToolRefusedError(reason="..."),
the agent has to either read your source or call every tool with
crafted bad arguments to enumerate them. Both work. Both are how
exception-based APIs in the rest of the industry get used by agents,
and both are why agents look unreliable. They aren’t unreliable;
the surface they’re using was designed for humans.
The fix is mechanical. Promote refusal_reasons to a field on the
tool definition:
@dataclass(frozen=True)
class ToolDefinition:
name: str
description: str
input_schema: dict[str, Any]
handler: ToolHandler
refusal_reasons: tuple[str, ...] = ()
Populate it per tool. Surface it in the catalogue JSON that ships to
the agent at handshake time. A drift test scans the handler source
for reason="..." literals and asserts that every literal lives in
some published vocabulary — so renaming a slug without updating its
tuple is a CI failure, not a silent regression in agent behaviour.
That last sentence is the load-bearing one. The drift test is what
turns the vocabulary into a contract rather than a comment. Without
it the published refusal_reasons is documentation, which means it’s
wrong within two weeks. With it the vocabulary is the surface.
Absence is never an empty result
This is the rule the rest of the surface follows once the contract is in place: if the precondition the tool needs is structurally absent — no backend, no input file, no required env var — refuse with a typed slug. Never return an empty / partial result that an agent can read as “nothing to report.” The slug is the agent’s discriminator between I saw the book and it’s quiet and I cannot see the book.
In code:
def _book_recent_provenance_handler(arguments):
# ... argument validation raises bad_limit / bad_strategy ...
with _open_book_state() as state: # <- raises no_state_root
rows = state.recent_provenance(limit=limit or _MAX_PROVENANCE_LIMIT)
return [_serialise(r) for r in rows]
The _open_book_state context manager is two lines of substance.
It checks the env var, raises the typed refusal if unset, opens the
parquet store otherwise. Every read tool that needs the book wraps
its body in with _open_book_state() as state: and the absence-
contract is enforced for free. Renaming the env var, redirecting it
to a fixture in CI, switching to a different backend — none of
that changes the agent’s mental model. The slug stays
no_state_root until the contract changes.
Where this connects
There’s a separate post about the citation graph as
substrate — the idea that
every decision-row sharing one cited_artifacts column gives the
platform an audit story it otherwise wouldn’t have. The refusal
vocabulary is the same idea applied to errors instead of citations.
The agent reads the citation column to understand the present
state of the book; it reads the refusal slug to understand why
this call didn’t produce one. Both are columns the agent branches
on. Both are shared across surfaces. Both refuse to ship hidden
behaviour as a falsely-empty result.
This is what people mean — or should mean — when they say a platform is “agent-ready.” It isn’t about RAG, model selection, or the agentic framework. It’s about whether the tool surface tells the agent the truth about what just happened. The agent isn’t going to read your prose error messages. It can read a forty-slug vocabulary just fine.