DEV Community

Sam Sun
Sam Sun

Posted on

Close the Trace on a Matching Receipt

An agent trace is closed when a local export receipt names the same tool set as the plan. A zero exit code does not close it. Process exit only proves the runtime stopped, which is a weaker fact than it looks.

The loading dock is the useful analogy. A truck door can shut while two crates remain on the pallet. The exit code is that door. The receipt is the scan of every crate ID.

When those lists disagree, a remote trace UI is a tour of an incomplete shipment. The missing crate gets blamed on a warehouse that never received it. That misread wastes the next edit.

Agent runs widen the gap. A model writes a plan, a runner invokes tools, and an exporter flushes spans on a timer the process does not share. Those clocks drift.

A tool can return, the process can exit, and the last batch can still sit in a buffer. A query at that moment reports a missing span. The defect is the gate, not the tool.

Membership before meaning

Three sets matter, and only two of them decide this gate. The plan is the inventory of tool names the run was supposed to invoke, stored as JSON before execution. The emitted set is what the local tracer recorded, failures included. The exported set is what a file or a collector acknowledged after flush.

Inspection waits until the plan and the exported set are equal for one run id. Emitted but not exported is an exporter problem. Planned but not emitted is a runner problem.

Exported but not planned is an unexpected tool, or a stale span that leaked across runs. Those are different edits. A combined log makes them look like one mysterious failure.

Name equality does not mean the run was correct. A span can carry the right name and a wrong argument, a partial write, or an error status. Equality answers a prior question only: is the trace complete enough to inspect?

Argument hashes and status checks are a later pass. Folding them into the receipt hides whether you lack data or already hold bad data. Separate files keep those failures distinct.

Parent and child linkage is a different question. This gate does not rebuild causality, and it does not score a retry. It checks membership. If the name is absent, there is nothing to link and nothing worth retrying.

Keep the first judge on disk

The comparison belongs next to the process, before any network hop. A remote collector, including a free one, is a second shelf. It is a poor first judge.

Network delay and flush delay produce the same query result: the span is not there. A dashboard cannot tell those delays apart. It will send you to edit the runner when the exporter simply has not finished.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. After the receipt is closed and the bodies are redacted, MonkeyCode's free model access can lint a plan file against a tool catalog you already trust, and its free server option can hold the exported trace for a later read. That is optional convenience, not the decision.

This article states no token allotment, model name, retention window, or hardware shape, because those facts go stale. Read the current docs before you depend on either option. Delete the product references and the local gate still stands.

Do not let the model invent the expected set. A model can skip a tool and then confirm a plan that matches its own skip. Pin the catalog in the repo. Let a model fill arguments if you want, but keep membership under version control when coverage is the question you are asking.

A receipt you can rerun

The script below is a proposal. It is not a recorded benchmark, and it does not open a socket. It writes receipt.json and exits 2 when the sets differ.

#!/usr/bin/env python3
"""Local receipt gate. Unexecuted example, not a measured result."""
import json
import sys
from pathlib import Path

def load_names(path, key, run_id):
    rows = json.loads(Path(path).read_text(encoding="utf-8"))
    found = []
    for row in rows:
        if row.get("run_id") != run_id:
            continue
        found.append(row[key])
    return set(found)

def main():
    run_id = sys.argv[3]
    planned = load_names(sys.argv[1], "tool", run_id)
    exported = load_names(sys.argv[2], "name", run_id)
    missing = sorted(planned - exported)
    extra = sorted(exported - planned)
    receipt = {
        "run_id": run_id,
        "planned_count": len(planned),
        "exported_count": len(exported),
        "missing": missing,
        "extra": extra,
        "closed": (not missing) and (not extra),
    }
    Path("receipt.json").write_text(
        json.dumps(receipt, indent=2) + "\n", encoding="utf-8"
    )
    print(json.dumps(receipt))
    return 0 if receipt["closed"] else 2

