Development Choices

Reconstructing What an Agent Did After the Fact

Author
Gregory MostizkySoftware Engineer
Published
Section
AI Agents
Length
7 min read3 sources cited

To reconstruct an agent run, record one trace per task with a span per model or tool call, log every tool call's arguments alongside its result, store the prompt actually sent rather than the template, and instrument to the OpenTelemetry generative-AI semantic conventions. Replaying the model is a new run; the tool sequence is what replays exactly.

Before you start

Steps

  1. Open one trace per task, and one span per model call and per tool call.

    Logs are the wrong shape for this. An agent run is a tree — task, turn, model call, the tool calls that turn produced, the next turn — and a log line has no parent. OpenTelemetry traces are trees of spans with explicit parent–child links, so the causal order is recorded at write time rather than rebuilt from timestamps and correlation IDs at read time. When a run fans out to sub-agents, make each one a child span of the delegating call; a run that ran in parallel still reads as one tree.

    Put the numbers on the span, not in a log body: input tokens, output tokens, and latency as span attributes on each model call, wall time on each tool call. That turns ‘which step spent the budget’ into a query over spans rather than a grep, and it is the raw material that attributing agent cost and latency to the work that caused it depends on.

  2. Name the attributes to the GenAI semantic conventions.

    The OpenTelemetry semantic conventions for generative AI already define attribute names for the model call: the operation, the requested model, the provider, input and output token usage. Use those names, not llm_tokens_in or whatever your first prototype called it.

    The reason is not tidiness. Dashboards, alerts, and next year’s incident query all key on attribute names, and a rename is a migration across every stored trace. Instrumenting to a published convention means the data is queryable by anything that understands the convention, and stays queryable after the person who chose the names has left. Two cautions: check the stability marker on the page you are reading, because parts of the conventions were still evolving when this was written, and pin the schema version you emit so a later rename is a known transformation rather than a mystery. The conventions cover the model call; for tool spans, pick one scheme — span name equals tool name, arguments and result as attributes — and hold it across every tool.

  3. Record every tool call’s arguments and its result, on the same span.

    The arguments are the record of intent: what the model decided to do. The result is the record of effect: what the world said back. Either one alone leaves the question open. With arguments only, you know the model asked to delete orders/2024/* but not whether that succeeded, errored, or was refused. With results only, you can see forty rows went away and cannot say which call asked for it or with what filter. The failures you actually get paged for — an edit landed in the wrong file, an API call went to the wrong tenant, a retry ran twice — are all answered by pairing the two and unanswerable from either half.

    Practicalities: results can be large. Record the full payload where you can; where you cannot, record a hash, the byte size, and a truncated head, and set an attribute saying the body was truncated so an absent result is distinguishable from an empty one. Record a tool that threw as span status plus the error text — a failed call is still a step the model saw and reacted to. This ordered list of argument–result pairs is what you will replay in step 5.

  4. Store the prompt actually sent, not the template.

    The template is in git; the interpolated content is not. What got interpolated is the retrieved documents, the tool results from earlier turns, whatever the agent loaded from memory, and the truncation applied when the context was trimmed to fit. That is where the interesting failures live: a retrieved chunk that contradicted the system instructions, a tool result containing text the model read as an instruction, a memory entry that was two deploys stale, a summary step that dropped the one constraint that mattered. None of that is visible from the template, and all of it is gone the moment the run ends unless you wrote it down.

    Do this per model call, attached to the model span, or in a blob store keyed by span ID if the payloads are too large for your trace backend. Store the model’s full response as well, including the raw tool-call block, not just the arguments you parsed out of it — sometimes the parse is the bug. The cost is storage that grows with prompt size times number of turns, and it is a real cost; decide a retention period rather than skipping the step. If some content must be redacted, redact at write time and leave a marker where it was, so a reader can tell ‘redacted’ from ‘never captured’.

  5. When you reconstruct, replay the tool sequence, not the model.

    Sending the same prompt to the same model does not reliably produce the same output, and nothing about a low temperature setting promises otherwise across providers or model versions. A replay that re-invokes the model is therefore a new run. It is useful for testing whether a fix changes the outcome; it is not evidence of what happened last Tuesday, and presenting it as such is how post-mortems go wrong.

    What can be replayed exactly is the tool sequence you recorded in step 3: the ordered argument–result pairs. Walk them against a sandbox or a dry-run mode, diff each live result against the recorded one, and the first divergence tells you where the world has moved since the run. In practice the question being asked — why did it delete that, who did it email, which branch did it push to — is answered by the tool sequence anyway; the model’s reasoning is context around the event, not the event.

    Reading order when you open a trace: start at the task span, find the turn where the outcome diverged from what was expected, read the prompt as sent for that turn (step 4) to see what the model was looking at, then read the tool spans under it for what it did and what came back.

What done looks like

Given only a task identifier, you can pull one trace and answer, without asking anyone who was there: which model calls happened and in what order; what each was shown, as the prompt was actually sent; what each decided, as tool arguments; what came back, as tool results or an explicit marker that a result was truncated or redacted; and how many tokens and how much time each step cost, under attribute names that match the OpenTelemetry generative-AI conventions so the query that works today works next year. You can re-execute the tool sequence against a safe target and get the recorded results back, or a diff that says exactly where reality has changed. What you cannot do, and should not claim to have done, is reproduce the model’s decision. That was one run, and the trace is the only copy of it.

Sources

  1. its write-up on building effective agentsanthropic.com
  2. tracesopentelemetry.io
  3. semantic conventions for generative AIopentelemetry.io

See also