feat(cmdfilter): port rtk's filter coverage (3 -> 24) and fix three DSL loss-typing bugs - #42
Merged
Merged
Conversation
added 7 commits
August 10, 2026 09:26
…SL loss-typing bugs cmdfilter shipped a 3-filter starter set against rtk's 63. The DSL engine was already a faithful port, so the gap was coverage — but rtk's selectors are not portable: it matches a shell COMMAND string, and a proxy only ever sees the OUTPUT. Every ported filter's selector is rewritten to an output-shape signature, and a guardrail test asserts each filter's own test input actually routes to that filter, so the rewrite is verified rather than hoped for. Engine fixes (each with a test): - truncate_lines_at never recorded loss: it ran before `loss` was initialized, so a real intra-line cut reported LossNone and no recovery hint was emitted. It now types as LossWhole (an intra-line cut is non-contiguous by nature). - truncate_lines_at cut silently. A mid-line cut with no marker reads as corrupted output to a model; it now appends "...", sized to fit inside the cap. - recoveryHint collapsed LossTail and LossWhole into one hint, making a cheap partial recovery look like an expensive whole-blob one. They are now distinct: a tail cut names the cut point. 23 filters ported with converted inline tests: pytest, npm-install, make, gradle, xcodebuild, gcc, swift-build, dotnet-build, turbo, nx, terraform-plan, terraform-init, pulumi, liquibase, ssh, ping, rsync, bundle-install, poetry-install, composer-install, uv-sync, brew-install, quarto-render. Filters whose output is indistinguishable are merged (terraform/tofu, the five pulumi subcommands). rtk's ~24 width-cap-only filters, 11 blank-line-only linter filters, spring-boot, filter_stderr, its command-detection rules and its native Rust filters are deliberately not ported; docs/components/cmdfilter.md records why. Every success-collapse rule carries an `unless` guard (rtk ships 9 of 11 unguarded) plus an explicit negative test: a build with a warning AND a success marker must not collapse. In a proxy the agent cannot re-run the command to discover what a bare collapse swallowed. Also: shared `cap` budget classes by signal density instead of 23 hand-picked max_lines; a 500-byte size floor (rtk's MIN_TEE_SIZE); load-time guardrails (duplicate names rejected, inline tests executed at load); a selector-miss ledger so the next filter to write is data; per-family cmdfilter metrics in /stats (additive fields only — the harness parsers keep working). Filter definitions and the DSL pipeline are adapted from rtk (Apache-2.0); a THIRD-PARTY-NOTICES entry records the notice and the modifications made. Closes #29 Assisted-By: Claude Opus 5 Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
…dger data Replaying the shipped selectors over a recorded Terminal-Bench tool-output dump showed which output shapes matched nothing. Two dominated: - apt/dpkg install boilerplate: 584 outputs, ~1.0 MB, the largest reachable output family on that benchmark. rtk has no apt filter at all. New `apt` filter: 76% compression on its fixtures, pure boilerplate collapses to one line, and every E:/W:/N:, dpkg error and prompt survives. - "<file>: In function 'main':" — gcc's diagnostic HEADER line, 108 outputs. rtk's gcc filter matches the command, so its patterns never had to name this shape; ported as written it missed the most common way gcc output starts. The IaC and mobile-build filters fired zero times on that dump. They stay (they are correct, tested, and free when inert) but the docs now say plainly that a filter set's value comes from the workload, not its size. TestAptKeepsProblems is kept as a permanent regression check: it caught a '^debconf: ' strip rule that swallowed "debconf: unable to initialize frontend" along with the harmless delaying notice. Narrowed to the delaying notice only. Assisted-By: Claude Opus 5 Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
Assisted-By: Claude Opus 5 Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
…dropped pytest is the filter that actually fires in practice — the only one to match in a recorded Terminal-Bench tool-output dump. Its inline tests were small synthetic cases, so add a full session fixture (failures, errors, skips, xfails, tracebacks, short summary) and assert every line the agent acts on survives while only passing noise is stripped. 35% saved with loss=None, so no recovery marker is needed. Assisted-By: Claude Opus 5 Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
…l identity
Replaying captured proxy traffic through the real registry exposed two defects that
no unit test would have caught, because both are about what the filters MISS.
1. The selector was one line, so a filter's reach depended on the agent's output
framing rather than on the tool that produced the output. 112 pytest runs
(311 KB) matched nothing because the harness prepends its own preamble
("Exit code 1", "Internet access disabled") or the report opens with a bare
"ERROR path::test", so pytest's session banner was never line 1. selectorKey now
spans the first 6 non-empty lines and match regexes compile with (?m) so ^/$
anchor per line. Measured on real SWE traffic: claimed output 13 -> 124 of 520
(2.5% -> 23.8%). Kept at 6 lines deliberately — a whole-blob scan would let a
generic pattern match some incidental line deep inside unrelated output.
That widening made generic filters shadow specific ones, exactly the ordering
hazard the multi-line selector introduces, and the routing guardrail test caught
every case. Resolved with priority: tool-identity banners at 20, gcc at -10
(its selector is the generic "some compiler said something" shape that also
occurs inside make/swift/dotnet output, so it is the last resort, not the first
match). poetry's "- Installing x (ver)" was also tightened — bare
"- Installing" is how terraform init reports providers.
2. swift-build's '^Compiling ' claimed CYTHON output in a live run and stripped its
"Compiling x.pyx because it changed" lines. A generic verb is not a tool
signature. Now requires Swift identity (a .swift file or a Swift build phase),
with a regression test asserting Cython and cargo output are not claimed.
The miss ledger keys on the first line only: the selector is multi-line, and
keying a bounded ledger on multi-line blobs would make nearly every entry unique
and spend the cap on noise instead of ranking real shapes.
Assisted-By: Claude Opus 5
Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
…and the tool-identity rule Assisted-By: Claude Opus 5 Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
The marker costs tokens too, so a filter that barely wins can still grow a message. A wider selector routes more output to more filters, so sweep every filter's own test inputs through the component and assert no message grows. Assisted-By: Claude Opus 5 Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
This was referenced Aug 10, 2026
OsherElhadad
added a commit
that referenced
this pull request
Aug 10, 2026
…rve merges (#49) Five PRs landed in quick succession (#33, #36, #40, #42, #43), each updating its own docs. Nobody checked they were coherent together, and several pages described behaviour that no longer exists. The largest error: every one of the nine preset compositions in docs/reference/presets.md still ended in `cacheinject`, which #36 removed from all of them in favour of the new `cachesplit` marker component. `agent`, `aggressive` and `general` were also missing `extract_llm`, `general` was documented only in prose, and `balanced` was called "the default" when the proxy has defaulted to `codesmart` for some time. Each of the eleven pipelines is now verified component-for-component against the `presets` map. `cachesplit` was a registered component with no page and no nav entry; it has both now. That was the only registered/documented gap — every other components.Register call already had one. Reference pages were the other systematic gap. docs/reference/routes.md documented 5 of the ~45 fields the `Snapshot` struct serves; it now covers all of them, grouped, including `discarded_changes`/`top_discarded` (#36), the SSE quintet (#33), the cmdfilter ledgers (#42) and the observe namespace (#43). config.md gained the `store` block, five missing env vars, and lost a `cacheinject` example. Corrections carrying evidence discipline rather than just names: - cacheinject's placement section was headed "measured, not asserted" over a simulation, and its one live post-fix reading (n=1, +7.9% cost per step, +61.9% cache-write, mechanism unexplained, 0 of 106 marks landing where the suspected mechanism requires) was buried below the favourable numbers. The simulation is now labelled as one, and the negative live reading leads. - The root cause of the discarded `tool_result` breakpoint was attributed to bifrost. It is this repo's own `toolMessage()` in `normalize`. - routes.md now warns that `saved_tokens` is cumulative: the unique totals behind the two studies are 234,119 and 15,457 tokens, 21x and 8x smaller. - cmdfilter.md said 23 filters and first-line selectors; it is 24 filters over six leading lines. The four filters predicted to matter fired zero times and apt+gcc carried ~73% of live savings — recorded as a failed prediction, not quietly dropped. - The `repairLostResult` removal and extract_llm's exclusion from freeze-repair (its replacement is a sampled model output) are now in design.md. - docs described `extract` as the LLM component. `extract_llm` is; `extract` never calls a model. Historical results pages keep their original pipeline names, annotated with what changed since, rather than being rewritten to numbers the runs did not produce. Untouched: docs/results/terminal-bench-*.md and improvement-plan.md (held on #23), and extract_llm's economics (#34's subject). mkdocs build --strict passes; no orphan pages, no dangling nav entries. Assisted-By: Claude Opus 5 Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com> Co-authored-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
This was referenced Aug 10, 2026
Merged
OsherElhadad
added a commit
that referenced
this pull request
Aug 10, 2026
…cords nothing enforced (#54) cmdfilter called c.FilterStats with no mode check, so an observe-only run -- zero enforced requests, sync_enforced 0 -- still reported real-looking cmdfilter_families, cmdfilter_filters and cmdfilter_selector_misses entries. Those tokens were never saved: nothing was forwarded. Unlike the two fields observe deliberately shares (cg_added_ms_avg, a true measurement of the enforced path whose reading of ~0 IS the headline result; and context-guru's own model spend, which is real money and labelled by observe_llm_notice), these three sit in the enforced namespace with no mode label and no potential_* counterpart. A consumer cannot tell them from real savings. That is the failure #31 named as its primary correctness risk -- a mislabelled hypothetical is worse than no number, because it silently inflates the product's own headline claim. The gate is a Ctx.Stats() accessor rather than a check at cmdfilter's two call sites. A component author reaching for c.FilterStats has no reason to think about operating modes, so the next sink added to Ctx would reproduce this exactly; an accessor makes the safe path the only convenient one. New leakage from #42 and #43 composing -- neither PR's own review could see it, because the stats sink and observe mode landed independently. The test asserts sync DOES record, so it proves the gate rather than a dead sink, and fails without the gate with the reported symptom (acts=1 in observe mode). Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com> Co-authored-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #29
cmdfiltershipped a 3-filter starter set against rtk's 63. The DSL engine was already a faithfulport, so the gap was coverage — but the port is not a copy, and the interesting work turned out to be
in the two things measurement contradicted.
The three engine bugs, fixed with tests
truncate_lines_atnever recorded loss. It ran beforelosswas initialized (dsl.go:192-200vs
:202), so a real intra-line cut reportedLossNoneandcmdfilteremitted no recoveryhint for a real loss. Now types as
LossWhole— an intra-line cut is non-contiguous by nature,since every long line loses its own tail.
truncate_lines_atcut silently. A mid-line cut with no marker reads as corrupted output to amodel. Now appends
..., sized to fit inside the cap so the line never grows.recoveryHintcollapsedLossTailandLossWhole. Both got the same "callcontext_guru_expand" text, making a cheap partial recovery look like an expensive whole-blobone and pushing the agent to the costly path. Now distinct: a tail cut names the cut point.
The port: 24 filters, every selector rewritten
rtk matches a shell command (
^terraform\s+plan); a proxy only ever sees the output. Everyselector is rewritten to an output-shape signature. Copied verbatim, rtk's regexes compile fine and
never fire — so
TestEveryBuiltinFilterHasTestsAndRoutesasserts each filter's own test inputactually routes to that filter. That test is what makes the rewrite verifiable instead of hopeful,
and it caught every shadowing regression below.
Ported (22):
pytestnpm-installmakegradlexcodebuildgccswift-builddotnet-buildturbonxterraform-planterraform-initpulumiliquibasesshpingrsyncbundle-installpoetry-installcomposer-installuv-syncbrew-installquarto-render.Filters whose output is indistinguishable are merged (terraform/tofu; the five pulumi subcommands).
Tier 3 not ported, with reasons recorded in
docs/components/cmdfilter.md: the ~24width-cap-only filters, the 11 blank-line-only linter filters,
spring-boot,filter_stderr, rtk's86 command-detection rules, and its ~50 native Rust filters.
Every success-collapse rule carries an
unlessguard (rtk ships 9 of 11 unguarded), each with anexplicit negative test: a build with a warning and a success marker must not collapse. In a proxy
the agent cannot re-run the command to find what a bare collapse swallowed.
TestEveryMatchOutputRuleIsGuardedfails the build if one is added without a guard.dotnet-buildneeded care: dotnet prints
0 Error(s)on success, so a guard on the word "error" would never let itcollapse — it guards on the diagnostic form (
error CS1002).Also: shared
capbudget classes by signal density instead of 24 hand-pickedmax_lines; a500-byte size floor (rtk's
MIN_TEE_SIZE); load-time guardrails (duplicate names rejected, inlinetests executed at load); a bounded selector-miss ledger; per-family metrics in
/stats.Two things measurement contradicted
I replayed the shipped selectors over recorded proxy traffic before trusting the plan. Both findings
changed the code.
1. The issue's Tier-1 priorities did not survive contact with the data. On a Terminal-Bench dump,
pulumi,terraform-plan,xcodebuildandgradlefired zero times. What dominated were twoshapes rtk has no filter for: apt/dpkg boilerplate (584 outputs, ~1.0 MB — the largest reachable
family) and gcc's
<file>: In function 'main':header. I added both;aptgets 76% compression andwent on to carry ~73% of all savings in the live run. The IaC filters stay — correct, tested, free
when inert — but the docs now say plainly that a filter set's value comes from the workload, not its
size. That is what the miss ledger is for.
2. The one-line selector was a real bug in my own port. 112 pytest runs (311 KB) matched nothing
because the agent harness prepends its own preamble (
Exit code 1,Internet access disabled) orthe report opens with a bare
ERROR path::test— so pytest's session banner is never line 1. Aone-line selector ties a filter's reach to the agent's output framing rather than to the tool
that produced the output.
selectorKeynow spans the first 6 non-empty lines, with(?m)so^/$anchor per line:
Widening it made generic filters shadow specific ones — exactly the ordering hazard #29 flagged — and
the routing guardrail caught every case. Resolved with explicit
priority: tool banners at 20,gccat -10 (its selector is the generic "some compiler said something" shape that also occursinside make/swift/dotnet output, so it is the last resort, not the first match).
A false positive caught in a live run:
swift-build's^Compilingclaimed Cython output andstripped its
Compiling x.pyx because it changedlines — it fired 80× on a C build task with almostno unique saving, visible directly in the new per-filter ledger. A generic verb is not a tool
signature. Fixed to require Swift identity, with a regression test asserting Cython and cargo output
are not claimed.
Benchmarks
Terminal-Bench, build-heavy tasks,
offbaseline vscodesmart, matched per task. Reward andsteps are the numbers that matter here — this is the failure mode that sank rtk on Terminal-Bench.
build-pmarsbuild-cython-extcompile-compcertCache-hit 95.97% → 97.43%; cache-write 393k → 239k (−39%) — the lever that actually mattered
here. Total including context-guru's own LLM cost: $2.862 (−11.7%). cmdfilter: 128 acts / 153 runs,
0 reverts, 0 expand bounces, ~2 ms/request.
Reward is net neutral (2 = 2), not improved — and I am claiming neither flip. Both went in
opposite directions, and in each case the losing trial was an
exception: truewithagent_wall_spinned exactly at its budget (1800.0 s and 4800.0 s). Both flips are wall-clock timeouts, not
information loss. The cython "win" and the compcert "loss" are the same artifact.
The one real concern, and it is not the rtk failure mode. compcert's codesmart trial burned +17%
agent wall (4089 s → 4800 s) and hit its budget. Steps went down overall and aggregate reward held,
so this is not the "agent lost information → more steps" spiral that sank rtk. It is added latency:
cg_added_ms_avg5 ms → 145 ms, upstream 12.9 s → 15.6 s/req — dominated byextract_llm(17.8 s across 2 calls, 0 tokens saved), not cmdfilter. On a task already near its wall clock that
was enough to push it over. That belongs to #28, which independently concluded
extract_llmisnet-negative on caching backends; it is not a reason to back off these filters.
Live per-family ledger (the new
/statsfields):Honest caveats, stated plainly:
proxy already held the port I had been assigned, so my first attempt was measuring through
someone else's config; after moving to a verified-free port, 2 of 3 codesmart trials aborted with
API Error: ConnectionRefusedfrom proxy restarts. One clean trial is not a control. What it didgive me is the capture file that exposed the selector bug — the more valuable outcome.
timeout, the reward column should be read as "no regression detected", not as a measurement.
the Cython false-positive fix landed after this run, so the merged version should produce more
output than these numbers show.
rounding error against the bill, consistent with the existing finding that cost tracks steps, not
unique tokens. The case for cmdfilter is that it is free — no LLM, ~2 ms, cache-safe — not that
it moves the bill on its own.
(the selector fix and the Cython fix both landed after it started).
Licensing
THIRD-PARTY-NOTICES(new) names rtk,github.com/rtk-ai/rtk, Apache-2.0, theCopyright 2024 rtk-ai and rtk-ai Labsnotice, and states that filter definitions and the DSLpipeline are "adapted from, with modifications" — enumerating them. Apache-2.0 §4 applies to the
filter TOMLs as copyrightable expression; the existing prose credit in
dsl.gois good practice butnot sufficient for a bulk port.
Verification
CGO_ENABLED=1 go build -tags cg_skeleton ./...·go test -tags cg_skeleton ./...·go test -race·make lint·gofmt -l— all clean./statsadditions are additive only, witha test asserting the keys
deploy/harbor/*.pyparses still exist.Docs:
docs/components/cmdfilter.md(full filter table, what each preserves, the Tier-3 rationale,the two measurement findings),
docs/components/dsl.md(loss typing,capclasses,priority),docs/how-to/custom-dsl-filter.md(both selector traps),THIRD-PARTY-NOTICES.Rebase note
This supersedes #37, which could not be rebased in place (branch protection blocks force-push). Same 7 commits, rebased onto
mainafter #33/#36/#40 merged. Two conflicts resolved and verified locally:metrics/metrics.go— additive counter blocks from four branches; kept all sides.components/component.go— required a manual merge. fix(cache): write cacheinject's breakpoints to the wire as metadata #36 adds anExistingBreakpointsfield toCtxwhile this branch adds aFilterStatsfield and aFilterStatsSinkinterface immediately after the struct. A naive keep-both interleaves the struct field into the interface body and produces invalid Go (syntax error: unexpected name Mode in interface type). Both fields now sit insideCtxwith the interface separate.Gates re-run after the rebase:
go build,go test -tags cg_skeleton ./...,go test -race ./components/... ./metrics/...,gofmt -l— all clean.