if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Two fixtures make the check reproducible without a live agent. Save the plan as plan.json.

[
  {"run_id": "run-17", "tool": "read_file"},
  {"run_id": "run-17", "tool": "apply_patch"}
]
Enter fullscreen mode Exit fullscreen mode

Save the flushed export as spans.json.

[
  {"run_id": "run-17", "name": "read_file", "status": "ok"},
  {"run_id": "run-17", "name": "apply_patch", "status": "ok"}
]
Enter fullscreen mode Exit fullscreen mode
python3 receipt_gate.py plan.json spans.json run-17
echo "gate_exit=$?"
python3 -c 'import json; print(json.load(open("receipt.json"))["closed"])'
Enter fullscreen mode Exit fullscreen mode

Exit 0 means the sets match for run-17. Exit 2 means stop. Do not open a remote query, and do not ask a model to explain a span that never left the machine.

The run id is the isolation boundary. Without it, a previous run's extra name fails a good plan, and you debug a ghost. Filter before you subtract.

If the exporter writes OTLP JSON, reduce it before the gate. This jq path is a schema assumption, not a standard. Adjust it to your exporter, or the gate will report every tool as missing, which is a signal about the adapter rather than the agent.

jq '[.resourceSpans[].scopeSpans[].spans[] | {run_id: (.attributes["run.id"] // .attributes["run_id"]), name: .name, status: (.status.code // "unset")}]' otel.json > spans.json
Enter fullscreen mode Exit fullscreen mode

A short numeric walk-through, still hypothetical, shows why the counts are enough. Suppose the plan lists four tools and the export lists three. missing has length 1. That name is the next action.

You still need one local split before you edit anything. Count the name in the emitted log, then count it in the export file. The two integers name the owner.

grep -c '"tool": "apply_patch"' emitted.jsonl
grep -c '"name": "apply_patch"' spans.json
Enter fullscreen mode Exit fullscreen mode

If the emitted count is already zero, the runner skipped the call. If the emitted count is one and the export count is zero, the flush did not finish. Same missing name, opposite owner.

The receipt does not make that split by itself. The two counts do, and they stay on disk where you can rerun them. Keep both commands next to the script so the split is repeatable.

Keep the fixtures in the same commit as the script. A gate without pinned inputs becomes a story about yesterday's exporter. When the schema moves, the fixtures fail in CI before anyone spends an afternoon on a blank trace. That failure is the point: planned_count and exported_count diverged, and the run id is quotable.

Difference Local signal Next edit
Missing name Absent in the emitted log Runner skip or catalog drift
Missing name Present in the emitted log, absent after export Flush or exporter
Extra name Exported count above the plan Run id leak or unexpected tool
Empty differences Counts equal and closed is true Arguments and status, not membership

Read the table as a routing rule, not as a score. One row should match. If two rows match, the run id filter failed and the sets are mixed. Fix isolation before you interpret either difference.

What not to conclude

Treat a green receipt as permission to inspect, not as a health certificate. After closed is true, compare argument hashes and refuse a healthy label when any status is error. Keep that pass in a second file so a red status cannot be mistaken for a missing span.

Redact before any upload. Tool arguments carry paths, tokens, and customer text. A free server does not repair a redaction miss. If you cannot strip those fields with confidence, leave the receipt local and skip the remote shelf.

Skip this approach when you need an archive of record, an access-controlled incident system, or a model-quality score. Free access can change, and a complete tool set can still do the wrong work. Coverage of calls is not correctness of outcome.

Teams that already export exactly once into a collector they trust can assert the same set equality in that API and drop the file. The predicate travels. The script is only the form that fits when the first store you control is a directory.

Run the gate once per run id, after the flush you already wait on. A non-zero exit blocks inspection. It does not indict the product. Store planned_count, exported_count, missing, and extra, then open a remote view only if that file says closed.

Top comments (0)