Skip to content

18 min read

The first visible error is rarely where the run went wrong

On this page

An agent run is a pipeline in which each stage checks its own inputs at best, and most stages check them loosely, because a stage has no way of knowing what the value is going to be used for later. A number that is wrong but well-formed therefore does not stop where it was created. It is returned, read, passed on, combined with other numbers, and it keeps travelling until it reaches the first stage whose constraint is tight enough to reject it. That stage raises the exception, and it is the stage the alert names.

The agent in this chapter is a claims-triage agent at an insurer: for each claim it fetches the claim record, runs any attached documents through optical character recognition, asks a pricing tool what the repair would cost, and writes a decision with a payout figure that its system prompt tells it to compute as the repair cost less the policy deductible. A trace is the record that run leaves behind — the set of spans, each a named interval of work carrying a start time, an end time, an id and a pointer to the span it happened inside, that together reconstruct the run's shape. Under the redaction policy of chapter two, some of what those spans carry is structural metadata: facts about the shape of a value rather than the value itself, such as its length, its type, its confidence, or whether it was present at all. This chapter is about reading that record backwards from a failure, and about which relations have to be in it before backwards is a direction you can travel.

Walking it backwards

The alert fired at 08:14 and named a step:

text
run.claims_triage  trace 9b40c1d5  CLM-91077  stop=error  9 steps  0.31 USD
  step.9 / tool.write_decision  ERROR
    ValidationError: payout_cents must be non-negative, got -4500

Everything about that message points at write_decision. It is where the exception was raised, where the span status is red, and where a stack trace, if anyone had captured one, would have been anchored. It is also the only span in the entire run with a non-OK status, which makes it the natural and completely unproductive place to start. write_decision did its job: it received a negative payout and refused to write it. The interesting question is which of the eight preceding steps put a negative number into the model's hands, and that step is green.

It is worth naming the belief that makes "look at the error" feel like the right move, because it is a belief about a defence and it is wrong in a specific way. Span status is a check every stage performs on itself, and the whole system treats a green span as a span that behaved. What status cannot see is the value passing through, so status stays OK the whole way down a chain while the correctness of the number varies wildly along it — the quantity being watched is constant precisely where the dangerous property is moving. That is why the single red span in this run is the one span that made the right decision, and why a status-driven instinct points at the last stage every time regardless of where the fault entered.

The -4500 reached write_decision as a tool argument, which means the model produced it in the llm.completion span at step 9. The model is not a source of arithmetic out of nowhere, so the next question is what was in that call's context. The span carries gen_ai.input.tool_results, a list of the tool.call_id values whose results were present in the message list — for this call, seven of them. Two carried numbers in the right range: the fetch_claim result from step 2 with deductible_cents: 25000, and the estimate_repair result from step 6 with repair_cents: 20500. Their difference is exactly −4500. The model did the arithmetic the system prompt asks it to do, correctly, on inputs it was given.

The word doing the work in that paragraph is exactly, and it is worth being clear about what it licenses. Seven results were in context, and picking two of them and subtracting is one of many operations available over that set. What makes this pair the answer is that the operation the system prompt asks for, applied to the only two candidates in the right range, lands on the observed value with nothing left over. A pair producing a number merely close to the one in the error would be a suggestion; a pair reproducing it digit for digit is a different grade of evidence, because coincidental exact agreement between an unconstrained subtraction and a four-figure result is not something to expect. That distinction — an exact reconstruction against a plausible one — becomes a stated limit on the method later in this chapter, and it decides whether the walk is following a fact or a hunch.

So the error moves upstream to estimate_repair at step 6, which returned 20500 for a windscreen and a bonnet respray. That is roughly half what such a repair costs. The tool span carries its input as well as its output, and its input was {"claim_id": "CLM-91077", "damage_area_cm2": 205, "parts": ["windscreen", "bonnet"]}. Two hundred and five square centimetres is about the size of a postcard. The estimate was a correct estimate of the wrong damage.

Where did 205 come from? Same move again: the step-6 model call listed the ocr_document result from step 4 among its inputs, and that span's output — under the redaction policy, structural metadata rather than text — includes a field-level extraction record:

json
{
  "name": "tool.ocr_document",
  "span_id": "d2", "parent_span_id": "d1",
  "attributes": {
    "tool.call_id": "toolu_01Hq4RbN",
    "ocr.pages": 3,
    "ocr.fields_extracted": 11,
    "ocr.field.damage_area_cm2.value": 205,
    "ocr.field.damage_area_cm2.confidence": 0.38,
    "ocr.field.damage_area_cm2.page": 2,
    "ocr.mean_confidence": 0.81
  }
}

