Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3c970c7
docs(proposals): the cold sweep should adjudicate, not compact
amiddavid Aug 27, 2026
41425ba
feat(extract): the cold-sweep adjudication contract, and the four rul…
amiddavid Aug 27, 2026
cdc33be
feat(offload): the sweep's drop path -- a shape residue that transpor…
amiddavid Aug 27, 2026
b2c0c0c
feat(offload): extract_llm_sweep -- the cold sweep adjudicates one ou…
amiddavid Aug 27, 2026
a4b0e66
test(sweep): pin the adjudicator's counter contract at both ends
amiddavid Aug 27, 2026
48589aa
fix(extract_llm): stop raising gates from the per-call goroutines (#119)
amiddavid Aug 27, 2026
9e538f1
feat(sweep)!: adjudicate a BATCH, not one output per call
amiddavid Aug 27, 2026
5d02669
refactor(extract_llm)!: remove the cold-sweep surface, which is its o…
amiddavid Aug 27, 2026
316ead4
fix(sweep): the batches never shared their prompt prefix -- split it,…
amiddavid Aug 27, 2026
fa00e4b
feat(prefixask): port prefix asks to main -- ask the request's model …
amiddavid Aug 27, 2026
bc016af
feat(sweep)!: ask the request's model over its cached transcript, and…
amiddavid Aug 27, 2026
4a67916
feat(sweep): ship the tool_use id as a locating anchor, and guard the…
amiddavid Aug 28, 2026
de6f3fa
fix(sweep): candidates are the whole transcript, and small inventorie…
amiddavid Aug 28, 2026
59606c5
refactor(metrics)!: split component events out of the declines series…
amiddavid Aug 28, 2026
7a2ef37
feat(expand): count a broken reversibility promise apart from an inve…
amiddavid Aug 28, 2026
275dbb8
docs(sweep): the proposal omitted min_inventory and still argued for …
amiddavid Aug 28, 2026
d0f318a
fix(apply): log a component's EVENTS, not only its declines
amiddavid Aug 28, 2026
35cbbd5
fix(dash,sweep): carry events to the dashboard, and stop the fallback…
amiddavid Aug 29, 2026
d162e10
fix(sweep,docs): parse a reply with prose in it, price the call, and …
amiddavid Aug 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apply/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,23 @@ func logDecisions(lg *slog.Logger, rr *components.RunReport) {
if len(rep.Gates) > 0 {
attrs = append(attrs, "gates", formatGates(rep.Gates))
}
// EVENTS TOO, or splitting the histogram silently blinded this line.
//
// Everything a component recorded used to land in Gates, so one field carried it all. After
// the split (#121) a component whose counters are all EVENTS logged no counter information
// whatsoever — and the component most affected is the one that only records successes when it
// works. Observed live: the turn that adjudicated twelve outputs, removed twelve and saved
// 33,340 tokens logged `verdict=acted saved=33340` and not one counter, because all eleven
// names it raised are events. That is the exact diagnosis this line exists to provide,
// missing precisely when the component succeeded.
//
// Rendered as one `name=n name=n` STRING for the same reason gates are: an attribute key is
// checked against the credential-name denylist, so a future event called `no_auth` would have
// its count replaced by «redacted». As a value it is scrubbed as content, where a short
// integer after `=` matches nothing.
if len(rep.Events) > 0 {
attrs = append(attrs, "events", formatGates(rep.Events))
}
if rep.Irreversible {
attrs = append(attrs, "irreversible", true)
}
Expand Down Expand Up @@ -452,6 +469,10 @@ func BodyOpts(ctx context.Context, pipe *components.Pipeline, st store.Store, o
nowMs := o.nowMs()
coldCache := false
idleMs := int64(0)
// ttlMs is the cache lifetime the cold decision below derives, carried onto the Ctx so a
// component can act BEFORE expiry rather than only after it. 0 when the cache-aware path did not
// run, which reads as "unknown" to every consumer.
ttlMs := int64(0)
maxCachedIdx := -1
if cacheAware && !bypass {
// Messages present on the previous turn of this session are already committed
Expand Down Expand Up @@ -521,6 +542,11 @@ func BodyOpts(ctx context.Context, pipe *components.Pipeline, st store.Store, o
ttl = a
}
coldCache = cacheIsCold(prevAt, nowMs, ttl)
// The SAME ttl the cold decision used, carried onto the Ctx. A component that wants to
// act BEFORE expiry rather than after needs the lifetime, not just the verdict, and
// re-deriving it there would be a second read of one fact — which is how the cold
// decision and the dashboard came to disagree once already (see ttlTier).
ttlMs = ttl.Milliseconds()
if prevAt > 0 && nowMs > prevAt {
idleMs = nowMs - prevAt
}
Expand Down Expand Up @@ -583,6 +609,8 @@ func BodyOpts(ctx context.Context, pipe *components.Pipeline, st store.Store, o
// provider's cap of four counts them all (issue #32, defect 2).
ExistingBreakpoints: bps.Total(),
Mode: mode,
PrefixAsk: o.PrefixAsk,
CacheTTLMs: ttlMs,
// Set BEFORE the run, so cachesplit's own report is right at the source and every
// consumer of it agrees. Amending the report afterwards fixed the dashboard and
// left /stats and the Prometheus component counters still saying "skipped",
Expand Down
60 changes: 60 additions & 0 deletions apply/logevents_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package apply

import (
"bytes"
"log/slog"
"strings"
"testing"

"github.com/rossoctl/context-guru/components"
)

// A COMPONENT WHOSE COUNTERS ARE ALL EVENTS MUST STILL LOG THEM.
//
// Splitting Report.Gates into Gates and Events (#121) silently blinded this line: it rendered only
// Gates, so a component that records successes rather than refusals logged no counter information at
// all. Observed live on the worst possible turn — the one that adjudicated twelve outputs, removed
// twelve and saved 33,340 tokens logged `verdict=acted saved=33340` and nothing else, because every
// name it raised was an event. That is the diagnosis this line exists to provide, absent exactly when
// the component worked.
//
// The fixture is deliberately events-ONLY. A report carrying both would pass even with the events
// branch removed, since the gates field would still appear.
func TestDecisionLogCarriesEventsNotOnlyGates(t *testing.T) {
var buf bytes.Buffer
lg := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))

var eventsOnly components.Report
eventsOnly.Component, eventsOnly.Kind = "extract_llm_sweep", "offload"
eventsOnly.TokensBefore, eventsOnly.TokensAfter = 34317, 977
eventsOnly.EventN("sweep_offered", 12)
eventsOnly.EventN("sweep_dropped", 12)
eventsOnly.Event("sweep_prefix_cache_read_ok")

logDecisions(lg, &components.RunReport{Components: []components.Report{eventsOnly}})
out := buf.String()

// Precondition: the line was emitted at all, or the assertions below pass on an empty buffer.
if !strings.Contains(out, "extract_llm_sweep") {
t.Fatalf("no component line was logged, so the assertion is vacuous: %q", out)
}
for _, want := range []string{"sweep_offered=12", "sweep_dropped=12", "sweep_prefix_cache_read_ok=1"} {
if !strings.Contains(out, want) {
t.Errorf("the decision line does not carry %q — a component that records only "+
"successes logs no counters, which is the diagnosis this line exists for: %s",
want, out)
}
}
// Gates and events must be distinguishable in the output, not merged into one field: they answer
// opposite questions and a reader cannot tell a refusal from a success otherwise.
var both components.Report
both.Component, both.Kind = "extract_llm", "offload"
both.GateN("below_output_floor", 11)
both.Event("reapplied_same_session")
buf.Reset()
logDecisions(lg, &components.RunReport{Components: []components.Report{both}})
line := buf.String()
if !strings.Contains(line, "gates=") || !strings.Contains(line, "events=") {
t.Errorf("declines and successes must appear as separate fields; got %s", line)
}
}
5 changes: 5 additions & 0 deletions apply/opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ type Opts struct {
// upgrade entirely rather than defaulting, so a host that forgets to resolve its
// configuration asks for nothing instead of asking on every request.
HeadTTLMinTokens int
// PrefixAsk, when set, lets a component put a question to the request's own model with the
// previous turn's SENT body as the cached prefix. See components.PrefixAsker. nil => a component
// that wants one gets none and decides for itself; today's behaviour for every caller that does
// not set it.
PrefixAsk components.PrefixAsker
// Tracker, when set, owns the per-session cached-prefix boundary. Supplying it also
// removes the concurrent-turn race in the legacy read-then-deferred-write of prevLen.
// nil => the legacy store-backed path, unchanged for library callers and /compact.
Expand Down
23 changes: 8 additions & 15 deletions apply/sweep_variants_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,13 @@ mode: sync
`},
// Osher's production document, verbatim.
{name: "prod-osher", yaml: `
pipeline: [format, toon, dedup, cmdfilter, extract_llm, extract, cachesplit]
pipeline: [format, toon, dedup, cmdfilter, extract_llm, extract_llm_sweep, extract, cachesplit]
components:
extract:
min_tokens: 400
extract_llm:
aggressiveness: medium
allow_on_caching_backend: false
cold_cache: {enabled: true, min_tokens: 1000}
context: recent
context_messages: 7
fire_on: pressure
Expand All @@ -34,89 +33,83 @@ components:
llm_max_per_session: 80
min_tokens: 1000
model: {model: claude-haiku-4-5, source: incoming}
per_output: false
strategy: code
trigger: {min_request_tokens: 3000}
extract_llm_sweep:
min_tokens: 1000
model: {model: claude-haiku-4-5, source: incoming}
mode: sync
`},
// The hypothesis: let it act on warm cached turns, fire on size, and let it see the
// file reads that AUTO would skip.
{name: "warm-tail", yaml: `
pipeline: [format, toon, dedup, cmdfilter, extract_llm, extract, cachesplit]
pipeline: [format, toon, dedup, cmdfilter, extract_llm, extract_llm_sweep, extract, cachesplit]
components:
extract:
min_tokens: 400
extract_llm:
aggressiveness: medium
allow_on_caching_backend: true
skip_file_reads: false
cold_cache: {enabled: true, min_tokens: 1000}
context: recent
context_messages: 7
fire_on: size
min_tokens: 1500
llm_max_per_request: 3
llm_max_per_session: 40
model: {model: claude-haiku-4-5, source: incoming}
per_output: true
strategy: code
mode: sync
`},
{name: "warm-tail-800", yaml: `
pipeline: [format, toon, dedup, cmdfilter, extract_llm, extract, cachesplit]
pipeline: [format, toon, dedup, cmdfilter, extract_llm, extract_llm_sweep, extract, cachesplit]
components:
extract:
min_tokens: 400
extract_llm:
aggressiveness: medium
allow_on_caching_backend: true
skip_file_reads: false
cold_cache: {enabled: true, min_tokens: 1000}
context: recent
fire_on: size
min_tokens: 800
llm_max_per_request: 4
llm_max_per_session: 60
model: {model: claude-haiku-4-5, source: incoming}
per_output: true
strategy: code
mode: sync
`},
{name: "warm-tail-high", yaml: `
pipeline: [format, toon, dedup, cmdfilter, extract_llm, extract, cachesplit]
pipeline: [format, toon, dedup, cmdfilter, extract_llm, extract_llm_sweep, extract, cachesplit]
components:
extract:
min_tokens: 400
extract_llm:
aggressiveness: high
allow_on_caching_backend: true
skip_file_reads: false
cold_cache: {enabled: true, min_tokens: 1000}
context: recent
fire_on: size
min_tokens: 1500
llm_max_per_request: 3
llm_max_per_session: 40
model: {model: claude-haiku-4-5, source: incoming}
per_output: true
strategy: code
mode: sync
`},
// Deterministic-only extraction on the same trigger: the free comparison arm. If this
// gets close to the LLM arms, the LLM calls are not buying much.
{name: "det-strategy", yaml: `
pipeline: [format, toon, dedup, cmdfilter, extract_llm, extract, cachesplit]
pipeline: [format, toon, dedup, cmdfilter, extract_llm, extract_llm_sweep, extract, cachesplit]
components:
extract:
min_tokens: 400
extract_llm:
allow_on_caching_backend: true
skip_file_reads: false
cold_cache: {enabled: true, min_tokens: 1000}
fire_on: size
min_tokens: 1500
strategy: deterministic
per_output: true
mode: sync
`},
}
Loading
Loading