Skip to content

Commit ea3adfe

Browse files
committed
compaction: idle cache-lapse guard so deep-ride never spikes the price
Deep-ride (95%) is cheap only while the prompt cache holds the prefix. The one case it would cost you: ride a 1M window to 950k, walk away past the cache TTL (~1h), and the next message re-prices all 950k at full input rate. That's the "price went up" case. Fix: a bounded, idle-only pre-compaction. StreamState stamps last_wire_at on every stream start; the Tick handler (update/meta.cpp), while idle and not already compacting, calls should_compact_on_idle() — which fires a compaction only when the prefix is large (>= 200k) AND we've been idle ~48 min (80% of the 1h TTL). The summary runs while the prefix is still a warm cache hit, so the user returns to a small warm context instead of eating a cold full re-price. Bounded to large + near-TTL-idle so ordinary pauses never trip it and active work still rides to 95% — no early compaction during a live session. Net: deep context retention with no price spike, ever. Tests cover should_compact_on_idle (fires only on large prefix + near-TTL idle; never before the first request; never on short idle even with a huge prefix). ARCHITECTURE.md updated. Full compaction/stream/ persist/palette test group green (8/8).
1 parent 1d411b7 commit ea3adfe

5 files changed

Lines changed: 127 additions & 0 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,18 @@ tax:
313313
(~150K) of the most recent transcript, keeping the first user turn so the
314314
original task framing survives. A cheap model compresses 150K of recent
315315
history far better — and cheaper — than 650K in one shot.
316+
- **Idle cache-lapse pre-compaction — the one guard against a price spike.**
317+
Deep-ride is cheap *only while the prompt cache holds the prefix*. The one
318+
case it would cost you: ride to 950K, walk away past the cache TTL (~1h),
319+
and your next message re-prices all 950K at full input rate. So the `Tick`
320+
handler (`update/meta.cpp`) watches for it: `should_compact_on_idle()`
321+
(`domain/session.hpp`) fires a compaction PRE-EMPTIVELY once the session
322+
has been idle ~48 min (`kIdleCompactAfter`, 80 % of the TTL) AND the prefix
323+
is large (≥ `kIdleCompactMinTokens`, 200K). The summary runs while the
324+
prefix is still a warm cache hit (cheap), so you return to a small warm
325+
context instead of eating a cold re-price. It is bounded to large +
326+
near-TTL-idle states, so ordinary pauses never trip it — this is the *only*
327+
proactive early-compaction path.
316328
- **The summarization request itself runs on the cheapest capable model on
317329
the active provider** (the same `cheapest_capable_model` router subagents
318330
use — see §8.5.1), not the flagship model you're chatting with.

