◉CLAUDE LABJP
●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/API & SDK
⬡ API & SDK/2026-07-09Advanced

Same Output, Different Path — Guarding Agent Trajectories with Invariants

When the default model changes, your final output can stay correct while the path your agent takes quietly shifts. Here is a trajectory regression harness built on recorded tool traces and deterministic invariants, with working code and measured numbers.

claude-api83tool-use24agent14regression-testingobservability21

✦ Premium Article

My nightly batch looked fine. The articles it generated were correct. The logs it posted were correct. Nothing was broken.

The billing noticed before I did. Tool calls per day had climbed to roughly 1.6x their previous level. The answers were right. The path to those answers had changed.

If you only watch output quality, this kind of change stays invisible forever. The output evaluation I described in detecting quality regressions with an eval harness measures the final string. It does not measure the trajectory.

Running unattended agents as an indie developer, this quiet shift in the path turned out to be the hard one. Nothing breaks, so nothing alerts you. And by the time you notice, either cost or wall-clock time has already grown.

What actually needs to be recorded

Record the process, not the final answer. In practice, I found five fields sufficient.

Recorded fieldWhat it protects against
Sequence of tool namesReordering, or an unexpected tool appearing
Normalized arguments per callRepeated calls with identical arguments
Turn count and total tool callsLoop inflation
Sequence of stop_reason valuesTruncation or pauses slipping in
Count of write-tool invocationsDuplicated side effects

The important part is that you do not store raw arguments. Timestamps and UUIDs make every trace unique, which makes comparison useless. Strip the volatile values before recording, and the trajectory becomes a stable object you can diff.

A thin wrapper that records the trace

Rather than reaching into the agent loop, wrap only the tool dispatch boundary. Your production loop stays untouched, which keeps the tested code and the shipped code from drifting apart.

# trace.py
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass, field, asdict
from typing import Any, Callable
 
# Values that change on every run must be dropped before comparison.
VOLATILE_KEYS = {"request_id", "timestamp", "trace_id", "nonce", "cursor"}
UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
ISO_RE = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}")
 
 
def normalize(value: Any) -> Any:
    if isinstance(value, dict):
        return {k: normalize(v) for k, v in sorted(value.items()) if k not in VOLATILE_KEYS}
    if isinstance(value, list):
        return [normalize(v) for v in value]
    if isinstance(value, str):
        value = UUID_RE.sub("<uuid>", value)
        return ISO_RE.sub("<ts>", value)
    return value
 
 
@dataclass
class ToolCall:
    name: str
    args_digest: str
    ok: bool
 
 
@dataclass
class Trace:
    case_id: str
    model: str
    calls: list[ToolCall] = field(default_factory=list)
    stop_reasons: list[str] = field(default_factory=list)
    turns: int = 0
 
    def record(self, name: str, args: dict, ok: bool) -> None:
        payload = json.dumps(normalize(args), ensure_ascii=False, sort_keys=True)
        digest = hashlib.sha256(payload.encode()).hexdigest()[:12]
        self.calls.append(ToolCall(name=name, args_digest=digest, ok=ok))
 
    @property
    def tool_names(self) -> list[str]:
        return [c.name for c in self.calls]
 
    def to_json(self) -> str:
        return json.dumps(asdict(self), ensure_ascii=False, indent=2, sort_keys=True)
 
 
def instrument(tool_fn: Callable[[str, dict], Any], trace: Trace) -> Callable[[str, dict], Any]:
    """Wrap the existing dispatcher. The loop itself needs no changes."""
    def wrapped(name: str, args: dict) -> Any:
        try:
            result = tool_fn(name, args)
        except Exception:
            trace.record(name, args, ok=False)
            raise
        trace.record(name, args, ok=True)
        return result
    return wrapped

Storing a digest rather than the arguments themselves means you can commit traces to your repository without leaking anything sensitive. When you want to inspect a difference, a hash mismatch tells you enough.

The loop only swaps the dispatcher and appends stop_reason.

# run_case.py
import anthropic
 
client = anthropic.Anthropic()
MODEL = "claude-sonnet-5"
 
 
def run_case(case_id: str, prompt: str, tools: list[dict], dispatch) -> Trace:
    trace = Trace(case_id=case_id, model=MODEL)
    traced_dispatch = instrument(dispatch, trace)
    messages = [{"role": "user", "content": prompt}]
 
    while trace.turns < 24:  # the cap is itself one of the invariants
        trace.turns += 1
        resp = client.messages.create(
            model=MODEL, max_tokens=4096, tools=tools, messages=messages,
        )
        trace.stop_reasons.append(resp.stop_reason)
        if resp.stop_reason != "tool_use":
            break
 
        messages.append({"role": "assistant", "content": resp.content})
        results = []
        for block in resp.content:
            if block.type != "tool_use":
                continue
            output = traced_dispatch(block.name, block.input)
            results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": json.dumps(output, ensure_ascii=False),
            })
        messages.append({"role": "user", "content": results})
 
    return trace

Returning every tool_result within the same turn is the requirement I covered in using Parallel Tool Use in production. Here it is enough to confirm that instrumentation does not disturb that structure.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦Complete implementation of a thin wrapper that records an agent's execution trace: tool sequence, normalized arguments, and side effects
✦Seven deterministic invariants that catch regressions without an LLM judge, plus how to set thresholds from measured variance
✦A measured record of median tool calls rising from 5 to 8 after a default model switch, and the procedure that surfaced it
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ API & SDK2026-09-03
Don't Hand Your Agent the Tool That Commits — Sorting Tools Into Advise, Prepare, and Commit
Before you wrap irreversible actions in an approval gate, decide which ones never belong in the tool list at all. A three-tier inventory, a rewritten tool definition, and a small script that flags commits hiding in your schema.
⬡ API & SDK2026-05-26
Stabilizing Claude API Structured Responses in Production — Notes on tool_use, JSON Schema, and Layered Validation
Getting Claude to return JSON takes a few lines. Keeping that JSON usable in production is a different problem. Here is the layered design I landed on after running a wallpaper classification pipeline through Claude API, built around tool_use, JSON Schema, and domain validation.
⬡ API & SDK2026-05-22
Why tool_result could not be submitted Keeps Coming Back, and How to Build a Recovery Handler That Actually Holds
Run a Claude agent long enough and one day it starts: 'tool_result could not be submitted', back to back, and retries change nothing. The error message hides four completely different root causes. Here is what I learned debugging it on the always-on agent jobs I run as an indie developer, with the TypeScript recovery handler I now ship in production.