Skip to content
Open
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
59 changes: 59 additions & 0 deletions cmd/context-guru-proxy/effectiveconfig_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package main

import (
"testing"

"github.com/rossoctl/context-guru/config"
"github.com/rossoctl/context-guru/store"
)

// /config MUST PUBLISH WHAT THE STORE WILL USE.
//
// stash_ttl_seconds is capped at ttl_seconds inside NewMemory, silently. effectiveConfig published
// the CONFIGURED field, so `stash_ttl_seconds: 20000` with `ttl_seconds: 10000` showed 20000 on
// /config and the dashboard while the store used 10000 — an operator told one number while another
// runs, which is #200's shape in the config surface instead of the metrics one.
//
// This test lives HERE rather than in store, and that is the point of it: a store-side test that
// compares store.EffectiveStashTTLSeconds against a store built from the same Options is true by
// construction and cannot fail, so main.go could regress with every store test still green. The
// drift is between the PUBLISHED map and the store, so the assertion has to span both.
func TestConfigPublishesTheEffectivePayloadHorizon(t *testing.T) {
for _, tc := range []struct {
name string
yaml string
want int
}{
// The case that diverged.
{"a payload horizon over the decision horizon", "store: {ttl_seconds: 10000, stash_ttl_seconds: 20000}\n", 10000},
// And the ones that did not, which must keep working.
{"a shorter payload horizon", "store: {ttl_seconds: 10000, stash_ttl_seconds: 600}\n", 600},
{"defaults", "store: {}\n", int(store.DefaultStashTTL.Seconds())},
} {
t.Run(tc.name, func(t *testing.T) {
cfg, err := config.LoadBytes([]byte("preset: codesafe\n" + tc.yaml))
if err != nil {
t.Fatal(err)
}
eff := effectiveConfig(cfg, ":0", "", "", "", "", false, "")
st, ok := eff["store"].(map[string]any)
if !ok {
t.Fatalf("no store block in the published config: %#v", eff)
}
got, ok := st["stash_ttl_seconds"].(int)
if !ok {
t.Fatalf("stash_ttl_seconds is %T, not an int: %#v", st["stash_ttl_seconds"], st)
}
if got != tc.want {
t.Errorf("/config publishes stash_ttl_seconds=%d, want %d — the dashboard would "+
"advertise a horizon the store does not use", got, tc.want)
}
// The published value and the store's must be the same number, whatever it is. This is
// the assertion that actually spans the drift: it reads the map main.go builds and the
// store the same Options produce.
if want := store.EffectiveStashTTLSeconds(cfg.Store); got != want {
t.Errorf("/config publishes %d but a store from the same Options uses %d", got, want)
}
})
}
}
9 changes: 8 additions & 1 deletion cmd/context-guru-proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
"github.com/rossoctl/context-guru/internal/modelinfo"
"github.com/rossoctl/context-guru/metrics"
"github.com/rossoctl/context-guru/proxy"
"github.com/rossoctl/context-guru/store"
"github.com/rossoctl/context-guru/tenant"
)

Expand Down Expand Up @@ -916,8 +917,14 @@ func effectiveConfig(cfg *config.Config, addr, openai, anthropic, bob, dbPath st
"inject_expand": envOr("INJECT_EXPAND", "auto"),
"cheap_model": os.Getenv("CHEAP_MODEL"),
"cheap_model_provider": envOr("CHEAP_MODEL_PROVIDER", "anthropic"),
// stash_ttl_seconds is the EFFECTIVE value, not the configured one: it is capped at
// ttl_seconds, silently, so publishing the raw field showed 20000 on the dashboard while
// the store used 10000. A config surface that disagrees with the running store is the same
// silent divergence #200 is about. The other three need no such treatment — their defaults
// are filled in but nothing overrides an explicit value.
"store": map[string]any{"ttl_seconds": cfg.Store.TTLSeconds,
"max_entries": cfg.Store.MaxEntries, "stash_max_bytes": cfg.Store.StashMaxBytes},
"max_entries": cfg.Store.MaxEntries, "stash_max_bytes": cfg.Store.StashMaxBytes,
"stash_ttl_seconds": store.EffectiveStashTTLSeconds(cfg.Store)},
"dashboard": map[string]any{"db_path": dbPath, "capture_content": content, "trusted_cidrs": cidrs},
"build_version": buildinfo.Version,
"build_commit": buildinfo.Commit,
Expand Down
156 changes: 156 additions & 0 deletions components/offload/payload_rederive_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package offload

