AI 7 min read

The Error Contract Matters More Than the Happy Path

What a production data agent taught me about treating tool failures as control flow, not exceptions.

A tool error is not the end of a function. In an agent, it is the next state of the control loop.

I spent several iterations making my agent's error messages better.

Its latency got worse.

Across several versions of the same failure class, the trace got materially slower instead of more reliable. The later versions gave the model more accurate schema information, clearer explanations, and stronger recovery guidance. They were better designed in isolation. The system as a whole still failed to converge.

That reversal taught me something I hadn't understood starting out:

An error envelope is a data structure. An error contract is an end-to-end control-flow agreement.

Each word in that second sentence — end-to-end, control-flow, agreement — hides a requirement. Miss any one and a beautifully structured error can still produce a loop, a false success, or a generic RUN_ERROR.

This post is about how I learned that through a data-analytics agent over precomputed metrics and governed tabular data.

The happy path was never the hard part

The agent had two main ways to answer a question.

For common questions like aggregate totals or trend summaries, it read precomputed metric summaries. For exploratory questions, it generated and executed SQL against a semantic layer.

The happy path looked straightforward:

user question
  → select a tool
    → call the tool
      → receive data
        → explain the result

Most early design work naturally concentrated there: tool names, parameters, response schemas, routing rules, and successful examples.

Then the metric store disappeared from a test environment.

The first tool call didn't return a state that meant the metric source is unavailable. It returned a thin result that looked close enough to no entity found or partial data. The agent treated that as a reason to supplement the answer with SQL.

It guessed a column that didn't exist, retried the same bad aggregation several times, and eventually hit the iteration guard after a long turn with many tool calls.

The model wasn't being irrational. Its interface had collapsed several different realities into one ambiguous signal:

  • the entity doesn't exist;
  • the query legitimately returned no rows;
  • the backend is unavailable;
  • the current tool can't answer this class of question.

Those states require different next actions. The tool response didn't preserve the distinction.

My first diagnosis was: the agent needs structured errors.

That was correct, but incomplete.

An agent error should describe a state, not just a symptom

The first change was to introduce a typed UNAVAILABLE state with an explicit SQL fallback. A missing row could remain NOT_FOUND; a missing table, endpoint failure, or infrastructure problem would become UNAVAILABLE.

That distinction matters because the appropriate control flow is different:

StateWhat it meansReasonable next move
OK_WITH_DATAThe query succeeded and returned dataAnswer from the result
OK_EMPTYThe query succeeded, no matching data existsReport the empty result honestly
NOT_FOUNDThe requested entity can't be resolvedSearch, clarify, or stop
UNAVAILABLEThe source couldn't answerUse an alternate source or degrade
SCHEMA_MISMATCHThe attempted query is structurally invalidInspect schema, repair, then retry
THROTTLEDA transient dependency limit was hitRetry the same call after backoff
TIMEOUTThe request was too expensive or broadNarrow the scope; don't repeat unchanged
UNAUTHORIZEDThe requested operation isn't permittedStop and surface a safe refusal

The important distinction isn't whether something is technically an "error." It's whether the state changes what the agent should do next.

Empty is a claim about the world

A later trace exposed an even subtler version of the same problem.

A tool promised per-entity detail for a project-like object. Its backend didn't actually implement that read path. The tool returned:

{
  "records": [],
  "total_count": 0,
  "hasMore": false
}

The agent interpreted the result as possibly truncated, retried with a different limit, received the same shape, and only then moved to SQL.

The retry was rational. Nothing in the response said, "This backend can't provide detail rows. Retrying this tool is futile."

[] is not the absence of information. It's an assertion that the query ran successfully and the matching set is empty.

Returning an empty collection when the backend is unimplemented, unavailable, or silently failed isn't graceful degradation. It's false evidence.

I changed the tool to return a non-retryable fallback state that explicitly named the SQL path. That choice had a trade-off: a genuinely empty project would also trigger the extra SQL call. The better long-term contract would distinguish confirmed empty from unable to verify rather than overloading either one.

Typed errors don't remove product decisions. They make those decisions explicit and measurable.

"Actionable" means the receiver can actually act

The next failure came from generated SQL.

When the model referenced a column that didn't exist, the execution tool initially leaked a raw warehouse exception:

Column 'category_label' cannot be resolved

It knew the query failed, but not enough to repair it efficiently. It repeated the same join shape, delegated SQL generation again, and eventually used SELECT * as an improvised schema probe.

I replaced the raw exception with a typed SCHEMA_MISMATCH result. The envelope identified the failing table and column and directed the agent to an authoritative schema tool.

But my first implementation still got the meaning of actionable wrong.

More information can make the loop worse

The SQL generator was a subagent. It produced SQL as an intermediate artifact. The parent agent executed that SQL. When execution failed, the parent invoked a fresh SQL subagent.

The error envelope reached the parent. The recovery tool lived inside the subagent.

Each retry looked like this:

parent receives SCHEMA_MISMATCH
  → parent invokes a fresh SQL subagent
    → subagent can't see the prior failure
      → subagent generates another plausible query
        → parent executes it
          → query fails
            → repeat

