A successful tool span is narrower evidence than it looks. A recorded HTTP 200 means whoever answered the request reported success, and that responder may be a proxy or gateway rather than the tool. OpenTelemetry's HTTP conventions leave span status unset for a 2xx response unless another error occurred, such as a network failure while receiving the body. The MCP tools specification reports tool execution failures, including API failures and business logic errors, inside a normal result with isError: true. So there are three layers: HTTP status, span status and the tool's own result. A green span means the instrumentation recorded no span error under its status rules. Check the HTTP response, the tool result and, when delivery matters, the tool's own receipt or execution record separately.
Even a clean tool result says nothing about whether the agent chose the right target, whether the data was current, whether the agent read it correctly, or whether the world now looks the way the user wanted. Some of those can be answered from captured inputs and outputs; others need the source system. One, "did the run that should have happened ever start", has no trace to look at at all. The rule this guide argues for: a check on the outcome is independent only if its inputs come from the request and the system of record, not from the run.
Outcome checks need inputs the agent never touched
Evaluation taxonomies already name most of these failures. Google's agent evaluation docs list "Semantically Incorrect Tool Parameters" (a call that is "syntactically valid but contains a logical or semantic error in the parameter values"), "Insufficient Tool Output" (the tool "executes successfully but returns insufficient or missing data"), "Incorrect Tool Output Processing" and "Omission of Required Tool Call". Microsoft's triage guide separates a backend returning incorrect data from an agent misusing correct output, and for the first case says: "Fix the tool, not the agent."
Those categories work once you know the right answer. In production you often don't. The trace still helps: Google's docs send you to the conversation history, model inputs, tool calls and responses to locate where a run went wrong. But a trace records what a tool returned, not whether it was true. If a cache served stale data, the trace may faithfully record it. Without captured freshness metadata or an independent comparison, the returned value alone does not establish whether it was current.
In practice, an independent check validates the intended target against the user's request and the system of record. It may use ids the run produced, such as a refund id, once it has confirmed separately that they belong to the intended target. A read-back that starts from the target the agent chose, or a judge that treats recorded tool output as true, inherits any earlier error.
A hypothetical refund, step by step
This example is invented.
A customer writes: "I was charged twice for my March order, please refund the duplicate." The support agent has four tools: search_orders(customer_id, month), get_charges(order_id), create_refund(charge_id, amount_minor) and get_refund(refund_id).
- Intended target: the second charge on order ORD-1182,
ch_B, £49.99. - Expected state afterwards:
ch_Brefunded in full, the first chargech_Auntouched, no refunds by the agent on any other charge.
Every tool span in the run is green. A day later the customer writes back, still charged. Here are four places it could have gone wrong, none of them visible in a status code.
1. Argument selection. search_orders returns two March orders, ORD-1179 and ORD-1182. The agent picks ORD-1179 and refunds its only charge. The span records create_refund(charge_id="ch_X", ...) and a success status. To call this wrong you need the intended id, and that comes from the ticket and the payment records, not from the run. If the ticket never named the order, the input was ambiguous, and the fault may be a missing clarifying question rather than the selection.
2. Tool response freshness and provenance. get_charges("ORD-1182") is served from a cache. It shows two charges, neither refunded. In the payment system, a colleague had refunded ch_B twenty minutes earlier. The agent refunds it again, or the provider refuses and the tool returns the refusal as text inside a successful result, which the agent ignores. If the body was captured, the span shows it. Freshness is visible only if some freshness evidence was captured somewhere in the trace: a fetched-at time or version in the body, a cache header recorded as a span attribute, or a separate span that compared the value with the source. Without any of those, the trace cannot tell you whether the data was current.
In a Hacker News thread, one practitioner describes this class as "a tool call that returns fine but the result shouldn't be trusted", citing stale cached data, and adds that from the trace alone a wrong tool result and a misread correct one "look pretty similar". That is one person's report, not a measure of how often it happens.
3. Agent interpretation. get_charges returns {"id": "ch_B", "amount": 4999, "currency": "GBP"}, in minor units. The agent calls create_refund(charge_id="ch_B", amount_minor=49.99), and a tool that truncates to an integer refunds 49 pence. Right target, valid call, wrong amount. The evidence is the tool output and the next call's arguments side by side, which requires both payloads to have been captured.
4. The final state, and the false negative. The agent ends with get_refund(refund_id), sees status: succeeded, and tells the customer it is done. This looks like verification. It is not independent. The read-back starts from the refund the agent created and confirms it exists on the charge the agent chose. If the agent chose ORD-1179 in step 1, the read-back passes and the outcome is still wrong. A check validates the intended target only when it starts from the intended target: find the duplicate charge from the customer id and order reference in the ticket, then ask the payment system for its refund state. The agent's refund id is still useful there, once the check has confirmed it points at that charge.
The sketch below is optional; the next paragraph carries the point. The payments client, its initiated_by filter and the find_identical_pair helper are hypothetical:
def check_duplicate_refund(ticket, payments, agent_key_id):
if not ticket.order_ref:
return ["unknown: ticket names no order"]
charges = payments.list_charges(customer_id=ticket.customer_id,
order_ref=ticket.order_ref)
pair = find_identical_pair(charges) # None unless exactly one pair
if pair is None:
return ["unknown: no single identical pair of charges"]
original, duplicate = sorted(pair, key=lambda c: c.created_at)
problems = []
if duplicate.refunded_amount != duplicate.amount:
problems.append(f"{duplicate.id}: not fully refunded")
if original.refunded_amount != 0:
problems.append(f"{original.id}: original has refunds")
for r in payments.list_refunds(customer_id=ticket.customer_id,
initiated_by=agent_key_id,
created_after=ticket.created_at):
if r.charge_id != duplicate.id:
problems.append(f"{r.charge_id}: agent refund elsewhere")
return problems
Every input comes from the ticket and the payment provider; nothing comes from the run. Test it against cases with a known answer before you trust it. If a colleague refunded the original charge last week, it reports ch_A as refunded: true about the account, though not necessarily the agent's doing. If the agent and your staff share one provider key, it cannot tell their refunds apart, and the collateral result should read "unknown". It also leaves invariants unchecked, such as a shared currency and what the customer was told, and its acceptance criterion ("the later of two identical charges is the duplicate") can be wrong. When it cannot find what it expects, it says unknown rather than pass or fail.
The evidence checklist
| Signal | Evidence needed | Plausible alternative explanation | Next safe check |
|---|---|---|---|
| Write to wrong target: success span, user says nothing changed | Intended target id derived from the request; the call's arguments; the candidate list the agent chose from | The request was ambiguous, or the expected target is wrong | Read the intended target's state from the system of record by id; make no further writes until it is confirmed |
| Stale result: tool output contradicts the source | Freshness evidence captured anywhere in the trace (fetched-at time, version, captured cache headers, a validation span); the source's change history | The source changed after the call, legitimately | Compare the source's change timestamps with the span timestamp |
| Misinterpretation: next call's arguments do not follow from the tool output | Both payloads, captured | The tool schema is ambiguous (units, field names), so the fix belongs in the tool definition | Replay the recorded output into the agent in a test with a deterministic assertion |
| Omitted required call: expected tool absent from the run | A written spec of required calls; the full span list; proof the tool is instrumented on every path | The call went through an uninstrumented path, or a valid branch skipped it | Check the target system's own logs for that window |
| Missing scheduled run: no trace in the expected window | Scheduler history, host logs, an external heartbeat | The run happened but its telemetry was never exported; the schedule changed | Check the job's side effects in the system of record, then the scheduler's own record |
An omitted call exists only against a written expectation. "This task type must call get_charges before create_refund" is a spec; without one, a missing span means nothing. Google's taxonomy names the extreme case "Hallucination of Action": the agent "claims to have completed an action without executing the necessary tool call". Before you treat a missing span as a missing call, confirm the tool is instrumented on every path the agent can take. A call through an uninstrumented client leaves no span either.
A missing scheduled run leaves nothing to inspect. Suppose a 02:00 UTC job re-checks yesterday's refunds against the payment provider. If the scheduler never fired, there is no run, no span and no error: your trace store shows a quiet night. Spans show the calls that were observed. They cannot show that a scheduler fired when no run exists. In the same Hacker News thread, a commenter describes a morning agent run that "just didn't happen" because the host slept through the slot, and asks about "the run that should have happened and didn't". A reply sketches the general technique: an independent monitor that expects a signal on a schedule, alerts when it does not arrive within a grace period, and runs elsewhere so it cannot sleep along with the task.
In practice that is a heartbeat, or dead man's switch. The job sends a check-in when it completes, with a count of what it processed. The monitor knows the schedule and the grace period and alerts on silence. Run it outside the system it watches. A second check-in at start helps separate "never began" from "began and died", when delivery is reliable. A check-in can itself be lost or delayed, so treat a missing signal as unknown until the scheduler's records, delivery logs or side effects in the system of record confirm what happened.
Compare against current source truth, then check the checker
When an outcome check or an eval says "wrong", two things besides the agent can be at fault: the expected value and the grader. Microsoft's guide makes this step one: manually review the actual response alongside the expected value and the grading method, and ask "Is the expected answer current and accurate against the source?" Its list of setup failures includes an outdated expected answer, an overly rigid grader, ambiguous acceptance criteria and "Grader factual error", where a model judge "invents a failure reason that isn't real". It suggests validating a grader on 5 to 10 cases with a known verdict and recalibrating if it disagrees on more than 20% of them (Microsoft triage guide). That threshold is Microsoft's guidance, not a universal number.
For the refund: when the check says ch_B is unrefunded, read the payment provider directly before acting. If a colleague refunded it an hour after the run, current truth differs from what the agent saw for a legitimate reason, and the source's own history (who changed it, and when) settles which. When a model judge flags a run, read the run yourself before you file it. When it passes a run, ask whether it compared the recorded tool output with the source or simply took it as true.
When evidence is missing or redacted
Capture settings decide what you can know. With metadata-only capture you may have tool names, status codes, timings and payload hashes, but not the arguments or the result body. You can say "create_refund was called once at 14:02 and returned a success status". You cannot say which charge it refunded, or whether the body reported an error.
Rules that hold up:
- Write "unknown: arguments not captured" in the incident record. Do not reconstruct the arguments from the agent's final message. That message is the agent's claim about what it did, which is the thing under investigation.
- Get the missing fact from the system of record, by id. The provider's refund list for that customer in that window answers "which charge" without the trace.
- Treat hashes as narrow evidence. Two matching argument hashes show matching captured representations, under whatever hashing scheme and capture stage your setup uses. They do not prove a retry: two legitimate refunds can carry identical arguments, and the same id can mean different things under different accounts. Before you call it a retry, correlate the tool, the account or tenant, the run and step ids, and explicit retry metadata such as OpenTelemetry's
http.request.resend_count. - Do not paste payloads into tickets, chat or postmortems to make a point. Refer to span ids, charge ids and hashes. Pattern-based redaction catches formats such as keys and card numbers; it does not catch names, addresses or free text in a customer's message.
Three kinds of check, three different questions
- Tests and evals. On known inputs with known expected outcomes, does the agent do the right thing? The Google and Microsoft taxonomies live here. They run before release, or on sampled production inputs.
- Independent outcome checks. Is the world now in the state the request asked for? Inputs come from the request and the system of record. They run after the action.
- Trace-based incident detectors. Did a run show a known operational pattern: a loop, a failing tool, a timeout, a cost jump? Inputs are spans.
None substitutes for another. A successful but semantically wrong refund can pass every status- and error-based detector. If an operational detector does fire on that run, say for a repeated call or a cost jump, the alert still does not validate the refund. An outcome check will not tell you an agent is looping. An eval suite will not notice the scheduler stopped. Put an independent outcome check on every action where a wrong outcome costs more than running the check.
Where Worksi fits, and where it does not
Worksi sits in the third group. Instrumented model and tool calls are stitched into runs, and eleven detectors watch for operational patterns, from agent loops and repeated tool calls to tool failures and stuck workflows. An incident comes with an evidence pack (failure location and numbered evidence) and a diagnosis that must cite that evidence. A person runs each recovery action. From the Startup plan, Worksi re-checks the detector's condition on new traffic after a recovery. The incidents page lists every detector and what it fires on.
The limits matter for this topic. None of the listed detectors checks a refund against your payment provider, and Worksi does not judge whether arguments were semantically right. The stuck-workflow detector fires when a run started and did not finish; a run that never started produces no spans, so it needs the separate heartbeat monitor described above. A verified recovery means the detector's condition cleared on new traffic, not that the outcome was correct. What evidence you can read depends on the capture mode, and redaction is pattern-based: it misses names and free text.
Originally published at blog.worksi.ai. Written by Worksi's AI marketing agents and checked by a separate AI fact-checking agent.
Top comments (0)