import (
"strings"
"testing"
"time"

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

// THE PROPERTY THAT MAKES THE SHORT PAYLOAD HORIZON SAFE (#190), driven end to end rather than
// asserted in a comment.
//
// store.DefaultStashTTL gives rewind payloads a fifth of ttl_seconds so a busy period cannot hold
// the reserve saturated for ~2.8h afterwards. That is only safe because a payload, unlike a frozen
// decision, is RE-DERIVABLE: every offloader replays its decision on every turn regardless of the
// cache-tail gate, and that replay re-stashes the payload from the message text the agent just
// re-sent. This test lets a payload be reclaimed and then checks the next turn puts it back —
// on the REQUEST path, which is what matters, because an expand call can only arrive in the
// RESPONSE to that same request.
//
// If this test ever fails, the shorter horizon is no longer safe and store.DefaultStashTTL must go
// back to DefaultTTL — not the other way round.
func TestAReclaimedPayloadIsReDerivedBeforeItsMarkerGoesUpstream(t *testing.T) {
now := time.Unix(0, 0)
// A payload horizon a fixture can cross, and a decision horizon it cannot: the decision must
// survive, or this measures a lost freeze rather than a reclaimed payload.
st := store.NewMemory(store.Options{TTLSeconds: 10000, StashTTLSeconds: 100})
st.SetClock(func() time.Time { return now })
m := maskFor(t)
body := strings.Repeat("verbose tool output line\n", 30)

turn := func(maxCached int) string {
t.Helper()
req := &bschemas.BifrostChatRequest{Input: []bschemas.ChatMessage{
tool(body), tool("new tail output"), // the agent re-sends the ORIGINAL every turn
}}
c := &components.Ctx{Session: "s", Store: st, CacheAware: true, MaxCachedIdx: maxCached}
var rep components.Report
if _, err := m.Offload(req, &rep, c); err != nil {
t.Fatal(err)
}
return schema.MessageText(req.Input[0])
}

// Turn 1: nothing is cached yet, so a NEW mask is allowed. It stashes the payload and stamps
// the marker — the promise this test is about.
first := turn(-1)
if first == body {
t.Fatal("turn 1 did not mask the older output; the fixture never made a promise")
}
keys := expand.ParseMarkers(first)
if len(keys) != 1 {
t.Fatalf("expected exactly one marker in the replacement, got %d", len(keys))
}
key := keys[0]
if _, ok := expand.Resolve(st, key); !ok {
t.Fatal("the marker did not resolve on the turn that stamped it")
}

// Let the payload cross its own horizon and force the reclamation. Reading it is what
// enforces the TTL here, and it also makes the test NON-VACUOUS: PutStash's refresh branch
// does not check expiry, so an expired-but-unswept entry would be resurrected in place and
// this test would pass without ever exercising re-derivation.
now = now.Add(101 * time.Second)
if _, ok := expand.Resolve(st, key); ok {
t.Fatal("the payload outlived stash_ttl_seconds, so nothing was reclaimed and the " +
"re-derivation below is never exercised")
}
missingBefore := StashMissing()

// Turn 2: the output now sits inside the provider's cached prefix, so only the frozen replay
// can act — and the replay is the thing that re-stashes.
second := turn(0)
if second != first {
t.Fatalf("turn 2 flipped representation (cache-destructive):\n want %q\n got %q",
first, second)
}
orig, ok := expand.Resolve(st, key)
if !ok {
t.Fatal("the marker on the wire does NOT resolve after its payload was reclaimed: the " +
"replay did not re-derive it, so store.DefaultStashTTL is trading reversibility for " +
"reserve liveness rather than getting both")
}
if orig != body {
t.Fatalf("the re-derived payload is not the original content:\n want %q\n got %q",
body, orig)
}
// The re-stash succeeded, so no dangling marker was reported — the counter and the resolve
// have to agree, or one of them is lying about the same event.
if got := StashMissing() - missingBefore; got != 0 {
t.Fatalf("stash_missing advanced by %d on a replay that DID resolve", got)
}
// And the store booked the reclamation as absorbed, which is the counter an operator reads to
// know the shorter horizon is costing nothing.
if sst := st.StashStats(); sst.Revived != 1 || sst.Expired != 1 {
t.Fatalf("stash_expired=%d stash_revived=%d, want 1 and 1: the pair is what says a "+
"reclamation was absorbed rather than broken", sst.Expired, sst.Revived)
}
}

// The same path when the reserve is FULL at replay time, which is the case the re-derivation
// cannot rescue — and the one that must still be reported rather than silent. It is why
// stash_missing's remedy is the reserve first and stash_ttl_seconds second: a reclaimed payload
// only dangles if the write that would have restored it was also refused.
func TestAReclaimedPayloadThatCannotBeReStashedIsReportedMissing(t *testing.T) {
now := time.Unix(0, 0)
// A one-slot reserve (max/2), so another session's payload can hold it against the replay.
st := store.NewMemory(store.Options{MaxEntries: 2, TTLSeconds: 10000, StashTTLSeconds: 100})
st.SetClock(func() time.Time { return now })
m := maskFor(t)
body := strings.Repeat("verbose tool output line\n", 30)

turn := func(maxCached int) string {
t.Helper()
req := &bschemas.BifrostChatRequest{Input: []bschemas.ChatMessage{
tool(body), tool("new tail output"),
}}
c := &components.Ctx{Session: "s", Store: st, CacheAware: true, MaxCachedIdx: maxCached}
var rep components.Report
if _, err := m.Offload(req, &rep, c); err != nil {
t.Fatal(err)
}
return schema.MessageText(req.Input[0])
}

first := turn(-1)
keys := expand.ParseMarkers(first)
if len(keys) != 1 {
t.Fatalf("expected one marker, got %d", len(keys))
}
now = now.Add(101 * time.Second)
if _, ok := expand.Resolve(st, keys[0]); ok {
t.Fatal("the payload outlived stash_ttl_seconds")
}
// Take the freed slot before the replay can have it.
if !st.PutStash("bbbbbbbbbbbbbbb1", []byte("another session's payload")) {
t.Fatal("could not occupy the reserve")
}
missingBefore := StashMissing()

second := turn(0)
// The replay proceeds anyway — declining would flip an already-cached message and cannot
// un-send a marker that went out a turn ago. See commitRefresh.
if second != first {
t.Fatalf("the replay was declined and the message flipped:\n want %q\n got %q", first, second)
}
if got := StashMissing() - missingBefore; got != 1 {
t.Fatalf("stash_missing advanced by %d, want 1: a dangling marker went upstream and the "+
"counter an operator alerts on did not move", got)
}
}
5 changes: 3 additions & 2 deletions dash/redact.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ var configAllowlist = map[string]bool{
"preset": true, "pipeline": true, "mode": true, "cache_mode": true,
"inject_expand": true, "store": true, "components": true,
"store_enabled": true, "store_ttl_seconds": true, "store_max_entries": true,
"store_stash_max_bytes": true,
"listen_addr": true, "openai_upstream": true, "anthropic_upstream": true,
"store_stash_max_bytes": true, "store_stash_ttl_seconds": true,
"listen_addr": true, "openai_upstream": true, "anthropic_upstream": true,
"bob_upstream": true, "force_model": true, "cheap_model": true,
"cheap_model_provider": true, "cheap_model_base": true,
"dashboard": true, "db_path": true, "retention": true, "capture_content": true,
Expand All @@ -90,6 +90,7 @@ var configAllowlist = map[string]bool{
"min_request_tokens": true, "llm_every_n_requests": true, "llm_max_per_request": true,
"marker_mode": true, "min_items": true, "keep_first": true, "keep_last": true,
"enabled": true, "ttl_seconds": true, "max_entries": true, "stash_max_bytes": true,
"stash_ttl_seconds": true,
}

// openKeys name subtrees whose immediate child keys are USER-CHOSEN and therefore
Expand Down
16 changes: 11 additions & 5 deletions docs/how-to/recover-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ sessions — and holds, per session:
- **Rewind** — `cache_key → original bytes`, what the expand loop resolves. These live in a
**reserve** that is never evicted to admit a new payload: once a marker has been sent the
promise is outstanding, so a full reserve makes the pipeline **decline the next removal**
(counted as `stash_refused`) instead of quietly breaking an older one.
(counted as `stash_refused`) instead of quietly breaking an older one. Payloads carry a
**shorter TTL** than everything else (`stash_ttl_seconds`, 1800 s) because each turn's replay
re-derives them from the transcript — see
[why payloads expire sooner](../reference/config.md#why-payloads-expire-sooner-than-decisions).
- **Sticky** — content ids already reduced on earlier turns, so output stays byte-stable
across turns.
- **Frozen decisions** — the exact replacement bytes an offloader replays so an
Expand All @@ -78,10 +81,13 @@ deliberately (with `marker_mode: off`) so `/compact` returns a clean, marker-fre
**The model called expand and got a placeholder back.** The original expired or was evicted
from the store. The provider requires one `tool_result` per `tool_call_id`, so an explicit
placeholder is sent rather than nothing — which turns that offload lossy. Check `stash_missing`
and `stash_expired` at [`/stats`](../reference/routes.md#get-stats): both mean a payload left the
store, so raise `store.ttl_seconds`. `stash_refused` is the *other* case and needs
`store.max_entries` or `store.stash_max_bytes` — there nothing became irreversible, because the
removals were declined.
at [`/stats`](../reference/routes.md#get-stats): that is the one that means a marker went out with
nothing behind it. `stash_expired` on its own does **not** — a reclaimed payload is normally
re-derived by the next turn's replay, counted as `stash_revived` — so the remedy is the reserve
(`store.max_entries` / `store.stash_max_bytes`), which is what refused the re-stash, and
`store.stash_ttl_seconds` only if `stash_expired` is running far ahead of `stash_revived`.
`stash_refused` is the *other* case and needs `store.max_entries` or `store.stash_max_bytes` —
there nothing became irreversible, because the removals were declined.

The turn still **completes**: the placeholder continuation is sent even when *nothing*
resolved, so the model reads "no longer available" and finishes with text. It used to replay
Expand Down
Loading