AI Agent Drift Detection: Catch It Before Your Rules Decay
The drift you actually care about
Thereâs a paper-flavored definition of âdriftâ in ML observability thatâs been around since long before LLMs: input data distribution shifts over time, model performance degrades, you retrain. Thatâs data drift and the standard answer is embedding distance. Sample embeddings of yesterdayâs queries vs todayâs, watch the distance grow.
Thatâs not what kills autonomous coding agents.
What kills autonomous coding agents is behavioral drift: the agent is shifting, not the data. The most common cause in 2026 is context-window compaction. Anthropicâs Claude Code compacts the context every N tokens; LangGraph workflows compact in their own way; every long-running autonomous agent has some equivalent. After compaction, the rules in your system prompt are less in scope than they were at minute one. The agent is, in a measurable sense, a different agent.
The single most-cited measurement of this effect is in Claude Code GH #40801:
âCompliance starts at 100% in the first 30 minutes â drops to 50â60% after first compaction â hits 20% after second compaction. Your rules were effectively gone.â
Thatâs a real number from a real Claude Code user, measured across a real long-running session. Itâs not a synthetic benchmark. And it captures the problem perfectly: the rule isnât broken at minute one, so a test suite passes; the rule is broken at minute 90, when no one is testing.
You need a way to see this drift while itâs happening without running an eval. Thatâs what this post is about.
What âembedding driftâ misses
Walk into any LLM observability vendorâs drift docs and youâll find some version of this:
âCompute embeddings of your prompts/responses over a sliding window. Compute the centroid of the baseline window. Track the cosine distance from new samples to that centroid. Alert when distance exceeds threshold T.â
This works for retrieval-augmented systems where the input distribution is the thing that shifts. It does not work for autonomous coding agents because:
- The agentâs inputs donât shift. Its outputs do. A Claude Code session reads roughly the same kinds of files and emits roughly the same kinds of tool calls. The shift is how often and in what order.
- Cosine distance on embeddings is hard to interpret. An alert that says âdrift = 0.31, threshold 0.30â is not actionable. An alert that says âthis session called
bash14x more than baselineâ is. - Embedding models are expensive at this volume. A long Claude Code session emits hundreds of spans. Running an embedding model on every prompt/response on a laptop is the wrong tradeoff.
The right primitive for behavioral drift is to look at the action distribution directly. How many tokens did this session use? How long did it take? How many tool calls? Which tools? In what proportion?
Thatâs the same data your providerâs invoice is computed from. Itâs already in your traces. You donât need an embedding model. You need three columns from a DuckDB table.
The TokenJam algorithm in one screen
The core implementation in tokenjam/core/drift.py is two ideas:
Idea 1: Per-metric Z-scores against a session-level baseline.
After the first N completed sessions for a given agent (default N = 10), we compute the mean (ÎŒ) and standard deviation (Ï) of four metrics:
input_tokens. Total prompt tokens summed across all LLM calls in the sessionoutput_tokens. Total completion tokens summed across all LLM calls in the sessionsession_duration. Wall-clock seconds from first span to last spantool_call_count. Total number ofgen_ai.tool.callspans
For each subsequent session, we compute Z = (x - ÎŒ) / Ï for each metric. If |Z| > 3 on any metric, we flag a drift candidate.
# tokenjam/core/drift.py (simplified)
def z_scores(session_stats, baseline):
z = {}
for metric in ("input_tokens", "output_tokens",
"session_duration", "tool_call_count"):
mu, sigma = baseline[metric]["mean"], baseline[metric]["stddev"]
if sigma == 0:
z[metric] = 0.0
continue
z[metric] = (session_stats[metric] - mu) / sigma
return z
def evaluate_drift(session_stats, baseline, threshold=3.0):
z = z_scores(session_stats, baseline)
tripped = {k: v for k, v in z.items() if abs(v) > threshold}
return tripped
Idea 2: Jaccard similarity on the set of tool calls.
The Z-score on tool-call count catches âthis session called more (or fewer) tools than baseline.â It misses âthis session called different tools than baseline.â A session that used to call read_file, edit_file, bash and now calls read_file, web_fetch, bash, curl, ssh has the same call count but a different action shape.
We capture this with Jaccard similarity on the multiset of tool names used:
J(A, B) = |A â© B| / |A âȘ B|
A session whose tool set is identical to baseline scores 1.0. A session that uses entirely different tools scores 0.0. We fire drift_detected when J drops below 0.5 against the baseline tool set, regardless of the Z-scores.
The full alert pipeline:
on session_end:
if agent has N completed sessions:
if no baseline:
build_baseline()
else:
stats = compute_session_stats()
z = z_scores(stats, baseline)
jaccard = tool_set_similarity(stats.tools, baseline.tools)
if any(|z[m]| > 3) or jaccard < 0.5:
fire_alert(DRIFT_DETECTED, payload={z, jaccard, sample_diff})
This is high-school statistics on three columns from a DuckDB table. Thatâs the point.
Why these specific metrics
We tried a handful of things before settling on these four. The decision criteria were:
- Available on every span TokenJam ingests. Input/output tokens come from the OTel GenAI semconv (
gen_ai.usage.input_tokens/gen_ai.usage.output_tokens). Duration is span start/end timestamps. Tool count is justcount(span_name == "gen_ai.tool.call"). No extra capture needed. - Independent of prompt content. If the user starts asking the agent harder questions, prompt content shifts. Thatâs not âthe agent is drifting,â itâs âthe workload shifted.â Token counts are a poor proxy for prompt difficulty but a reasonable proxy for agent verbosity / loopiness.
- Interpretable in the alert message. An alert that says âoutput_tokens Z=4.2 (this session: 142k, baseline: 38k ± 24k)â is one you can act on. An alert that says âdrift score 0.71â is not.
We considered a per-tool frequency vector (multinomial Z-score) and rejected it for now. Too many degrees of freedom for the baseline size, too noisy at N=10. Worth revisiting at N=50+.
We considered using a learned anomaly detector (isolation forest, one-class SVM). Rejected because the maintenance / interpretability cost was too high for the practical lift. Z-scores are unfashionable; they also work and they explain themselves.
What this looks like in the alert
Concretely, when drift_detected fires from tj, the Discord/ntfy message looks like this:
đš drift_detected. Agent: claude-code-overnight session: 2026-05-17-0143
Z-score trip:
output_tokens Z = +4.21 (session: 142,300 baseline: 38,200 ± 24,700)
session_duration Z = +3.78 (session: 89 min baseline: 22 min ± 17 min)
tool_call_count Z = +5.10 (session: 247 baseline: 41 ± 40)
Jaccard similarity (tool set): 0.62 (above threshold, not flagged)
Likely failure mode: long-tail tool loop (output tokens + duration + tool count all elevated)
Inspect: tj trace 2026-05-17-0143
The âlikely failure modeâ hint is rule-based. We have a small table mapping Z-score signatures to plain-English hypotheses:
| Signature | Hypothesis |
|---|---|
| â output_tokens, â duration, â tool_call_count | Long-tail tool loop |
| â output_tokens, â duration, ~ tool_call_count | Verbose generation, not loopy |
| â tool_call_count, â duration, ~ output_tokens | Repeated small tool calls. Possible retry |
| â Jaccard | New tool surface. Possible scope creep or fresh task |
| â input_tokens only | Context bloat. Possibly compaction wasnât aggressive enough |
These are hypotheses, not diagnoses. The alert tells you which trace to open.
Calibration: what we got wrong the first time
The first version of this fired way too often. Three lessons from the calibration pass.
1. N = 10 is too small. With ten sessions you get a baseline Ï thatâs noisy enough to make Z = 3 trip on perfectly normal sessions. We landed on N = 10 as the minimum to build a baseline but added a confidence field. A baseline built from 10 sessions doesnât fire drift_detected at severity critical; it fires at warning until N â„ 30.
2. Sessions that include --continue are different animals. Continued sessions inherit context from a prior session and almost always have inflated input_tokens. We segregate continued sessions in the baseline. They get their own ÎŒ, Ï.
3. Tool sets with rare-but-legitimate calls trip Jaccard too easily. A user who once-a-week runs tj against a different project will look like J < 0.5 on the rare day. We added a âtool seen at least 2x in baseline windowâ filter. Jaccard is computed only over tools that appeared in â„ 2 baseline sessions, not the long tail.
These are the kinds of corrections that only show up after running the detector against real session history for a couple weeks. Worth being honest that this is calibration territory, not pure-theory territory.
How this relates to the CLAUDE.md compaction problem specifically
The GH #40801 quote is striking because itâs a measurement of a rule degradation curve. The author was tracking compliance against specific rules in their CLAUDE.md. They observed 100% â 50% â 20% across the first two compactions.
TokenJam doesnât measure rule compliance directly. We measure the behavioral signal that correlates with it. When rules are honored, the agent calls a particular set of tools in a particular pattern. When the rules quietly fall out of scope after a compaction, the tool distribution shifts. Sometimes subtly, sometimes dramatically.
Weâve found empirically that in long Claude Code sessions, the Z-score on tool_call_count and the Jaccard similarity on tool set both move in the direction youâd expect, on the timeline youâd expect, when compaction degrades rule compliance. Thatâs not a controlled study; we donât have the sample size to publish one. The signal is strong enough that drift_detected fires on the kind of session thatâs about to do something you didnât want.
What the alert canât tell you is which rule stopped being honored. For that you have to open the trace. The alert tells you when to open it, which is the entire job.
Compared to the âdriftâ youâll find in LangSmith, Langfuse, and Arize
This is where the local-first, opinionated-taxonomy angle matters.
- LangSmith doesnât ship behavioral drift detection out of the box. It has an âonline evalsâ workflow where you wire up a scorer that re-runs evals against new traces. Thatâs powerful, and itâs the eval workflow rather than a drift detector. Different shape, different cost (LLM-as-judge per evaluation).
- Langfuseâs drift story is also evals-as-judge. Write a scoring function, run it asynchronously, alert when scores drop. Same architectural choice.
- Arize Phoenix ships embedding-based drift detection. The data-drift framing from classical ML, cosine distance on embedded inputs. Good for retrieval systems, not what weâre describing here.
- AgentOps logs errors; doesnât ship a behavioral drift detector.
TokenJam is the only one of these where drift is built in, runs locally, uses pure-statistical metrics with no LLM in the loop, and fires synchronously when a session ends. Thatâs not a fluke. Itâs the entire architectural premise. The taxonomy of agent failure modes is opinionated and named. drift_detected is one of 13.
Try it on your own session history
If youâve been running Claude Code for a few weeks, you almost certainly already have â„ 10 sessions worth of baseline data. Claude Code emits OTel telemetry when CLAUDE_CODE_ENABLE_TELEMETRY=1 is set, and TokenJam ingests it directly.
$ pipx install tokenjam
$ tj onboard --claude-code
$ # ... Run Claude Code as normal for a few sessions ...
$ tj drift report
agent: claude-code baseline: 12 sessions (built 2026-05-12)
input_tokens: ÎŒ = 12,400 Ï = 6,800
output_tokens: ÎŒ = 4,100 Ï = 2,300
session_duration: ÎŒ = 18 min Ï = 11 min
tool_call_count: ÎŒ = 24 Ï = 13
tool set (top 5): read_file, edit_file, bash, grep, ls
recent sessions outside ±2Ï on any metric:
2026-05-14-2247 output_tokens Z=+3.1 tool_call_count Z=+2.8
2026-05-15-1432 session_duration Z=+4.0 (the one you sent us)
That last row is real, by the way. One of us let Claude Code chew on a refactor for 94 minutes when our baseline says 18 ± 11. The drift detector caught it. The Discord ping fired. We cancelled the run, saved $7-ish in projected spend, didnât lose anything important.
This is the small, daily, undramatic version of the value. The horror-story prevention ($1,700 overnight, $437 weekend) is the big, viral version. Theyâre the same mechanism: fire while itâs happening and name the failure mode, so you donât have to read the JSON the next morning.
Limitations and where this fails
Worth being precise about where the detector is bad:
- First N=10 sessions are baseline-building. No alerts during this period. If your first session is the bad one, this doesnât catch it. (Use
retry_loopandcost_budget_sessionalerts instead. Those are within-session and fire immediately.) - The baseline gets stale. If your workflow legitimately shifts (new project, different agent harness), the baseline trips on every normal session until you reset. We support
tj drift baseline --reset --agent <name>for this; we donât yet auto-rebuild incrementally. - Z-scores assume roughly normal-ish distributions. Real session metrics are often right-skewed (a few very long sessions). We use Ï; a more outlier-resistant alternative would be MAD (median absolute deviation). On the roadmap.
- Cross-agent baselines arenât shared. Each agent name maintains its own. If you have 5 agents that are functionally identical, you build 5 baselines. (This is a feature, not a bug, when agents have genuinely different shapes. And a friction when they donât.)
If any of those bite you, file an issue. The whole detector is in tokenjam/core/drift.py and ~300 lines of straightforward Python.
The wider point
Behavioral drift is the second-most-common autonomous-agent failure mode after retry loops, and itâs the one that most resembles âthe agent is silently broken and you donât know yet.â Itâs why you can run the same CLAUDE.md rules on a fresh agent and get great results, then watch the same agent quietly stop honoring them an hour later.
Detecting it doesnât require an embedding model, a vendor cloud, or a billing relationship. It requires three columns in a DuckDB table and two formulas. Plus a willingness to fire alerts on statistics you can explain to a colleague in one sentence.
Thatâs the entire premise of TokenJam: name the autonomous-agent failure modes that span-counting dashboards canât see, and fire alerts while theyâre happening. All of it runs locally on your laptop. Drift detection is one of thirteen.
pipx install tokenjam
tj onboard --claude-code
tj drift report # after ~10 sessions
- GitHub: Metabuilder-Labs/tokenjam
- PyPI:
pipx install tokenjam - The launch post: I let Claude Code run overnight in âdangerously-skip-permissions
- The pricing teardown: LangSmith TCO is 10.7x sticker
Common questions
- Why Z-scores instead of a learned anomaly detector?
- Interpretability. An isolation forest can flag the same anomaly with a single number. It can't tell you which feature drove the flag. Z-scores tell you 'output_tokens is 4Ï above baseline'. That's a sentence you can put in a Discord alert that someone can act on at 2am. The maintenance burden of a learned detector also didn't justify itself at the scale we operate (per-agent baselines of dozens to low hundreds of sessions).
- Why N=10 baseline minimum? Isn't that statistically thin?
- Yes, intentionally. The point is to get a usable baseline fast. A user shouldn't have to wait a month before drift detection turns on. At N=10 we fire alerts at 'warning' severity. At N=30 we fire at 'critical'. The math is honestly noisy at N=10; the answer is to communicate the confidence, not refuse to fire.
- Does this work for any agent or just Claude Code?
- Any OTel-compliant agent. The four metrics we use (input tokens, output tokens, duration, tool count) come from the OTel GenAI semantic conventions and are emitted by Claude Code, Codex CLI, LangChain, LangGraph, CrewAI, AutoGen, and anything else that follows the spec. The Jaccard similarity on tool sets works on any agent that emits gen_ai.tool.call spans.
- How is this different from embedding drift in Arize Phoenix?
- Embedding drift measures *semantic distance between input distributions over time*. Useful for retrieval systems where the question is 'are users asking different kinds of questions?' Behavioral drift measures *action-distribution distance for the same agent over time*. Useful for autonomous coding where the question is 'is my agent doing different things than it used to?' Both can be useful. They answer different questions. We picked the latter because that's the one our wedge audience (indie Claude Code operators) has.
- Can drift detection prevent the $1,700 overnight bill?
- Partially. Drift detection fires at session end. It'll catch the runaway session *after* it's done. The cost prevention story is mostly retry_loop (fires within seconds of the loop starting) and cost_budget_session/cost_budget_daily (fires when burn rate exceeds a threshold). Drift is for catching the *slow* failure. The one where the agent's behavior has been shifting for two days and nobody noticed until the bill arrived. Different alert, different timescale, same architectural premise.
- What does the GH #40801 quote actually measure?
- The author of that issue was tracking specific rule violations against rules they'd written in CLAUDE.md, across one long Claude Code session that included two compaction events. Compliance was hand-scored on each rule. The numbers. 100% â 50% â 20%. Are a single-user measurement, not a controlled study. But they capture an effect that many Claude Code users describe. TokenJam doesn't measure rule compliance directly; we measure the behavioral signals that correlate with it, which is the practical observable.
Further reading
- Claude Code Issue #40801. Context compaction degrades rule compliance. The source quote.
- I let Claude Code run overnight in âdangerously-skip-permissions. The launch post.
- LangSmith TCO teardown. Pricing piece.
- OpenTelemetry for AI agents. The OTel substrate this all sits on.
- What is agent observability?. Category background.