Skip to main content

git diff for your agent's context window — a context-window debugger for LLM agents

Project description

ctxdiff

License: Apache 2.0 Python 3.10+ Status: v1 · capture → diff → tokens → cache → viewer PRs welcome

git diff for your agent's context window. See exactly what your LLM saw — turn by turn, block by block.

ctxdiff is a local-first debugger for the context window of LLM agents. Wrap your OpenAI, Anthropic, Gemini, or Bedrock client in one line, run your agent, and every call's context is recorded — as content-hashed, deduplicated blocks — into a single-file SQLite trace you can inspect, diff, and share. Nothing leaves your machine.

Prompt wording is ~10% of the battle. The other 90% is context engineering — what the model sees, in what order, at what cost. When an agent misbehaves at turn 8, ctxdiff answers the three questions a raw JSON log can't: what exactly did the model see, what changed since turn 7, and what did it cost?

from ctxdiff import trace
from openai import OpenAI

tracer = trace.init("customer-support-agent")
client = tracer.wrap(OpenAI())          # ← the only line you add

client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "What's your refund window?"}],
)

tracer.close()                          # writes ./customer-support-agent-<id>.ctrace

Status

ctxdiff was built in milestones; v1 is complete — capture, the diff/tokens/cache analyzers, and the HTML viewer all ship today.

Milestone What it adds State
M1 — Capture + Store One-line SDK wrapping, content-hashed .ctrace traces, fail-open capture, redaction, token counting available now
M2 — CLI diff ctxdiff diff --turn 7 --turn 8 — git-style added/evicted/modified context available now
M3 — Token heatmap ctxdiff tokens — token allocation per turn, "schema bloat" detection available now
M4 — Cache profiler ctxdiff cache — prompt-cache prefix-break detection + wasted-spend estimate available now
M5 — Web viewer ctxdiff view / ctxdiff export — time-travel scrubber + self-contained HTML export available now

See The CLI below for every subcommand, with real sample output.


Install

ctxdiff targets Python ≥ 3.10.

# from source (PyPI package coming soon)
git clone https://github.com/salmanzafar949/ctxdiff
cd ctxdiff
pip install -e .

The only runtime dependency is tiktoken (for exact OpenAI token counts). The provider SDKs (openai, anthropic, …) are not dependencies — ctxdiff wraps whatever client you already use.

To run the real-SDK evaluation suite, install the optional extra:

pip install -e ".[eval]"   # openai, anthropic, google-genai, boto3, langchain, respx — for tests only

Quickstart

Wrap a client, use it exactly as you normally would, then read the trace back.

from ctxdiff import trace
from ctxdiff.store.ctrace import CTrace
from openai import OpenAI

# 1. Start a trace and wrap your client
tracer = trace.init("support-agent")
client = tracer.wrap(OpenAI())

# 2. Use the client normally — every call is recorded
client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": "You are a support agent. Be precise."},
        {"role": "user", "content": "What's your refund window?"},
    ],
)
tracer.close()

# 3. Read the trace back
ct = CTrace.open(tracer.path)
for call in ct.get_calls():
    print(f"turn {call.seq}  usage={call.usage}")
    for cb in ct.get_call_blocks(call.id):
        b = cb.block
        print(f"  [{cb.label:<11}] {b.role:<9} {b.token_count:>4} tok  {b.text[:60]!r}")
ct.close()
turn 1  usage={'prompt_tokens': 24, 'completion_tokens': 8, 'total_tokens': 32}
  [system     ] system       9 tok  'You are a support agent. Be precise.'
  [user       ] user         5 tok  "What's your refund window?"

A .ctrace is just a SQLite file — you can also inspect it with any SQLite tool:

sqlite3 support-agent-*.ctrace "SELECT seq, usage FROM call ORDER BY seq;"

The CLI

Every subcommand reads a .ctrace; --run PATH picks which one, and defaults to the most recently modified *.ctrace in the current directory when omitted — the common case (one run in the working dir) needs no flag at all. Color is automatic (git-style ANSI) and turns off whenever stdout isn't a real terminal, or when NO_COLOR is set — the output below has NO_COLOR=1 so it pastes cleanly.

ctxdiff diff --turn N --turn M

Git-style block diff between two turns: added (+, green), evicted (, red), and modified (~, yellow, with an inline char-level diff) blocks, with unchanged blocks folded into one summary line.