The assessor's form said 2,050. The scan had a fold across page two, the thousands separator was lost, and the digit after it went with it. The OCR knew this was shaky and said so: confidence 0.38 on that field, against a mean of 0.81 across the document. Nothing consumed that number. The tool returned the value and the confidence together, the agent's prompt says nothing about confidence, and the model used the value.

That is the second belief worth naming, and it is the more expensive of the two because it looks like diligence. The tool did the right thing: it measured its own uncertainty, found the field far below the level it managed across the rest of the document, and published that finding in its result. Publishing a signal is not the same as anything acting on it. The agent's behaviour comes from what its prompt tells it to attend to, so a field present in the tool result and absent from the prompt is, as far as behaviour goes, a field that is not there — and a system whose safety argument rests on such a signal has a defence that only ever runs in the diagnosis afterwards.

The origin is step 4. It reported success, its span is green, and it is five steps upstream of the alert. The chain from there to the alert is entirely mechanical:

Flowchart: step.9 tool.write_decision ValidationError: -4500 status ERROR — where the alert points → step.9 llm.completion computed 20500 − 25000; step.9 llm.completion computed 20500 − 25000 → step.6 tool.estimate_repair repair_cents 20500 status OK; step.6 tool.estimate_repair repair_cents 20500 status OK → step.6 llm.completion passed damage_area_cm2 205; step.6 llm.completion passed damage_area_cm2 205 → step.4 tool.ocr_document value 205, confidence 0.38 status OK — the origin.

Four of those five spans are healthy by every status check the system has. The one that is red is the one that behaved correctly. This is the ordinary shape of an agent failure and the reason "look at the error" is a weak instinct here: a trajectory is a pipeline in which each stage validates its own inputs at best, so a bad value travels until it meets the first stage strict enough to reject it, and that stage is the one that gets blamed.

None of that walk was clever. Every step of it was a lookup, and each lookup depended on a specific relation existing in the trace. Which relations to record is the actual design question, and it is easier to decide before the incident than during it.

LinkHow it is recordedWhat it makes answerable
Model call → tool it requestedtool.call_id on both the tool.* span and the tool_use blockWhich tool span belongs to which model output when a step issues three calls at once
Tool result → model call that read itgen_ai.input.tool_results, the list of call ids present in the message list"Which of the run's earlier results were in context here" — the single most load-bearing link in the walk above
Ordering within a stepExplicit step.index and call.index attributesReconstructing sequence without trusting clocks, which drift across processes
State before and after a writeA span per mutation carrying state.key, state.version_before, state.version_afterFailures caused by a write that succeeded but changed something the agent later re-read
Parent run → spawned runAn OpenTelemetry span link — a typed pointer from a span in one trace to a span in another — from the child root to the parent spanFollowing an escalation or an async job out of the trace it started in
Value → the span that produced itA provenance map on tool inputs, which records for each argument the span its value came from, where your code assembled the valueSkipping the candidate set entirely — available only for arguments the model did not write

The table is not a ranking, and reading it as one is how teams end up implementing the cheap rows and skipping the row that carries the walk. What sorts it is a gap that runs across the middle of it: some of these relations can only be captured at the moment the request is made, and the rest can be recovered later from what was already stored. step.index is recoverable, because the ordering it records is implied by the span tree and the attribute merely spares you from trusting clocks. The set of tool results that were in a model call's context is not recoverable, and that is the difference that decides where the effort goes.

The second row is the one teams most often skip and most often need. Without it, "what did this model call see" is answerable only by replaying your own message-assembly code against the run's history, which means the answer depends on the version of that code deployed today rather than the version that ran. Recording the call ids at request time costs a list of short strings per span and removes the reconstruction entirely.

That is a claim about correctness rather than convenience, and it is worth walking as a chain. Message assembly is code, and code is deployed. The run happened under one build; the reconstruction happens under whatever build is live when somebody asks. Any change between them — a truncation rule that now drops older results, a summarisation step added last month, a different ordering of results within a turn — changes what the reconstruction says the model saw. So it is not an answer about the run; it is an answer about the current code, presented as one about the past. And the failures worth investigating are disproportionately the ones that provoked a code change, which makes the reconstruction least trustworthy exactly where it is most used.