The parent tried to compensate by putting progressively stronger prose into the next delegation:

Previous attempt failed...
CRITICAL: this field does not exist...
ABSOLUTE CONSTRAINT...

The system cycled through several different column hallucinations across five subagent calls. The error message had become more informative, but it had been delivered to a component that didn't own the retry context.

This is why the trace grew slower as the local fixes became more sophisticated.

The fix was architectural: I removed the intermediate-artifact subagent and moved schema inspection into the parent agent's toolset. Now the same component could:

execute SQL
  → observe SCHEMA_MISMATCH
    → inspect the schema
      → revise the SQL
        → retry with the failure still in context

On replayed question classes, tool-call counts fell by roughly half, with no calls classified as wasted.

The results still weren't perfect. Those runs pressed against the iteration ceiling because the synthetic tables had no clean join path. But it stopped the amnesic retry pattern.

An error is actionable only when the recipient has the information, authority, tools, and memory required to perform the suggested action.

The runtime can destroy a correct error contract

The previous sections were about making tools produce the right error state. But a correct error can still break if the runtime delivers it through the wrong channel.

In one evaluation, the agent generated a valid count-of-groups SQL query using a derived table. A stale security rule rejected all subqueries. The rejection was raised inside a pre-execution hook. The terminal trace was:

RUN_ERROR: Subqueries are not allowed. Use a single SELECT statement.
RUN_ERROR: An unexpected error occurred. Please try again.

The first line was the actual policy decision. The second was the runtime treating it as an unhandled failure. The model never received either line as an observation, so it had no opportunity to choose another plan or explain the limitation.

The fix wasn't better wording. It was to use the framework's recoverable cancellation path instead of raising from the hook. The same pattern showed up in an input sanitizer: a valid injection detection raised before the streaming lifecycle started, and the user got a generic error instead of a polite refusal.

Both of those failures went in one direction: a recoverable state escalated into a fatal one. I also hit the mirror image. A tool result carried status="error", but the event mapper dropped the status field before writing traces. Downstream, everything showed status=ok. The model had actually recovered, so users didn't notice. But metrics couldn't count the failure, and evaluators couldn't explain the later fallback. The behavioral contract worked. The diagnostic contract didn't.

The common thread: a pre-execution hook, a tool body, an input classifier, and an event mapper are four different sites in the runtime, each with its own control-flow rules. An error correct at one site can be swallowed, escalated, or silently rewritten before it reaches the model, the user, or the trace.

Before raising or catching a failure, map how the framework routes errors at the exact site where the failure originates.

The contract I use now

I no longer design a tool by specifying only its input schema and success response. I design its state machine.

A useful error envelope for an agent usually needs these dimensions:

DimensionQuestion it answers
ClassificationWhat state are we actually in?
Retry semanticsCan the exact same call succeed, or must something change first?
Recovery actionRetry, inspect, repair, narrow, fall back, degrade, or stop?
EvidenceWhich argument, field, table, policy, or dependency caused the state?
ScopeWhat entity, scope, or query did this apply to?
User presentationIs there safe language the agent may surface?

For example:

{
  "status": "error",
  "code": "SCHEMA_MISMATCH",
  "message": "Column 'entity_id' does not exist on 'analytics_detail'.",
  "retry_mode": "REPAIR",
  "suggested_action": {
    "tool": "get_schema",
    "arguments": {
      "name": "analytics_detail"
    }
  },
  "details": {
    "failing_column": "entity_id",
    "tables_referenced": [
      "metric_summary",
      "analytics_detail"
    ]
  },
  "trace_id": "tr-..."
}

The exact JSON isn't the important part. The semantics are.

In particular, I separate four forms of "try again":

RETRY     The same request may succeed after backoff.
REPAIR    Change the arguments or plan, then call again.
FALLBACK  Use another tool, source, or representation.
NARROW    Reduce the requested scope before retrying.

Everything else should move toward a terminal outcome: continue with a partial result and an explicit caveat, or stop cleanly and give the user a safe, honest explanation.

This prevents a dangerous ambiguity in a simple boolean like retryable. A throttle may justify the same call after backoff. A deterministic timeout often means the request must be narrowed first. Those aren't the same retry.

The lesson I keep

The most important shift wasn't adding more error codes. It was changing how I diagnose failures.

Earlier, a looping trace tempted me to add prompt instructions like "do not retry the same query." Those rules sound sensible, but they place responsibility on the model for information the harness failed to provide.

The happy-path contract tells the agent what a tool does when the world matches our assumptions. The error contract tells it how to continue.

In this agent, that second contract carried more of the reliability burden. I expect that holds for any model-driven system: it determines whether the agent repairs, falls back, narrows scope, degrades honestly, or burns its remaining iterations repeating a plan that can never work.

I started by trying to write better errors. I ended up redesigning who owns recovery, how failures travel through the runtime, and how the system proves to itself that recovery happened.

That's why I now treat the error path as the primary interface, not the unhappy appendix to the happy path.

Details, names, and numbers have been altered; the lessons are real.