CLAUDE LABJP
COWORK — Claude Cowork expands to web and mobile with remote sessions, synced files, and a shared Chat and Cowork home across devicesM365 — Claude Cowork adds Microsoft 365 write tools to draft and send email, manage calendars, and update OneDrive and SharePoint filesMCP — Admins can provision MCP connectors org-wide through their identity provider, starting with Okta, with access granted on first loginCODE — Claude Code fixes SessionStart hook streaming in headless sessions so remote workers are not idle-reaped mid-hookGOV — Claude Code and Claude Cowork are in public beta in Claude for Government Desktop on a FedRAMP High authorized environmentLIMITS — Claude Code weekly usage limits are increased by 50% through July 13COWORK — Claude Cowork expands to web and mobile with remote sessions, synced files, and a shared Chat and Cowork home across devicesM365 — Claude Cowork adds Microsoft 365 write tools to draft and send email, manage calendars, and update OneDrive and SharePoint filesMCP — Admins can provision MCP connectors org-wide through their identity provider, starting with Okta, with access granted on first loginCODE — Claude Code fixes SessionStart hook streaming in headless sessions so remote workers are not idle-reaped mid-hookGOV — Claude Code and Claude Cowork are in public beta in Claude for Government Desktop on a FedRAMP High authorized environmentLIMITS — Claude Code weekly usage limits are increased by 50% through July 13
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-api79tool-use22agent13regression-testing2observability18

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-06-29
When Context Editing Made My Agent Re-run the Same Search — Field Notes on Clear Boundaries and Cache Invalidation
After turning on Context Editing to auto-clear tool results, the agent forgot what it had just read, re-ran the same tool, and the cache rebuilt every turn so costs went up. Field notes on instrumenting the silent regression and setting trigger, keep, and clear_at_least from measured data.
API & SDK2026-05-05
Building a 'Think-and-Search' AI Agent — Claude API Extended Thinking × Tool Use
A deep dive into combining Claude API Extended Thinking and Tool Use. Covers frequent errors, a complete research agent implementation in Python, plus cost estimation, timeout design, and error recovery for production use.
API & SDK2026-06-21
Don't Carry Search Results Twice: Trimming Consumed Blocks with response_inclusion
When an agent runs dynamic filtering, output tokens balloon because the raw search-result blocks a code execution call already consumed get echoed back into the response. Here is when response_inclusion: excluded is safe to use, when you must keep full, with implementation and a decision table.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →