PyPI / CI passing / MIT / python ≥ 3.10

Point it at your AI agent's OTLP traces. Get a report on what's actually wasted.

Not a guess. redundo classifies repeated calls as waste, legitimate, or unclear, and checks separately for context drift.

~/agents/support-bot
$ pip install redundo
$ redundo collect --out-dir ./otlp_traces &
# enable your framework's OTLP export, run it, then:
$ redundo adapt ./otlp_traces --summary | redundo analyze --format html > report.html
Coverage: 16/25 events priced (64%). $0.1060 of tracked spend is what this analysis actually covers.
Candidate redundant-repeat pairs: 8
3 confirmed_waste · 3 likely_legitimate · 2 unclassified · 0 near_duplicate / 0 cross_task_redundancy / 0 recurring_pattern
Real output of redundo analyze examples/demo_trace.jsonl. Every count traces back to a case in that file.
Read the source ↗

Runs on your machine. No account, no SaaS, nothing to stand up but a local OTLP receiver.

01 / THE PROBLEM

Agents repeat themselves quietly, and the invoice is the only place it shows up.

A loop re-reads the same file eleven times. A planner re-asks a question it already answered because the answer never made it back into context. Nothing errors, so nothing alerts.

But a repeat is not automatically waste. Polling until a value changes is correct. Re-reading a file after writing to it is correct. Retrying a failed call is correct. A tool that counts duplicates and calls the total "waste" is guessing.

The whole design question is which repeats had a reason to exist, and saying so plainly when the trace doesn't contain enough to tell.
Two of eight pairs in the demo trace get no verdict at all.
Waste isn't the only failure mode worth watching. Context drift is the quieter one: the agent's picture of the task slips out of sync with what's actually true, and it keeps acting on it anyway.
redundo drift flags this separately from wasted spend, see docs/context-drift.md.
02 / HOW IT WORKS

Three small programs joined by a plain schema, installed as one package.

Each stage only needs the one before it to speak the schema in between, so any stage can be swapped for your own: a source plugin, an analysis plugin, or a report format, with no fork and no PR against the repo.

STAGE 01
redundo collect

A local OTLP receiver. Writes whatever telemetry it's sent to disk, nothing more. Optional. Skip it if your exporter already writes files.

→ ./otlp_traces
STAGE 02
redundo adapt

Turns one framework's raw telemetry into the common event schema. Tells the sources apart from the data itself; --source forces one.

→ NDJSON on stdout
STAGE 03
redundo analyze

Pairs up repeated calls from that schema and classifies each pair into one of six buckets. Doesn't know or care where the input came from.

reads stdin or a file
OUTPUT
report.html

One self-contained file that opens with its own coverage line. Or --format json for anything downstream.

light + dark, prints clean
Each half also runs alone: redundo adapt ./otlp_traces -o trace.jsonl · redundo analyze trace.jsonl --format json
03 / THE SIX BUCKETS

Three of these are verdicts. Three are only ever surfaced for review.

Keeping those two things apart is the point. Full reasoning for each bucket lives in docs/schema.md and in classify.py's module docstring.

VERDICTS
confirmed_waste

The call repeated, the result didn't change, nothing wrote to state in between, and the task still failed.

ALL FOUR MUST HOLD

Drop any one and this is a guess, not a finding.

ACTIONCache the result or guard the retry. This spend bought nothing.

likely_legitimate

A specific reason it isn't waste: the result changed (polling worked), a write intervened (verification), or the task succeeded.

ANY ONE SUFFICES

And neither the result nor the write status already confirms waste on its own.

ACTIONLeave these alone. Cache them and you'll break polling and verification.

unclassified

A required signal (the result, the write status, or the task outcome) was missing from the trace.

NO VERDICT, ON PURPOSE

Counted and named rather than guessed at.

ACTIONEmit result hashes and task outcome, then re-run to get a verdict.

SURFACED FOR REVIEW: NOT SCORED, NOT A VERDICT
near_duplicate
Arguments similar but not identical to an earlier call on the same path: a SimHash fingerprint comparison, not exact content_hash equality. Read these by hand. docs/hashing.md covers what a similarity fingerprint can and can't support.
cross_task_redundancy
The same or a near-identical call in a different task, where the two tasks have a source-reported delegation link, never one inferred from timing or content. What changed between them isn't checked yet, so this is not a waste verdict.
recurring_pattern
The same call recurring across tasks with no confirmed relationship, most likely a common operation, not redundant work, and not evidence the tasks are related. Worth memoizing if it recurs a lot; not evidence of wasted spend on its own.
04 / SUPPORTED SOURCES

Each source has a genuinely different OTLP shape, and a different blind spot.

Four frameworks work with redundo adapt today: Hermes and anything OpenInference-instrumented (including the OpenAI Agents SDK and Google ADK), Claude Code across the CLI, IDE extensions, and Agent SDK, Claude Cowork, and OpenClaw. Adapter quality is bounded by what each framework actually emits.

Hermes ↗ OpenAI Agents SDK ↗ Google ADK ↗ Claude Code ↗ Claude Cowork ↗ OpenClaw ↗
NOT LISTED?

Pipe your own NDJSON matching the schema straight into analyze, or write an adapter plugin. No PR against the repo required.

05 / TRUST & PRIVACY

Every number is checkable, and nothing leaves the machine.

Local by construction

The only service involved is redundo collect, an OTLP receiver you run yourself that writes to a directory you name. No account, no upload, no hosted anything. The report is a single file that renders with the network off.

Content is compared as hashes

Repeats are matched on content_hash equality, and near-duplicates on a SimHash fingerprint, not on stored prompt text. What each hash can and cannot support is written down rather than asserted.

Content capture is opt-in, per source

Where a framework can emit raw content, the switch is yours and defaults to off: captureContent and captureIdentifiers on the OpenClaw plugin, OTEL_LOG_* on Claude Code. More signal or less; stated either way.

Coverage before conclusions

Every report opens with how much of the trace it could actually price (64% in the demo above) and excludes unpriced events from every dollar figure rather than estimating them into the total.

Priced from a snapshot you can refresh

When a source doesn't report cost_usd, cost comes from a bundled per-model pricing snapshot: local, inspectable, and updated on your command with redundo update-pricing.

Each bucket links back to a real case

Reports list sample cases by task and step, like task=session-001 step=2, so any count can be checked by hand against the trace that produced it.

06 / QUICKSTART

First report in about two minutes.

01
Install, and start the local receiver
$ pip install redundo
$ redundo collect --out-dir ./otlp_traces &
02
Turn on your framework's OTLP export
export CLAUDE_CODE_ENABLE_TELEMETRY=1 export CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1 export OTEL_TRACES_EXPORTER=otlp export OTEL_LOGS_EXPORTER=otlp export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 export OTEL_TRACES_EXPORT_INTERVAL=1000 export OTEL_LOG_TOOL_DETAILS=1 # tool arguments + MCP tool_input
Also covers the Claude Agent SDK: it launches the claude binary as a subprocess, so set these in whatever process calls query(), not inside the SDK's own options. Full detail: docs/claude-code.md.
03
Drive real turns through it, then adapt and analyze
$ redundo adapt ./otlp_traces --summary \
| redundo analyze --format html > report.html
04
Open report.html and read the coverage line first. Under 80% and the totals are indicative, not auditable. Want it without instrumenting anything? redundo analyze examples/demo_trace.jsonl reproduces the coverage output shown at the top of this page. There's also redundo drift, a separate context-drift heuristic: docs/context-drift.md.