$ ctxdiff diff --turn 1 --turn 2
── turn 1 → turn 2 · 3 blocks changed · +56 −26 tokens ──
~ [system·system] You are a support agent. Be precise. Current time: 2026-07-24T10:00:0[-0-]{+4+}Z
+ [history·assistant] 'Checking that for you.'  +5 tok
+ [rag·user] 'Context: Refund policy: 30 days from delivery, unworn items only. Also…'  +25 tok
= 3 unchanged blocks · 138 tok

ctxdiff tokens [--turn N]

Token allocation per turn as a proportional bar chart, one label slice per row, biggest spender first; reconciles against provider-reported usage when available (Δ line); appends a schema-bloat warning when a registered tool schema is never invoked anywhere in the run.

$ ctxdiff tokens
turn 1 · 164 tokens
  ████████████████████████       tool_schema       133 tok   81.1%
  █████                          system             26 tok   15.9%
  █                              user                5 tok    3.0%
  provider reports 55 prompt tokens · Δ -109

turn 3 · 255 tokens
  ████████████████               tool_schema       133 tok   52.2%
  ██████                         history            50 tok   19.6%
  ████                           user               32 tok   12.5%
  ███                            system             26 tok   10.2%
  ██                             tool_output        14 tok    5.5%
  provider reports 85 prompt tokens · Δ -170

⚠ schema bloat: issue_refund — 1 of 2 registered tools never used this run — 77 tok (37.7% of avg context) spent on dead schemas every call

A call whose total mixes any estimate-method blocks in with exact ones is marked (~approx) next to its token total — never presented as exact when it isn't.

ctxdiff cache

Prefix-stability report across every consecutive turn pair: finds where the provider's byte-for-byte cache prefix breaks, attributes it to the responsible block, and estimates the wasted re-billed spend — price-free, since per-token discounts vary by provider and change over time.

$ ctxdiff cache
⚠ warning: [system·modified] breaks the prefix on every turn (2/2 pairs)
  'You are a support agent. Be precise. Current time: 2026-07-24T10:00:04Z'
  modified system block — first difference at char 69: '0' → '4'