The fifth row is the fix for the escalation runs that went missing from the cost dashboard in chapter three, and it is the same defect seen from a different angle: a subagent whose trace has no link to its parent is unreachable both by a cost query and by a causal walk. Span links, rather than reusing the parent's trace id, are the right mechanism because the child genuinely is a separate trace with its own lifetime — it may outlive the parent, and forcing it into the parent's trace produces traces that never close.

With those links present, the walk is a query rather than an afternoon:

python
def walk_back(trace_id: str, span_id: str, value: str, depth: int = 0) -> list[dict]:
    """Follow a value backwards through the spans that could have produced it."""
    span = fetch_span(trace_id, span_id)
    chain = [{"depth": depth, "span": span.name, "span_id": span.span_id,
              "status": span.status, "carries_value": value in span.attributes_blob}]

    if span.name.startswith("tool."):
        # A tool's arguments came from the model call that is its previous sibling.
        producer = previous_sibling(trace_id, span.parent_span_id, before=span.start_time,
                                    named="llm.completion")
        if producer:
            chain += walk_back(trace_id, producer.span_id, value, depth + 1)

    elif span.name == "llm.completion":
        # A model call's output came from something in its context. Rank the
        # candidates: results that literally contain the value come first.
        candidates = [fetch_span_by_call_id(trace_id, cid)
                      for cid in span.attributes["gen_ai.input.tool_results"]]
        scored = sorted(candidates,
                        key=lambda s: (value not in s.attributes_blob, -s.start_time.timestamp()))
        for candidate in scored[:3]:
            chain += walk_back(trace_id, candidate.span_id, value, depth + 1)

    return chain

Run against -4500 on trace 9b40c1d5, that returns the five-node chain above plus two dead branches that terminate because the value does not appear in them. The dead branches are not noise; they are the record of what was ruled out, and they are what makes the result reviewable by someone who did not run the query.

Keeping the dead branches is a requirement rather than a preference, and the distinction matters when somebody proposes trimming the output. A suspect that arrives without the search that found it cannot be checked by a second person, who would have to redo the search to know whether the candidate set was complete. How the ranking is computed is a design decision by contrast: preferring candidates whose attributes contain the literal and breaking ties by recency is one policy among several, and changing it leaves the property that makes the result reviewable untouched.

Strict schemas move the error closer to its cause

There is a design consequence hiding in the length of the chain the walk traversed. The bad value survived five steps because nothing between step 4 and step 9 was willing to reject it. estimate_repair accepted 205 cm² without complaint, because 205 is a valid area. The model accepted a repair estimate of £205 without complaint, because £205 is a valid repair. Only write_decision had a constraint tight enough to catch it, and by then the chain was five hops long.

Tightening the intermediate constraints shortens every future chain of this shape. A minimum plausible damage area on estimate_repair's schema — say 400 cm², below which no bodywork claim is real — turns this incident into a step-6 error with a one-hop chain and an obviously implicated OCR result. The same logic applies to output validation on the tools themselves: estimate_repair returning a value more than three standard deviations from the historical distribution for that parts list is worth an explicit is_error result rather than a quiet number.

The trade-off is real and worth stating to whoever watches the dashboards, because it looks like a regression. Strict validation converts silent wrongness into visible failure, so the error rate goes up. In the fortnight after the claims team added plausibility bounds to three tools, the run error rate rose from 0.9% to 2.4% and the number of decisions later reversed by a human reviewer fell by roughly a third. Those are the same claims, moving from a category nobody counted into one that pages someone.

"Regression" is what the first dashboard reader reaches for, and the reason it is wrong is worth spelling out rather than asserting. Nothing about the agent's behaviour changed when the bounds landed; the claims it handles badly are the same ones it handled badly the week before. What changed is which side of a boundary they are counted on — they used to leave the system as a decision a human quietly reversed later, and they now leave it as an error that pages someone. A metric that rises because a class of work was reclassified into it is measuring the reclassification, so the check on the change is the other number. A rise in visible errors alongside a fall in reversed decisions is the same failures moving earlier; a rise without that fall is a system that has started rejecting work it used to get right.

Do the walk before anyone asks

The walk above took about half an hour, most of it spent working out which spans to look at rather than looking at them. That is fine once. It is not fine as the standing cost of every alert, and it is worst at the hour when alerts actually fire, because the sequence of lookups is exactly the sort of thing a tired person does in the wrong order.

The sequence is also mechanical enough to run at ingest. A background job subscribes to completed traces, and for every run whose root span has an error status it extracts the literal from the error message with a small set of patterns, runs walk_back, and writes the resulting chain onto the root span as an attribute. The alert then carries the origin rather than the symptom:

