Skip to content

feat(cmdfilter): port rtk's filter coverage (3 -> 24) and fix three DSL loss-typing bugs - #42

Merged
OsherElhadad merged 7 commits into
mainfrom
feat/i29-cmdfilter-rebased
Aug 10, 2026
Merged

feat(cmdfilter): port rtk's filter coverage (3 -> 24) and fix three DSL loss-typing bugs#42
OsherElhadad merged 7 commits into
mainfrom
feat/i29-cmdfilter-rebased

Conversation

@OsherElhadad

Copy link
Copy Markdown
Collaborator

Closes #29

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 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

  1. truncate_lines_at never recorded loss. It ran before loss was initialized (dsl.go:192-200
    vs :202), so a real intra-line cut reported LossNone and cmdfilter emitted no recovery
    hint 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.
  2. truncate_lines_at cut silently. A mid-line cut with no marker reads as corrupted output to a
    model. Now appends ..., sized to fit inside the cap so the line never grows.
  3. recoveryHint collapsed LossTail and LossWhole. Both got the same "call
    context_guru_expand" text, making a cheap partial recovery look like an expensive whole-blob
    one 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. Every
selector is rewritten to an output-shape signature. Copied verbatim, rtk's regexes compile fine and
never fire — so TestEveryBuiltinFilterHasTestsAndRoutes asserts each filter's own test input
actually routes to that filter. That test is what makes the rewrite verifiable instead of hopeful,
and it caught every shadowing regression below.

Ported (22): 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).

Tier 3 not ported, with reasons recorded in docs/components/cmdfilter.md: the ~24
width-cap-only filters, the 11 blank-line-only linter filters, spring-boot, filter_stderr, rtk's
86 command-detection rules, and its ~50 native Rust filters.

Every success-collapse rule carries an unless guard (rtk ships 9 of 11 unguarded), each with 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 find what a bare collapse swallowed.
TestEveryMatchOutputRuleIsGuarded fails the build if one is added without a guard. dotnet-build
needed care: dotnet prints 0 Error(s) on success, so a guard on the word "error" would never let it
collapse — it guards on the diagnostic form (error CS1002).

Also: shared cap budget classes by signal density instead of 24 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 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, xcodebuild and gradle fired zero times. What dominated were two
shapes 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; apt gets 76% compression and
went 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) or
the report opens with a bare ERROR path::test — so pytest's session banner is never line 1. A
one-line selector ties a filter's reach to the agent's output framing rather than to the tool
that produced the output. selectorKey now spans the first 6 non-empty lines, with (?m) so ^/$
anchor per line:

workload claimed before claimed after
SWE-bench captured traffic (520 eligible outputs) 13 (2.5%) 124 (23.8%)
Terminal-Bench dump (223 eligible outputs) 140 (62.8%) 172 (77.1%)

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,
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).

A false positive caught in a live run: swift-build's ^Compiling claimed Cython output and
stripped its Compiling x.pyx because it changed lines — it fired 80× on a C build task with almost
no 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, off baseline vs codesmart, matched per task. Reward and
steps are the numbers that matter here
— this is the failure mode that sank rtk on Terminal-Bench.

task reward off → cs steps off → cs norm cost off → cs
build-pmars 1.0 → 1.0 32 → 34 $0.461 → $0.503
build-cython-ext 0.0 → 1.0 70 → 59 $1.177 → $1.213
compile-compcert 1.0 → 0.0 55 → 58 $1.602 → $1.128
aggregate 2/3 = 2/3 52.3 → 50.3 (−3.8%) $3.240 → $2.843 (−12.3%)

Cache-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: true with agent_wall_s
pinned 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_avg 5 ms → 145 ms, upstream 12.9 s → 15.6 s/req — dominated by extract_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_llm is
net-negative on caching backends; it is not a reason to back off these filters.

Live per-family ledger (the new /stats fields):

cmdfilter: 128 acts, 0 reverted
  pkg    (apt)          — the largest reachable family, ~73% of live savings
  tests  (pytest)
  builds (swift-build ← the Cython false positive, now fixed)

Honest caveats, stated plainly:

  • The SWE-bench control arm is unusable and I am not reporting it as a result. Another agent's
    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: ConnectionRefused from proxy restarts. One clean trial is not a control. What it did
    give me is the capture file that exposed the selector bug — the more valuable outcome.
  • n=1 per task. All three rewards carry single-trial noise; with two of the three decided by a
    timeout, the reward column should be read as "no regression detected", not as a measurement.
  • This ran the PRE-FIX binary. The selector widening (2.5% → 23.8% match rate on SWE traffic) and
    the Cython false-positive fix landed after this run, so the merged version should produce more
    output than these numbers show.
  • Unique savings are small in absolute terms (~3.6k tokens). On a ~96% cached agent this is a
    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 TB arm ran the pre-fix binary, so its cmdfilter numbers understate the merged version
    (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, the
Copyright 2024 rtk-ai and rtk-ai Labs notice, and states that filter definitions and the DSL
pipeline 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.go is good practice but
not 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. /stats additions are additive only, with
a test asserting the keys deploy/harbor/*.py parses 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, cap classes, 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 main after #33/#36/#40 merged. Two conflicts resolved and verified locally:

  • metrics/metrics.go — additive counter blocks from four branches; kept all sides.
  • components/component.gorequired a manual merge. fix(cache): write cacheinject's breakpoints to the wire as metadata #36 adds an ExistingBreakpoints field to Ctx while this branch adds a FilterStats field and a FilterStatsSink interface 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 inside Ctx with 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.

Osher-Elhadad 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>
@OsherElhadad
OsherElhadad merged commit f78f746 into main Aug 10, 2026
6 checks passed
@github-project-automation github-project-automation Bot moved this from New/ToDo to Done in Rossoctl Issue Prioritization 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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

feat(cmdfilter): port rtk's filter coverage (3 -> ~25 filters) and fix three DSL loss-typing bugs

2 participants