stable prefix (min): 133 tokens
re-billed: 183 tokens
183 tokens re-billed across 2 turns that a stable prefix would have served from cache (cached input is typically billed at a fraction of the full input price — check your provider's current rates)
hint: a dynamic value inside an early system block breaks the prefix every turn — move volatile content below the stable blocks

A run with a stable prefix throughout prints a single green ✓ prefix stable across all N turn pairs line instead.

ctxdiff runs

Lists every *.ctrace in the working directory with its project, provider, and turn count — a quick "what runs do I have here" before picking one with --run.

ctxdiff export [--out FILE.html] / ctxdiff view [--no-open]

Write (export) or write-and-open (view) the self-contained HTML dashboard — see HTML dashboard below.


HTML dashboard

ctxdiff view opens a local time-travel dashboard for a run in your browser; ctxdiff export --out run.html writes the same dashboard to a path you choose, without opening anything — the one you attach to a bug ticket. Both call the same exporter, so they're always in sync.

The output is one self-contained .html file: the page, styles, script, and the entire run's data are embedded in a single JSON island — no CDN, no font, no image, no external request of any kind (asserted in tests: the file contains no http:///https:// substring anywhere). It opens from a file:// URL, works offline, and is safe to email or attach to an issue tracker.

Seven panels, all reading from the same precomputed analyzer output the CLI uses (one source of truth — the dashboard never re-implements diff/token/cache logic in JavaScript):

  • Scrubber — a turn-by-turn strip across the top; click a bar or use ← → to jump between turns.
  • Turn diff — the selected turn's added/evicted/modified blocks vs. the previous turn.
  • Token allocation — the selected turn's label breakdown, same data as ctxdiff tokens.
  • Cache alignment — every prefix break found across the run, same data as ctxdiff cache.
  • Blocks — the full block list for the selected turn (role, kind, label, token count, an 8-char content-hash prefix).
  • Growth — context size across turns, so a run that balloons is visible at a glance.
  • Header stats — project, provider, run start time, distinct-vs-total block counts (the dedup story).

Block text is written into the page as a JSON island and rendered with textContent at view time — never innerHTML — so a captured block containing </script> or literal HTML markup can never execute or break out of the tag, even though it's shown verbatim. The one deliberate redaction on export: each call's stored params is reduced to {"model": ...} — sampling settings, API keys, or anything else that might have ridden along in params never makes it into the shareable file (block text redaction is still governed by your own redact() hook, applied earlier at capture time).


Supported providers

ctxdiff detects the provider from the client you pass to wrap() and applies the matching adapter. Detection keys off the client's module, so anything built on the OpenAI or Anthropic SDK works — including Azure and OpenAI-compatible OSS endpoints.

Provider Client Notes
OpenAI openai.OpenAI(...) Chat Completions
Azure OpenAI openai.AzureOpenAI(...) Same adapter, zero config
Anthropic / Claude anthropic.Anthropic(...) Messages API
Google Gemini google.genai.Client(...) Generate Content API (models.generate_content)
AWS Bedrock boto3.client("bedrock-runtime") Converse API (client.converse(...))
Open-source models openai.OpenAI(base_url="http://localhost:11434/v1", ...) Any OpenAI-compatible endpoint — Ollama, vLLM, LM Studio, Together, Groq, …
LangChain langchain_openai.ChatOpenAI(...) Via client injection — see LangChain

Passing an unrecognized client raises immediately, so misconfiguration fails loudly at setup rather than silently at record time:

tracer.wrap(some_unknown_client)
# ValueError: ctxdiff: unrecognized client module '...'; supported providers: ['anthropic', 'bedrock', 'gemini', 'openai']

How it works

[ your agent ]
     │  client = tracer.wrap(OpenAI())
     ▼
[ CAPTURE ]   a transparent proxy intercepts the completion call
     │        · calls the real method first (host is never delayed or altered)
     │        · fail-open: a ctxdiff error can never break your app
     ▼
[ STORE ]     one SQLite .ctrace file per run
     │        · every message / content part / tool schema is a content-hashed "block"
     │        · identical blocks are stored once, referenced per call (dedup)
     ▼
[ READ ]      CTrace.open(path) → runs, calls, blocks
     ▼
[ ANALYZE ]   diff_turns / analyze_run / analyze_cache — pure functions the
     │        CLI and HTML viewer both call, so every number agrees
     ▼
[ RENDER ]    the CLI (colored text) or `ctxdiff view`/`export` (HTML)

Capture is deliberately dumb; interpretation lives downstream. The proxy records what was actually sent on the wire and nothing more. Whether a block is "a RAG chunk" or "history" is decided by labels, not baked into capture — so the recorder has no opinions to get wrong, and re-analysis of an old trace never needs a re-run.

The block model

The smallest independently-diffable unit of context is a block: one message, one content part, or one tool schema. Each block's identity is sha256(role + kind + text), so a stable system prompt reused across 40 turns is stored once and referenced 40 times. Diffing two turns then reduces to comparing two ordered lists of hashes.


Usage guide

Wrapping a client

trace.init() starts a run; wrap() returns a transparent proxy. The proxy behaves exactly like the original client — same attributes, same return values — it just records the completion call as a side effect.

tracer = trace.init(
    "my-agent",                     # project name (labels the run)
    path="runs/session-42.ctrace",  # optional; defaults to ./my-agent-<id>.ctrace
)
client = tracer.wrap(OpenAI())
# ... use client ...
tracer.close()

tracer.path tells you where the trace was written. Call tracer.close() when the run is done to close the store cleanly.

Semantic tagging

Blocks are auto-labeled by a cheap heuristic (system, user, history, tool_schema, tool_output). For exact provenance — especially distinguishing retrieved RAG chunks from ordinary user text — tag the content before the call it belongs to:

chunks = retriever.search("refund policy")          # your RAG retrieval
tracer.tag("rag", [c.text for c in chunks])         # applies to the NEXT call

client.chat.completions.create(
    model="gpt-4o",
    messages=[
        system_prompt,
        {"role": "user", "content": f"Context:\n{joined_chunks}\n\nAnswer: ..."},
    ],
)

Any block whose text contains a tagged string is stored with label="rag" and label_source="tagged". Untagged apps lose nothing but label precision — capture, dedup, and token counting all work regardless. tag() accepts a list of strings or dicts (it reads a text/content field from dicts).

Reading a trace back

Traces are read through the CTrace API. A run has calls (one per LLM request, in seq order); each call has ordered blocks.

from ctxdiff.store.ctrace import CTrace

ct = CTrace.open("runs/session-42.ctrace")

run = ct.get_run()
# Run(id, project, started_at, provider, models, ctxdiff_version)

for call in ct.get_calls():
    # Call(id, run_id, seq, params, usage, latency_ms, error)
    print(call.seq, call.params.get("model"), call.usage, call.latency_ms)

    for cb in ct.get_call_blocks(call.id):
        # CallBlock(block, position, label, label_source)
        b = cb.block
        # Block(content_hash, role, kind, text, token_count, token_method)
        print(cb.position, cb.label, b.role, b.token_count, b.token_method)

ct.close()

Because a .ctrace is plain SQLite, ad-hoc queries work too:

# how many DISTINCT blocks vs total references? (shows dedup at work)
sqlite3 session-42.ctrace "SELECT COUNT(*) FROM block;"
sqlite3 session-42.ctrace "SELECT COUNT(*) FROM call_block;"

Redaction

Context payloads are the most sensitive data in an AI stack — system prompts, retrieved customer documents, tool arguments — and .ctrace files get attached to bug tickets. A redact hook runs on every block before it is written to disk:

import dataclasses
from ctxdiff.models import Block

def scrub(block: Block) -> Block:
    """Return a redacted copy of the block (Block is a frozen dataclass)."""
    return dataclasses.replace(block, text=my_pii_scrubber(block.text))

tracer = trace.init("my-agent", redact=scrub)

The block's content_hash is computed from the original text (so dedup stays stable), and only the stored text is replaced. A redactor that raises does not break the run — the block's text falls back to "[redaction-error]" and capture continues.

Scope: redaction applies to block text. Request params, provider usage, and error names are stored as-is — keep that in mind if you pass sensitive values as sampling params.

Fail-open guarantee

A debugging tool must never break the program it debugs. Every capture path is wrapped so that any error inside ctxdiff — an adapter bug, a full disk, a tokenizer failure — is caught, logged once, and swallowed; your original call proceeds and returns its real result. The only exception that propagates is your own LLM call's error, re-raised unchanged:

try:
    client.chat.completions.create(...)   # raises RateLimitError from the provider
except RateLimitError:
    ...   # you still get YOUR error — and the failed call is recorded with error set

Token counting

Every block records a token_count and an honest token_method:

  • OpenAI-family → exact counts via tiktoken (token_method="tiktoken").
  • Anthropic, Gemini, Bedrock → a documented estimate, since none publishes a local tokenizer (token_method="estimate").

Estimates are always labeled as such — never presented as exact. If tiktoken is unavailable for any reason, counting degrades to an estimate rather than dropping the capture, and never reaches the network at record time.


Provider recipes

OpenAI

from openai import OpenAI
client = tracer.wrap(OpenAI())
client.chat.completions.create(model="gpt-4o", messages=[...])

Azure OpenAI

No special configuration — Azure clients live in the openai package, so the OpenAI adapter applies automatically.

from openai import AzureOpenAI
client = tracer.wrap(AzureOpenAI(
    azure_endpoint="https://<resource>.openai.azure.com",
    api_version="2024-02-01",
))
client.chat.completions.create(model="<deployment-name>", messages=[...])

Anthropic / Claude

The Anthropic adapter handles the top-level system field and input_tokens/output_tokens usage shape.

from anthropic import Anthropic
client = tracer.wrap(Anthropic())
client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    system="You are a support agent.",
    messages=[{"role": "user", "content": "What's your refund window?"}],
)

