Skip to main content
2026-05-21 · 5 min read · ← 2 · ai · platform · agents

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:

  1. 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.”
  2. 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’s since > 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.

deskgdfindgfstrategiesgsfeaturesgepromotionsgpapigabotgbwritinggw

Shortcuts are no-ops while typing in an input or textarea.