include/agentty/domain/session.hpp

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,15 @@ static_assert( is_legal_transition(PhaseKind::ExecutingTool, PhaseKind::Idl
406406
struct StreamState {
407407
Phase phase = phase::Idle{};
408408
std::chrono::steady_clock::time_point last_tick{};
409+
// Wall-clock (steady) of the most recent request we sent to the model —
410+
// stamped when a stream starts. Drives the idle cache-lapse compaction
411+
// trigger (see should_compact_on_idle): the prompt cache that makes a
412+
// deep prefix cheap only survives ~kCacheTtl of idle, so once we've been
413+
// idle nearly that long AND the prefix is large, we compact NOW (while
414+
// the summary is still a cheap cache hit) rather than let the next user
415+
// message re-price the whole prefix at fresh input rate after the cache
416+
// has lapsed. Zero until the first request of the session.
417+
std::chrono::steady_clock::time_point last_wire_at{};
409418
int tokens_in = 0;
410419
int tokens_out = 0;
411420
int context_max = 200000;
@@ -468,6 +477,43 @@ struct StreamState {
468477
if (thr < 0) thr = 0;
469478
return static_cast<int>(thr);
470479
}
480+
481+
// ── Idle cache-lapse pre-compaction ──────────────────────────────────
482+
// The one case where riding deep (95 %) WOULD spike the price: the
483+
// prompt cache that makes a large live prefix cheap only survives a
484+
// bounded idle window (Anthropic's extended TTL is 1 hour; OpenAI's
485+
// prompt-cache reuse similarly decays). Ride to 950 k, walk away past
486+
// the TTL, and your next message re-prices all 950 k at FULL input rate
487+
// — exactly the "price went up" the user never wants.
488+
//
489+
// Fix: while idle, if the prefix is large enough that a cold re-price
490+
// would hurt AND we're approaching the cache TTL, compact PRE-EMPTIVELY.
491+
// The summary request runs now, while the prefix is STILL a warm cache
492+
// hit (cheap), and the user returns to a small warm context instead of a
493+
// huge cold one. Net: the expensive cold re-price never happens.
494+
//
495+
// Bounded so it never fires early during ACTIVE work (that path is the
496+
// 95 % threshold): it needs both a genuinely large prefix
497+
// (kIdleCompactMinTokens) and a near-TTL idle gap.
498+
static constexpr int kIdleCompactMinTokens = 200000;
499+
static constexpr std::chrono::seconds kCacheTtl{3600}; // 1 h
500+
// Fire at 80 % of the TTL so the summary request completes and re-warms
501+
// the (now small) prefix before the old cache would have lapsed.
502+
static constexpr std::chrono::seconds kIdleCompactAfter{2880}; // 48 min
503+
504+
// Should we pre-emptively compact because we've gone idle long enough
505+
// that the prefix cache is about to lapse and a cold re-price looms?
506+
// `est_prefix_tokens` is the calibrated wire estimate of the current
507+
// transcript. Returns false when idle-timing is unknown (no request yet)
508+
// or the prefix is small (a cold re-price of < 200 k is not worth a
509+
// summary round).
510+
[[nodiscard]] bool should_compact_on_idle(
511+
std::chrono::steady_clock::time_point now,
512+
int est_prefix_tokens) const noexcept {
513+
if (last_wire_at.time_since_epoch().count() == 0) return false;
514+
if (est_prefix_tokens < kIdleCompactMinTokens) return false;
515+
return (now - last_wire_at) >= kIdleCompactAfter;
516+
}
471517
// True while a compaction round is in flight: the request that
472518
// includes the synthesised "summarise per spec" prompt has been
473519
// dispatched and the assistant is streaming its summary into the

src/runtime/app/update/meta.cpp

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,34 @@ Step meta_update(Model m, msg::MetaMsg mm) {
607607
return {std::move(m), std::move(midrun_trim)};
608608
if (!settle_freeze_trim.is_none())
609609
return {std::move(m), std::move(settle_freeze_trim)};
610+
611+
// ── Idle cache-lapse pre-compaction ───────────────────────────
612+
// Deep-ride (95 %) is cheap only while the prompt cache holds the
613+
// prefix. If we've sat idle long enough that the cache is about
614+
// to lapse AND the prefix is large, the user's NEXT message would
615+
// re-price the whole thing at full input rate. Pre-empt it: fire
616+
// a compaction NOW while the prefix is still a warm cache hit, so
617+
// they come back to a small warm context. This is the ONLY
618+
// proactive early-compaction path; it is bounded to genuinely
619+
// large + near-TTL-idle states so ordinary pauses never trip it.
620+
if (m.s.is_idle()
621+
&& !m.s.compacting
622+
&& !m.s.autocompact_disabled
623+
&& !m.d.current.messages.empty()) {
624+
const int est = static_cast<int>(
625+
cmd::estimate_wire_tokens(m.d.current) * m.s.est_calibration);
626+
if (m.s.should_compact_on_idle(now, est)) {
627+
// Clear last_wire_at so we don't re-fire every Tick while
628+
// the compaction request is being dispatched; StreamStarted
629+
// re-stamps it when the summary request actually goes out.
630+
m.s.last_wire_at = {};
631+
auto compact_cmd = Cmd<Msg>::task(
632+
[](std::function<void(Msg)> dispatch) {
633+
dispatch(CompactContext{});
634+
});
635+
return {std::move(m), std::move(compact_cmd)};
636+
}
637+
}
610638
return done(std::move(m));
611639
},
612640
[&](Quit) -> Step {

src/runtime/app/update/stream.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,12 @@ Step stream_update(Model m, msg::StreamMsg sm) {
784784
return std::visit(overload{
785785
[&](StreamStarted) -> Step {
786786
auto now = std::chrono::steady_clock::now();
787+
// Stamp the last-request clock for the idle cache-lapse
788+
// pre-compaction trigger (StreamState::should_compact_on_idle).
789+
// NOT gated on !compacting: a compaction request re-warms the
790+
// (soon-to-be-small) prefix too, so it legitimately resets the
791+
// idle timer.
792+
m.s.last_wire_at = now;
787793
// The phase variant guarantees a non-null ctx when active;
788794
// StreamStarted only fires after submit_message / retry has
789795
// already moved us into Streaming.

tests/compaction_threshold_test.cpp

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,41 @@ int main() {
9797
std::puts("threshold: unknown/zero window never triggers");
9898
}
9999

100+
// ── idle cache-lapse pre-compaction ──────────────────────────────────
101+
// The safety net for the ONE case deep-ride would spike price: sitting
102+
// idle past the cache TTL with a huge prefix. It fires only when BOTH
103+
// the prefix is large and the idle gap is near the TTL, and never before
104+
// the first request of the session.
105+
{
106+
using namespace std::chrono;
107+
StreamState s;
108+
const auto t0 = steady_clock::time_point{} + hours{5}; // arbitrary base
109+
110+
// No request sent yet → never fires, regardless of size/idle.
111+
assert(!s.should_compact_on_idle(t0 + hours{2}, 500000));
112+
113+
s.last_wire_at = t0;
114+
115+
// Large prefix + long idle (48+ min) → FIRES.
116+
assert(s.should_compact_on_idle(t0 + StreamState::kIdleCompactAfter,
117+
300000));
118+
assert(s.should_compact_on_idle(t0 + minutes{55}, 300000));
119+
120+
// Large prefix but still ACTIVELY working (short idle) → does NOT
121+
// fire — no early compaction during a live session.
122+
assert(!s.should_compact_on_idle(t0 + minutes{5}, 900000));
123+
assert(!s.should_compact_on_idle(t0 + minutes{30}, 900000));
124+
125+
// Long idle but SMALL prefix → not worth a summary round.
126+
assert(!s.should_compact_on_idle(t0 + hours{2},
127+
StreamState::kIdleCompactMinTokens - 1));
128+
129+
// Exactly at the min-tokens boundary + past the idle gap → fires.
130+
assert(s.should_compact_on_idle(t0 + hours{2},
131+
StreamState::kIdleCompactMinTokens));
132+
std::puts("idle-compaction: fires only on large prefix + near-TTL idle");
133+
}
134+
100135
std::puts("ALL COMPACTION-THRESHOLD TESTS PASSED");
101136
return 0;
102137
}

0 commit comments

Comments
 (0)