Google Gemini

The Gemini adapter handles google-genai's shape: config.system_instruction/config.tools (a single bag that also carries sampling params like temperature) and contents (a string, or a list of role/parts entries).

from google import genai
client = tracer.wrap(genai.Client(api_key="..."))
client.models.generate_content(
    model="gemini-2.0-flash",
    contents="What's your refund window?",
    config={"system_instruction": "You are a support agent."},
)

AWS Bedrock

The Bedrock adapter handles boto3's bedrock-runtime Converse API: system (a list of {"text": ...} blocks, never a bare string), messages, toolConfig.tools, and inferenceConfig's sampling fields — detection keys off the client's class name (BedrockRuntime), since every boto3 service client shares the same botocore.client module.

import boto3
client = tracer.wrap(boto3.client("bedrock-runtime", region_name="us-east-1"))
client.converse(
    modelId="anthropic.claude-3-haiku-20240307-v1:0",
    system=[{"text": "You are a support agent."}],
    messages=[{"role": "user", "content": [{"text": "What's your refund window?"}]}],
    inferenceConfig={"maxTokens": 256},
)

Open-source models

Any model served behind an OpenAI-compatible endpoint (Ollama, vLLM, LM Studio, Together, Groq, …) is just an openai.OpenAI client with a custom base_url — it captures identically:

from openai import OpenAI
client = tracer.wrap(OpenAI(base_url="http://localhost:11434/v1", api_key="ollama"))
client.chat.completions.create(model="llama3", messages=[...])

LangChain

LangChain holds its own SDK client internally, so wrap the underlying OpenAI client and inject the proxy into ChatOpenAI:

from openai import OpenAI
from langchain_openai import ChatOpenAI

oa = OpenAI()
wrapped = tracer.wrap(oa)

