feat(transforms): attribute read_lifecycle + tool_crush tags#249
feat(transforms): attribute read_lifecycle + tool_crush tags#249gglucass wants to merge 1 commit intochopratejas:mainfrom
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Enrich `transforms_applied` tags so downstream UIs can show WHAT a compression acted on, not just that it happened: - `read_lifecycle:<state>` → `read_lifecycle:<state>:<file_path>` - `tool_crush:<n>` → `tool_crush:<n>:<tool1,tool2,...>` when the assistant's tool_use metadata resolves the crushed tool names. Falls back to `tool_crush:<n>` when no names can be resolved. Paths containing `:` survive because consumers are expected to bound their split to 3 parts. Only written to `transforms_applied`; the opaque `x-headroom-transforms` response header is unaffected (no consumers parse it). Adds 8 tests covering OpenAI + Anthropic formats, paths with colons, dedup of repeated tools, the no-name fallback, and name-index skips when id / name are missing.
7a53ede to
8b39dc2
Compare
|
@chopratejas in user conversations it often comes up that they (a) don't understand where the tokens savings are coming from and (b) that they are unsure the token savings are even real. This is a first attempt to enable bringing more transparency to this, which will be vital in sales conversations where you need to convince skeptical committees That said, I'm not 100% sure this is the right approach. It might be better to add a What do you think? Shall I also raise a PR to add |
Description
Enrich the
transforms_appliedtags emitted byReadLifecycleManagerandToolCrusherso each tag carries the specific target it acted on — the source file for a stale/superseded Read replacement, and the tool names for crushed tool outputs. Today these tags are opaque counters (read_lifecycle:stale,tool_crush:42), which tells you that a transform ran but not what it operated on. Any consumer of/transformations/feed— dashboards, logs, metrics, or downstream UIs — has to treat these as black boxes. Threading the per-item target through the existing tag vocabulary is the smallest-surface way to expose that information without adding new fields or endpoints.Type of Change
Changes Made
headroom/transforms/read_lifecycle.py:read_lifecycle:<state>→read_lifecycle:<state>:<file_path>. A new_format_read_lifecycle_transformhelper composes the tag from the existingReadClassification.file_path, so no new data collection is needed. Paths containing:are preserved — consumers are expected to bound their split to 3 parts (s.split(":", 2)).headroom/transforms/tool_crusher.py:tool_crush:<n>→tool_crush:<n>:<tool1,tool2,...>when the assistant'stool_useblocks (Anthropic) ortool_callsentries (OpenAI) resolve the crushed tool names. A new_build_tool_name_indexhelper builds the id→name map in one pass over assistant messages; a_format_tool_crush_transformhelper emits the tail only when at least one name resolves. Tags fall back to the legacytool_crush:<n>shape when no names are available (e.g. orphaned tool results).Testing
pytest)ruff check .andruff format --check .)mypy headroom)8 new tests cover: enriched read_lifecycle tag shape (OpenAI + Anthropic formats), file paths containing
:, enriched tool_crush tag shape (OpenAI + Anthropic formats), duplicate-tool dedup, the no-name fallback, and the name-index skipping entries that are missingid/name. All 25 existing tests intest_read_lifecycle.pyandtest_tool_crusher.pycontinue to pass unchanged — the substring assertions inTestTransformTracking("stale" in t) are compatible with the enriched shape by design.Test Output
Checklist
Additional Notes
Compatibility considerations
transforms_appliedconsumers in this repo:proxy/cost.py::_summarize_transforms— uses a plainCounter. Enriched tags are distinct keys, so two stale reads on different files now count as two entries instead of one key with count=2. This is arguably more faithful to what happened; callers that want a coarser roll-up can still bucket by prefix. The docstring example was already illustrative, not asserted.proxy/cost.pyuncompressed-reason categorisation — uses substring matches for"excluded"/"protected". Unaffected.x-headroom-transformsresponse header (gemini/openai/anthropic handlers) — emitted via",".join(transforms_applied). The newtool_crush:<n>:<A,B,C>shape contains commas, making the joined header ambiguous if re-split. Grep confirms the header is only written and never parsed in this repo or by any known downstream, so this is documented as a latent concern rather than a break./transformations/feed— the JSON field remainstransforms_applied: string[]. Consumers that treat entries as opaque strings continue to work.Why tag enrichment rather than a new structured field
Adding
savings_breakdown: [{category, target, tokens}]on the request log was the alternative I considered. It's cleaner but a bigger surface: a new field on the hot log schema, new JSON shape on/transformations/feed, and mandatory churn for every consumer. Enriching the existing tags reuses infrastructure that's already downstream-compatible. If a structured breakdown ever lands, these tags remain a useful compact summary alongside it.🤖 Generated with Claude Code