Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
87 changes: 87 additions & 0 deletions components/all/freeze_repair_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package all_test

import (
"context"
"strings"
"sync"
"testing"
"time"

bschemas "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/components"
"github.com/rossoctl/context-guru/schema"
"github.com/rossoctl/context-guru/store"
)

// varyingModel returns a DIFFERENT valid projection on each call — exactly what a SAMPLED
// model may do. cheapmodel sends no temperature and no seed, so extract_llm's replacement
// text is not reproducible.
type varyingModel struct {
mu sync.Mutex
n int
}

func (m *varyingModel) Complete(context.Context, string) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.n++
return "OUTPUT = \"projection variant " + strings.Repeat("x", m.n) + "\"\n", nil
}

func (m *varyingModel) calls() int {
m.mu.Lock()
defer m.mu.Unlock()
return m.n
}

// A LOST extract_llm decision must NOT be re-derived inside the provider's cached prefix.
// Re-deriving costs a sampled model call that can emit DIFFERENT bytes at depth — the very
// corruption the repair was meant to prevent — and it buys nothing: if the bytes differ the
// suffix is cache-written either way, so the model call is pure loss. The message is
// therefore left verbatim, like any other tail-gated miss.
//
// Only mask/failed_run get the depth repair, because their replacement is a pure function
// of (content, config) and so is genuinely reproducible.
func TestLostExtractLLMResultIsNotReDerivedAtDepth(t *testing.T) {
off := newComp(t, "extract_llm",
"strategy: code\nmin_tokens: 1\nmodel:\n source: config\ntrigger:\n min_request_tokens: 1\n")
now := time.Unix(0, 0)
st := store.NewMemory(store.Options{TTLSeconds: 10})
st.SetClock(func() time.Time { return now })
vm := &varyingModel{}
body := strings.Repeat("verbose tool output line\n", 60)

// The message under test is Input[1]; MaxCachedIdx >= 1 puts it in the CACHED PREFIX.
run := func(maxCached int) string {
req := &bschemas.BifrostChatRequest{Input: []bschemas.ChatMessage{
userMsg("find the keep records in output.txt"), toolMsg(body), toolMsg("tail"),
}}
c := &components.Ctx{Ctx: context.Background(), Session: "s", Store: st,
Model: components.ModelSpec{Static: vm}, CacheAware: true,
MaxCachedIdx: maxCached, CtxWindow: 200000}
var rep components.Report
if _, err := off.Offload(req, &rep, c); err != nil {
t.Fatal(err)
}
return schema.MessageText(req.Input[1])
}

first := run(-1) // tail turn: a NEW compaction is allowed and gets cached
if first == body {
t.Fatal("turn 1 must compact the tail output (fixture no longer exercises the path)")
}
callsAfterFirst := vm.calls()

// Force the loss, then re-present the message at DEPTH.
now = now.Add(11 * time.Second)
got := run(1)

if got != body {
t.Fatalf("a lost LLM decision must leave the cached-prefix message VERBATIM, not "+
"re-project it with freshly sampled bytes:\n turn1=%q\n turn2=%q", first, got)
}
if vm.calls() > callsAfterFirst+1 {
t.Fatalf("no re-derivation call for the depth message (calls %d -> %d)",
callsAfterFirst, vm.calls())
}
}
15 changes: 9 additions & 6 deletions components/offload/extract_llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,7 @@ func (e *ExtractLLM) Offload(req *bschemas.BifrostChatRequest, rep *components.R
continue
}
if cached, hit := getResult(c, id); hit {
summary, _ := getSummary(c, id)
apply(i, content, string(cached), summary)
apply(i, content, cached.Projected, cached.Summary)
dbgReapply++
continue
}
Expand All @@ -238,6 +237,13 @@ func (e *ExtractLLM) Offload(req *bschemas.BifrostChatRequest, rep *components.R
// caching is off, any message is fair game. File reads included (largest mass);
// safe because we never touch already-cached content and freeze+reapply the result.
sz := schema.TextTokens(content)
// No lost-decision repair here, unlike mask/failed_run: this replacement is a SAMPLED
// model output (cheapmodel sends no temperature/seed), so re-deriving at depth could
// emit different bytes inside the cached prefix — the very thing the repair exists to
// prevent. And the trade doesn't pay even setting that aside: if the bytes differ the
// suffix is cache-written either way, so re-deriving would buy a model call for
// nothing. The model may also not run at all (throttle, timeout, floor), which would
// leave the output verbatim at depth after the gate had already been lifted.
if c.CacheAware && !c.TailOnly(i) {
dbgTail++
if sz >= floor {
Expand Down Expand Up @@ -303,10 +309,7 @@ func (e *ExtractLLM) Offload(req *bschemas.BifrostChatRequest, rep *components.R
if out[k].projected == "" {
continue
}
putResult(c, cands[k].id, []byte(out[k].projected))
if out[k].summary != "" {
putSummary(c, cands[k].id, out[k].summary)
}
putResult(c, cands[k].id, out[k].projected, out[k].summary)
apply(cands[k].i, cands[k].content, out[k].projected, out[k].summary)
}
}
Expand Down
8 changes: 6 additions & 2 deletions components/offload/failed_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,12 @@ func (fr *FailedRun) Offload(req *schemas.BifrostChatRequest, rep *components.Re
// transitions on SWE-50). On a cached agent the superseded run already bills at
// the cheap cache-read rate, so collapsing it doesn't pay: skip NEW collapses
// entirely (frozen ones are still reapplied above for stability). With caching
// OFF, collapse freely — there the content cut is a direct saving.
if c.CacheAware {
// OFF, collapse freely — there the content cut is a direct saving. The one
// exception is a freeze this session established and the store then LOST: the
// provider already holds the collapsed bytes for this run, so re-deriving them
// (deterministic) preserves its cache, while leaving the run verbatim is what
// forces the suffix re-write.
if c.CacheAware && !repairLostFreeze(c, fr.Name(), content) {
continue
}
newText, key, eff, ok := tryMark(c, fr.mode, content, " [full output: call "+expand.ToolName+"]",
Expand Down
88 changes: 88 additions & 0 deletions components/offload/flipcost_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package offload

import (
"fmt"
"strings"
"testing"
"time"

bschemas "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/components"
"github.com/rossoctl/context-guru/schema"
"github.com/rossoctl/context-guru/store"
)

// replaySession drives a long-horizon session through `mask` and counts the provider
// cache-write it would incur from REPRESENTATION FLIPS: a message inside the
// already-cached prefix whose forwarded bytes differ from the bytes sent last turn
// invalidates the cache from its index onward, so the whole suffix is re-written.
//
// ttl/slide select the store behavior under test. This is the issue's cost hypothesis
// (~15 reverts ≈ 2.5M cache-write tokens) reduced to something deterministic and
// measurable in CI: no gateway, no model, just the replay bookkeeping the fix changes.
func replaySession(t *testing.T, ttlSeconds int, slide bool, turns, secsPerTurn int) (flips, writeTokens int) {
t.Helper()
st := store.NewMemory(store.Options{TTLSeconds: ttlSeconds})
now := time.Unix(0, 0)
st.SetClock(func() time.Time { return now })
if !slide {
st.DisableSlidingTTLForTest()
}
// keep_recent: 0 so the NEWEST (uncached) tool output is itself a mask candidate —
// with a keep-recent window the tail is never masked, so no decision is ever frozen
// and there is nothing for the TTL to lose.
comp, err := newMask([]byte("keep_recent: 0\nmin_tokens: 100\n"))
if err != nil {
t.Fatal(err)
}
m := comp.(*Mask)

var hist []bschemas.ChatMessage
prev := map[int]string{}
for turn := 0; turn < turns; turn++ {
hist = append(hist, tool(fmt.Sprintf("output %d\n", turn)+
strings.Repeat("verbose tool output line\n", 60)))
// The agent re-sends the ORIGINAL history verbatim every turn, plus a new tail.
req := &bschemas.BifrostChatRequest{Input: append([]bschemas.ChatMessage(nil), hist...)}
c := &components.Ctx{Session: "s", Store: st, CacheAware: true,
MaxCachedIdx: len(hist) - 2} // all but the newest message is already cached
var rep components.Report
if _, err := m.Offload(req, &rep, c); err != nil {
t.Fatal(err)
}
for i := 0; i < len(req.Input)-1; i++ {
got := schema.MessageText(req.Input[i])
if was, seen := prev[i]; seen && was != got {
flips++
for j := i; j < len(req.Input); j++ {
writeTokens += schema.TextTokens(schema.MessageText(req.Input[j]))
}
}
prev[i] = got
}
now = now.Add(time.Duration(secsPerTurn) * time.Second)
}
return flips, writeTokens
}

// The headline claim: over a session longer than the old TTL, the write-only TTL flips
// already-cached messages and the sliding TTL does not. Numbers are logged so the PR can
// quote them, and asserted so a regression fails the build.
func TestFlipCostOverLongSession(t *testing.T) {
const turns, secsPerTurn = 120, 26 // 120 turns x 26 s/req = 3120 s > the old 1800 s TTL
oldFlips, oldWrite := replaySession(t, 1800, false, turns, secsPerTurn)
newFlips, newWrite := replaySession(t, 1800, true, turns, secsPerTurn)

t.Logf("write-only TTL (old): flips=%d cache-write=%d tokens premium@$2.30/M=$%.2f",
oldFlips, oldWrite, float64(oldWrite)*2.30/1e6)
t.Logf("sliding TTL (new): flips=%d cache-write=%d tokens premium@$2.30/M=$%.2f",
newFlips, newWrite, float64(newWrite)*2.30/1e6)

if oldFlips == 0 {
t.Fatal("the old write-only TTL must reproduce the flips this issue is about")
}
if newFlips != 0 {
t.Fatalf("the sliding TTL must eliminate representation flips, got %d (%d tokens)",
newFlips, newWrite)
}
}
Loading
Loading