llm = ChatOpenAI(
    client=wrapped.chat.completions,   # inject the wrapped resource
    root_client=wrapped,
    model="gpt-4o",
)

llm.invoke("What's your refund window?")   # captured, with usage

Wrapping the ChatOpenAI object directly raises (it isn't an SDK client) — inject as above. Streaming caveat: with streaming=True, the call is captured but token usage is not (the interceptor sees the stream before it's consumed). Streaming usage capture is tracked for a later milestone.


The .ctrace format

One run = one SQLite file. The schema is small and stable, versioned by schema_version so an old or foreign file is rejected with a clear error rather than misread.

Table Row Key columns
run one per file project, provider, started_at, ctxdiff_version, schema_version
call one per LLM request seq, params (JSON), usage (JSON), latency_ms, error
block one per distinct context unit content_hash (PK), role, kind, text, token_count, token_method
call_block membership of a block in a call call_id, block_id, position, label, label_source

Block labels: system · user · history · rag · tool_schema · tool_output Label source: heuristic (auto) · tagged (via tracer.tag) Token method: tiktoken (exact) · estimate

Because blocks are content-addressed and stored once, a long run with a stable prefix stays compact, and future diffing is a hash comparison.


Design principles

  • Local-first. No network calls, no telemetry, no external services. Your context never leaves your machine.
  • Fail-open, always. Capture can never break your application.
  • Wire-level truth. The proxy records what was actually sent, verbatim; interpretation is a separate, re-runnable layer.
  • Honest numbers. Estimated token counts are always labeled as estimates.
  • One file per run. The .ctrace is the shareable artifact — no bundle, no server.

Roadmap

v1 is complete: capture across OpenAI, Anthropic, Gemini, and Bedrock (plus Azure and OpenAI-compatible OSS endpoints via the OpenAI adapter), the diff/tokens/cache analyzers, and the CLI + HTML viewer all ship today. Next:

  • VS Code extension — embeds the same self-contained viewer used by ctxdiff view/export, inline in the editor.
  • Live tail — file-watch a .ctrace while the agent is still running, instead of post-run-only analysis.
  • Background recording — capture a long-lived process's runs without an explicit tracer.close() per session.
  • Streaming usage capture — today, a streamed completion (streaming=True) is captured but its token usage is not, since the interceptor sees the stream before it's consumed.
  • A native LangChain callback integration — replacing today's client-injection recipe with a first-class callback handler.
  • Populating run.models — currently left empty; models are tracked per-call in params, not yet rolled up onto the run.

Development

pip install -e ".[dev]"     # ctxdiff + pytest
pytest                      # unit suite

pip install -e ".[eval]"    # + real provider SDKs and respx
pytest tests/eval           # real-SDK integration tests (HTTP stubbed, no network, no keys)

The eval suite drives the real openai, anthropic, google-genai, boto3, and langchain SDKs with their HTTP transport stubbed (respx for httpx-based SDKs, botocore.stub.Stubber for boto3), so it needs no API keys and makes no network calls. It skips cleanly if the eval extra isn't installed.


License

Apache-2.0.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ctxdiff-0.1.0.tar.gz (109.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ctxdiff-0.1.0-py3-none-any.whl (77.7 kB view details)

Uploaded Python 3

File details

Details for the file ctxdiff-0.1.0.tar.gz.

File metadata

  • Download URL: ctxdiff-0.1.0.tar.gz
  • Upload date:
  • Size: 109.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for ctxdiff-0.1.0.tar.gz
Algorithm Hash digest
SHA256 7c180daaeb763f542f25a849c9b466a648e154ffa6f4b327aee192219eeb907e
MD5 dc6baad95dd7b98d0d9eb5f40d4daa2d
BLAKE2b-256 d952211b9992df53b73d56412ae4c9da1d9a70fcb2bfd03111ce433dcf82e93d

See more details on using hashes here.

Provenance

The following attestation bundles were made for ctxdiff-0.1.0.tar.gz:

Publisher: publish.yml on salmanzafar949/ctxdiff

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file ctxdiff-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: ctxdiff-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 77.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for ctxdiff-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2d2fada9692c4998e5cde721c8f9558752910c1890d1f76f32e67d4fa531feaf
MD5 6ee6f50034e9410fe3019d86b935adaf
BLAKE2b-256 1956b4ad2c4097f60d99f83d5b666600f12f71492388b9220bb85cebb66ed7bd

See more details on using hashes here.

Provenance

The following attestation bundles were made for ctxdiff-0.1.0-py3-none-any.whl:

Publisher: publish.yml on salmanzafar949/ctxdiff

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page