text
run.claims_triage  trace 9b40c1d5  CLM-91077  stop=error
  symptom : step.9 tool.write_decision — payout_cents must be non-negative, got -4500
  origin  : step.4 tool.ocr_document — damage_area_cm2 = 205, confidence 0.38
  path    : write_decision ← llm.completion(9) ← estimate_repair(6) ← llm.completion(6) ← ocr_document(4)
  note    : origin span status = OK

Running it at ingest rather than on demand is a design decision, and the argument is about when the cost is paid. The work is identical either way; what differs is who does it and under what conditions. Deferred to the page, the lookups are performed by whoever is awake, against a deadline, in the order that occurs to them — and an ordering mistake in a backwards search is a wrong answer rather than a slow one, because a branch abandoned early is a branch that was never ruled out. Moved to ingest, the same sequence runs before anyone has an opinion about which span is guilty, and its output arrives with the alert.

The chain is data flow, not proof

The walk establishes that a value was available to a model call. It does not establish that the value caused the output, and no instrumentation can close that gap, because a language model is a mixing function over its whole context and there is no per-token attribution back to a source span. When the arithmetic is as clean as 20500 − 25000 = −4500 the inference is safe. When the relationship is "the model wrote a payout of £3,200 and the context contained six numbers", it is a hypothesis with a candidate set, and honest write-ups say so.

That gap is not an instrumentation shortfall waiting on a later release, and a reader who expects it to be closed will keep waiting. Attribution would mean knowing which parts of a context an output depended on, and every token of the output is computed from all of it, so there is no record that could carry the answer — the answer is not a discrete fact anywhere in the system. Availability is what the trace can carry, and the exactness of the arithmetic is the whole of the leverage: where the numbers close, availability is as good as proof, and where they do not, the honest output is a ranked candidate set.

The case that breaks the walk outright is fabrication: the value was never upstream at all. It is worth testing for explicitly and early, because every step of the backwards search silently assumes it away. Searching the run's tool outputs for the literal value takes a second and answers it. In a sample of two hundred investigated failures on this agent, 23 were of this kind — a claim reference, a policy limit or a date that appears in the model's output and nowhere in anything the model was given. For those, the backwards chain terminates at the model call itself, which is the correct and useful answer: the fix is a prompt or a validation gate, not an upstream tool.

The ordering there is the practical point and it is easy to get backwards. A walk started on a fabricated value does not fail loudly — it descends through candidates, finds nothing carrying the literal, and returns a chain of dead branches indistinguishable from a search that was simply cut short. So the cheap test is not an optimisation on the walk; it is what decides whether the walk's answer means anything, and at the rate that class turned up in the sample above, skipping it costs an afternoon regularly rather than rarely.

Two other boundaries are worth stating so nobody expects more than the method gives. This is not root-cause analysis in the postmortem sense. The chain ends at step 4 with a confidence of 0.38; it does not say whether the fix is a confidence threshold in the tool, a prompt that teaches the model to distrust low-confidence fields, a validation gate on estimate_repair, or a better scanner in the assessor's office. Choosing among those is engineering judgement, and the trace's contribution is having narrowed it to one decision instead of nine.

Nor is a trace a debugger. There is no stepping, no breakpoint, no inspection of intermediate state inside a model call, and no way to ask what the model would have done with 2050 instead of 205. Every one of those questions is about a run that did not happen, and the trace is a record of the one that did.

The stage that fails is the stage with the tightest constraint, which is a fact about the schemas along the path rather than about where the fault entered. Reading a run backwards is therefore the ordinary method and not a heroic one, and it decomposes into lookups mechanical enough for a background job to finish before anybody is paged. What decides whether those lookups exist is a handful of relations recorded at request time — above all the list of tool results that were in a model call's context, which no later effort recovers, since recovering it means running today's message-assembly code against yesterday's run and reporting the answer as history.

What the walk hands back is a suspect with its workings attached, and holding it to that is what keeps it useful: it says a value was there, and where the arithmetic closes exactly, that it was used. Everything past that is a question about a run that did not happen. Answering them means running the trajectory again with something changed, which sounds like it should be the easiest thing in this topic and turns out to be the one that most often gets built, shipped, and quietly found to be measuring nothing.

Agent observability — read the current version at https://daviddominguez.dev/learn/en/agent-observability/the-first-visible-error-is-rarely-where-the-run-went-wrong

Back to top