From a72e49aef117eed908f7faf3912a8e3409368647 Mon Sep 17 00:00:00 2001 From: bkrabach <702425+bkrabach@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:07:34 -0700 Subject: [PATCH 1/7] Revert "fix: _capture_fork_prefix no longer forks onto an array that was never sent (#jnt) (#28)" This reverts commit 5cdbc62253ca80cb93f11a2fa6f7a5d3940394c78. Not in the owner's literal revert list (#20-27), but merged AFTER #27 and exists solely to fix a bug in the summary_call_mode="fork" code path that #27 introduced. Once #27 (the fork feature itself) is reverted next, this fix has no surviving target -- keeping it would leave dead code / an orphaned test file referencing a feature no longer on main. Reverting it first, before #27, keeps the revert of #27 itself clean. --- DONE-NOTE.md | 174 --------------- amplifier_module_context_simple/__init__.py | 138 ++---------- tests/test_summary_call_mode_fork.py | 229 +------------------- 3 files changed, 30 insertions(+), 511 deletions(-) diff --git a/DONE-NOTE.md b/DONE-NOTE.md index c790066..0bd7671 100644 --- a/DONE-NOTE.md +++ b/DONE-NOTE.md @@ -1129,177 +1129,3 @@ would bury this change in unrelated noise. No PII, no team-internal data, no individual attribution. No merges to main. No files touched outside this module. No infrastructure created; nothing to tear down. - ---- - -# DONE-NOTE - model_performance-jnt - -**`_capture_fork_prefix()` appends to an array that was never sent (11 of 20 -forks measured).** - -**Verdict: CORRUPTING, not cosmetic.** It is not dead code and it produces a -record. It silently substitutes a message array that never went on the wire -for one that did, on a path whose entire purpose is byte-parity with the wire. -Fixed. **No 6da measured number is invalidated** — see §3. - -## 1. The array, named, at file:line - -Everything below is `amplifier_module_context_simple/__init__.py` at the -pre-fix commit `a877b36`. - -| | | -|---|---| -| **the array** | **`self._last_request_view`** | -| created | **:1015** (`__init__`), written **:1778** inside `_finalize_view()` | -| what it holds | this module's own last RETURNED view, pre-strip | -| why it is "never sent" | it is written on **every view served**, and a view served is not a request sent | -| consumed by | **:4187** `_capture_fork_prefix()`, as the fallback source | -| flows to | `_maybe_trigger_summary_compaction` **:4040** → `_run_summary_compaction_task` → `_build_fork_request` **:4422** → the provider | - -The selection at **:4187–4198** preferred the caller's recorded wire array -(`_sent_messages`, **:1017/:1210**) only while -`_sent_serial == _view_serial` (**:4189**), and substituted -`_last_request_view` whenever that equality failed (**:4187**, **:4192–4197** -— `logger.debug`, not warning). - -**Why the equality fails in production.** `_view_serial` counts **views -served**, not **requests sent**, and the two are not 1:1. The real -orchestrator serves the view more than once per sent request: -`amplifier-module-loop-streaming/__init__.py` calls -`context.get_messages_for_request()` at **:3215** and then re-fetches at -**:3329** and **:3453** after persisting an ephemeral injection — up to three -views for one `ChatRequest`. The summary trigger is evaluated inside *every* -one of them (**:1364**, which runs *before* that call's `_finalize_view` at -**:1480/:1482**). So: - -- trigger on the **first** view of a request → serials match → the wire array - is used → **correct fork** (wire offset 0 or 1); -- trigger on a **re-fetched** view → serials differ by 1–2 → the wire array is - discarded in favour of `_last_request_view`, which at that instant holds - **the view the re-fetch just superseded** — built, thrown away, never sent. - -That is the 9-vs-11 split 6da measured, and it reproduces exactly. - -## 2. Consequence — unambiguous - -**Corrupting to fork-mode behaviour, and self-concealing.** - -1. **Guaranteed cache miss.** A superseded view is not a prefix any provider - holds. Fork mode's only justification is appending onto a cached prefix; a - fork that misses pays full price for the whole conversation, which is - *strictly worse* than the standalone call it replaces. -2. **Silent.** `last_summary_call_stats["mode_used"]` reported `"fork"` for - all 20 calls. The substitution was `logger.debug`. There was no field - distinguishing the two sources — this is precisely why 6da had to - reconstruct the distinction from the provider's request log. -3. **Backwards under uncertainty.** The check traded the array with *positive - evidence* of having been sent (the caller said so) for one with *none* - (fate unknown to this module), and did so exactly when uncertainty was - highest. - -Not affected: history, `_seq` allocation, span selection, tool-pair -integrity, the served view, or any default-mode behaviour. The blast radius -is fork mode's cache economics and the honesty of its self-report. - -## 3. Does this invalidate any of 6da's measured numbers? **NO.** - -Stated plainly for the manager, because the item asked for it loudly: - -- **G-FORK-PREFIX (2/7/11 offset distribution, 45% aligned) — VALID, and is - the direct measurement of this bug.** Scored from the wire, not from the - module's self-report. -- **G-FORK-CACHED, G-FORK-NOBOUNDARY, the Anthropic guardrail, quality - parity, and the −0.8% run-cost delta — VALID.** All are wire/usage-derived - and none depend on `_capture_fork_prefix()` having chosen correctly. -- **The §7 correction (summarizer share ≈30%, not 2.4%/8.3–10.9%) — VALID - and untouched.** Independent of the fork path. - -**One caveat, in 6da's favour, not against it:** the −0.8% run-cost delta was -measured with only ~45% of forks byte-aligned. It is a **lower bound** on -what a correctly-aligned fork arm would deliver, not an upper bound. 6da's -"the mechanism works and the lever does not pay / DON'T-SHIP as-is" verdict -therefore **still stands as written**, but its cost figure is now known to -have been measured on a partially-broken treatment and should be **re-measured -before the DON'T-SHIP call is made final**. 6da itself flagged this -("one of them has a cheap fix worth a follow-up item"); this is that fix. - -## 4. The fix (minimal) - -`_capture_fork_prefix()` (**:4176**) no longer substitutes: - -- a recorded wire array, when one exists, is **used** — it is the only source - carrying positive evidence it was on the wire, so it is never traded for one - that carries none. Extra views served since the send are **reported, not - acted on**; -- the module's own view is used **only** when the caller has never supplied a - message array (the documented explicit-breakpoint/Anthropic path, unchanged); -- staleness in the wire record is still caught, but by an **exact** check - instead of a proxy: a record too old to contain the span fails - `_prefix_contains_span` and **refuses LOUDLY** — standalone call, `WARNING`, - `_summary_fork_fallbacks` incremented, named `reason`. "A fork that silently - missed" is no longer reachable on this path. - -Also added, because 6da needed it and could not get it: **`prefix_source`** -(`"wire_record"` / `"module_view"` / `None`) and **`prefix_views_since_send`** -on `last_summary_call_stats`. The next arm can separate the two populations -from the module's own report instead of reconstructing them from the wire. - -**Config surface: unchanged. Default `summary_call_mode` remains -`"standalone"`.** - -## 5. Tests - -`286 passed, 1 skipped` (was 281 passed at `a877b36`). `ruff check`: clean. - -New Group F in `tests/test_summary_call_mode_fork.py`: - -| Test | Pins | -|---|---| -| `test_a_re_fetched_view_does_not_displace_the_recorded_wire_array` | THE regression: a superseding re-fetch must not displace the wire array | -| `test_the_module_view_is_never_substituted_when_a_wire_record_exists` | same defect from the other side: never-sent content cannot reach the fork | -| `test_prefix_source_names_the_module_view_path_honestly` | the module-view path is allowed but reported as what it is | -| `test_prefix_source_is_none_when_the_call_did_not_fork` | a refused fork claims no alignment | -| `test_tool_pair_integrity_and_seq_stability_survive_the_re_fetch_path` | no `_seq` consumed, history byte-identical, same span absorbed, served view identical to an unforked control | - -**These three fail against the pre-fix selection logic and pass against the -fix** — verified by temporarily restoring the old branch and re-running; they -are load-bearing, not decoration. - -One existing test changed: `test_a_stale_caller_message_record_is_ignored_not_trusted` -→ `test_a_stale_caller_message_record_refuses_loudly_not_silently`. It -asserted the substitution *as correct behaviour*; it now asserts the loud -refusal. The rewritten docstring records why the original resolution was -wrong, so the reversal is not silent. - -Default-mode byte-identity re-verified by the pre-existing Group A/C tests, -strengthened with `assert context._fork_prefix_source is None` in -`test_default_mode_never_records_a_fork_prefix`. - -## 6. Residual, disclosed - -The **module-view path** (`note_request_sent(tools=...)` with no `messages`) -can still append to a superseded view — this module genuinely cannot know -whether its own view was sent. Not silently, now: `prefix_source == -"module_view"` says so on every call. **A caller that wants byte-parity must -pass `messages`.** Closing this properly needs a caller-side confirmation -signal, which is an orchestrator change and out of this lane's scope. - -Unchanged and still true: fork mode cannot fork the **first** summarization of -a CLI turn (each turn is a fresh `amplifier run --resume` process, and the -trigger is evaluated before any request is sent). 6da measured 12 of 24 -refusals from this; this fix does not address it. - -## 7. Deliverable ledger - -| Deliverable | Status | -|---|---| -| DRAFT PR on origin, branch `lane/jnt-fork-prefix-capture`, tests green, default inline byte-identical | **DONE** | -| The array named at file:line with why it was never sent + cosmetic-vs-corrupting verdict | **DONE** — §1, §2 (**corrupting**) | -| Explicit statement of whether any of 6da's measured fork numbers are invalidated | **DONE** — §3 (**none invalidated**; −0.8% is a lower bound and warrants re-measurement) | -| DONE-NOTE.md in the PR body | **DONE** — this section | - -**Spend: $0.00.** No API calls, no DTU, no containers, no infrastructure -created — the item was answerable from the code, the shipped tests, and 6da's -existing evidence files. Nothing to tear down; nothing registered in the infra -ledger. No PII or team-internal data. No merge to main. No files touched -outside this module. diff --git a/amplifier_module_context_simple/__init__.py b/amplifier_module_context_simple/__init__.py index 47f8055..9fed112 100644 --- a/amplifier_module_context_simple/__init__.py +++ b/amplifier_module_context_simple/__init__.py @@ -360,24 +360,6 @@ # "inline" is accepted as an alias for "standalone": the lane brief that # commissioned this work named the default mode "inline" while the work # item named it "standalone". Both mean "today's behavior, unchanged". -# Where a forked summarizer call's prefix came from, reported verbatim on -# `last_summary_call_stats["prefix_source"]`. This distinction is not -# cosmetic: only ONE of these two sources carries positive evidence that -# the array was ever on the wire. -# -# "wire_record" -- the caller's own note_request_sent(messages=...) -# record. The caller told us it sent exactly this. -# "module_view" -- this module's last RETURNED view. Whether it was -# actually sent is unknown to this module: an -# orchestrator may append a tail to it, or discard it -# entirely and re-fetch a fresh view before sending -# (amplifier's loop-streaming does exactly that, 1-3 -# times per sent request). A superseded view was never -# on the wire, so forking onto one is a guaranteed -# cache miss wearing a correct-looking API call. -FORK_PREFIX_SOURCE_WIRE = "wire_record" -FORK_PREFIX_SOURCE_VIEW = "module_view" - SUMMARY_CALL_MODE_STANDALONE = "standalone" SUMMARY_CALL_MODE_FORK = "fork" _VALID_SUMMARY_CALL_MODES = (SUMMARY_CALL_MODE_STANDALONE, SUMMARY_CALL_MODE_FORK) @@ -1037,11 +1019,6 @@ def __init__( self._sent_tools: Any = None self._sent_tools_supplied: bool = False self._sent_model: str | None = None - # Which of the two sources the last captured fork prefix came from - # (FORK_PREFIX_SOURCE_*). Reported on `last_summary_call_stats` so a - # measurement can tell a wire-parity fork from a module-view fork - # WITHOUT reconstructing it from the provider's request log. - self._fork_prefix_source: str | None = None # Observability: what the LAST summarizer call actually did, and how # many times a requested fork had to fall back. An eval arm reads # these to tell a real fork from a silently unforked one. @@ -1203,19 +1180,12 @@ def note_request_sent( enters `self.messages`, consumes a `_seq`, or moves any compaction state. Callers that do not know about it lose nothing. - Call it EVERY request, not once. The most recent `messages` record - is the only array this module has positive evidence was ever on the - wire, so it is what a fork appends to -- a one-time wiring would - have turn 40's fork append to turn 1's request. That case is not - silently rerouted (rerouting is what produced the never-sent-array - bug); it is caught exactly, by the span-presence check, and refused - LOUDLY as a standalone call with a named reason. - - Calling it more than once per request is harmless, and so is - serving the view more than once per request: extra views do not - invalidate the record. `last_summary_call_stats` reports - `prefix_views_since_send` so that re-fetching is observable rather - than inferred. + Call it EVERY request, not once. A `messages` record is only used + while it still describes the most recent request this module served + -- a one-time wiring would otherwise have turn 40's fork append to + turn 1's request, which is a guaranteed cache miss dressed up as a + correct API call. A stale record is ignored (this module's own last + view is used instead), never trusted. Args: messages: The exact message array sent, if known. Gives the fork @@ -1250,11 +1220,9 @@ def last_summary_call_stats(self) -> dict[str, Any] | None: None before the first one. Otherwise a dict with `mode_requested`, `mode_used`, `reason` (None when the requested mode was honored), - `prefix_messages`, `prefix_source` (FORK_PREFIX_SOURCE_*, None when - not forked), `prefix_views_since_send`, and `fork_fallbacks` - (session-cumulative). This is how an eval arm distinguishes a real - fork from a silently unforked one -- and a wire-parity fork from a - module-view one -- WITHOUT patching the module. + `prefix_messages`, and `fork_fallbacks` (session-cumulative). This + is how an eval arm distinguishes a real fork from a silently + unforked one WITHOUT patching the module. """ return dict(self._last_summary_call) if self._last_summary_call else None @@ -1662,7 +1630,6 @@ def _reset_summary_strategy_state(self) -> None: self._sent_tools = None self._sent_tools_supplied = False self._sent_model = None - self._fork_prefix_source = None async def should_compact(self) -> bool: """Check if context should be compacted. @@ -4209,68 +4176,26 @@ def _fork_armed(self) -> bool: def _capture_fork_prefix(self) -> list[dict[str, Any]] | None: """Snapshot the message array a forked call would append to. - Uses the caller's own `note_request_sent(messages=...)` record when - there is one -- byte-parity with the wire, including any tail the - orchestrator injected after this module returned. Only when the - caller has never supplied a message array does this fall back to - the module's own last returned view, which is where an + Prefers the caller's own `note_request_sent(messages=...)` record + (byte-parity with the wire, including any tail the orchestrator + injected after this module returned). Falls back to this module's + last returned view, which still ends exactly where an explicit-breakpoint provider places its cache breakpoint. - - WHY THERE IS NO SILENT DOWNGRADE BETWEEN THE TWO (the bug this - replaced): the previous implementation preferred the wire record - only while `_sent_serial == _view_serial`, and substituted - `_last_request_view` whenever that equality failed. That equality - asks "have any views been served since the caller last confirmed a - send?" -- which conflates two situations a view counter cannot tell - apart, and gets the important one backwards. - - A real orchestrator serves the view MORE THAN ONCE per sent request - (amplifier's loop-streaming re-fetches after persisting an ephemeral - injection -- `get_messages_for_request()` at three separate call - sites in one iteration). The summary trigger is evaluated inside - EVERY one of those calls. On the second and third, the serials no - longer match, and the old code swapped the caller's genuine wire - array for `_last_request_view` -- which at that instant holds a view - that was built, superseded by the re-fetch, and NEVER SENT. - - Measured on the wire (model_performance-6da, 20 forked calls): - 9 appended to a request the provider actually saw; the other 11 - appended to an array that was never sent as any request. The - substitution was invisible from outside -- `mode_used` reported - "fork" either way. - - The asymmetry that settles it: the wire record is the only source - carrying positive evidence that it was ever on the wire, so it is - never traded for one that carries none. Staleness in the wire record - is still caught, but by an EXACT check rather than a proxy: a record - too old to contain the span being absorbed fails - `_prefix_contains_span` and refuses LOUDLY (standalone + warning + - counter), which is the outcome a fork that cannot be byte-aligned is - supposed to have. """ if not self._fork_armed(): return None + source = self._last_request_view if self._sent_messages is not None: - self._fork_prefix_source = FORK_PREFIX_SOURCE_WIRE - if self._sent_serial != self._view_serial: - # Not an error, and deliberately not a substitution: the - # caller has served extra views since confirming this send - # (a re-fetch, or a view that was never sent at all). The - # last CONFIRMED array is still the best-evidenced prefix. + if self._sent_serial == self._view_serial: + source = self._sent_messages + else: logger.debug( - "context-simple: fork prefix is the caller's recorded " - f"wire array from view {self._sent_serial} (now at view " - f"{self._view_serial}); {self._view_serial - (self._sent_serial or 0)} " - "view(s) have been served since it was confirmed sent, " - "which is normal for an orchestrator that re-fetches the " - "view within a single request" + "context-simple: ignoring a stale note_request_sent() " + f"message record (recorded at view {self._sent_serial}, " + f"now at view {self._view_serial}); appending to this " + "module's own last returned view instead" ) - return list(self._sent_messages) - if self._last_request_view is not None: - self._fork_prefix_source = FORK_PREFIX_SOURCE_VIEW - return list(self._last_request_view) - self._fork_prefix_source = None - return None + return list(source) if source is not None else None @staticmethod def _message_identity(msg: dict[str, Any]) -> tuple[str, str, str]: @@ -4549,25 +4474,6 @@ async def _run_summary_compaction_task( if call_mode == SUMMARY_CALL_MODE_FORK else 0 ), - # WHICH array the fork appended to, from the module's own - # report rather than reconstructed from the wire. Only one - # of the two sources is evidenced as having been sent (see - # FORK_PREFIX_SOURCE_*), so a measurement that cannot see - # this field cannot tell a byte-aligned fork from a fork - # onto a view the provider never received. - "prefix_source": ( - self._fork_prefix_source - if call_mode == SUMMARY_CALL_MODE_FORK - else None - ), - # How many views were served since the caller last confirmed - # a send. >0 is normal (an orchestrator may re-fetch the view - # within one request); it is reported, not acted on. - "prefix_views_since_send": ( - self._view_serial - self._sent_serial - if self._sent_serial is not None - else None - ), "fork_fallbacks": self._summary_fork_fallbacks, } diff --git a/tests/test_summary_call_mode_fork.py b/tests/test_summary_call_mode_fork.py index c75af84..e039f93 100644 --- a/tests/test_summary_call_mode_fork.py +++ b/tests/test_summary_call_mode_fork.py @@ -242,7 +242,6 @@ async def test_default_mode_never_records_a_fork_prefix(): assert context._last_request_view is None assert context._sent_tools_supplied is False assert context._summary_fork_fallbacks == 0 - assert context._fork_prefix_source is None assert context.last_summary_call_stats is None @@ -608,24 +607,11 @@ async def test_fork_refuses_when_the_prefix_ends_on_unanswered_tool_calls(): @pytest.mark.asyncio -async def test_a_stale_caller_message_record_refuses_loudly_not_silently(caplog): +async def test_a_stale_caller_message_record_is_ignored_not_trusted(): """A caller that wires note_request_sent() ONCE (startup helper, first - turn only) would have turn N's fork append to turn 1's request. - - This test previously asserted the OPPOSITE resolution -- that the - module silently substituted its own last returned view. That - substitution is the defect this file now pins against - (model_performance-jnt): the module cannot know whether its own view - was ever sent, and under a real orchestrator that re-fetches the view - within a single request, the view it substitutes is one that was - superseded and never went on the wire. Trading an array KNOWN to have - been sent for one whose fate is unknown is backwards, and it was - invisible -- `mode_used` said "fork" either way. - - The correct resolution for a record too old to be usable is the one - every other misalignment already gets: refuse, run standalone, and SAY - SO. Caught exactly (the span is not in that prefix), not by a proxy. - """ + turn only) would otherwise have turn N's fork append to turn 1's + request -- a guaranteed miss AND a wasted cache write, wearing a + correct-looking API call. The fresh module view must win.""" context = _summary_manager(summary_call_mode="fork") await _fill(context, turns=10) # Turn 1: the caller records what it sent. @@ -639,28 +625,13 @@ async def test_a_stale_caller_message_record_refuses_loudly_not_silently(caplog) _cross_trigger(context) provider = _FakeProvider() - with caplog.at_level(logging.WARNING): - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - stats = context.last_summary_call_stats - assert stats["mode_used"] == "standalone", ( - "a prefix too stale to contain the span must refuse, not fork onto " - "a substituted view" - ) - assert "not present in the recorded prefix" in stats["reason"] - assert context._summary_fork_fallbacks == 1 - assert any("ran STANDALONE instead" in r.message for r in caplog.records) + await context.get_messages_for_request(provider=provider) + await _await_pending_task(context) - # The substitution specifically must not have happened: the standalone - # request is the two-message one, not an append onto the fresh view. request = provider.calls[0] - assert len(request.messages) == 2 - assert _digest(request.messages) != _digest(fresh_view) + assert context.last_summary_call_stats["mode_used"] == "fork" + assert _digest(request.messages[:-1]) == _digest(fresh_view) assert not any("long ago" in str(m.content) for m in request.messages) - # And the summary still happened -- refusing costs today's price, never - # the summary itself. - assert context._pending_summary is not None @pytest.mark.asyncio @@ -902,187 +873,3 @@ async def test_concurrent_forks_are_still_serialized_by_the_in_flight_guard(): assert len(provider.calls) == 1 assert context._pending_summary is not None assert context.last_summary_call_stats["mode_used"] == "fork" - - -# --------------------------------------------------------------------------- -# Group F -- the fork prefix is an array that was actually SENT -# -# model_performance-jnt. Measured on the wire (model_performance-6da, 20 -# forked calls): 9 appended to a request the provider actually saw, and 11 -# appended to an array that was never sent as any request. The module -# reported `mode_used == "fork"` for all 20. -# -# Cause: `_capture_fork_prefix()` preferred the caller's recorded wire array -# only while `_sent_serial == _view_serial`, and substituted -# `_last_request_view` otherwise. A real orchestrator serves the view more -# than once per sent request (amplifier's loop-streaming re-fetches after -# persisting an ephemeral injection), the trigger is evaluated inside every -# one of those calls, and on the second the substituted view is one that was -# superseded by the re-fetch and never went on the wire. -# -# These tests are written against that substitution, not against the happy -# path -- which the existing Group B tests already cover. -# --------------------------------------------------------------------------- - - -async def _serve_below_trigger(context: SimpleContextManager) -> list[dict]: - """Serve one more view without re-arming, i.e. a re-fetch within the - same request. `_arm_below_trigger` is the first view of a request; this - is the second one, which the orchestrator then supersedes.""" - view = await context.get_messages_for_request(provider=_FakeProvider()) - assert context._is_summarizing is False - return view - - -@pytest.mark.asyncio -async def test_a_re_fetched_view_does_not_displace_the_recorded_wire_array(): - """THE REGRESSION. An extra view served since the caller confirmed its - send is normal, not a reason to stop trusting the send. The wire array - is the only one with positive evidence of having been on the wire; the - intervening view has none.""" - context = _summary_manager(summary_call_mode="fork") - await _fill(context) - module_view = await _arm_below_trigger(context) - wire = [ - *module_view, - {"role": "user", "content": "injected"}, - ] - context.note_request_sent(wire, tools=_tools()) - sent_at = context._view_serial - - # The orchestrator re-fetches the view within the same request. This - # view is built, superseded, and never sent. - superseded = await _serve_below_trigger(context) - assert context._view_serial > sent_at, "the re-fetch must advance the view serial" - - _cross_trigger(context) - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - stats = context.last_summary_call_stats - assert stats["mode_used"] == "fork" - assert stats["prefix_source"] == "wire_record" - assert stats["prefix_views_since_send"] >= 1, ( - "the re-fetch must be visible in the stats, not silently acted on" - ) - - request = provider.calls[0] - assert _digest(request.messages[:-1]) == _digest(wire), ( - "the fork must append to the array the caller said it sent" - ) - assert _digest(request.messages[:-1]) != _digest(superseded) - assert "injected" in request.messages[-2].content - - -@pytest.mark.asyncio -async def test_the_module_view_is_never_substituted_when_a_wire_record_exists(): - """Belt and braces on the same defect, asserted from the other side: a - `_last_request_view` holding content that was demonstrably never sent - must not be able to reach the forked request at all.""" - context = _summary_manager(summary_call_mode="fork") - await _fill(context) - module_view = await _arm_below_trigger(context) - context.note_request_sent(module_view, tools=_tools()) - # A view that was built and discarded -- exactly what a re-fetch leaves - # behind, and what the old code would have forked onto. - context._last_request_view = [ - {"role": "user", "content": "a view that was never sent"} - ] - context._view_serial += 1 - - _cross_trigger(context) - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - request = provider.calls[0] - assert context.last_summary_call_stats["mode_used"] == "fork" - assert context.last_summary_call_stats["prefix_source"] == "wire_record" - assert not any("never sent" in str(m.content) for m in request.messages) - assert _digest(request.messages[:-1]) == _digest(module_view) - - -@pytest.mark.asyncio -async def test_prefix_source_names_the_module_view_path_honestly(): - """When the caller supplies tools but never a message array, the fork - appends to this module's own view -- whose send this module cannot - confirm. That is still allowed (it is what an explicit-breakpoint - provider wants), but it must be REPORTED as what it is, so a - measurement can separate the two populations without patching.""" - context = _summary_manager(summary_call_mode="fork") - await _fill(context) - context.note_request_sent(tools=_tools()) - parent_view = await _arm_below_trigger(context) - _cross_trigger(context) - - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - stats = context.last_summary_call_stats - assert stats["mode_used"] == "fork" - assert stats["prefix_source"] == "module_view" - assert stats["prefix_views_since_send"] is None, ( - "no message array was ever recorded, so there is no send to count from" - ) - assert _digest(provider.calls[0].messages[:-1]) == _digest(parent_view) - - -@pytest.mark.asyncio -async def test_prefix_source_is_none_when_the_call_did_not_fork(): - """A refused fork reports no prefix source. Reporting one would make a - standalone call look byte-aligned with something.""" - context = _summary_manager(summary_call_mode="fork") - await _fill(context) - _cross_trigger(context) - - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - stats = context.last_summary_call_stats - assert stats["mode_used"] == "standalone" - assert stats["prefix_source"] is None - - -@pytest.mark.asyncio -async def test_tool_pair_integrity_and_seq_stability_survive_the_re_fetch_path(): - """The re-fetch path must change WHICH array is appended to and nothing - else: the same span is selected, no `_seq` is consumed, history is - untouched, and the next served view matches an unforked control.""" - forked = _summary_manager(summary_call_mode="fork") - control = _summary_manager() - for ctx in (forked, control): - await _fill(ctx) - - view = await _arm_below_trigger(forked) - forked.note_request_sent([*view, {"role": "user", "content": "tail"}], tools=_tools()) - await _serve_below_trigger(forked) # the superseding re-fetch - await _arm_below_trigger(control) - for ctx in (forked, control): - _cross_trigger(ctx) - - seq_before = forked._next_seq - history_before = json.dumps(_strip_timestamps(forked.messages), default=str) - - for ctx in (forked, control): - await ctx.get_messages_for_request(provider=_FakeProvider("SAME SUMMARY")) - await _await_pending_task(ctx) - - assert forked.last_summary_call_stats["mode_used"] == "fork" - assert forked.last_summary_call_stats["prefix_source"] == "wire_record" - assert forked._next_seq == seq_before, "the fork must not consume a _seq" - assert ( - json.dumps(_strip_timestamps(forked.messages), default=str) == history_before - ), "the fork must not append to, reorder, or edit history" - assert set(forked._pending_summary["seqs"]) == set(control._pending_summary["seqs"]), ( - "the call mode must not change WHICH span is absorbed" - ) - - for ctx in (forked, control): - ctx.compact_threshold = 0.3 - forked_view = await forked.get_messages_for_request(provider=_FakeProvider()) - control_view = await control.get_messages_for_request(provider=_FakeProvider()) - assert _strip_timestamps(forked_view) == _strip_timestamps(control_view) - assert forked._removed_seqs == control._removed_seqs From d157004324b077ab3965ac6a5c8f0d67192dbf70 Mon Sep 17 00:00:00 2001 From: bkrabach <702425+bkrabach@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:07:40 -0700 Subject: [PATCH 2/7] Revert "feat: summary_call_mode -- cache-safe fork of the summarization call (#27)" This reverts commit a877b36671e04add7d472c93240a606540533417. Merge policy: main carries wins only. summary_call_mode's fork mode was shipped with benefit explicitly labeled unmeasured (see the original merge note: "benefit correctly labeled unmeasured"). Unproven default-off feature -- belongs on a branch for evaluation, not on main. --- DONE-NOTE.md | 217 ----- README.md | 122 --- amplifier_module_context_simple/__init__.py | 594 +------------ tests/test_summary_call_mode_fork.py | 875 -------------------- 4 files changed, 16 insertions(+), 1792 deletions(-) delete mode 100644 tests/test_summary_call_mode_fork.py diff --git a/DONE-NOTE.md b/DONE-NOTE.md index 0bd7671..412d612 100644 --- a/DONE-NOTE.md +++ b/DONE-NOTE.md @@ -12,7 +12,6 @@ the other. Each note keeps its own `# DONE-NOTE - ` heading verbatim. | `model_performance-x7p` | `protected_tool_results=0` protected ALL tool results (negative-slice bug) | | `model_performance-x1r` | tool-result budget (token-denominated, head+tail, per-tool) + spill-to-disk | | `model_performance-2o9` | `clear_at_least` — a worth-the-rebuild predicate in front of compaction (+ summary shrink guard) | -| `model_performance-7k2` | `summary_call_mode` — cache-safe forking of the summarization call | --- @@ -913,219 +912,3 @@ property of the module, not new exposure, but now with one more field. No PII, no team-internal data, no individual attribution in any output. No merges to main; no files touched outside this module. - ---- - -# DONE-NOTE — W3-2 / `model_performance-7k2` - -`context-simple: cache-safe forking of the summarization call (summary_call_mode: "fork")` - -**Status: draft PR, mechanism shipped behind an off-by-default flag, nothing -measured.** Default is byte-identical to today. Lane spend: **$0.00** (the -item's authority was $0; no API calls, no DTU, no infrastructure created, -nothing to tear down). - -## 1. Step 0 — the free measurement that could have cancelled this item - -The item said: read the summarizer call; if it already prepends the parent's -assembled request, §2h conflict #8 resolves in favour of [P6]'s 2.4% table -figure and **there is nothing to build**. - -It does not. `_run_summary_compaction_task` built, verbatim: - -```python -request = ChatRequest( - messages=[ - Message(role="system", content=prompt), # the ~955-char prompt - Message(role="user", content=formatted), # the span, re-rendered as plain text - ], - model=self.summarization_model, -) -``` - -No parent history. No tools. Its own `role: "system"` prompt. This is the -standalone shape, so **the branch that cancels the item does not apply** and -the fix is real work. §2d's indirect evidence was right: G3 had to split out -the summarizer's own `instructions` population to get a clean hash precisely -*because* the summarizer sends its own separate system prompt. - -**What this does NOT resolve:** the 8.3–10.9% (prose) vs 2.4% (same source's -own table) conflict about the summarizer's cost *share*. Being standalone -makes the cost avoidable; it does not say how much cost there is. That number -must come from a run's own per-call table, and the follow-up item requires it. - -## 2. What shipped - -`summary_call_mode: "standalone" | "fork"`, default `"standalone"`, consulted -only when `compaction_strategy == "summary"`. - -- **`"standalone"`** — the pre-existing two-message call, unchanged. - `"inline"` is accepted as an alias (the lane brief and the work item named - the same default differently; both mean "today's behaviour"). Unknown values - warn and fall back, matching every other enum in this module. -- **`"fork"`** — the same ask re-issued as a **pure append**: - `[...the exact messages of the last request...] + [one user message: prompt + scope]`. - Pure append is the one mutation measured as a HIT under grow-only - (P4: identical-repeat 9,789 HIT, pure-append 9,789 HIT, truncation 0, - middle-drop 0). - -Three consequences, each load-bearing rather than incidental: - -1. **The span is not re-sent.** It is already inside the prefix. Re-sending it - would cost what standalone costs today *plus* the prefix — a regression - wearing an optimization's clothes. This is the single decision that - determines whether the feature is worth anything. -2. **The prompt moves into the appended `role: "user"` message.** A fork must - not add a `role: "system"` message: providers hoist every system-role - message into one top-level system block, so a per-summarization one would - rewrite the cached system prefix — the exact failure already measured for - the summary tier (cache_read 46,307 → 21,523) and the compaction notice. -3. **Scope must be stated.** Standalone scopes by construction (it can only - see the span). A fork can see everything, so the appended message names the - span: its message count plus a bounded (300-char) verbatim excerpt of its - final message as the "summarize up to here" marker. **This is a real - behavioural difference between the two modes**, not formatting — flagged as - a residual risk in the follow-up item, not hand-waved. - -New optional public seam: **`note_request_sent(messages=None, *, tools=None, -model=None)`** — see §3, which is the finding this lane most wants read. - -Observability: `last_summary_call_stats` property (`mode_requested`, -`mode_used`, `reason`, `prefix_messages`, `fork_fallbacks`) and a `call_mode` -field on `context:pre_summarize` / `context:post_summarize`, so an eval arm -can count *real* forks without patching the module. - -Size: **170 executable lines** across the new helpers (item estimated 50–70). -The overrun is entirely the refusal ladder (§3) and the staleness guard (§4); -the happy path is ~30 lines. - -## 3. The finding: this cannot be done from inside the context module alone - -**Tool specs are part of the cached prefix and this module is never handed -them.** Anthropic serializes `tools` *ahead of* the system block; OpenAI's -implicit cache matches forward from a cached entry. A summarizer request with -`tools=None` diverges from the parent at byte zero, so it hits nothing — and -because it now carries the whole conversation, it costs *more than the -standalone call it replaced*. That is not a missed win, it is a regression. - -I looked for a way to obtain them without changing any caller and found none -that is contract-backed: - -- `amplifier_core.interfaces` defines no tool-registry protocol; the - coordinator exposes `mount_points`, not tool specs in send order. -- The `llm:request` event carries `model`/`message_count`/thinking flags, and - the actual tool payload only under provider `raw: true` — where it is wire - dicts passed through `redact_secrets()`, i.e. not reconstructable into - `ToolSpec` byte-faithfully. -- The default orchestrator lives in the compiled Rust engine, so there is no - seam to read it from. - -So fork mode requires the caller to say what it sent. **Decision taken without -waiting** (per SCOPE-OUTS): add one optional public method rather than a -config knob that lets an operator *assert* alignment they cannot verify. -Passing `tools` **at all** — even `None`/`[]` for a genuinely tool-free -session — is what arms the fork; "not supplied" and "supplied as empty" are -deliberately distinguishable, because guessing between them is exactly how a -fork silently misaligns. - -**Every misalignment refuses and falls back to standalone, loudly** — WARNING -naming the precondition (once per distinct reason, so a session that can never -fork costs a handful of log lines, not one per summarization), a session -counter, and the mode actually used reported on both hooks and the stats -property. The refusals: seam never called · `summarization_model` set (a -summarizer routed elsewhere reads none of the main line's cache — deepseek -states this explicitly) · no request recorded yet · prefix ends on an assistant -turn with unanswered `tool_calls` (appending there would interleave between -`tool_use` and `tool_result`) · span absent from the recorded prefix · the -forked request fails to build. - -Falling back to *standalone* rather than skipping is deliberate: standalone is -correct and costs what it always did. Losing a summary over a cache -optimization would be strictly worse than not optimizing. - -**Consequence for the eval, stated plainly:** the S5-CRAC harness must wire -`note_request_sent()` or the treatment arm silently degrades into the control -arm and every gate is vacuous. That is the first thing the follow-up item -(`model_performance-6da`) tells the runner to verify — on a 1-run smoke, -before any spend. - -## 4. Provider asymmetry, and the staleness trap - -**Anthropic vs OpenAI need different fork sources, and the seam covers both.** -Anthropic places its cache breakpoint on the last *stable* message, walking -back past ephemeral/injected content — which is exactly where this module's -own returned view ends. So `tools` alone is enough there. OpenAI's implicit -cache measured **MISS on strict truncation** (P4), i.e. it needs a strict -superset of a cached request; an orchestrator-injected tail this module never -sees would break that. So on OpenAI the caller must also pass `messages`. -`_capture_fork_prefix` prefers the caller's record and falls back to the -module's own view. - -**The trap I built a guard for.** A caller that wires `note_request_sent()` -once (a startup helper, first turn only) would have turn 40's fork append to -turn 1's request — a guaranteed miss *and* a wasted cache write, wearing a -correct-looking API call. A `_view_serial`/`_sent_serial` pair means a -`messages` record is only used while it still describes the most recent -request served; a stale one is ignored (module view used instead), never -trusted. Tested. - -## 5. Evidence — 282 tests green (250 before, 32 new) - -`tests/test_summary_call_mode_fork.py`, written against the ways this goes -silently wrong rather than the way it is supposed to work: - -| Group | What it pins | -|---|---| -| **A — the default must not move** | The standalone request is asserted against **independently rebuilt** expected content (2 messages, system prompt then formatted span, `model` from `summarization_model`), not against itself. Plus: the fork bookkeeping is never even *written* unless armed (`_last_request_view is None` after 5 requests in both default configs) — an always-on capture would be a silent per-request list allocation on the hot path. Plus: `note_request_sent()` is fully inert when fork is not configured. | -| **B — pure append** | `sha256(fork[:-1]) == sha256(parent_view)` (G-FORK-PREFIX in unit form); exactly one appended message; **no new system message**; tools and model pinned from the seam; the span is **not** re-sent (`formatted not in appended`, and the appended message is shorter than the formatted span); the caller's `messages` record wins verbatim when supplied, including an injected tail. | -| **C — the main line is not the summarizer's scratchpad** | No `_seq` consumed; history byte-identical before/after; `_removed_seqs` unmoved; **`_last_sent_estimate` unmoved** (building the fork through `_finalize_view` would silently rewrite the hybrid meter's conservatism comparand with the summarizer's own request — this is why `_build_fork_request` calls `_strip_internal_metadata` directly); and a forked session serves views **bit-for-bit identical** to an unforked control, same `_removed_seqs`. | -| **D — refusals** | All six refusal paths fall back to the 2-message standalone request with the reason recorded; warning fires; warn-once-per-reason while the counter still counts every one; a failed fork build still produces the summary; hooks report the mode actually used; the stale-record guard prefers the fresh view. | -| **E — tool pairs** | `_select_summary_absorb_seqs` returns **identical** spans in both modes on a tool-pair-heavy history; end-to-end, no tool result is ever served without its call. The call mode changes how the summarizer is *called*, never what is *selected*. | -| **F — reset/lifecycle** | `clear()` and `set_messages()` drop all fork alignment state; the fork prefix is snapshotted at **trigger** time, not at task-scheduling time (proved by mutating `_last_request_view` while the task is in flight); the single-summarization-in-flight guard still holds under `asyncio.gather` of three concurrent requests. | - -`ruff check` clean. `ruff format` was **not** run: the repo is not -format-clean today (7 pre-existing files would be reformatted), so running it -would bury this change in unrelated noise. - -## 6. What is NOT claimed - -- **No cache win. No cost win. Nothing was measured.** G-FORK-PREFIX / - G-FORK-CACHED / G-FORK-COST / G-FORK-NOBOUNDARY are the follow-up item - (`model_performance-6da`, filed `discovered-from` this one, with the arm - design, the blocking prerequisite, and the residual risks). $0 spent here by - mandate. -- **Honest ceiling, unchanged from the item's own statement:** this reduces - the separate summarizer charge only. It does **not** touch the boundary - rebuild, which §2d measured as the dominant cost (+84% boundaries → +83% run - cost). If the summarizer's real share is the table's 2.4% rather than the - prose's 8.3–10.9%, a *perfect* fork is worth ~2% of run cost and the honest - recommendation may be "mechanism proven, not worth enabling". The follow-up - item is required to report that share from the run's own table and to say so - if it lands there. -- **Retention parity is not established.** Fork mode scopes by instruction - where standalone scopes by construction. The follow-up item reports S5 score - and `b_constraints`/`c_post_compaction` per arm so a cost win bought with a - retention loss cannot be reported as a win. -- **`reasoning_effort` / thinking configuration is not reproduced** by the - fork — it is not observable from inside a context module. If a provider keys - its cache on it, the fork misses despite byte-aligned messages. - G-FORK-CACHED is the detector; this is disclosed, not designed around. -- **The compaction-buffer reserve (condition (a) in the item) is not - implemented.** The appended instruction is ~1.2k chars, not the ~8,000-token - reserve the item contemplated, and the fork never grows the *main* line's - request. If the follow-up eval shows the fork's own request crowding the - window, that reserve is the fix, and G-FORK-NOBOUNDARY is the gate that - would catch it. - -## 7. Deliverable ledger - -| Deliverable | Status | -|---|---| -| DRAFT PR on origin, branch `lane/7k2-summary-call-fork`, default byte-identical, tests green | **DONE** | -| Prefix-stability test proving the fork does not touch the main line | **DONE** — Group C (no `_seq`, history byte-identical, `_last_sent_estimate` unmoved, forked-vs-control views identical) | -| Follow-up eval item filed via `work_file` with its arm design | **DONE** — `model_performance-6da` | -| DONE-NOTE.md in the PR body | **DONE** — this section | - -No PII, no team-internal data, no individual attribution. No merges to main. -No files touched outside this module. No infrastructure created; nothing to -tear down. diff --git a/README.md b/README.md index 632c947..8af2339 100644 --- a/README.md +++ b/README.md @@ -364,7 +364,6 @@ for the full write-up. Neither defect is inherited here: module = "context-simple" config = { compaction_strategy = "summary", # default: "progressive" - summary_call_mode = "standalone", # default; "fork" appends onto the live prefix summary_trigger = 0.60, # usage fraction that starts the async summarizer summarization_model = "...", # optional; None uses the provider default summarization_prompt_path = "...", # optional file override for the 5-section prompt @@ -440,127 +439,6 @@ absorptions cannot cycle; **hysteresis** on `summary_trigger` (arm at should be validated against the same cost metrics before this flag is enabled anywhere by default. -## Cache-safe fork of the summarization call (`summary_call_mode`) - -**Default is `"standalone"` — byte-identical to the summarizer call this -module has always made.** `"inline"` is accepted as an alias for it. Only -consulted when `compaction_strategy == "summary"`. - -### The defect - -The summarizer's request is, today, a **standalone** two-message call: its -own ~955-char `role: "system"` prompt, plus a freshly formatted plain-text -rendering of the span being absorbed. It shares **not one byte of prefix** -with the main conversation. So every token of that span is billed as -*fresh input* — while the provider is already holding that exact span warm -in the main line's cache. - -This resolves the internal conflict recorded in `00-what-we-know.md` §2d -(prose said the summarizer was "8.3–10.9% of run cost", the same source's -own table said 2.4%): whatever the share, the call is genuinely standalone, -so the cost is genuinely avoidable. It does **not** touch the boundary -rebuild, which §2d shows is the dominant cost — see "honest ceiling" below. - -### The shape - -`summary_call_mode: "fork"` re-issues the same ask as a **pure append**: - -``` -[ ...the exact messages of the last request... ] + [ one user message: prompt + scope ] -``` - -Pure append is the *one* mutation measured as a cache HIT under the -grow-only rule (probe P4: identical-repeat 9,789 HIT, **pure-append 9,789 -HIT**, strict truncation 0 MISS, middle-drop 0 MISS). - -Three things follow, and each is load-bearing: - -1. **The span is not re-sent.** It is already inside the prefix. Re-sending - it would cost exactly what standalone costs today *plus* the prefix — a - regression wearing an optimization's clothes. -2. **The prompt moves into the appended `role: "user"` message.** A - per-summarization `role: "system"` message is hoisted into the - provider's single top-level system block and rewrites the cached system - prefix — the same failure already measured for the summary tier and the - compaction notice. -3. **Scope has to be stated.** Standalone scopes by construction (it can - only see the span). A fork can see everything, so the appended message - names the span explicitly: message count, plus a bounded verbatim - excerpt of the span's final message as the "summarize up to here" - marker. This is a real behavioral difference between the two modes, not - a formatting detail. - -### The precondition you must wire: `note_request_sent()` - -Tool specs are serialized **ahead of** the system block, and this module is -handed messages, never tools. It also never sees a tail an orchestrator -injects *after* `get_messages_for_request()` returns. A fork missing either -is not an append onto the cached prefix at all. - -So fork mode requires the caller to say what it actually sent: - -```python -messages = await context.get_messages_for_request(provider=provider) -request = ChatRequest(messages=messages, tools=tools, model=model) -context.note_request_sent(messages, tools=tools, model=model) # <- every request -response = await provider.complete(request) -``` - -- Passing `tools` **at all** — even `None`/`[]` for a genuinely tool-free - session — is what arms the fork. "Not supplied" and "supplied as empty" - are deliberately distinguishable; guessing between them is how a fork - silently misaligns. -- Passing `messages` gives byte-parity with the wire, which an **implicit, - match-forward-only cache (OpenAI)** requires — its measured behavior - misses on anything that is not a strict superset of a cached request - (that is the same finding as "strict truncation → 0"). Omit it and the - fork appends to this module's own last returned view, which is what an - **explicit-breakpoint cache (Anthropic)** needs, since the breakpoint is - placed on the last *stable* message — exactly where this module's view - ends, before any ephemeral injection. -- Call it **every request**. A record that no longer describes the latest - request is ignored, not trusted: a one-time wiring would otherwise have - turn 40's fork append to turn 1's request. - -### A silently unforked fork is the failure mode that matters - -A fork that does not reproduce the parent's prefix wins nothing **and pays -for the whole conversation** — strictly worse than the standalone call it -replaces. Every misalignment therefore refuses, falls back to standalone, -and says so: a `WARNING` naming the precondition (once per distinct -reason), a session counter, and the mode actually used reported on -`context:pre_summarize` / `context:post_summarize` and via -`last_summary_call_stats`. - -The refusals: `note_request_sent()` never called · `summarization_model` -set (a summarizer routed elsewhere reads none of the main line's cache) · -no request recorded yet (first request of a session) · the prefix ends on -an assistant turn with unanswered `tool_calls` (appending there would -interleave between `tool_use` and `tool_result`) · the span is absent from -the recorded prefix · the forked request fails to build. - -### What is proven here, and what is not - -**Proven, structurally, by `tests/test_summary_call_mode_fork.py`:** the -default request is byte-identical and the fork bookkeeping is never even -written unless armed; the forked request is the parent prefix plus exactly -one `role: "user"` message (`sha256(fork[:-1]) == sha256(parent)`); no -`_seq` is consumed, history is untouched, `_last_sent_estimate` (the hybrid -meter's comparand) does not move, and a forked session serves views -bit-for-bit identical to an unforked control; tool-pair snapping is -unchanged; every refusal falls back loudly. - -**Not proven — no cache or cost win is claimed.** G-FORK-PREFIX / -G-FORK-CACHED / G-FORK-COST / G-FORK-NOBOUNDARY are a separately funded -evaluation; none of it was run here. - -**Honest ceiling, stated in advance:** this reduces the separate summarizer -charge only. It does **not** reduce the boundary rebuild, which §2d -measured as the dominant cost (+84% boundaries drives the +83% run cost). -If the summarizer really is 2.4% of run cost rather than 8.3–10.9%, then -even a perfect fork is worth ~2% — the arm design must therefore report the -summarizer's cost share from the run's own per-call table, not from prose. - ## Tool-result budget, shape, and spill **Every flag in this section defaults to a no-op.** With no configuration the diff --git a/amplifier_module_context_simple/__init__.py b/amplifier_module_context_simple/__init__.py index 9fed112..fdc71ce 100644 --- a/amplifier_module_context_simple/__init__.py +++ b/amplifier_module_context_simple/__init__.py @@ -155,66 +155,6 @@ / trigger hysteresis). OPT-IN, EXPERIMENTAL -- do not enable by default. -Cache-safe forking of the summarization call (opt-in, default off -- see -config `summary_call_mode`; only consulted when compaction_strategy == -"summary"): - • THE PROBLEM. The summarizer's request is, today, a STANDALONE two - message call: its own ~955-char system prompt plus a freshly - formatted plain-text rendering of the span being absorbed. It shares - not one byte of prefix with the main line, so every token of the span - is billed as FRESH input -- even though the provider is already - holding that exact span in a warm cache for the main conversation. - • THE SHAPE THAT FIXES IT. `summary_call_mode: "fork"` re-issues the - same ask as a PURE APPEND onto the prefix the main line already sent: - [ ...the exact messages of the last request... ] + [ one user message - carrying the summarization instruction ]. Pure append is the one - mutation measured as a cache HIT under the grow-only rule (probe P4: - identical-repeat 9,789 HIT, pure-append 9,789 HIT, strict truncation - 0 MISS, middle-drop 0 MISS). The span itself is NOT re-sent -- it is - already in the appended-to prefix; re-sending it would cost exactly - what standalone costs today PLUS the prefix, i.e. a regression. - • THE PROMPT MOVES INTO THE USER MESSAGE. The standalone call puts the - summarization prompt in a `role: "system"` message. A fork MUST NOT: - providers hoist every system-role message into a single top-level - system block, so a per-summarization system message would rewrite the - cached system prefix -- the exact failure mode already documented for - the summary tier and the compaction notice. In fork mode the prompt - rides the appended `role: "user"` message instead. - • THE MAIN LINE IS NEVER TOUCHED. The forked request is built from a - read-only snapshot. No `_seq` is consumed, nothing is appended to - `self.messages`, no sticky/removal state moves, and `_last_sent_estimate` - (the hybrid meter's conservatism comparand) is NOT rewritten -- which - is why the fork calls `_strip_internal_metadata` directly rather than - `_finalize_view`. The summary that eventually lands is produced by the - unchanged `_swap_in_pending_summary` path. - • A SILENTLY UNFORKED FORK IS THE FAILURE MODE THAT MATTERS. If the - forked request does not reproduce the parent's prefix byte-for-byte - it wins nothing AND pays for the whole conversation -- strictly worse - than standalone. So the fork is attempted ONLY when every alignment - precondition holds, and any miss falls back to the standalone call - LOUDLY (a warning naming the precondition, a counter, and the mode - actually used reported on `context:post_summarize` and in - `last_summary_call_stats`). It never silently half-forks. - • THE PRECONDITION YOU MUST WIRE. Tool specs are part of the cached - prefix (they are serialized ahead of the system block), and this - module is handed messages, never tools. So a caller that wants fork - mode MUST hand over the request it actually sent, via the optional - public `note_request_sent(messages=..., tools=..., model=...)` seam. - Without it the fork refuses (warning, once) and standalone is used. - Supplying `messages` too gives exact parity with what went on the - wire -- including any hook-injected tail the orchestrator appended - after this module returned its view, which is what an implicit, - match-forward-only cache (OpenAI) requires. With only `tools`, the - fork appends to this module's own last returned view, which is what - an explicit-breakpoint cache (Anthropic) needs, since the provider - places its breakpoint at the last STABLE message -- i.e. exactly - where this module's view ends. - • UNMEASURED. The gates (G-FORK-PREFIX / G-FORK-CACHED / G-FORK-COST / - G-FORK-NOBOUNDARY) are a separately funded eval; nothing in this - module claims a measured cache or cost win yet. What IS proven here - is structural: default byte-identity, main-line non-mutation, and - pure-append shape. - Tool-result budget and spill (opt-in, default off -- see config `tool_result_budget_tokens` / `tool_result_shape` / `tool_result_budget_by_tool` / `tool_result_exempt_tools` / @@ -348,29 +288,6 @@ COMPACTION_STRATEGY_SUMMARY, ) -# summary_call_mode config values -- HOW the summarizer's own LLM call is -# shaped when compaction_strategy == "summary". "standalone" (default) is -# byte-identical to the pre-existing behavior: its own tiny two-message -# request (system prompt + formatted span), sharing nothing with the main -# line. "fork" issues the SAME summarization ask as a PURE APPEND onto the -# prefix the main line already sent, so the provider's prompt cache can be -# read instead of paying fresh input tokens for the span. See module -# docstring "Cache-safe forking of the summarization call". -# -# "inline" is accepted as an alias for "standalone": the lane brief that -# commissioned this work named the default mode "inline" while the work -# item named it "standalone". Both mean "today's behavior, unchanged". -SUMMARY_CALL_MODE_STANDALONE = "standalone" -SUMMARY_CALL_MODE_FORK = "fork" -_VALID_SUMMARY_CALL_MODES = (SUMMARY_CALL_MODE_STANDALONE, SUMMARY_CALL_MODE_FORK) -_SUMMARY_CALL_MODE_ALIASES = {"inline": SUMMARY_CALL_MODE_STANDALONE} - -# How much of the span's final message is quoted back in the fork -# instruction as the "summarize up to HERE" boundary marker. Long enough to -# be unambiguous in a real transcript, short enough that it is never a -# material share of the appended (uncached) tail. -_FORK_BOUNDARY_EXCERPT_CHARS = 300 - # tool_result_shape config values. "head" (default) keeps the leading slice # only -- the pre-existing behavior. "head_tail" splits the budget in half # and keeps both ends with an explicit omission marker between them. See @@ -502,28 +419,6 @@ def _is_real_user_message(entry: dict[str, Any]) -> bool: """ -def _normalize_summary_call_mode(value: Any) -> str: - """Canonicalize a `summary_call_mode` config value. - - Accepts the two real modes plus the documented "inline" alias for - "standalone" (the commissioning lane brief and the work item named the - same default differently -- see _SUMMARY_CALL_MODE_ALIASES). An - unusable value logs a warning and falls back to "standalone", matching - how every other enum in this module is validated: a context manager - that refuses to start is worse than one that runs at the old default - and says so. - """ - mode = _SUMMARY_CALL_MODE_ALIASES.get(value, value) - if mode not in _VALID_SUMMARY_CALL_MODES: - logger.warning( - f"context-simple: unknown summary_call_mode {value!r} (expected " - f"one of {_VALID_SUMMARY_CALL_MODES!r}); falling back to " - f"{SUMMARY_CALL_MODE_STANDALONE!r}" - ) - return SUMMARY_CALL_MODE_STANDALONE - return mode - - async def mount(coordinator: ModuleCoordinator, config: dict[str, Any] | None = None): """ Mount the simple context manager. @@ -581,21 +476,8 @@ async def mount(coordinator: ModuleCoordinator, config: dict[str, Any] | None = boundaries / +83% run cost in the T0/T1 eval. Raising this is the cheapest lever; see README "Known issue: boundary refire". - - summary_call_mode: "standalone" (default, byte-identical to the - pre-existing summarizer call) or "fork" -- issue the - summarization ask as a pure append onto the prefix the main - line already sent, so it can be cache-read instead of paying - fresh input tokens for the span. "inline" is accepted as an - alias for "standalone". Only consulted when - compaction_strategy == "summary". Fork mode additionally - requires the caller to have called note_request_sent(); see - module docstring "Cache-safe forking of the summarization - call". - summarization_model: Model identifier passed to the summarizer's - ChatRequest (default: None, i.e. provider default). Setting it - CONFLICTS with summary_call_mode "fork" (a summarizer routed to - a different model cannot read the main line's cache) and makes - the fork fall back to standalone, loudly. + ChatRequest (default: None, i.e. provider default). - summarization_prompt_path: Path to a file overriding DEFAULT_SUMMARIZATION_PROMPT (default: None). - summarization_timeout_s: Seconds to wait for the summarizer's @@ -656,10 +538,6 @@ async def mount(coordinator: ModuleCoordinator, config: dict[str, Any] | None = ) compaction_strategy = COMPACTION_STRATEGY_PROGRESSIVE - summary_call_mode = _normalize_summary_call_mode( - config.get("summary_call_mode", SUMMARY_CALL_MODE_STANDALONE) - ) - context = SimpleContextManager( max_tokens=config.get("max_tokens", 200_000), compact_threshold=config.get("compact_threshold", 0.92), @@ -683,7 +561,6 @@ async def mount(coordinator: ModuleCoordinator, config: dict[str, Any] | None = ), compaction_strategy=compaction_strategy, summary_trigger=config.get("summary_trigger", 0.60), - summary_call_mode=summary_call_mode, summarization_model=config.get("summarization_model"), summarization_prompt_path=config.get("summarization_prompt_path"), summarization_timeout_s=config.get("summarization_timeout_s", 30.0), @@ -775,7 +652,6 @@ def __init__( compact_max_consecutive_skips: int = DEFAULT_MAX_CONSECUTIVE_SKIPS, compaction_strategy: str = COMPACTION_STRATEGY_PROGRESSIVE, summary_trigger: float = 0.60, - summary_call_mode: str = SUMMARY_CALL_MODE_STANDALONE, summarization_model: str | None = None, summarization_prompt_path: str | None = None, summarization_timeout_s: float = 30.0, @@ -841,21 +717,8 @@ def __init__( compaction boundaries (+84%) and run cost (+83%) -- see module docstring and README "Known issue: boundary refire". - summary_call_mode: "standalone" (default; the pre-existing - two-message summarizer call, byte-identical) or "fork" (the - same ask appended onto the main line's already-sent prefix - so the provider can cache-read it). "inline" is an accepted - alias for "standalone". Only consulted when - compaction_strategy == "summary". See module docstring - "Cache-safe forking of the summarization call" -- in - particular, fork mode needs note_request_sent() to have been - called, and falls back to standalone (loudly) when it has - not. summarization_model: Model identifier for the summarizer's own ChatRequest. None uses the provider's default model. - Incompatible with summary_call_mode "fork" -- a summarizer - pointed at another model reads no cache the main line wrote, - so the fork falls back to standalone rather than pretending. summarization_prompt_path: Path to a file overriding DEFAULT_SUMMARIZATION_PROMPT. None uses the built-in prompt. summarization_timeout_s: Seconds to wait for the summarizer's @@ -915,7 +778,6 @@ def __init__( ) compaction_strategy = COMPACTION_STRATEGY_PROGRESSIVE self.compaction_strategy = compaction_strategy - self.summary_call_mode = _normalize_summary_call_mode(summary_call_mode) self.summary_trigger = summary_trigger self.summarization_model = summarization_model self.summarization_prompt_path = summarization_prompt_path @@ -990,43 +852,6 @@ def __init__( self._summarization_failures: int = 0 self._summarization_task: "asyncio.Task[None] | None" = None self._summary_absorbed_count: int = 0 - # --- Cache-safe summarizer fork state (summary_call_mode == "fork") --- - # ALL of these stay None/0/False unless BOTH compaction_strategy == - # "summary" AND summary_call_mode == "fork"; nothing below is even - # written in any other configuration (see _finalize_view), which is - # what makes the default path byte-identical by construction rather - # than by inspection. - # - # `_last_request_view` is this module's own last returned view, kept - # PRE-strip (it still carries `_seq`) so the fork can verify the span - # it was asked to summarize is actually present in the prefix it is - # about to append to. `_sent_*` is the richer, optional truth handed - # over by the caller through note_request_sent(). - # - # `_view_serial` / `_sent_serial` exist for one reason: a caller - # that wires note_request_sent() ONCE (at startup, in a helper that - # only runs on the first turn) would otherwise hand the fork a - # request from turn 1 to append to on turn 40. That prefix is not - # the parent's prefix any more, so appending to it is a guaranteed - # miss AND a wasted cache write -- a silent misalignment wearing a - # correct-looking API call. Matching serials is how "the caller told - # us about THIS request" is distinguished from "the caller told us - # about SOME request, once". - self._last_request_view: list[dict[str, Any]] | None = None - self._view_serial: int = 0 - self._sent_messages: list[dict[str, Any]] | None = None - self._sent_serial: int | None = None - self._sent_tools: Any = None - self._sent_tools_supplied: bool = False - self._sent_model: str | None = None - # Observability: what the LAST summarizer call actually did, and how - # many times a requested fork had to fall back. An eval arm reads - # these to tell a real fork from a silently unforked one. - self._last_summary_call: dict[str, Any] | None = None - self._summary_fork_fallbacks: int = 0 - # Precondition names already warned about, so a session that can - # never fork logs once per reason instead of once per summarization. - self._fork_warned: set[str] = set() # Real-usage token meter state (see _on_llm_response / # _measure_working_tokens). `_last_measured_prompt_tokens` holds the # most recent real usage observed via `llm:response` @@ -1154,78 +979,6 @@ async def set_system_prompt_factory( self._system_prompt_factory = factory logger.info("System prompt factory registered - will refresh on each request") - def note_request_sent( - self, - messages: list[dict[str, Any]] | None = None, - *, - tools: Any = None, - model: str | None = None, - ) -> None: - """OPTIONAL. Tell this module what request the caller actually sent. - - This exists for exactly one reason: `summary_call_mode: "fork"` - re-issues the summarization ask as a PURE APPEND onto the prefix the - main line already sent, and this module cannot see that prefix in - full. It is handed messages; it is never handed the tool specs, and - it never sees the hook-injected tail an orchestrator may append - AFTER `get_messages_for_request()` returns. Both are part of what a - provider caches -- tool specs are serialized ahead of the system - block -- so a fork built without them is not an append onto the - cached prefix at all, and would pay full price for the whole - conversation. That is strictly worse than the standalone call it - replaces, so fork mode refuses to run without this. - - Completely inert unless BOTH compaction_strategy == "summary" AND - summary_call_mode == "fork". Never mutates history: nothing here - enters `self.messages`, consumes a `_seq`, or moves any compaction - state. Callers that do not know about it lose nothing. - - Call it EVERY request, not once. A `messages` record is only used - while it still describes the most recent request this module served - -- a one-time wiring would otherwise have turn 40's fork append to - turn 1's request, which is a guaranteed cache miss dressed up as a - correct API call. A stale record is ignored (this module's own last - view is used instead), never trusted. - - Args: - messages: The exact message array sent, if known. Gives the fork - byte-parity with the wire -- required for an implicit, - match-forward-only cache (OpenAI, whose measured behavior - misses on anything that is not a strict superset of a cached - request). Omit it and the fork appends to this module's own - last returned view instead, which is what an - explicit-breakpoint cache (Anthropic) needs, since the - breakpoint lands on the last STABLE message -- exactly where - this module's view ends, before any ephemeral injection. - tools: The tool specs sent, in the order sent. Passing this at - all -- even as None or [] for a genuinely tool-free session - -- is what arms fork mode; "not supplied" and "supplied as - empty" are deliberately distinguishable, because guessing - between them is how a fork silently misaligns. - model: The resolved model, if the caller knows it. Pins the - fork to the same model the parent used; a summarizer routed - elsewhere reads no cache the main line wrote. - """ - if messages is not None: - self._sent_messages = list(messages) - self._sent_serial = self._view_serial - self._sent_tools = list(tools) if isinstance(tools, list) else tools - self._sent_tools_supplied = True - if model is not None: - self._sent_model = model - - @property - def last_summary_call_stats(self) -> dict[str, Any] | None: - """What the most recent summarizer call actually did. - - None before the first one. Otherwise a dict with `mode_requested`, - `mode_used`, `reason` (None when the requested mode was honored), - `prefix_messages`, and `fork_fallbacks` (session-cumulative). This - is how an eval arm distinguishes a real fork from a silently - unforked one WITHOUT patching the module. - """ - return dict(self._last_summary_call) if self._last_summary_call else None - async def get_messages_for_request( self, token_budget: int | None = None, @@ -1617,19 +1370,6 @@ def _reset_summary_strategy_state(self) -> None: self._summarization_failures = 0 self._summarization_task = None self._summary_absorbed_count = 0 - # Fork state is history-derived: a reset session's old prefix is not - # a prefix of anything any more, and the caller's note_request_sent() - # facts describe a request that no longer relates to this history. - # Keeping either would be exactly the stale-alignment bug fork mode - # exists to refuse. `_summary_fork_fallbacks` is a session-cumulative - # observability counter and deliberately survives. - self._last_request_view = None - self._view_serial = 0 - self._sent_messages = None - self._sent_serial = None - self._sent_tools = None - self._sent_tools_supplied = False - self._sent_model = None async def should_compact(self) -> bool: """Check if context should be compacted. @@ -1769,14 +1509,6 @@ def _finalize_view(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]] """ view = self._strip_internal_metadata(messages) self._last_sent_estimate = self._estimate_tokens(view) - # Remember the PRE-strip list (it still carries `_seq`) so a forked - # summarizer call can both reproduce this exact view byte-for-byte - # AND verify the span it was asked to summarize is present in it. - # Guarded so the default path allocates nothing new and stays - # byte-identical by construction, not by inspection. - if self._fork_armed(): - self._last_request_view = list(messages) - self._view_serial += 1 return view def _cache_aggregates(self) -> dict[str, int] | None: @@ -4030,18 +3762,9 @@ async def _maybe_trigger_summary_compaction( ) return - # Snapshot the fork prefix HERE, synchronously, rather than letting - # the background task read it whenever it happens to be scheduled. - # `_last_request_view` is rewritten on every request; a task that - # read it later would append to a prefix chosen by scheduling order. - # Capturing at trigger time makes the forked request a pure function - # of this moment -- deterministic, and therefore testable. None in - # every configuration but fork. - fork_prefix = self._capture_fork_prefix() - self._is_summarizing = True self._summarization_task = asyncio.create_task( - self._run_summary_compaction_task(seqs, fork_prefix=fork_prefix) + self._run_summary_compaction_task(seqs) ) def _select_summary_absorb_seqs(self, excess_tokens: int) -> list[int] | None: @@ -4158,284 +3881,7 @@ def result_indices(assistant_msg: dict[str, Any]) -> list[int]: return 0 return 0 # defensive; unreachable given the termination argument above - # --- Cache-safe fork of the summarizer call (summary_call_mode) -------- - # - # Everything from here to _run_summary_compaction_task is inert unless - # BOTH compaction_strategy == "summary" AND summary_call_mode == "fork". - # The default path never calls any of it (see _build_summary_request's - # first branch), which is what makes "default is byte-identical" a - # structural property rather than a claim. - - def _fork_armed(self) -> bool: - """True only when a forked summarizer call is actually configured.""" - return ( - self.compaction_strategy == COMPACTION_STRATEGY_SUMMARY - and self.summary_call_mode == SUMMARY_CALL_MODE_FORK - ) - - def _capture_fork_prefix(self) -> list[dict[str, Any]] | None: - """Snapshot the message array a forked call would append to. - - Prefers the caller's own `note_request_sent(messages=...)` record - (byte-parity with the wire, including any tail the orchestrator - injected after this module returned). Falls back to this module's - last returned view, which still ends exactly where an - explicit-breakpoint provider places its cache breakpoint. - """ - if not self._fork_armed(): - return None - source = self._last_request_view - if self._sent_messages is not None: - if self._sent_serial == self._view_serial: - source = self._sent_messages - else: - logger.debug( - "context-simple: ignoring a stale note_request_sent() " - f"message record (recorded at view {self._sent_serial}, " - f"now at view {self._view_serial}); appending to this " - "module's own last returned view instead" - ) - return list(source) if source is not None else None - - @staticmethod - def _message_identity(msg: dict[str, Any]) -> tuple[str, str, str]: - """A content-level identity for a message, used only to check span - presence when the prefix came from a caller and therefore had its - internal `_seq` already stripped.""" - return ( - str(msg.get("role", "")), - str(msg.get("content", "")), - str(msg.get("tool_call_id", "")), - ) - - def _prefix_contains_span( - self, prefix: list[dict[str, Any]], span: list[dict[str, Any]] - ) -> bool: - """Is every message of the span actually present in the prefix? - - A fork does NOT re-send the span -- the whole point is that the span - is already inside the prefix being appended to. If it is not (a - compaction removed it between the last request and this trigger), - the forked call would be asking the model to summarize text it - cannot see. Checked by `_seq` when the prefix carries them, and by - content identity when it came from a caller (post-strip). - """ - prefix_seqs = {self._extract_seq(m) for m in prefix} - {None} - span_seqs = {self._extract_seq(m) for m in span} - {None} - if span_seqs and span_seqs <= prefix_seqs: - return True - present = {self._message_identity(m) for m in prefix} - return all(self._message_identity(m) in present for m in span) - - def _fork_refusal_reason( - self, - messages_to_summarize: list[dict[str, Any]], - fork_prefix: list[dict[str, Any]] | None, - ) -> str | None: - """Why this summarization CANNOT be forked, or None if it can. - - Every branch here is a case where the forked request would not be a - true append onto the parent's cached prefix. A fork that misses pays - for the entire conversation as fresh input -- strictly worse than - the standalone call it replaces -- so each of these falls back - rather than half-forking. - """ - if not self._sent_tools_supplied: - return ( - "note_request_sent() has never been called, so the tool specs " - "the parent sent are unknown; tool specs are serialized ahead " - "of the system block, so a fork without them is not an append " - "onto the cached prefix at all" - ) - if self.summarization_model: - return ( - f"summarization_model={self.summarization_model!r} points the " - "summarizer at a different model than the main line, which " - "reads none of the cache the main line wrote" - ) - if not fork_prefix: - return ( - "no request has been recorded yet, so there is no prefix to " - "append to" - ) - if fork_prefix[-1].get("tool_calls"): - return ( - "the prefix ends on an assistant turn with unanswered " - "tool_calls; appending a user message there would interleave " - "between tool_use and tool_result" - ) - if not self._prefix_contains_span(fork_prefix, messages_to_summarize): - return ( - "the span selected for absorption is not present in the " - "recorded prefix, so a forked call could not see the text it " - "was asked to summarize" - ) - return None - - def _note_fork_fallback(self, reason: str) -> None: - """Record and (once per distinct reason) announce a refused fork. - - WARNING level and counted, never silent: a fork that quietly did not - happen is the exact failure mode that makes this treatment look like - it does not work. Once per reason, not once per call, so a session - that can never fork logs a handful of lines instead of hundreds. - """ - self._summary_fork_fallbacks += 1 - if reason in self._fork_warned: - logger.debug(f"context-simple: summarizer fork refused again ({reason})") - return - self._fork_warned.add(reason) - logger.warning( - f"context-simple: summary_call_mode='fork' requested but this " - f"summarization ran STANDALONE instead -- {reason}. The standalone " - "call is correct and costs what it always did; no cache reuse was " - "attempted. This is logged once per distinct reason." - ) - - def _span_boundary_excerpt(self, msg: dict[str, Any]) -> str: - """A short verbatim excerpt of the span's final message, used as the - 'summarize up to HERE' marker in the fork instruction.""" - content = msg.get("content", "") - if isinstance(content, list): - parts: list[str] = [] - for block in content: - if isinstance(block, dict): - text = block.get("text", "") - if text: - parts.append(str(text)) - elif hasattr(block, "text"): - parts.append(str(block.text)) - content = "\n".join(parts) - text = str(content).strip() - if not text: - # A tool_calls-only assistant turn has no text of its own; name - # the tools instead of emitting an empty, useless marker. - names = [ - str(tc.get("name") or tc.get("tool") or "") - for tc in (msg.get("tool_calls") or []) - if isinstance(tc, dict) - ] - names = [n for n in names if n] - text = ( - f"[{msg.get('role', 'unknown')} turn calling: {', '.join(names)}]" - if names - else f"[{msg.get('role', 'unknown')} turn with no text content]" - ) - if len(text) > _FORK_BOUNDARY_EXCERPT_CHARS: - text = text[:_FORK_BOUNDARY_EXCERPT_CHARS] + "..." - return text - - def _format_fork_instruction( - self, messages_to_summarize: list[dict[str, Any]], prompt: str - ) -> str: - """The single user message a forked call appends. - - Carries the summarization prompt (which in standalone mode is a - `role: "system"` message -- see module docstring for why a fork must - NOT add one) plus explicit scoping, because a fork does not re-send - the span: the model reads it from the prefix it is already holding, - so the instruction has to say which part of that prefix to - summarize. This is a REAL difference from standalone, which scopes - by construction: standalone can only see the span, a fork can see - everything and is asked to attend to the span. - """ - n = len(messages_to_summarize) - excerpt = self._span_boundary_excerpt(messages_to_summarize[-1]) - return ( - f"{prompt}\n\n" - "SCOPE OF THIS SUMMARY. Summarize ONLY the OLDEST part of the " - f"conversation above: the first {n} message(s) following the " - "system prompt -- the span about to be retired from context to " - "make room. That span ENDS with the message excerpted below. Do " - "not summarize anything after it, and do not describe this " - "instruction.\n\n" - "--- final message of the span (verbatim excerpt) ---\n" - f"{excerpt}\n" - "--- end excerpt ---" - ) - - def _build_summary_request( - self, - messages_to_summarize: list[dict[str, Any]], - fork_prefix: list[dict[str, Any]] | None, - ) -> tuple[Any, str, str | None]: - """Build the summarizer's ChatRequest. - - Returns (request, mode_actually_used, fallback_reason_or_None). The - standalone branch below is verbatim the pre-existing call and is the - ONLY branch reachable unless fork mode is both configured and - satisfiable. - """ - from amplifier_core import ChatRequest, Message - - prompt = self._get_summarization_prompt() - reason: str | None = None - - if self._fork_armed(): - reason = self._fork_refusal_reason(messages_to_summarize, fork_prefix) - if reason is None: - try: - assert fork_prefix is not None # guaranteed by the check above - return ( - self._build_fork_request( - messages_to_summarize, fork_prefix, prompt - ), - SUMMARY_CALL_MODE_FORK, - None, - ) - except Exception as e: - # A prefix message this module never created (unexpected - # content shape from a caller, say) can fail Message - # validation. Falling back keeps the summary happening at - # today's cost instead of turning a cache optimization - # into a lost summary. - reason = f"the forked request could not be built ({e!r})" - self._note_fork_fallback(reason) - - formatted = self._format_messages_for_summarization(messages_to_summarize) - request = ChatRequest( - messages=[ - Message(role="system", content=prompt), - Message(role="user", content=formatted), - ], - model=self.summarization_model, - ) - return request, SUMMARY_CALL_MODE_STANDALONE, reason - - def _build_fork_request( - self, - messages_to_summarize: list[dict[str, Any]], - fork_prefix: list[dict[str, Any]], - prompt: str, - ) -> Any: - """The forked request: the parent's prefix, then ONE appended user - message. Nothing else -- no extra system message, no re-sent span, - no reordering. - - Deliberately calls `_strip_internal_metadata` and NOT - `_finalize_view`: the latter also rewrites `_last_sent_estimate`, - the hybrid meter's conservatism comparand, which describes the view - the MAIN line sent. A summarizer call must not move it. - """ - from amplifier_core import ChatRequest, Message - - prefix_view = self._strip_internal_metadata(fork_prefix) - messages = [Message(**msg) for msg in prefix_view] - messages.append( - Message( - role="user", - content=self._format_fork_instruction(messages_to_summarize, prompt), - ) - ) - return ChatRequest( - messages=messages, - tools=self._sent_tools, - model=self._sent_model, - ) - - async def _run_summary_compaction_task( - self, seqs: list[int], fork_prefix: list[dict[str, Any]] | None = None - ) -> None: + async def _run_summary_compaction_task(self, seqs: list[int]) -> None: """Background task: call the summarizer over the message span identified by `seqs` and stash the result in `_pending_summary` for the next get_messages_for_request()/_compact_ephemeral() call to @@ -4462,29 +3908,24 @@ async def _run_summary_compaction_task( if provider is None: raise RuntimeError("no cached provider available for summary compaction") - request, call_mode, fallback_reason = self._build_summary_request( - messages_to_summarize, fork_prefix + prompt = self._get_summarization_prompt() + formatted = self._format_messages_for_summarization(messages_to_summarize) + + from amplifier_core import ChatRequest, Message + + request = ChatRequest( + messages=[ + Message(role="system", content=prompt), + Message(role="user", content=formatted), + ], + model=self.summarization_model, ) - self._last_summary_call = { - "mode_requested": self.summary_call_mode, - "mode_used": call_mode, - "reason": fallback_reason, - "prefix_messages": ( - len(request.messages) - 1 - if call_mode == SUMMARY_CALL_MODE_FORK - else 0 - ), - "fork_fallbacks": self._summary_fork_fallbacks, - } if self._hooks is not None: try: await self._hooks.emit( "context:pre_summarize", - { - "message_count": len(messages_to_summarize), - "call_mode": call_mode, - }, + {"message_count": len(messages_to_summarize)}, ) except Exception as e: logger.warning(f"Could not emit context:pre_summarize: {e}") @@ -4503,10 +3944,7 @@ async def _run_summary_compaction_task( try: await self._hooks.emit( "context:post_summarize", - { - "summary_length": len(summary_text), - "call_mode": call_mode, - }, + {"summary_length": len(summary_text)}, ) except Exception as e: logger.warning(f"Could not emit context:post_summarize: {e}") diff --git a/tests/test_summary_call_mode_fork.py b/tests/test_summary_call_mode_fork.py deleted file mode 100644 index e039f93..0000000 --- a/tests/test_summary_call_mode_fork.py +++ /dev/null @@ -1,875 +0,0 @@ -"""Adversarial tests for `summary_call_mode: "fork"`. - -STEP 0 finding this file exists to lock in: before this change the -summarizer sent a STANDALONE two-message request -- its own ~955-char -`role: "system"` prompt plus a freshly formatted plain-text rendering of -the span -- sharing not one byte of prefix with the main conversation. -Every token of the span was billed as fresh input while the provider was -already holding that exact span warm for the main line. - -`summary_call_mode: "fork"` re-issues the same ask as a PURE APPEND onto -the prefix the main line already sent. Pure append is the one mutation -measured as a cache HIT under the grow-only rule (probe P4: identical -repeat 9,789 HIT, pure append 9,789 HIT, strict truncation 0 MISS, -middle-drop 0 MISS). - -The tests below are written against the ways this can silently go wrong, -not against the way it is supposed to work: - - 1. THE DEFAULT MUST NOT MOVE. A new branch that is "usually" inert is - not inert. Group A pins the standalone request byte-for-byte and - proves the fork bookkeeping is never even written unless armed. - 2. A SILENTLY UNFORKED FORK IS THE REAL FAILURE. A fork that does not - reproduce the parent's prefix wins nothing AND pays for the whole - conversation -- strictly worse than what it replaces. Group D proves - every misalignment refuses, falls back to the standalone call, and - SAYS SO (warning + counter + reported mode). - 3. THE MAIN LINE IS NOT THE SUMMARIZER'S SCRATCHPAD. Group C proves the - fork consumes no `_seq`, appends nothing to history, does not move - the hybrid meter's `_last_sent_estimate` comparand, and leaves the - next served view byte-identical to an unforked control. - 4. TOOL PAIRS STAY WHOLE. Group E proves the absorb-boundary snapping - (the donor's exact production failure) is not perturbed by the call - mode -- the fork changes how the summarizer is CALLED, never what is - selected. -""" - -import asyncio -import hashlib -import json -import logging - -import pytest -from amplifier_core import ChatResponse, Message, TextBlock -from amplifier_module_context_simple import SimpleContextManager, mount - - -class _FakeProvider: - """Minimal stand-in for a Provider -- records every request it is - handed so tests can assert on the exact shape that would go on the - wire. Deliberately has neither `get_model_info` nor `get_info`, so - `_calculate_budget` falls back to `self.max_tokens`.""" - - def __init__(self, response_text: str = "SUMMARY TEXT"): - self.response_text = response_text - self.calls: list = [] - - async def complete(self, request): - self.calls.append(request) - return ChatResponse(content=[TextBlock(type="text", text=self.response_text)]) - - -class _Coordinator: - def __init__(self): - self.hooks = None - self.mounted = {} - - async def mount(self, kind, instance): - self.mounted[kind] = instance - - -def _tools(): - """A tool spec list shaped like what an orchestrator actually sends.""" - return [ - { - "name": "bash", - "description": "run a command", - "parameters": {"type": "object", "properties": {}}, - } - ] - - -def _digest(messages) -> str: - """Canonical hash of a message array, whatever form it arrives in.""" - dumps = [] - for m in messages: - if isinstance(m, Message): - dumps.append(m.model_dump()) - else: - dumps.append(Message(**m).model_dump()) - return hashlib.sha256( - json.dumps(dumps, sort_keys=True, default=str).encode() - ).hexdigest() - - -def _strip_timestamps(messages: list[dict]) -> list[dict]: - result = [] - for msg in messages: - meta = dict(msg.get("metadata") or {}) - meta.pop("timestamp", None) - result.append({**msg, "metadata": meta}) - return result - - -async def _await_pending_task(context: SimpleContextManager) -> None: - task = context._summarization_task - assert task is not None, "expected a background summarization task in flight" - await task - - -def _summary_manager(**overrides) -> SimpleContextManager: - """A manager whose summary trigger is reachable without needing a - 200k-token fixture. `max_tokens` is corrected per test once real - estimator usage is known (same technique the existing summary tests - use).""" - kwargs = dict( - compaction_strategy="summary", - compaction_notice_enabled=False, - protected_recent=0.5, - summary_trigger=0.3, - target_usage=0.2, - compact_threshold=0.99, # keep the outer progressive gate CLOSED - max_tokens=1_000_000, - ) - kwargs.update(overrides) - return SimpleContextManager(**kwargs) - - -async def _fill(context: SimpleContextManager, turns: int = 30) -> None: - for i in range(turns): - await context.add_message( - {"role": "user", "content": f"user turn {i} " + "x" * 40} - ) - await context.add_message( - {"role": "assistant", "content": f"assistant reply {i} " + "y" * 40} - ) - - -async def _arm_below_trigger(context: SimpleContextManager) -> list[dict]: - """Serve one request with usage BELOW summary_trigger. - - This is what records the prefix a later fork appends to -- and it is - also the honest ordering: on the very first request of a session there - is no sent prefix yet, so there is nothing to fork onto. - """ - raw = context._estimate_tokens(context.messages) - context.max_tokens = int(raw / 0.1) # usage ~0.10, below trigger 0.3 - view = await context.get_messages_for_request(provider=_FakeProvider()) - assert context._is_summarizing is False, "must not trigger below summary_trigger" - return view - - -def _cross_trigger(context: SimpleContextManager) -> None: - raw = context._estimate_tokens(context.messages) - context.max_tokens = int(raw / 0.5) # usage ~0.50: above 0.3, below 0.99 - - -# --------------------------------------------------------------------------- -# Group A -- the default must not move -# --------------------------------------------------------------------------- - - -def test_default_summary_call_mode_is_standalone(): - assert SimpleContextManager().summary_call_mode == "standalone" - assert ( - SimpleContextManager(compaction_strategy="summary").summary_call_mode - == "standalone" - ) - - -def test_inline_is_an_accepted_alias_for_standalone(caplog): - """The commissioning lane brief named the default mode "inline"; the - work item named it "standalone". Both must mean today's behavior, and - neither may produce a warning.""" - with caplog.at_level(logging.WARNING): - context = SimpleContextManager(summary_call_mode="inline") - assert context.summary_call_mode == "standalone" - assert not [r for r in caplog.records if "summary_call_mode" in r.message] - - -def test_unknown_summary_call_mode_falls_back_with_warning(caplog): - with caplog.at_level(logging.WARNING): - context = SimpleContextManager(summary_call_mode="bogus") - assert context.summary_call_mode == "standalone" - assert any("unknown summary_call_mode" in r.message for r in caplog.records) - - -@pytest.mark.asyncio -async def test_mount_threads_summary_call_mode_through(): - coordinator = _Coordinator() - await mount(coordinator, {"compaction_strategy": "summary", "summary_call_mode": "fork"}) - assert coordinator.mounted["context"].summary_call_mode == "fork" - - coordinator = _Coordinator() - await mount(coordinator, {"summary_call_mode": "inline"}) - assert coordinator.mounted["context"].summary_call_mode == "standalone" - - coordinator = _Coordinator() - await mount(coordinator, {"summary_call_mode": "nonsense"}) - assert coordinator.mounted["context"].summary_call_mode == "standalone" - - -@pytest.mark.asyncio -async def test_default_mode_summarizer_request_is_byte_identical(): - """The standalone request must remain EXACTLY what it was before this - feature existed: two messages, system prompt then formatted span, - model from `summarization_model`. Asserted against independently - rebuilt expected content, not against itself.""" - context = _summary_manager(summarization_model="gpt-test") - await _fill(context) - _cross_trigger(context) - - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - assert len(provider.calls) == 1 - request = provider.calls[0] - assert len(request.messages) == 2 - assert request.model == "gpt-test" - assert request.tools is None - - seqs = sorted(context._pending_summary["seqs"]) - span = [m for m in context.messages if context._extract_seq(m) in set(seqs)] - assert request.messages[0].role == "system" - assert request.messages[0].content == context._get_summarization_prompt() - assert request.messages[1].role == "user" - assert request.messages[1].content == context._format_messages_for_summarization(span) - assert context.last_summary_call_stats["mode_used"] == "standalone" - assert context.last_summary_call_stats["reason"] is None - - -@pytest.mark.asyncio -async def test_default_mode_never_records_a_fork_prefix(): - """The fork bookkeeping must not merely be unused in the default mode - -- it must never be WRITTEN. An always-on capture would be a silent - per-request list allocation on the hot path.""" - for kwargs in ({}, {"compaction_strategy": "summary"}): - context = SimpleContextManager(**kwargs) - for i in range(5): - await context.add_message({"role": "user", "content": f"m{i}"}) - await context.get_messages_for_request(provider=_FakeProvider()) - assert context._last_request_view is None - assert context._sent_tools_supplied is False - assert context._summary_fork_fallbacks == 0 - assert context.last_summary_call_stats is None - - -@pytest.mark.asyncio -async def test_note_request_sent_is_inert_when_fork_is_not_configured(): - """A caller that always calls the seam must not change behavior for - every session that has not opted into forking.""" - context = _summary_manager() # standalone - await _fill(context) - context.note_request_sent(tools=_tools(), model="pinned-model") - _cross_trigger(context) - - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - request = provider.calls[0] - assert len(request.messages) == 2 - assert request.tools is None - assert request.model is None - assert context._last_request_view is None - assert context._summary_fork_fallbacks == 0 - - -@pytest.mark.asyncio -async def test_note_request_sent_never_touches_history(): - context = _summary_manager(summary_call_mode="fork") - await _fill(context, turns=3) - before = json.dumps(_strip_timestamps(context.messages), default=str) - seq_before = context._next_seq - - context.note_request_sent( - [{"role": "user", "content": "an injected tail the orchestrator added"}], - tools=_tools(), - model="m", - ) - - assert json.dumps(_strip_timestamps(context.messages), default=str) == before - assert context._next_seq == seq_before - - -# --------------------------------------------------------------------------- -# Group B -- the fork is a pure append onto what the main line actually sent -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_fork_request_is_the_parent_prefix_plus_exactly_one_user_message(): - context = _summary_manager(summary_call_mode="fork") - await _fill(context) - context.note_request_sent(tools=_tools(), model="pinned-model") - parent_view = await _arm_below_trigger(context) - _cross_trigger(context) - - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - request = provider.calls[0] - assert context.last_summary_call_stats["mode_used"] == "fork" - assert len(request.messages) == len(parent_view) + 1, ( - "a fork appends exactly one message -- no extra system message, no " - "re-sent span" - ) - # G-FORK-PREFIX, in unit form: the fork minus its trailing message is - # byte-identical to the parent request. - assert _digest(request.messages[:-1]) == _digest(parent_view) - assert request.messages[-1].role == "user" - - -@pytest.mark.asyncio -async def test_fork_adds_no_system_message(): - """A per-summarization `role: "system"` message would be hoisted into - the provider's single top-level system block and rewrite the cached - system prefix -- the exact failure already measured for the summary - tier and the compaction notice. The prompt must ride the appended user - message instead.""" - context = _summary_manager(summary_call_mode="fork") - await _fill(context) - context.note_request_sent(tools=_tools()) - parent_view = await _arm_below_trigger(context) - _cross_trigger(context) - - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - request = provider.calls[0] - parent_systems = sum(1 for m in parent_view if m.get("role") == "system") - fork_systems = sum(1 for m in request.messages if m.role == "system") - assert fork_systems == parent_systems - assert context._get_summarization_prompt() in request.messages[-1].content - - -@pytest.mark.asyncio -async def test_fork_pins_tools_and_model_from_note_request_sent(): - """Tool specs are serialized ahead of the system block, and a - summarizer routed to another model reads none of the cache the main - line wrote. Both must come from what the caller says it sent.""" - context = _summary_manager(summary_call_mode="fork") - await _fill(context) - tools = _tools() - context.note_request_sent(tools=tools, model="pinned-model") - await _arm_below_trigger(context) - _cross_trigger(context) - - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - request = provider.calls[0] - assert request.model == "pinned-model" - assert request.tools is not None and len(request.tools) == len(tools) - assert request.tools[0].name == "bash" - - -@pytest.mark.asyncio -async def test_fork_does_not_resend_the_span(): - """The entire point: the span is already inside the prefix. Re-sending - it would cost exactly what standalone costs today PLUS the prefix -- - a regression dressed as an optimization.""" - context = _summary_manager(summary_call_mode="fork") - await _fill(context) - context.note_request_sent(tools=_tools()) - await _arm_below_trigger(context) - _cross_trigger(context) - - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - appended = provider.calls[0].messages[-1].content - seqs = set(context._pending_summary["seqs"]) - span = [m for m in context.messages if context._extract_seq(m) in seqs] - assert len(span) > 3, "fixture must produce a span worth not re-sending" - formatted = context._format_messages_for_summarization(span) - assert formatted not in appended - # Only the boundary marker is quoted back, and it is bounded. - assert len(appended) < len(formatted) - - -@pytest.mark.asyncio -async def test_fork_instruction_carries_the_prompt_and_explicit_scope(): - """A fork can SEE the whole conversation, unlike standalone which can - only see the span. Scoping therefore has to be stated, and the span's - end has to be identifiable.""" - context = _summary_manager(summary_call_mode="fork") - await _fill(context) - context.note_request_sent(tools=_tools()) - await _arm_below_trigger(context) - _cross_trigger(context) - - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - appended = provider.calls[0].messages[-1].content - seqs = set(context._pending_summary["seqs"]) - span = [m for m in context.messages if context._extract_seq(m) in seqs] - - assert context._get_summarization_prompt() in appended - assert f"the first {len(span)} message(s)" in appended - assert "Do not summarize anything after it" in appended - last_text = str(span[-1]["content"])[:60] - assert last_text in appended, "the span's final message must be identifiable" - - -@pytest.mark.asyncio -async def test_fork_uses_caller_supplied_messages_verbatim_when_given(): - """An orchestrator may append hook-injected content AFTER this module - returns its view. An implicit, match-forward-only cache misses on - anything that is not a strict superset of what it cached, so when the - caller tells us what actually went on the wire, THAT is what gets - appended to.""" - context = _summary_manager(summary_call_mode="fork") - await _fill(context) - module_view = await _arm_below_trigger(context) - wire = [*module_view, {"role": "user", "content": "injected"}] - context.note_request_sent(wire, tools=_tools()) - _cross_trigger(context) - - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - request = provider.calls[0] - assert len(request.messages) == len(wire) + 1 - assert _digest(request.messages[:-1]) == _digest(wire) - assert "injected" in request.messages[-2].content - - -# --------------------------------------------------------------------------- -# Group C -- the main line is not the summarizer's scratchpad -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_fork_consumes_no_seq_and_appends_nothing_to_history(): - context = _summary_manager(summary_call_mode="fork") - await _fill(context) - context.note_request_sent(tools=_tools()) - await _arm_below_trigger(context) - _cross_trigger(context) - - seq_before = context._next_seq - history_before = json.dumps(_strip_timestamps(context.messages), default=str) - removed_before = set(context._removed_seqs) - - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - assert context.last_summary_call_stats["mode_used"] == "fork" - assert context._next_seq == seq_before, "the fork must not consume a _seq" - assert ( - json.dumps(_strip_timestamps(context.messages), default=str) == history_before - ), "the fork must not append to, reorder, or edit history" - assert context._removed_seqs == removed_before - - -@pytest.mark.asyncio -async def test_fork_does_not_move_the_hybrid_meter_comparand(): - """`_last_sent_estimate` describes the view the MAIN line sent; it is - what the hybrid meter's conservatism guard compares the next - `llm:response` against. Building the fork through `_finalize_view` - would silently rewrite it with the summarizer's own request.""" - context = _summary_manager(summary_call_mode="fork") - await _fill(context) - context.note_request_sent(tools=_tools()) - await _arm_below_trigger(context) - _cross_trigger(context) - - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - estimate_after_main_request = context._last_sent_estimate - await _await_pending_task(context) - - assert context.last_summary_call_stats["mode_used"] == "fork" - assert context._last_sent_estimate == estimate_after_main_request - - -@pytest.mark.asyncio -async def test_forked_and_unforked_sessions_serve_identical_views(): - """The call mode changes how the summarizer is CALLED. What the main - line is served must be bit-for-bit the same either way.""" - forked = _summary_manager(summary_call_mode="fork") - control = _summary_manager() - for ctx in (forked, control): - await _fill(ctx) - forked.note_request_sent(tools=_tools()) - await _arm_below_trigger(forked) - await _arm_below_trigger(control) - for ctx in (forked, control): - _cross_trigger(ctx) - - for ctx in (forked, control): - await ctx.get_messages_for_request(provider=_FakeProvider("SAME SUMMARY")) - await _await_pending_task(ctx) - - assert forked.last_summary_call_stats["mode_used"] == "fork" - assert control.last_summary_call_stats["mode_used"] == "standalone" - - for ctx in (forked, control): - ctx.compact_threshold = 0.3 - forked_view = await forked.get_messages_for_request(provider=_FakeProvider()) - control_view = await control.get_messages_for_request(provider=_FakeProvider()) - - assert _strip_timestamps(forked_view) == _strip_timestamps(control_view) - assert forked._removed_seqs == control._removed_seqs - - -# --------------------------------------------------------------------------- -# Group D -- every misalignment refuses, falls back, and says so -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_fork_without_note_request_sent_falls_back_loudly(caplog): - """Tool specs are part of the cached prefix and this module is never - handed them. Guessing "probably no tools" is precisely how a fork - silently misaligns and pays for the whole conversation.""" - context = _summary_manager(summary_call_mode="fork") - await _fill(context) - await _arm_below_trigger(context) - _cross_trigger(context) - - provider = _FakeProvider() - with caplog.at_level(logging.WARNING): - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - assert len(provider.calls[0].messages) == 2, "must be the standalone request" - stats = context.last_summary_call_stats - assert stats["mode_requested"] == "fork" - assert stats["mode_used"] == "standalone" - assert "note_request_sent()" in stats["reason"] - assert context._summary_fork_fallbacks == 1 - assert any("ran STANDALONE instead" in r.message for r in caplog.records) - - -@pytest.mark.asyncio -async def test_fork_with_summarization_model_falls_back_loudly(caplog): - context = _summary_manager(summary_call_mode="fork", summarization_model="other") - await _fill(context) - context.note_request_sent(tools=_tools()) - await _arm_below_trigger(context) - _cross_trigger(context) - - provider = _FakeProvider() - with caplog.at_level(logging.WARNING): - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - assert len(provider.calls[0].messages) == 2 - assert provider.calls[0].model == "other", "the explicit model is still honored" - assert "different model" in context.last_summary_call_stats["reason"] - assert any("ran STANDALONE instead" in r.message for r in caplog.records) - - -@pytest.mark.asyncio -async def test_fork_on_the_first_request_of_a_session_falls_back(): - """Nothing has been sent yet, so there is no prefix to append to. This - is normal, not an error -- but it must not pretend to fork.""" - context = _summary_manager(summary_call_mode="fork") - await _fill(context) - context.note_request_sent(tools=_tools()) - _cross_trigger(context) - - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - assert len(provider.calls[0].messages) == 2 - assert "no request has been recorded yet" in context.last_summary_call_stats["reason"] - - -@pytest.mark.asyncio -async def test_fork_refuses_when_the_prefix_ends_on_unanswered_tool_calls(): - """Appending a user message after an assistant turn whose tool results - have not arrived interleaves between tool_use and tool_result -- the - same atomicity the compaction notice already guards.""" - context = _summary_manager(summary_call_mode="fork") - await _fill(context, turns=10) - context.note_request_sent(tools=_tools()) - await _arm_below_trigger(context) - # The recorded prefix now ends on an assistant turn awaiting results. - context._last_request_view = [ - *context._last_request_view, - { - "role": "assistant", - "content": "", - "tool_calls": [{"id": "call-1", "tool": "bash", "arguments": {}}], - }, - ] - _cross_trigger(context) - - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - assert len(provider.calls[0].messages) == 2 - assert "unanswered" in context.last_summary_call_stats["reason"] - - -@pytest.mark.asyncio -async def test_a_stale_caller_message_record_is_ignored_not_trusted(): - """A caller that wires note_request_sent() ONCE (startup helper, first - turn only) would otherwise have turn N's fork append to turn 1's - request -- a guaranteed miss AND a wasted cache write, wearing a - correct-looking API call. The fresh module view must win.""" - context = _summary_manager(summary_call_mode="fork") - await _fill(context, turns=10) - # Turn 1: the caller records what it sent. - await _arm_below_trigger(context) - context.note_request_sent( - [{"role": "user", "content": "turn one, long ago"}], tools=_tools() - ) - # Several more turns go by without the caller telling us anything. - await _fill(context, turns=10) - fresh_view = await _arm_below_trigger(context) - _cross_trigger(context) - - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - request = provider.calls[0] - assert context.last_summary_call_stats["mode_used"] == "fork" - assert _digest(request.messages[:-1]) == _digest(fresh_view) - assert not any("long ago" in str(m.content) for m in request.messages) - - -@pytest.mark.asyncio -async def test_fork_refuses_when_the_span_is_absent_from_the_prefix(): - """A fork does not re-send the span. If the recorded prefix no longer - contains it, the model would be asked to summarize text it cannot - see.""" - context = _summary_manager(summary_call_mode="fork") - await _fill(context) - context.note_request_sent(tools=_tools()) - await _arm_below_trigger(context) - # A prefix that is real but unrelated to the span being absorbed. - context.note_request_sent( - [{"role": "user", "content": "an unrelated conversation"}], tools=_tools() - ) - _cross_trigger(context) - - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - assert len(provider.calls[0].messages) == 2 - assert "not present in the recorded prefix" in ( - context.last_summary_call_stats["reason"] - ) - - -@pytest.mark.asyncio -async def test_repeated_fallbacks_warn_once_per_reason_but_count_every_time(caplog): - """A session that can never fork should cost a handful of log lines, - not one per summarization -- while the counter still tells the truth.""" - context = _summary_manager(summary_call_mode="fork") - await _fill(context, turns=6) - - with caplog.at_level(logging.WARNING): - for _ in range(4): - context._note_fork_fallback("reason one") - context._note_fork_fallback("reason two") - - assert context._summary_fork_fallbacks == 5 - warnings = [r for r in caplog.records if "ran STANDALONE instead" in r.message] - assert len(warnings) == 2 - - -@pytest.mark.asyncio -async def test_a_failed_fork_build_falls_back_instead_of_losing_the_summary(): - """A prefix message this module never created can fail request - validation. Losing the summary over a cache optimization would be a - strictly worse outcome than paying today's price.""" - context = _summary_manager(summary_call_mode="fork") - await _fill(context) - await _arm_below_trigger(context) - context.note_request_sent( - [{"role": "not-a-real-role", "content": "x"}], tools=_tools() - ) - # Make the span check pass so the failure comes from request building. - context._prefix_contains_span = lambda prefix, span: True - _cross_trigger(context) - - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - assert len(provider.calls[0].messages) == 2 - assert "could not be built" in context.last_summary_call_stats["reason"] - assert context._pending_summary is not None, "the summary still happened" - - -@pytest.mark.asyncio -async def test_hooks_report_the_mode_actually_used(): - """An eval arm has to be able to count real forks without patching the - module.""" - events: list[tuple[str, dict]] = [] - - class _Hooks: - async def emit(self, event, data): - events.append((event, data)) - - context = _summary_manager(summary_call_mode="fork") - context._hooks = _Hooks() - await _fill(context) - context.note_request_sent(tools=_tools()) - await _arm_below_trigger(context) - _cross_trigger(context) - - await context.get_messages_for_request(provider=_FakeProvider()) - await _await_pending_task(context) - - modes = { - name: data.get("call_mode") - for name, data in events - if name in ("context:pre_summarize", "context:post_summarize") - } - assert modes == { - "context:pre_summarize": "fork", - "context:post_summarize": "fork", - } - - -# --------------------------------------------------------------------------- -# Group E -- tool-pair integrity is not perturbed by the call mode -# --------------------------------------------------------------------------- - - -async def _fill_with_tool_pairs(context: SimpleContextManager) -> None: - for i in range(12): - await context.add_message( - {"role": "user", "content": f"do thing {i} " + "x" * 30} - ) - await context.add_message( - { - "role": "assistant", - "content": "working", - "tool_calls": [{"id": f"call-{i}", "tool": "bash", "arguments": {}}], - } - ) - await context.add_message( - {"role": "tool", "tool_call_id": f"call-{i}", "content": "out " + "y" * 30} - ) - - -@pytest.mark.asyncio -async def test_call_mode_does_not_change_which_span_is_selected(): - forked = _summary_manager(summary_call_mode="fork") - control = _summary_manager() - for ctx in (forked, control): - await _fill_with_tool_pairs(ctx) - - assert forked._select_summary_absorb_seqs(500) == control._select_summary_absorb_seqs( - 500 - ) - - -@pytest.mark.asyncio -async def test_fork_mode_never_serves_an_orphaned_tool_result(): - """The donor's exact production failure (a dropped `function_call` - whose `function_call_output` survived) must stay impossible in fork - mode too.""" - context = _summary_manager(summary_call_mode="fork", protected_recent=0.3) - await _fill_with_tool_pairs(context) - context.note_request_sent(tools=_tools()) - await _arm_below_trigger(context) - _cross_trigger(context) - - await context.get_messages_for_request(provider=_FakeProvider()) - await _await_pending_task(context) - context.compact_threshold = 0.3 - view = await context.get_messages_for_request(provider=_FakeProvider()) - - call_ids = { - tc.get("id") - for m in view - for tc in (m.get("tool_calls") or []) - if isinstance(tc, dict) - } - result_ids = {m.get("tool_call_id") for m in view if m.get("role") == "tool"} - assert result_ids <= call_ids, "a tool result was served without its call" - - -# --------------------------------------------------------------------------- -# Group F -- reset -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_reset_clears_fork_alignment_state(): - """After set_messages()/clear() the recorded prefix is a prefix of - nothing, and the caller's facts describe a request unrelated to this - history. Keeping either is the stale-alignment bug fork mode exists to - refuse.""" - context = _summary_manager(summary_call_mode="fork") - await _fill(context, turns=4) - context.note_request_sent(tools=_tools(), model="m") - await _arm_below_trigger(context) - assert context._last_request_view is not None - - await context.clear() - - assert context._last_request_view is None - assert context._sent_messages is None - assert context._sent_tools is None - assert context._sent_tools_supplied is False - assert context._sent_model is None - - -@pytest.mark.asyncio -async def test_fork_state_survives_nothing_across_set_messages(): - context = _summary_manager(summary_call_mode="fork") - await _fill(context, turns=4) - context.note_request_sent(tools=_tools()) - await _arm_below_trigger(context) - - await context.set_messages([{"role": "user", "content": "resumed session"}]) - - assert context._last_request_view is None - assert context._sent_tools_supplied is False - - -@pytest.mark.asyncio -async def test_fork_snapshot_is_taken_at_trigger_time_not_task_time(): - """The background task must append to the prefix chosen when the - trigger fired, not to whatever `_last_request_view` happens to hold - when the event loop gets around to running it.""" - context = _summary_manager(summary_call_mode="fork") - await _fill(context) - context.note_request_sent(tools=_tools()) - parent_view = await _arm_below_trigger(context) - _cross_trigger(context) - - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - # Simulate another request landing while the summarizer is in flight. - context._last_request_view = [{"role": "user", "content": "a later, different view"}] - await _await_pending_task(context) - - assert _digest(provider.calls[0].messages[:-1]) == _digest(parent_view) - - -@pytest.mark.asyncio -async def test_concurrent_forks_are_still_serialized_by_the_in_flight_guard(): - """Nothing about forking may weaken the single-summarization-in-flight - invariant.""" - context = _summary_manager(summary_call_mode="fork") - await _fill(context) - context.note_request_sent(tools=_tools()) - await _arm_below_trigger(context) - _cross_trigger(context) - - provider = _FakeProvider() - await asyncio.gather( - context.get_messages_for_request(provider=provider), - context.get_messages_for_request(provider=provider), - context.get_messages_for_request(provider=provider), - ) - task = context._summarization_task - if task is not None: - await task - - assert len(provider.calls) == 1 - assert context._pending_summary is not None - assert context.last_summary_call_stats["mode_used"] == "fork" From 99438c5401b5e5a82814a929d66d9855a97085ce Mon Sep 17 00:00:00 2001 From: bkrabach <702425+bkrabach@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:07:51 -0700 Subject: [PATCH 3/7] Revert "feat: clear_at_least -- a worth-the-rebuild predicate in front of compaction (#2o9) (#26)" This reverts commit f851d122a834b94329a034d89b68d920561dc78f. Merge policy: main carries wins only. clear_at_least was an unproven, default-off predicate. Belongs on a branch for evaluation, not on main. --- DONE-NOTE.md | 267 ------- README.md | 194 ----- amplifier_module_context_simple/__init__.py | 423 +---------- tests/test_clear_at_least_predicate.py | 770 -------------------- 4 files changed, 5 insertions(+), 1649 deletions(-) delete mode 100644 tests/test_clear_at_least_predicate.py diff --git a/DONE-NOTE.md b/DONE-NOTE.md index 412d612..4b822d7 100644 --- a/DONE-NOTE.md +++ b/DONE-NOTE.md @@ -11,7 +11,6 @@ the other. Each note keeps its own `# DONE-NOTE - ` heading verbatim. |---|---| | `model_performance-x7p` | `protected_tool_results=0` protected ALL tool results (negative-slice bug) | | `model_performance-x1r` | tool-result budget (token-denominated, head+tail, per-tool) + spill-to-disk | -| `model_performance-2o9` | `clear_at_least` — a worth-the-rebuild predicate in front of compaction (+ summary shrink guard) | --- @@ -646,269 +645,3 @@ Artifacts written (outside the module repo, under the authorized capture path No PII, no team-internal data, no individual attribution in any output. Spill paths are the only new content class in a transcript; they are caller-configured and contain no absolute path this module invents. - - ---- - -# DONE-NOTE — W3-4 / `model_performance-2o9` - -`clear_at_least` — a worth-the-rebuild predicate in front of compaction -(context-simple), plus deepseek's summary shrink guard in the same PR. - -**Spend: $0.00.** No API calls, no eval runs, no DTU, no containers, no -infrastructure created or registered (and therefore none to tear down). -Everything below is local `pytest` / `python` on this host. The lane's spend -authority was $0 and none of it was used. - ---- - -## 1. Deliverables - -| deliverable | status | -|---|---| -| DRAFT PR on origin, branch `lane/2o9-clear-at-least`, default byte-identical, full suite green | **DONE** — [microsoft/amplifier-module-context-simple#26](https://github.com/microsoft/amplifier-module-context-simple/pull/26) (draft, rebased on `3972070`) | -| Tests for block / allow / default byte-identity / prefix stability | **DONE** — 41 new tests, 249 passed + 1 skipped total | -| Follow-up eval item filed with a Given/When/Then gate | **DONE** — `model_performance-wxs`, filed `discovered-from` this item | -| DONE-NOTE.md in the PR body | **DONE** — this note | - ---- - -## 2. The one design question the item asked me to answer honestly - -The spec (`POC-SPECS/04-clear-at-least-predicate.md`) assumes a plan/apply -split: - -``` -plan = build_compaction_plan(messages, target) -freed = tokens(messages) - tokens(apply(plan, messages)) -``` - -**That split does not exist in this module.** `_compact_ephemeral` has no plan -object: it mutates an ephemeral copy level by level (levels 1–8), threading a -running `current_tokens` through every rung, and returns through -`_finalize_compaction_with_stats` from eight different call sites. There is no -point at which a projection is available *before* the work is done. - -The GOAL says: *"if the projection is not available before the decision, say so -and implement the cheapest honest alternative, documenting the choice."* So, -stated plainly: - -**Chosen: judge the ladder's actual result, not a projection of it.** The -predicate runs at `_finalize_compaction_with_stats` — the single terminal choke -point every level returns through — and *before* that method writes any durable -effect (sticky level, stats, `context:compaction` event). If the reclaim is -below the floor, the escalation is rolled back and the baseline view is -returned. - -Three things make this the honest choice rather than a workaround: - -1. **It is exact, not estimated.** The ladder runs on an ephemeral copy that - never touches `self.messages` (`_truncate_tool_wave` does - `messages[i] = self._truncate_tool_result(msg)` — a *new* dict; - `_remove_messages_with_protection` returns a *new* list). So running it is a - simulation whose only durable output is three sticky-decision sets, which are - snapshot-and-restored. The number the predicate acts on is what the - compaction *did*, not what it might do. -2. **No second estimator was introduced.** Both sides of the comparison go - through the same `_estimate_tokens` the rest of the ladder runs on — which is - exactly what the item required ("use the existing projection the ladder - already computes; do not add a second estimator"). -3. **The cost of a refusal is CPU, not tokens.** A refused boundary costs one - wasted ladder pass over an in-memory list. The thing being avoided is a full - cold prompt-cache rebuild. The asymmetry is not close. - -**`freed` is the MARGINAL reclaim**, not the distance from raw history: - -``` -freed = tokens(view this call would have returned had it decided nothing new) - - tokens(view the ladder actually produced) -``` - -This is deliberate and load-bearing. What breaks the provider's cache is *this -view differing from the last one*, not its distance from raw history. Measured -against raw history, an already-compacted session would report a large stale -"freed" on every single call and the predicate would wave through boundaries -that free nothing — the exact failure it exists to prevent. -`test_freed_is_marginal_not_measured_against_raw_history` pins this. - ---- - -## 3. Decisions taken without waiting (per SCOPE-OUTS), and why - -| # | decision | reasoning | -|---|---|---| -| 1 | **Fail-loud = `raise RuntimeError`**, not log-and-compact-anyway | The POC's own Risks §1 says the gate is *"written so the failure mode fails the run rather than degrading quietly"*, and G-CAL-NOSKIPHANG requires the run to FAIL. A silent override would let an eval pass while the predicate had stopped applying — the one outcome the gate exists to catch. Local precedent (`_provenance_overrides`) points the other way, but that escape exists to avoid a *guaranteed* provider hard-failure; here the operator has a knob to move, and the error names it. Unreachable while the predicate is disabled. | -| 2 | **The summary swap is NOT gated by the predicate** | By the time `_swap_in_pending_summary` returns it has already appended to `self.messages` and consumed a `_seq` — irreversible. Rather than build a fragile rollback for `self.messages`, that path is governed by the shrink guard (below), which checks *before* mutating. The predicate governs the progressive ladder, which is where the boundary-refire cost actually lives. | -| 3 | **Fraction form added** (`0 < v < 1` = fraction of budget) | The GOAL says "at least N tokens (or a fraction of the window)"; the POC spec said `int` only. A hardcoded token floor silently means something different on a 200k window than a 45k one. `1.0` is read as **absolute (1 token)**, not "100% of budget" — a floor of one whole budget can never be met, so the fraction reading would turn a plausible config into a guaranteed fail-loud. | -| 4 | **`compact_max_consecutive_skips <= 0` clamps UP to 1** | Read literally, 0 means "tolerate unlimited refusals" — precisely the silent hang the cap exists to prevent. | -| 5 | **A malformed value disables the predicate with a warning** | Consistent with how this module already handles `token_meter` / `compaction_strategy` / `tool_result_shape`. Never take a session down on a config typo. | -| 6 | Config names `compact_clear_at_least` / `compact_max_consecutive_skips` | Namespaced with the existing `compact_threshold`; the spec's bare `max_consecutive_skips` would have read as unrelated to compaction. | -| 7 | **No knob for the shrink guard** | There is no defensible reason to want a summary that makes the context bigger. | -| 8 | Predicate state does **not** survive `clear()` / `set_messages()` | A skip streak accumulated against one message set says nothing about a different one; inheriting it would fail loud on the first refusal after a resume. | - ---- - -## 4. An interaction bug this lane introduced and fixed inside itself - -The branch was cut from `f47c894`, but `origin/main` had advanced two commits -(`49e2799` tool-result budget, `3972070` last-user replay). **Rebased onto -`origin/main` (3972070)**; three conflicts, all additive, resolved by keeping -both sides. - -The replay feature then created a real interaction: -`replay_last_user_on_compaction` fires once per compaction *boundary*, where a -boundary is `(_sticky_level, _summary_absorbed_count)`. **A refusal leaves both -unchanged by design** — the verdict runs before `_sticky_level` is bumped — so -on the first-ever refusal that identity still differs from the initial `None` -and would have looked like a fresh boundary. A verbatim user replay would have -been appended after a compaction that did not happen: misleading to the model, -and spending the tokens the refusal exists to save. - -Fixed with an explicit call-scoped flag (`_clear_at_least_last_refused`) rather -than by changing the replay's own boundary logic, so the fix **cannot alter -main's behaviour** — the flag is always `False` while the predicate is -disabled. Pinned by `test_a_refusal_is_not_a_boundary_for_the_last_user_replay` -and its positive counterpart. - -This is worth naming as evidence for the parallel-lane process: the bug did not -exist when this lane started and would not have been found by either lane -alone. - ---- - -## 5. Evidence - -### 5.1 Test suite - -``` -249 passed, 1 skipped in 5.57s -``` - -**41 new tests** in `tests/test_clear_at_least_predicate.py`; the 208 tests -already on `origin/main` are unmodified and green. The new tests are written -adversarially against the predicate's *own* failure mode (starvation), not just -its happy path: - -- **Blocks** — an unsatisfiable floor refuses the boundary; the returned view is - the untouched history; `context:compaction` is **not** emitted; - `context:compaction-skipped` carries freed/required/level/skips. -- **Allows** — a floor of 1 token produces a view **byte-identical** to running - with the predicate off (`test_allowed_boundary_is_identical_to_the_disabled_path`). -- **Default byte-identity** — `None`, `0`, and a negative value produce - identical views *and* identical event streams; `_clear_at_least_pending` stays - `None`, proving the guarded path is never *entered*, not merely that it agreed. -- **Rollback** — all three sticky sets, `_sticky_level`, and - `_last_compaction_stats` are unchanged after a refusal; no compaction notice - appears. -- **Prefix / `_seq` stability** — two consecutive refused calls with one turn of - growth in between share a byte-identical prefix (exact comparison, no - normalisation), and `_seq` identity is untouched. -- **Tool-pair integrity** — every `tool_calls` id in a refused view is answered - by a `tool_call_id`. -- **Starvation** — no-op calls never count as skips; the cap raises with a - message naming freed/required/protected set; state is rolled back *before* the - raise; the streak resets on an accepted compaction and on clear/resume. -- **Floor resolution** — 12 parametrised cases covering None/0/negative/int/ - fraction/1.0-boundary/zero-budget/malformed. -- **Shrink guard** — larger summary refused (history untouched, span not - recorded as removed, **not** counted as a summarizer failure); *equal-sized* - summary refused (the `>=` boundary exercised directly by searching for a - text that prices exactly at the span's estimate); genuinely smaller summary - still swaps. - -### 5.2 Default byte-identity vs `origin/main` (the honest stash-compare) - -Captures: -`/home/bkrabach/dev/openai-evals-team-ci/.amplifier/evaluation/treatment-validation/20260902-2o9-clear-at-least/` -(`byte_identity_check.py`, `baseline_origin_main__init__.py`, -`treatment__init__.py`, `byte_identity_result.txt`, -`negctl_predicate_on_by_default__init__.py`, `negative_control_result.txt`). - -Both module versions are loaded **in one process** and driven through identical -scenarios with identical inputs. Non-determinism is *eliminated*, not normalised -away: message timestamps are frozen to a fixed string after history is built, so -the two runs differ in nothing but the module source. Compared per call: the -returned view, every emitted hook event (name + payload), all three sticky -decision sets, `_sticky_level`, and `_last_compaction_stats`. - -**11 of 11 scenarios byte-identical:** - -``` -IDENTICAL default IDENTICAL tool_result_head_tail -IDENTICAL token_meter_actual IDENTICAL replay_last_user -IDENTICAL token_meter_hybrid IDENTICAL notice_disabled -IDENTICAL summary_strategy_no_provider IDENTICAL protected_tool_results_zero -IDENTICAL tool_result_budget IDENTICAL larger_budget - IDENTICAL system_prompt_factory -ALL SCENARIOS BYTE-IDENTICAL -``` - -The scenario set deliberately covers **every other opt-in feature**, not just -the default path: "default byte-identical" must also mean "does not perturb a -feature that was already opt-in". - -**The check is not vacuous.** A negative control that changes exactly one line — -`DEFAULT_CLEAR_AT_LEAST = None` → `20_000` — diverges immediately on the same -harness, and does so by raising the fail-loud error, which doubles as a live -demonstration of its content: - -``` -RuntimeError: context-simple: compact_clear_at_least=20000 could not be -satisfied 3 consecutive times (cap: 3). The compaction ladder reached level 8 -and freed only 7,698 tokens against a required floor of 20,000. Protected set -holding the floor: protected_tool_results=1 (of 30 tool results in the view), -protected_recent=20% of 124 messages. Baseline view is 7,852 tokens against a -budget of 1,200. Lower compact_clear_at_least, lower -protected_recent/protected_tool_results, or raise the budget -- compacting -harder will not help. -``` - -(That fixture's budget is 1,200 tokens, so a 20k floor is unsatisfiable by -construction — i.e. exactly the misconfiguration the fail-loud path exists for, -and the message names the right knobs.) - ---- - -## 6. What is NOT claimed - -**No workload measurement exists. No performance claim rides into source, the -PR body, or the README** (§5 rule 6). - -- The mechanism is implemented and unit-tested. Its effect on boundary count, - cost, waste, or latency on a real workload is **unmeasured**. -- The eval is filed as **`model_performance-wxs`** with gates pre-registered - *before* any spend: `G-CAL-BOUNDARIES` (monotonic dose-response across - `{null, 10k, 20k, 40k}`, n≥3 — a flat response falsifies the mechanism), - `G-CAL-NOSKIPHANG`, `G-CAL-LATENCY` (the pre-registered **win**), - `G-CAL-COST` and `G-CAL-WASTE` (non-regression only), `G-CAL-SKIPCOUNT` (an - instrument check that catches a run where every floor was trivially - satisfiable), and the Anthropic guardrail from raw wire fields. -- **A cost reduction is deliberately NOT pre-registered as a win.** §2b is - explicit that fewer boundaries "buys latency, not money" (`cad-fewer`: −29% - requests, −14% wall, *same* cost, same quality), and the underlying fit - (`waste ≈ 36.3 − 0.357 × boundaries`, r = −0.586, 12 points) is suggestive, - not established. -- **A retention gate is rejected as vacuous** here as everywhere: `b_constraints` - 40/40 and `c_post_compaction` 20/20 in every run of every arm across probes - 1–6. Deferred to `model_performance-cb2`. -- **The 20,000 starting value is not pinned.** It derives from an 18,458-token - head computed through a 4.59 chars/token constant that is tokenizer- and - model-version-specific (DESIGN-SPACE.md C-4). The eval item requires - re-deriving the head *in tokens* from provider usage first. The README says so - too. -- **Do not run this on the bare estimator.** The default `token_meter: - "estimate"` is `len(str)//4`, never reconciled against provider usage and ~2× - off in production sessions; a predicate acting on a 2× off number refuses the - wrong boundaries. `model_performance-q69` landed the provider-anchored hybrid - meter with provenance precisely so this predicate has an anchored number. - README and the eval item both state `token_meter: "hybrid"` as the operating - condition. - -**Known limitation, disclosed:** a refused call pays one wasted ladder pass -(CPU over an in-memory list, no tokens). Also, like the rest of this module's -shared mutable state, the predicate's call-scoped state assumes -`get_messages_for_request` is not re-entered concurrently — a pre-existing -property of the module, not new exposure, but now with one more field. - -No PII, no team-internal data, no individual attribution in any output. No -merges to main; no files touched outside this module. diff --git a/README.md b/README.md index 8af2339..fc62917 100644 --- a/README.md +++ b/README.md @@ -721,200 +721,6 @@ of every run), so it cannot show a retention difference in either direction. Do not enable by default, and do not claim a quality benefit, until a discriminating eval has run. -## Worth-the-rebuild predicate (`compact_clear_at_least`) - -**Default `null` (disabled). Byte-identical to before this feature existed.** - -### The problem - -The compaction trigger fires on a usage *threshold* (`compact_threshold`, -default 0.92) and **never asks how many tokens the compaction will actually -free**. - -That omission is not free, because every compaction *shrinks* the request, and -a shrink is a guaranteed cold prompt-cache rebuild on the OpenAI path. The -measured mechanism (micro-probe v3, 3 reps, ~9.8k-token payloads, validity gate -passed): identical repeat **9,789 cache_read HIT**, pure append **9,789 HIT**, -and a **byte-identical strict prefix of the cached request — 0, MISS**. The -cache matches forward from a cached entry, never backward into one. So a -boundary that frees 3k tokens still pays a full rebuild of an ~18.4k-token -pinned head plus everything after it. We take that trade silently, every time. - -What the omission costs, measured: the `cad-deep` arm set `target_usage: 0.15` -— a 6,750-token target *below* the ~32k system floor — so compaction escalated -to max level on every request: **25 boundaries** (more than stock's 21.6), -prefix retention **0.0%**, **$3.16** against stock's **$2.58**, and no quality -upside. A worth-the-rebuild predicate would have refused every one of them. - -### What it does - -Lifted from Anthropic's context-editing API, whose parameter of the same name -is documented as: *"If the API can't clear at least the specified amount, the -strategy will not be applied. This helps determine if context clearing is worth -breaking your prompt cache."* Vendor-agnostic — implemented here client-side, -with no API support required. - -```yaml -compact_clear_at_least: 20000 # absolute token floor -compact_clear_at_least: 0.15 # or a fraction of the budget -compact_max_consecutive_skips: 3 # fail loud after this many refusals -``` - -| value | meaning | -|---|---| -| `null` (default), `0`, negative | disabled — today's behaviour exactly | -| int `>= 1` | absolute token floor | -| float in `(0, 1)` | that fraction of the per-call budget | -| unparseable | disabled, with a logged warning (never raises on config alone) | - -`1.0` is deliberately read as **absolute (1 token)**, not "100% of budget": a -floor of one entire budget can never be met, so reading it as a fraction would -turn a plausible-looking config into a guaranteed fail-loud. - -### What `freed` means here, precisely - -This module's ladder has **no separable plan/apply split** — it mutates an -ephemeral copy level by level, threading a running `current_tokens` through -every rung. So the predicate does not build a second estimator or a projection. -It compares two counts the ladder already produces: - -``` -freed = tokens(view this call would have returned had it decided nothing new) - - tokens(view the ladder actually produced) -``` - -That is the **marginal** reclaim of this boundary, and it is the right number: -what breaks the provider's cache is this view differing from the last one, not -its distance from raw history. (A predicate measured against raw history would -report a large stale "freed" on every call of an already-compacted session and -approve boundaries that free nothing.) Both sides go through the same -`_estimate_tokens` the rest of the ladder runs on, recomputed exactly rather -than read off the running counter, which carries small deliberate -approximations (e.g. the flat ~18-token stub charge at level 8). - -### On refusal - -The escalation is **rolled back completely**: the sticky truncate/remove/stub -decisions recorded during it are discarded, `_last_compaction_stats` is left -untouched (so the tail compaction notice stays byte-stable across a skip), -`_sticky_level` does not move, **no `context:compaction` event is emitted**, and -a `context:compaction-skipped` event carries `freed_tokens`, `required_tokens`, -`level_reached`, `consecutive_skips`, `baseline_tokens`, `budget`, and the -protected-set knobs. The returned view is the unchanged baseline, so a refusal -is strictly append-only with respect to the previous request — which is the -entire point of refusing. - -A refusal is also **not** a compaction boundary for `replay_last_user_on_compaction`: -the replay fires once per boundary identified by `(sticky_level, -summary_absorbed_count)`, and a refusal leaves both unchanged by design, so -without an explicit check the first-ever refusal would still look like a fresh -boundary and replay after a compaction that did not happen. - -### Escalation path: it fails loud, it does not hang - -This predicate can starve compaction. If it refuses and usage keeps climbing, -the next request is larger and the predicate will normally pass — but if the -plan can *never* free the floor (the protected set is too large, or the floor -is simply misconfigured), skipping forever would end in an opaque provider -context-overflow error, and quietly compacting anyway would let a run "pass" -while the predicate had silently stopped applying. - -So after `compact_max_consecutive_skips` (default 3) **consecutive** refusals it -raises `RuntimeError`, naming what the ladder freed, what was required, the -level it reached, the baseline size against the budget, and the protected set -holding the floor — i.e. which knob to actually move: - -``` -context-simple: compact_clear_at_least=20000 could not be satisfied 3 -consecutive times (cap: 3). The compaction ladder reached level 8 and freed -only 7,698 tokens against a required floor of 20,000. Protected set holding -the floor: protected_tool_results=1 (of 30 tool results in the view), -protected_recent=20% of 124 messages. Baseline view is 7,852 tokens against a -budget of 1,200. Lower compact_clear_at_least, lower -protected_recent/protected_tool_results, or raise the budget -- compacting -harder will not help. -``` - -Two deliberate details: - -- **Calls that decided nothing are never judged and never count as a skip.** - Once sticky state alone keeps the view under threshold, the ladder returns - early having refused nothing; counting those would fail loud on a session - that is behaving perfectly. -- **A cap of `0` clamps up to `1`, not down to "never fail".** Read literally, - 0 means "tolerate unlimited refusals" — exactly the silent hang the cap - exists to prevent. -- The streak does **not** survive `clear()` or `set_messages()`: a streak - accumulated against one message set says nothing about a different one. - -### Why it is not shipped on by default - -`freed` is a token count, and in the default `token_meter: "estimate"` mode it -comes from the `len(str)//4` heuristic — never reconciled against real provider -usage, and roughly 2× off in production sessions. **A predicate that refuses -boundaries based on a number that is 2× off will refuse the wrong boundaries.** -The honest resolution already shipped in this module: `token_meter: "hybrid"` -anchors on the provider's own reported total and carries provenance -(`kind ∈ {usage, estimated, none}`). **Run this predicate with -`token_meter: "hybrid"`, not on the bare estimator.** - -No workload evaluation has been run. The pre-registered gates are in the -follow-up item: boundary count must fall **monotonically** across -`{null, 10k, 20k, 40k}` at n≥3 (a flat response falsifies the mechanism), -`max_consecutive_skips` must never be reached, cost must not rise, and re-billed -waste must not rise. **A cost *reduction* is deliberately not pre-registered as -a win**: fewer boundaries measurably "buys latency, not money" -(`cad-fewer`: −29% requests, −14% wall, same cost, same quality), and the -underlying fit (`waste ≈ 36.3 − 0.357 × boundaries`, r = −0.586 over 12 points) -is suggestive, not established. A retention gate on S5-CRAC would be **vacuous** -(40/40 constraints and 20/20 post-compaction in every run of every arm across -probes 1–6) and is deferred. - -The suggested starting value of **20,000** comes from the observed pinned -`cache_read` head of 18,458 tokens — but that number was derived through a -4.59 chars/token constant that is tokenizer- and model-version-specific. -**Re-derive the head in tokens from real provider usage before pinning it.** - -## Summary shrink guard - -**Always on when `compaction_strategy: "summary"`. No config knob.** - -The rolling summarizer previously swapped in whatever the summarizer returned. -This refuses a summary that is **not smaller** than the span of messages it -replaces — including exactly equal, since paying a cache rebuild to swap content -for content of identical cost buys nothing. - -Lifted from deepseek-harness's `compaction-basic` region check: *"a summary that -is not smaller than what it replaces is a silent cost regression, and they -simply refuse it."* We had nothing like it, and a terse span plus a verbose -5-section summary is entirely capable of growing. - -On refusal the pending summary is **discarded** and the pass falls back to -progressive compaction — the same graceful path a stale summary already takes, -and explicitly **not** counted as a summarizer failure. Both sides are measured -as full message dicts through the same `_estimate_tokens`, so the comparison is -apples-to-apples. - -There is no knob because there is no defensible reason to want a summary that -makes the context bigger. - -### Byte-identity evidence - -`compact_clear_at_least: null` (the default) was verified against -`origin/main` by loading both module versions in one process and driving them -through identical scenarios with identical inputs (message timestamps frozen, so -the two runs differ in nothing but the module source), comparing every returned -view, every emitted hook event, the full sticky-decision state, and -`_last_compaction_stats` after every call. - -**11 of 11 scenarios byte-identical**: `default`, `token_meter_actual`, -`token_meter_hybrid`, `summary_strategy_no_provider`, `tool_result_budget`, -`tool_result_head_tail`, `replay_last_user`, `notice_disabled`, -`protected_tool_results_zero`, `larger_budget`, `system_prompt_factory`. - -The check is **not vacuous**: a negative control that flips only the default to -`20_000` diverges immediately and loudly on the same harness. - ## Dependencies - `amplifier-core>=1.0.0` diff --git a/amplifier_module_context_simple/__init__.py b/amplifier_module_context_simple/__init__.py index fdc71ce..919b07c 100644 --- a/amplifier_module_context_simple/__init__.py +++ b/amplifier_module_context_simple/__init__.py @@ -55,66 +55,6 @@ • Ported from amplifier-module-context-handoff's proven `_on_llm_response` meter. See README "Real-usage token meter" for the full rationale. -Worth-The-Rebuild Predicate (opt-in, default off -- see config -`compact_clear_at_least`): - • THE PROBLEM IT SOLVES: the compaction trigger fires on a usage - THRESHOLD (`compact_threshold`) and never asks how many tokens the - compaction will actually free. Every compaction shrinks the request, - and on the OpenAI path a shrink is a guaranteed full cold rebuild of - the prompt cache (the cache matches forward from a cached entry, never - backward into one -- so a strict prefix of a cached request MISSES). - A boundary that frees 3k tokens therefore pays a full rebuild of an - ~18k-token pinned head plus everything after it to buy very little. - We currently take that trade silently, every time. - • THE FIX, lifted from Anthropic's context-editing API: `clear_at_least` - -- "If the API can't clear at least the specified amount, the strategy - will not be applied. This helps determine if context clearing is worth - breaking your prompt cache." Vendor-agnostic; implemented here - client-side, no API support required. - • `compact_clear_at_least: None` (DEFAULT) or `0` disables the predicate - entirely: not evaluated, no new event, no behavioural difference. The - default path is byte-identical to before this feature existed. - • An int >= 1 is an absolute token floor. A float in (0, 1) is a - FRACTION OF THE BUDGET, resolved per call (so it tracks the model's - real window rather than a hardcoded number). - • WHAT "freed" MEANS HERE, precisely: this module's ladder has no - separable plan/apply split -- it mutates an ephemeral copy level by - level, threading a running `current_tokens` through every rung. So the - predicate does not estimate a projection; it compares the count the - ladder ALREADY computes for the view it produced against the count of - the view this call would have returned had it decided nothing new - (the sticky baseline). That delta is the MARGINAL reclaim of this - boundary -- which is the right number, because what breaks the cache - is this view differing from the last one, not the distance from raw - history. It is exact for the ladder's own units, not a second - estimator (see _clear_at_least_required / _finalize_compaction_with_stats). - • ON REFUSAL: the new escalation is rolled back (the sticky - truncate/remove/stub decisions recorded during it are discarded), the - baseline view is returned unchanged, and `context:compaction-skipped` - is emitted. No `context:compaction` event fires, because no compaction - happened. `_last_compaction_stats` is untouched, so the tail notice - stays byte-stable across a skip. - • ESCALATION PATH -- this predicate can starve compaction, so it must - not become a silent hang. After `compact_max_consecutive_skips` - (default 3) consecutive refusals it FAILS LOUD: raises RuntimeError - naming what the ladder freed, what was required, the level it reached, - and the protected-set size that is holding the floor. Degrading - quietly instead would let an eval pass while the predicate had - silently stopped applying, which is the one outcome its gate - (G-CAL-NOSKIPHANG) is written to catch. Unreachable while the - predicate is disabled. - • Calls where sticky state alone was already sufficient (no NEW decision - made) are not judged and never count as a skip -- nothing was refused. - -Summary Shrink Guard (active whenever `compaction_strategy == "summary"`): - • Refuses to swap in a rolling summary that is not SMALLER than the span - of messages it replaces. A summary that grows the context is a silent - cost regression -- it pays a cache rebuild to make the request bigger. - • Lifted from deepseek-harness's compaction-basic region check. The - pending summary is discarded (the same graceful path a stale summary - already takes) and this pass falls back to progressive compaction. - • No config knob: there is no defensible reason to want the opposite. - Summary Compaction Strategy (opt-in, default off -- see config `compaction_strategy`): • `compaction_strategy: "progressive"` (default) is this module's @@ -305,13 +245,6 @@ # tokens rather than chars. _TOOL_RESULT_CHARS_PER_TOKEN = 4 -# Worth-the-rebuild predicate defaults (see module docstring). The predicate -# is DISABLED by default -- `None` (and `0`) mean "today's behaviour exactly". -# The skip cap exists solely so a predicate that can never be satisfied fails -# loud instead of looping; it is unreachable while the predicate is disabled. -DEFAULT_CLEAR_AT_LEAST = None -DEFAULT_MAX_CONSECUTIVE_SKIPS = 3 - # The summary message's envelope source tag and metadata type marker. The # envelope is what makes foundation's is_real_user_message() classify this # role="user" message as NOT a real user turn (see module docstring); the @@ -450,17 +383,6 @@ async def mount(coordinator: ModuleCoordinator, config: dict[str, Any] | None = falling back to the estimator before then. An unrecognized value falls back to "estimate" with a logged warning rather than crashing mount(). See module docstring. - - compact_clear_at_least: Worth-the-rebuild predicate in front of - compaction (default: None = disabled, today's behaviour - exactly). An int >= 1 is an absolute token floor; a float in - (0, 1) is a fraction of the budget. A compaction that would - free less than this is REFUSED and rolled back rather than - paying a full prompt-cache rebuild for a small reclaim. See - module docstring "Worth-The-Rebuild Predicate". - - compact_max_consecutive_skips: How many consecutive predicate - refusals are tolerated before failing loud with a RuntimeError - (default: 3). Only consulted when compact_clear_at_least is - enabled; unreachable otherwise. - compaction_strategy: "progressive" (default) or "summary". See module docstring "Summary compaction strategy". An unrecognized value falls back to "progressive" with a logged @@ -553,12 +475,6 @@ async def mount(coordinator: ModuleCoordinator, config: dict[str, Any] | None = compaction_notice_min_level=config.get("compaction_notice_min_level", 1), output_reserve_fraction=config.get("output_reserve_fraction", 0.5), token_meter=token_meter, - compact_clear_at_least=config.get( - "compact_clear_at_least", DEFAULT_CLEAR_AT_LEAST - ), - compact_max_consecutive_skips=config.get( - "compact_max_consecutive_skips", DEFAULT_MAX_CONSECUTIVE_SKIPS - ), compaction_strategy=compaction_strategy, summary_trigger=config.get("summary_trigger", 0.60), summarization_model=config.get("summarization_model"), @@ -648,8 +564,6 @@ def __init__( compaction_notice_min_level: int = 1, output_reserve_fraction: float = 0.5, token_meter: str = TOKEN_METER_ESTIMATE, - compact_clear_at_least: int | float | None = DEFAULT_CLEAR_AT_LEAST, - compact_max_consecutive_skips: int = DEFAULT_MAX_CONSECUTIVE_SKIPS, compaction_strategy: str = COMPACTION_STRATEGY_PROGRESSIVE, summary_trigger: float = 0.60, summarization_model: str | None = None, @@ -690,20 +604,6 @@ def __init__( session -- see module docstring "Real-Usage Token Meter"). An unrecognized value falls back to "estimate" with a logged warning rather than raising. - compact_clear_at_least: Worth-the-rebuild predicate in front of - compaction. None (default) or 0 disables it entirely -- - byte-identical to before this feature existed. An int >= 1 - is an absolute token floor; a float in (0, 1) is a fraction - of the per-call budget. A compaction whose MARGINAL reclaim - is below the floor is refused and rolled back rather than - paying a full prompt-cache rebuild for a small reclaim. See - module docstring "Worth-The-Rebuild Predicate". - compact_max_consecutive_skips: Consecutive predicate refusals - tolerated before failing loud with RuntimeError (default 3). - Only consulted when compact_clear_at_least is enabled. A - value <= 0 is treated as 1 (refuse once, then fail) rather - than as "never fail", because "never fail" is the silent - hang this cap exists to prevent. compaction_strategy: "progressive" (default, byte-identical to pre-existing behavior) or "summary" -- see module docstring "Summary compaction strategy". An unrecognized value falls @@ -764,12 +664,6 @@ def __init__( ) token_meter = TOKEN_METER_ESTIMATE self.token_meter = token_meter - self.compact_clear_at_least = compact_clear_at_least - # A cap of 0 or less would mean "tolerate unlimited refusals", i.e. - # exactly the silent starvation this cap exists to prevent. Clamp to - # 1 (refuse once, then fail loud) rather than honouring a value that - # disables the only safety net the predicate has. - self.compact_max_consecutive_skips = max(1, int(compact_max_consecutive_skips)) if compaction_strategy not in _VALID_COMPACTION_STRATEGIES: logger.warning( f"context-simple: unknown compaction_strategy {compaction_strategy!r} " @@ -828,22 +722,6 @@ def __init__( self._last_replayed_boundary: tuple[int, int] | None = None self._hooks = hooks self._last_compaction_stats: dict[str, Any] | None = None - # --- Worth-the-rebuild predicate state (compact_clear_at_least) --- - # `_clear_at_least_skips` counts CONSECUTIVE refusals; any accepted - # compaction resets it to 0. `_clear_at_least_pending` is call-scoped - # state handed from _compact_ephemeral to its single terminal choke - # point (_finalize_compaction_with_stats): the baseline view this - # call would have returned had it decided nothing new, that view's - # token count, and the sticky-decision snapshot to roll back to. It - # is always None outside a single _compact_ephemeral call, and stays - # None entirely while the predicate is disabled. - self._clear_at_least_skips: int = 0 - self._clear_at_least_pending: dict[str, Any] | None = None - # True only for the remainder of a call whose escalation the - # predicate refused. Read by get_messages_for_request() so a - # refusal is not mistaken for a compaction boundary by the - # last-user replay. Always False while the predicate is disabled. - self._clear_at_least_last_refused: bool = False # --- Summary compaction strategy state (compaction_strategy == "summary") --- # Unused, and never touched, in the default "progressive" mode. self._cached_provider: Any = None @@ -1169,17 +1047,7 @@ async def get_messages_for_request( # suppressed (tail unsafe) or the replay lands first and the # notice's own guard then sees the replay -- a plain user # message with no tool_calls -- and proceeds correctly. - # A refused compaction is NOT a boundary. Without this check the - # replay would fire on a call where nothing was shed at all -- - # `_current_compaction_boundary()` is unchanged by a refusal (by - # design: the verdict runs before `_sticky_level` is bumped), so - # on the first-ever refusal it still differs from the initial - # `None` and would look like a fresh boundary. Appending a - # verbatim user replay after a compaction that did not happen - # would both mislead the model and spend tokens the refusal - # exists to save. Always False while the predicate is disabled, - # so this cannot change existing behaviour. - if self.replay_last_user_on_compaction and not self._clear_at_least_last_refused: + if self.replay_last_user_on_compaction: self._maybe_append_last_user_replay(compacted, effective_budget) if self.compaction_notice_enabled and self._last_compaction_stats: @@ -1324,7 +1192,6 @@ async def set_messages(self, messages: list[dict[str, Any]]) -> None: self._sticky_level = 0 self._last_compaction_stats = None self._last_replayed_boundary = None - self._reset_clear_at_least_state() self._reset_summary_strategy_state() logger.info(f"Restored {len(messages)} messages to context") @@ -1347,7 +1214,6 @@ async def clear(self) -> None: self._tool_name_by_call_id = {} self._spilled_paths = set() self._last_replayed_boundary = None - self._reset_clear_at_least_state() self._reset_summary_strategy_state() logger.info("Context cleared") @@ -1860,194 +1726,6 @@ def _apply_sticky_decisions( result.append(dict(msg)) return result - # --- Worth-the-rebuild predicate helpers (compact_clear_at_least) --- - # - # See the module docstring section "Worth-The-Rebuild Predicate" for the - # why. In short: every compaction shrinks the request, and a shrink is a - # guaranteed cold prompt-cache rebuild on the OpenAI path, so a boundary - # that frees very little is close to pure loss. This predicate refuses - # those boundaries. It is DISABLED by default and, while disabled, - # `_clear_at_least_pending` is never populated and no code path below the - # `required > 0` check is ever entered. - - def _reset_clear_at_least_state(self) -> None: - """Drop predicate state -- called from clear() and set_messages(). - - The consecutive-skip streak is per-conversation: a resumed or cleared - session has a fresh message set, so a streak accumulated against the - old one says nothing about the new one and must not be inherited (it - would otherwise fail loud on the first refusal after a resume). - """ - self._clear_at_least_skips = 0 - self._clear_at_least_pending = None - self._clear_at_least_last_refused = False - - def _clear_at_least_required(self, budget: int) -> int: - """Resolve `compact_clear_at_least` to an absolute token floor for - THIS call, or 0 meaning the predicate is disabled. - - None / 0 / negative -> 0 (disabled; today's behaviour exactly) - 0 < value < 1 -> that FRACTION of `budget`, resolved per call - value >= 1 -> that many tokens, absolutely - - The fraction form exists because a hardcoded token floor silently - means something completely different on a 200k window than on a 45k - one; a fraction tracks the real window. - - The 1.0 boundary is deliberately read as ABSOLUTE, not as "100% of - budget": a floor of one entire budget can never be met by any - compaction, so reading it as a fraction would turn a plausible-looking - config into a guaranteed fail-loud. A malformed value disables the - predicate with a warning rather than raising -- consistent with how - this module handles every other unrecognized config value. - """ - raw = self.compact_clear_at_least - if raw is None: - return 0 - try: - value = float(raw) - except (TypeError, ValueError): - logger.warning( - f"context-simple: compact_clear_at_least={raw!r} is not a number; " - f"disabling the worth-the-rebuild predicate for this session " - f"(compaction behaves exactly as if it were unset)." - ) - return 0 - if value <= 0: - return 0 - if value < 1: - return int(budget * value) if budget > 0 else 0 - return int(value) - - def _snapshot_sticky_decisions(self) -> dict[str, set[int]]: - """Copy the three sticky decision sets so a refused escalation can be - rolled back exactly. - - These sets are the ONLY durable state a progressive escalation writes - before it reaches `_finalize_compaction_with_stats` (which is where - the predicate runs). `_sticky_level`, `_last_compaction_stats` and the - `context:compaction` event are all written AFTER the predicate, so a - refusal leaves no trace of them at all -- which is what keeps the tail - compaction notice byte-stable across a skip. - """ - return { - "removed": set(self._removed_seqs), - "truncated": set(self._truncated_seqs), - "stubbed": set(self._stubbed_seqs), - } - - def _restore_sticky_decisions(self, snapshot: dict[str, set[int]]) -> None: - """Undo every sticky decision recorded since `snapshot` was taken.""" - self._removed_seqs = set(snapshot["removed"]) - self._truncated_seqs = set(snapshot["truncated"]) - self._stubbed_seqs = set(snapshot["stubbed"]) - - async def _clear_at_least_verdict( - self, - final_messages: list[dict[str, Any]], - max_level_reached: int, - ) -> list[dict[str, Any]] | None: - """Judge a completed escalation against the worth-the-rebuild floor. - - Returns None when the compaction is ACCEPTED (the caller finalizes - normally), or the baseline view to return INSTEAD when it is refused. - Raises RuntimeError when the consecutive-refusal cap is reached. - - `freed` is the MARGINAL reclaim of this boundary: the count of the - view this call would have returned had it decided nothing new, minus - the count of the view the ladder actually produced. That is the right - comparand because what breaks the provider's cache is this view - differing from the last one -- not its distance from raw history. - - Both sides are `_estimate_tokens` over a full message list, i.e. the - same units the ladder itself uses, recomputed exactly rather than read - off the ladder's running counter (which carries small deliberate - approximations, e.g. the flat ~18-token stub charge at level 8). - No second estimator is introduced. - """ - pending = self._clear_at_least_pending - self._clear_at_least_pending = None - if pending is None: - # Predicate disabled, or this call made no new decision to judge. - return None - - required: int = pending["required"] - baseline_tokens: int = pending["baseline_tokens"] - freed = baseline_tokens - self._estimate_tokens(final_messages) - - if freed >= required: - self._clear_at_least_skips = 0 - logger.debug( - f"context-simple: clear_at_least ALLOWED this boundary -- " - f"level {max_level_reached} frees {freed:,} tokens " - f"(floor {required:,})" - ) - return None - - # --- REFUSED: roll the escalation back completely --- - self._clear_at_least_last_refused = True - self._clear_at_least_skips += 1 - self._restore_sticky_decisions(pending["sticky"]) - - baseline_view: list[dict[str, Any]] = pending["baseline_view"] - protected_tool_results_present = sum( - 1 for m in baseline_view if m.get("role") == "tool" - ) - protected_summary = ( - f"protected_tool_results={self.protected_tool_results} " - f"(of {protected_tool_results_present} tool results in the view), " - f"protected_recent={self.protected_recent:.0%} " - f"of {len(baseline_view)} messages" - ) - - if self._clear_at_least_skips >= self.compact_max_consecutive_skips: - # FAIL LOUD, do not degrade. Continuing to skip would let usage - # climb until the provider hard-fails with an opaque - # context-overflow error; silently compacting anyway would let a - # run "pass" while the predicate had quietly stopped applying. - # Neither tells the operator which knob to move -- this does. - raise RuntimeError( - f"context-simple: compact_clear_at_least={self.compact_clear_at_least!r} " - f"could not be satisfied {self._clear_at_least_skips} consecutive times " - f"(cap: {self.compact_max_consecutive_skips}). The compaction ladder " - f"reached level {max_level_reached} and freed only {freed:,} tokens " - f"against a required floor of {required:,}. Protected set holding the " - f"floor: {protected_summary}. Baseline view is {baseline_tokens:,} " - f"tokens against a budget of {pending['budget']:,}. Lower " - f"compact_clear_at_least, lower protected_recent/protected_tool_results, " - f"or raise the budget -- compacting harder will not help." - ) - - logger.info( - f"context-simple: clear_at_least REFUSED this boundary -- level " - f"{max_level_reached} would free only {freed:,} tokens against a floor of " - f"{required:,}; not worth a full prompt-cache rebuild. Escalation rolled " - f"back; returning the unchanged view " - f"({self._clear_at_least_skips}/{self.compact_max_consecutive_skips} " - f"consecutive skips)." - ) - - if self._hooks is not None: - try: - await self._hooks.emit( - "context:compaction-skipped", - { - "freed_tokens": freed, - "required_tokens": required, - "level_reached": max_level_reached, - "consecutive_skips": self._clear_at_least_skips, - "max_consecutive_skips": self.compact_max_consecutive_skips, - "baseline_tokens": baseline_tokens, - "budget": pending["budget"], - "protected_recent": self.protected_recent, - "protected_tool_results": self.protected_tool_results, - }, - ) - except Exception as e: # pragma: no cover - defensive - logger.warning(f"Could not emit compaction-skipped event: {e}") - - return baseline_view - async def _compact_ephemeral( self, budget: int, source_messages: list[dict[str, Any]] | None = None ) -> list[dict[str, Any]]: @@ -2077,13 +1755,6 @@ async def _compact_ephemeral( source_messages: Messages to compact. If None, uses self.messages. This allows compacting factory-generated message lists. """ - # The worth-the-rebuild predicate's comparand is strictly - # call-scoped. Clear it up front so a value left behind by an - # earlier call that raised can never be consumed by this one. - # A no-op while the predicate is disabled (it is never set). - self._clear_at_least_pending = None - self._clear_at_least_last_refused = False - messages_to_compact = ( source_messages if source_messages is not None else self.messages ) @@ -2240,38 +1911,6 @@ async def _compact_ephemeral( target_tokens, ) - # === WORTH-THE-REBUILD PREDICATE: capture the comparand === - # - # This is the last point at which nothing new has been decided. - # `working_messages` here is exactly what this call would return if - # it escalated no further (the sticky baseline), and `current_tokens` - # is exactly its token count -- `_estimate_tokens` is a per-message - # sum, so system_tokens + non_system_tokens IS the count of the - # combined list, not an approximation of it. - # - # Placed AFTER both early returns on purpose: a call where sticky - # state alone was already sufficient decided nothing, so there is - # nothing to judge and it must never count as a skip. The - # summary-swap-only path is likewise not judged here -- a swap has - # already mutated self.messages irreversibly by this point, so it - # cannot be rolled back; that path is governed by the summary shrink - # guard in _swap_in_pending_summary instead. - # - # The verdict itself runs in _finalize_compaction_with_stats, the - # single terminal choke point every escalation level returns through - # -- and BEFORE that method records stats or emits - # `context:compaction`, so a refused escalation never emits an event - # claiming a compaction that did not happen. - clear_at_least_required = self._clear_at_least_required(budget) - if clear_at_least_required > 0: - self._clear_at_least_pending = { - "required": clear_at_least_required, - "baseline_view": system_messages + list(working_messages), - "baseline_tokens": current_tokens, - "sticky": self._snapshot_sticky_decisions(), - "budget": budget, - } - logger.info( f"Compacting context (new escalation): {old_count} raw messages, {old_tokens:,} raw tokens " f"-> {len(working_messages)} messages, {current_tokens:,} tokens after sticky state " @@ -2970,17 +2609,6 @@ async def _finalize_compaction_with_stats( # System messages were extracted before compaction and must be restored final_messages = system_messages + working_messages - # === WORTH-THE-REBUILD PREDICATE: the verdict === - # Runs before ANY durable effect of this method (sticky level, - # stats, `context:compaction` event), so a refusal leaves no trace - # that a compaction occurred. Returns None -- and costs nothing but - # a None check -- while the predicate is disabled. - refused_view = await self._clear_at_least_verdict( - final_messages, max_level_reached - ) - if refused_view is not None: - return refused_view - final_tokens = self._estimate_tokens(final_messages) system_count = len(system_messages) tool_use_count = sum(1 for m in final_messages if m.get("tool_calls")) @@ -4024,52 +3652,11 @@ async def _swap_in_pending_summary( ) return non_system_messages, False - absorbed_messages = [ - msg for msg in non_system_messages if self._extract_seq(msg) in absorb_seqs - ] - summary_message = self._make_summary_message(pending["text"]) - - # === SHRINK GUARD (deepseek-harness compaction-basic, region.ts) === - # - # Refuse a summary that is not SMALLER than the span it replaces. Such - # a swap is a pure loss twice over: it pays a full cold prompt-cache - # rebuild (any shrink or edit invalidates the cached prefix) AND ends - # up with a bigger request than it started with -- a silent cost - # regression with no upside whatsoever. Nothing in this module - # previously checked it; a terse span plus a verbose 5-section summary - # is entirely capable of growing. - # - # Both sides are measured as full message dicts through the same - # `_estimate_tokens` the rest of the ladder uses, so the comparison is - # apples-to-apples (metadata overhead counted on both sides). - # - # There is no config knob for this: there is no defensible reason to - # want a summary that makes the context bigger. On refusal the pending - # summary is DISCARDED and this pass falls back to progressive - # compaction -- exactly the graceful path a stale summary already - # takes, not a failure. - # - # `_make_summary_message` has already consumed a `_seq` for the message - # being discarded. That gap is deliberately left rather than rewound: - # sequence ids need only be unique and monotonic (see - # _apply_sticky_decisions / _hybrid_split), and rewinding a shared - # counter is a worse hazard than an unused number. - absorbed_tokens = self._estimate_tokens(absorbed_messages) - summary_tokens = self._estimate_tokens([summary_message]) - if summary_tokens >= absorbed_tokens: - logger.warning( - f"context-simple: REFUSING summary swap -- the summary is " - f"{summary_tokens:,} tokens against the {absorbed_tokens:,} tokens " - f"of the {len(absorbed_messages)} messages it would replace, so it " - f"would GROW the context while still paying a full prompt-cache " - f"rebuild. Discarding it; falling back to progressive compaction " - f"for this pass." - ) - return non_system_messages, False - - for msg in absorbed_messages: - self._record_removed(msg) + for msg in non_system_messages: + if self._extract_seq(msg) in absorb_seqs: + self._record_removed(msg) + summary_message = self._make_summary_message(pending["text"]) self.messages.append(summary_message) self._summary_absorbed_count += len(absorb_seqs) diff --git a/tests/test_clear_at_least_predicate.py b/tests/test_clear_at_least_predicate.py deleted file mode 100644 index c12d520..0000000 --- a/tests/test_clear_at_least_predicate.py +++ /dev/null @@ -1,770 +0,0 @@ -"""Adversarial tests for the worth-the-rebuild predicate -(`compact_clear_at_least`) and the summary shrink guard. - -WHY THE PREDICATE EXISTS ------------------------- -The compaction trigger fires on a usage THRESHOLD and never asks how many -tokens the compaction will actually free. Every compaction shrinks the -request, and a shrink is a guaranteed cold prompt-cache rebuild on the OpenAI -path (the cache matches forward from a cached entry, never backward into one: -a strict byte-identical PREFIX of a cached request measured 0 cache_read). -So a boundary that frees very little pays a full rebuild of an ~18k-token -pinned head to buy almost nothing. - -`compact_clear_at_least` refuses those boundaries. It is Anthropic's -context-editing parameter of the same name, implemented client-side. - -WHAT THESE TESTS ARE ADVERSARIAL ABOUT --------------------------------------- -The predicate's own failure mode is STARVATION: refuse forever, usage climbs, -and the provider eventually hard-fails with an opaque context-overflow error. -So the tests below deliberately try to: - - 1. make it skip a call that decided nothing new (which must NOT count as a - skip -- otherwise a quiet session fails loud for no reason); - 2. make it starve silently (it must raise, naming the protected set); - 3. make it leave state behind after a refusal (sticky decisions, stats, the - tail notice, and the emitted events must all look as if the escalation - never happened); - 4. make the DEFAULT path behave differently from before the feature existed - (it must not -- byte-identical, no new events, predicate state never - even populated); - 5. split a tool_use/tool_result pair across a refusal. - -The shrink-guard tests do the same for a summary that would GROW the context: -a swap that pays a cache rebuild to make the request bigger is a pure loss -twice over, and nothing in this module previously checked for it. -""" - -from typing import Any - -import pytest -from amplifier_module_context_simple import SimpleContextManager - - -class _FakeHooks: - """Records every emitted event so tests can assert on the compaction / - compaction-skipped lifecycle without real HookRegistry internals.""" - - def __init__(self): - self.emitted: list[tuple[str, dict]] = [] - - async def emit(self, event, data): - self.emitted.append((event, data)) - - def names(self) -> list[str]: - return [name for name, _ in self.emitted] - - def payloads(self, event: str) -> list[dict]: - return [data for name, data in self.emitted if name == event] - - -def _padded(i: int, role: str, size: int = 80) -> dict: - """A message with enough bulk to move the token counter meaningfully.""" - return {"role": role, "content": f"{role} message {i} " + ("x" * size)} - - -def _tool_call(call_id: str, tool: str = "bash") -> dict: - return { - "role": "assistant", - "content": "", - "tool_calls": [{"id": call_id, "tool": tool, "arguments": {}}], - } - - -def _tool_result(call_id: str, content: str = "result") -> dict: - return {"role": "tool", "tool_call_id": call_id, "content": content} - - -def _make_context(**overrides: Any) -> SimpleContextManager: - """Same shape the sticky/notice suite uses, so these tests exercise the - real ladder rather than a bespoke configuration.""" - config: dict[str, Any] = { - "max_tokens": 2000, - "compact_threshold": 0.5, - "target_usage": 0.3, - "protected_recent": 0.2, - "protected_tool_results": 1, - "truncate_chars": 40, - "compaction_notice_enabled": True, - "compaction_notice_min_level": 1, - } - config.update(overrides) - return SimpleContextManager(**config) - - -async def _fill_until_compacted( - context: SimpleContextManager, turns: int = 40 -) -> None: - for i in range(turns): - await context.add_message(_padded(i, "user")) - await context.add_message(_padded(i, "assistant")) - - -def _normalize(messages: list[dict]) -> list[dict]: - """Drop the two fields that legitimately differ between two separately - built contexts (wall-clock `timestamp`) or between stored history and a - returned view (`_seq`, which `_finalize_view` strips at the module - boundary). Everything else must match exactly. - """ - out: list[dict] = [] - for msg in messages: - meta = { - k: v - for k, v in (msg.get("metadata") or {}).items() - if k not in ("timestamp", "_seq") - } - copy = {k: v for k, v in msg.items() if k != "metadata"} - if meta or "metadata" in msg: - copy["metadata"] = meta - out.append(copy) - return out - - -def _strip_ephemeral(messages: list[dict]) -> list[dict]: - """Drop the trailing ephemeral compaction notice, which is deliberately - outside the cached prefix and expected to vary.""" - out = list(messages) - while out and (out[-1].get("metadata") or {}).get("ephemeral"): - out.pop() - return out - - -# --------------------------------------------------------------------------- -# DEFAULT OFF: today's behaviour exactly -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_default_is_disabled_and_never_populates_predicate_state(): - """With no config, the predicate must not merely pass -- it must never be - evaluated at all. `_clear_at_least_pending` staying None is the proof that - the guarded code path is not entered, not just that it agreed.""" - context = _make_context() - assert context.compact_clear_at_least is None - await _fill_until_compacted(context) - - for _ in range(3): - await context.get_messages_for_request() - assert context._clear_at_least_pending is None - assert context._clear_at_least_skips == 0 - - assert context._last_compaction_stats is not None, ( - "setup must actually compact for this test to mean anything" - ) - - -@pytest.mark.asyncio -async def test_default_view_is_identical_to_explicit_zero_and_to_disabled(): - """`None` (default), `0`, and a negative value must all produce the exact - same returned view AND the exact same emitted events as each other.""" - views: list[list[dict]] = [] - event_names: list[list[str]] = [] - - for value in (None, 0, -5000): - hooks = _FakeHooks() - context = _make_context(compact_clear_at_least=value, hooks=hooks) - await _fill_until_compacted(context) - # Several calls, with history growing in between: exercises the sticky - # path and the escalation path, not just one call. - collected: list[dict] = [] - for i in range(3): - collected = await context.get_messages_for_request() - await context.add_message(_padded(5000 + i, "user")) - await context.add_message(_padded(5000 + i, "assistant")) - views.append(collected) - event_names.append(hooks.names()) - - assert _normalize(views[0]) == _normalize(views[1]) == _normalize(views[2]) - assert event_names[0] == event_names[1] == event_names[2] - assert "context:compaction-skipped" not in event_names[0] - - -@pytest.mark.asyncio -async def test_disabled_predicate_emits_no_skipped_event_ever(): - hooks = _FakeHooks() - context = _make_context(hooks=hooks) - await _fill_until_compacted(context) - for _ in range(5): - await context.get_messages_for_request() - await context.add_message(_padded(1, "user")) - assert "context:compaction-skipped" not in hooks.names() - assert "context:compaction" in hooks.names() - - -# --------------------------------------------------------------------------- -# BLOCK: a low-yield boundary is refused, and refused CLEANLY -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_predicate_blocks_a_low_yield_boundary(): - """An unreachable floor must refuse the boundary: the view comes back - uncompacted-by-this-call, and a `context:compaction-skipped` event carries - what was freed vs. what was required.""" - hooks = _FakeHooks() - context = _make_context(compact_clear_at_least=10_000_000, hooks=hooks) - await _fill_until_compacted(context) - - baseline_uncompacted = [ - m for m in context.messages if m.get("role") != "system" - ] - view = await context.get_messages_for_request() - - assert "context:compaction" not in hooks.names(), ( - "a refused escalation must not emit a compaction event -- no " - "compaction happened" - ) - skipped = hooks.payloads("context:compaction-skipped") - assert len(skipped) == 1 - assert skipped[0]["required_tokens"] == 10_000_000 - assert skipped[0]["freed_tokens"] < 10_000_000 - assert skipped[0]["consecutive_skips"] == 1 - assert skipped[0]["level_reached"] >= 1, ( - "the ladder must actually have run -- the predicate judges a real " - "result, not a guess made before doing the work" - ) - - # The returned view is the untouched history (no truncation, no removal). - assert _normalize(_strip_ephemeral(view)) == _normalize(baseline_uncompacted) - - -@pytest.mark.asyncio -async def test_refusal_rolls_back_every_sticky_decision(): - """The escalation's truncate/remove/stub decisions are the only durable - state written before the predicate runs. A refusal must undo all three -- - otherwise the NEXT call silently replays a compaction that was refused.""" - context = _make_context(compact_clear_at_least=10_000_000) - await _fill_until_compacted(context) - - before = ( - set(context._removed_seqs), - set(context._truncated_seqs), - set(context._stubbed_seqs), - context._sticky_level, - ) - await context.get_messages_for_request() - after = ( - set(context._removed_seqs), - set(context._truncated_seqs), - set(context._stubbed_seqs), - context._sticky_level, - ) - assert before == after - assert context._last_compaction_stats is None, ( - "a refused escalation must leave no stats behind -- otherwise the " - "tail notice would announce a compaction that never happened" - ) - - -@pytest.mark.asyncio -async def test_refusal_leaves_no_compaction_notice(): - context = _make_context(compact_clear_at_least=10_000_000) - await _fill_until_compacted(context) - view = await context.get_messages_for_request() - assert not any( - (m.get("metadata") or {}).get("source") == "context-compaction" - for m in view - ) - - -@pytest.mark.asyncio -async def test_refusal_preserves_tool_pair_integrity(): - """The refused view is raw history, so every tool_calls message must still - be answered by its tool result. A refusal must never be able to strand a - tool_use without its tool_result.""" - context = _make_context(compact_clear_at_least=10_000_000) - for i in range(30): - await context.add_message(_padded(i, "user")) - await context.add_message(_tool_call(f"call-{i}")) - await context.add_message(_tool_result(f"call-{i}", "r" * 200)) - - view = await context.get_messages_for_request() - - called = [ - tc["id"] - for m in view - for tc in (m.get("tool_calls") or []) - ] - answered = [ - m["tool_call_id"] for m in view if m.get("role") == "tool" - ] - assert called, "setup must produce tool calls" - assert sorted(called) == sorted(answered) - - -# --------------------------------------------------------------------------- -# ALLOW: a high-yield boundary passes, byte-identically to disabled -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_predicate_allows_a_high_yield_boundary(): - hooks = _FakeHooks() - context = _make_context(compact_clear_at_least=1, hooks=hooks) - await _fill_until_compacted(context) - await context.get_messages_for_request() - - assert "context:compaction" in hooks.names() - assert "context:compaction-skipped" not in hooks.names() - assert context._clear_at_least_skips == 0 - assert context._last_compaction_stats is not None - - -@pytest.mark.asyncio -async def test_allowed_boundary_is_identical_to_the_disabled_path(): - """A floor of 1 token is satisfiable by any real compaction, so the - resulting view must be byte-identical to running with the predicate off. - This is what proves the predicate ALLOWS rather than perturbs.""" - allowed = _make_context(compact_clear_at_least=1) - disabled = _make_context() - for ctx in (allowed, disabled): - await _fill_until_compacted(ctx) - - for _ in range(3): - a = await allowed.get_messages_for_request() - d = await disabled.get_messages_for_request() - assert _normalize(a) == _normalize(d) - for ctx in (allowed, disabled): - await ctx.add_message(_padded(7001, "user")) - await ctx.add_message(_padded(7001, "assistant")) - - -@pytest.mark.asyncio -async def test_freed_is_marginal_not_measured_against_raw_history(): - """The predicate must judge the MARGINAL reclaim of this boundary, not the - distance from raw history. If it used raw history, an already-compacted - session would report a huge (stale) "freed" every call and the predicate - would approve boundaries that free nothing. - - Here: allow the first escalation with a low floor, then raise the floor - beyond anything a later marginal escalation can free, and assert the later - call is judged on the marginal number. - """ - hooks = _FakeHooks() - context = _make_context(compact_clear_at_least=1, hooks=hooks) - await _fill_until_compacted(context) - await context.get_messages_for_request() - assert "context:compaction" in hooks.names() - - stats = context._last_compaction_stats - assert stats is not None - already_freed = stats["before_tokens"] - stats["after_tokens"] - assert already_freed > 0 - - # Now require MORE than the whole first compaction freed. Any further - # marginal escalation frees far less than that, so it must be refused. - context.compact_clear_at_least = already_freed + 100_000 - for i in range(20): - await context.add_message(_padded(8000 + i, "user")) - await context.add_message(_padded(8000 + i, "assistant")) - await context.get_messages_for_request() - - skipped = hooks.payloads("context:compaction-skipped") - assert skipped, ( - "the second boundary must be judged on its own marginal reclaim, " - "which is far below the raw-history delta" - ) - assert skipped[-1]["freed_tokens"] < already_freed - - -# --------------------------------------------------------------------------- -# STARVATION: the predicate's own failure mode -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_no_op_calls_never_count_as_skips(): - """THE trap. Once sticky state alone keeps the view under threshold, the - ladder decides nothing new and returns early. Those calls must NOT be - judged: nothing was refused, so counting them would fail loud on a session - that is behaving perfectly.""" - context = _make_context(compact_clear_at_least=1, compact_max_consecutive_skips=2) - await _fill_until_compacted(context) - await context.get_messages_for_request() # a real, allowed escalation - - # Many further calls with no growth: sticky state alone suffices. - for _ in range(10): - await context.get_messages_for_request() - - assert context._clear_at_least_skips == 0 - - -@pytest.mark.asyncio -async def test_fails_loud_after_max_consecutive_skips(): - """Silent starvation is the one outcome this must never produce. After the - cap it raises, naming what was freed, what was required, and the protected - set that is holding the floor -- i.e. which knob to move.""" - context = _make_context( - compact_clear_at_least=10_000_000, compact_max_consecutive_skips=3 - ) - await _fill_until_compacted(context) - - await context.get_messages_for_request() - assert context._clear_at_least_skips == 1 - await context.get_messages_for_request() - assert context._clear_at_least_skips == 2 - - with pytest.raises(RuntimeError) as exc: - await context.get_messages_for_request() - - message = str(exc.value) - assert "compact_clear_at_least" in message - assert "10000000" in message.replace(",", "") - assert "protected_recent" in message - assert "protected_tool_results" in message - assert "freed only" in message - - -@pytest.mark.asyncio -async def test_fail_loud_still_rolls_back_state_before_raising(): - """Even on the fatal path the escalation is undone first, so a caller that - catches the error is not left with half-applied compaction decisions.""" - context = _make_context( - compact_clear_at_least=10_000_000, compact_max_consecutive_skips=1 - ) - await _fill_until_compacted(context) - before = set(context._removed_seqs), set(context._truncated_seqs) - - with pytest.raises(RuntimeError): - await context.get_messages_for_request() - - assert (set(context._removed_seqs), set(context._truncated_seqs)) == before - assert context._clear_at_least_pending is None, ( - "call-scoped state must never leak past a raise" - ) - - -@pytest.mark.asyncio -async def test_skip_streak_resets_after_an_accepted_compaction(): - context = _make_context( - compact_clear_at_least=10_000_000, compact_max_consecutive_skips=5 - ) - await _fill_until_compacted(context) - await context.get_messages_for_request() - await context.get_messages_for_request() - assert context._clear_at_least_skips == 2 - - context.compact_clear_at_least = 1 - await context.get_messages_for_request() - assert context._clear_at_least_skips == 0 - - -@pytest.mark.asyncio -async def test_max_consecutive_skips_zero_is_clamped_to_one_not_to_infinity(): - """A cap of 0 read literally means "tolerate unlimited refusals" -- which - is exactly the silent hang the cap exists to prevent. It must clamp UP to - 1, never be honoured as "never fail".""" - context = _make_context( - compact_clear_at_least=10_000_000, compact_max_consecutive_skips=0 - ) - assert context.compact_max_consecutive_skips == 1 - await _fill_until_compacted(context) - with pytest.raises(RuntimeError): - await context.get_messages_for_request() - - -@pytest.mark.asyncio -async def test_skip_streak_does_not_survive_clear_or_resume(): - """A streak accumulated against one message set says nothing about a - different one; inheriting it would fail loud on the first refusal after a - resume.""" - context = _make_context( - compact_clear_at_least=10_000_000, compact_max_consecutive_skips=3 - ) - await _fill_until_compacted(context) - await context.get_messages_for_request() - assert context._clear_at_least_skips == 1 - - await context.clear() - assert context._clear_at_least_skips == 0 - - await _fill_until_compacted(context) - await context.get_messages_for_request() - assert context._clear_at_least_skips == 1 - - await context.set_messages([_padded(i, "user") for i in range(3)]) - assert context._clear_at_least_skips == 0 - assert context._clear_at_least_pending is None - - -# --------------------------------------------------------------------------- -# PREFIX / _seq STABILITY across a refusal -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_refusal_is_prefix_stable_and_append_only(): - """Two consecutive refused calls with one turn of growth in between must - share a byte-identical prefix -- the whole point of refusing is to keep - the cached prefix intact, so a refusal that reshuffled the view would be - worse than compacting.""" - context = _make_context( - compact_clear_at_least=10_000_000, compact_max_consecutive_skips=99 - ) - await _fill_until_compacted(context) - - first = _strip_ephemeral(await context.get_messages_for_request()) - await context.add_message(_padded(9001, "user")) - await context.add_message(_padded(9001, "assistant")) - second = _strip_ephemeral(await context.get_messages_for_request()) - - assert len(second) == len(first) + 2 - assert second[: len(first)] == first, ( # exact: same context, no normalisation - "a refusal must be strictly append-only: the shared prefix cannot move" - ) - - -@pytest.mark.asyncio -async def test_seq_identity_is_untouched_by_a_refusal(): - """`_seq` is compaction identity. A refusal must not renumber, drop, or - duplicate any of them.""" - context = _make_context( - compact_clear_at_least=10_000_000, compact_max_consecutive_skips=99 - ) - await _fill_until_compacted(context) - before = [m["metadata"]["_seq"] for m in context.messages] - - for _ in range(3): - await context.get_messages_for_request() - - after = [m["metadata"]["_seq"] for m in context.messages] - assert before == after - assert len(set(after)) == len(after) - - -@pytest.mark.asyncio -async def test_refusal_then_acceptance_still_compacts_correctly(): - """A refusal must not poison the next real escalation: once the floor is - satisfiable, compaction proceeds and produces a genuinely smaller view.""" - context = _make_context( - compact_clear_at_least=10_000_000, compact_max_consecutive_skips=99 - ) - await _fill_until_compacted(context) - refused = await context.get_messages_for_request() - - context.compact_clear_at_least = 1 - accepted = await context.get_messages_for_request() - - assert context._estimate_tokens(accepted) < context._estimate_tokens(refused) - assert context._last_compaction_stats is not None - - -# --------------------------------------------------------------------------- -# Floor resolution: absolute vs fraction, and malformed values -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize( - "raw, budget, expected", - [ - (None, 100_000, 0), - (0, 100_000, 0), - (-1, 100_000, 0), - (-0.5, 100_000, 0), - (20_000, 100_000, 20_000), - (1, 100_000, 1), - (0.25, 100_000, 25_000), - (0.25, 0, 0), - (0.999, 1_000, 999), - (1.0, 100_000, 1), - ("nonsense", 100_000, 0), - (object(), 100_000, 0), - ], -) -def test_floor_resolution(raw, budget, expected): - """A float in (0, 1) is a FRACTION of the budget -- a hardcoded token floor - silently means something different on a 200k window than a 45k one. - - 1.0 is deliberately ABSOLUTE (1 token), not "100% of budget": a floor of - one whole budget can never be met, so reading it as a fraction would turn - a plausible-looking config into a guaranteed fail-loud. - """ - context = _make_context(compact_clear_at_least=raw) - assert context._clear_at_least_required(budget) == expected - - -@pytest.mark.asyncio -async def test_malformed_floor_disables_rather_than_crashing_a_session(): - """Consistent with how this module handles every other unrecognized config - value: warn and fall back, never take a session down on config alone.""" - context = _make_context(compact_clear_at_least="twenty thousand") - await _fill_until_compacted(context) - view = await context.get_messages_for_request() - assert view - assert context._last_compaction_stats is not None - assert context._clear_at_least_skips == 0 - - -@pytest.mark.asyncio -async def test_fraction_floor_scales_with_the_budget(): - """Same fraction, two budgets: the small-budget session's boundary clears - a floor the large-budget session's does not.""" - small = _make_context(max_tokens=2000, compact_clear_at_least=0.05) - assert small._clear_at_least_required(2000) == 100 - assert small._clear_at_least_required(200_000) == 10_000 - - -# --------------------------------------------------------------------------- -# Summary shrink guard -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_shrink_guard_refuses_a_summary_larger_than_what_it_replaces(): - """A summary that GROWS the context is a pure loss twice over: it pays a - full cache rebuild AND ends up with a bigger request. Refuse it.""" - context = SimpleContextManager(compaction_strategy="summary", protected_recent=0.9) - for i in range(5): - await context.add_message({"role": "user", "content": f"t{i}"}) - - seqs = [m["metadata"]["_seq"] for m in context.messages[:2]] - context._pending_summary = {"seqs": frozenset(seqs), "text": "V" * 20_000} - - non_system = [m for m in context.messages if m.get("role") != "system"] - messages_before = list(context.messages) - - result, did_swap = await context._swap_in_pending_summary(non_system) - - assert did_swap is False - assert result == non_system - assert context.messages == messages_before, ( - "a refused summary must never be appended to history" - ) - assert context._pending_summary is None - assert context._summary_absorbed_count == 0 - assert context._removed_seqs == set(), ( - "a refused summary must not record its span as removed" - ) - assert context._summarization_failures == 0, ( - "refusing an unprofitable summary is a graceful fallback, not a " - "summarizer failure" - ) - - -@pytest.mark.asyncio -async def test_shrink_guard_allows_a_summary_that_is_genuinely_smaller(): - """The guard must not block the case the feature exists for.""" - context = SimpleContextManager(compaction_strategy="summary", protected_recent=0.9) - for i in range(5): - await context.add_message({"role": "user", "content": f"turn {i} " + "x" * 400}) - - seqs = [m["metadata"]["_seq"] for m in context.messages[:2]] - context._pending_summary = {"seqs": frozenset(seqs), "text": "tiny summary"} - - non_system = [m for m in context.messages if m.get("role") != "system"] - result, did_swap = await context._swap_in_pending_summary(non_system) - - assert did_swap is True - assert context._summary_absorbed_count == 2 - assert set(seqs) <= context._removed_seqs - assert result[-1]["metadata"]["type"] == "context_summary" - - -@pytest.mark.asyncio -async def test_shrink_guard_refuses_an_equal_sized_summary(): - """"Not smaller" includes "exactly the same size": paying a cache rebuild - to swap content for content of identical cost buys nothing.""" - context = SimpleContextManager(compaction_strategy="summary", protected_recent=0.9) - await context.add_message({"role": "user", "content": "x"}) - - seqs = [context.messages[0]["metadata"]["_seq"]] - absorbed_tokens = context._estimate_tokens(context.messages[:1]) - - # Binary-search a summary text whose full message dict prices at exactly - # the absorbed span's estimate, so the >= boundary itself is exercised. - text = "" - while True: - probe_seq = context._next_seq - probe = context._make_summary_message(text) - context._next_seq = probe_seq # probe only; do not consume the id - if context._estimate_tokens([probe]) >= absorbed_tokens: - break - text += "y" - - context._pending_summary = {"seqs": frozenset(seqs), "text": text} - non_system = list(context.messages) - _result, did_swap = await context._swap_in_pending_summary(non_system) - assert did_swap is False - - -@pytest.mark.asyncio -async def test_progressive_mode_never_reaches_the_shrink_guard(): - """The guard lives on the summary path only; the default mode must be - untouched by it.""" - context = _make_context() - await _fill_until_compacted(context) - await context.get_messages_for_request() - assert context._pending_summary is None - assert context._summary_absorbed_count == 0 - assert context._last_compaction_stats is not None - assert "messages_absorbed_by_summary" not in context._last_compaction_stats - - -# --------------------------------------------------------------------------- -# Composition -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_predicate_composes_with_hybrid_token_meter(): - """The predicate acts on the ladder's own estimator units, so it must not - break when the TRIGGER is driven by a provider-anchored count instead.""" - context = _make_context(compact_clear_at_least=1, token_meter="hybrid") - await _fill_until_compacted(context) - view = await context.get_messages_for_request() - assert view - - -@pytest.mark.asyncio -async def test_predicate_survives_a_context_with_no_hooks(): - """Event emission is optional; refusing must not depend on it.""" - context = _make_context( - compact_clear_at_least=10_000_000, compact_max_consecutive_skips=99, hooks=None - ) - await _fill_until_compacted(context) - view = await context.get_messages_for_request() - assert view - assert context._clear_at_least_skips == 1 - - -@pytest.mark.asyncio -async def test_a_refusal_is_not_a_boundary_for_the_last_user_replay(): - """`replay_last_user_on_compaction` fires once per compaction BOUNDARY, - identified by (sticky_level, summary_absorbed_count). A refusal leaves - both unchanged by design -- so on the first-ever refusal that identity - still differs from the initial `None` and would look like a fresh - boundary. Appending a verbatim user replay after a compaction that did - not happen would both mislead the model and spend the tokens the refusal - exists to save. - """ - context = _make_context( - compact_clear_at_least=10_000_000, - compact_max_consecutive_skips=99, - replay_last_user_on_compaction=True, - ) - await _fill_until_compacted(context) - view = await context.get_messages_for_request() - - assert context._clear_at_least_last_refused is True - assert not any( - (m.get("metadata") or {}).get("source") == "context-replay" - for m in view - ), "a refused compaction must not mark or replay a boundary" - assert context._last_replayed_boundary is None - - -@pytest.mark.asyncio -async def test_replay_still_fires_on_an_allowed_boundary(): - """The guard above must not disable the feature it is protecting.""" - context = _make_context( - compact_clear_at_least=1, replay_last_user_on_compaction=True - ) - await _fill_until_compacted(context) - view = await context.get_messages_for_request() - - assert context._clear_at_least_last_refused is False - assert any( - (m.get("metadata") or {}).get("source") == "context-replay" - for m in view - ) From d5605e740c7e19ff14954cdf87d77531fadae839 Mon Sep 17 00:00:00 2001 From: bkrabach <702425+bkrabach@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:08:02 -0700 Subject: [PATCH 4/7] Revert "feat: replay_last_user_on_compaction -- opt-in last-user replay at the tail (#4i3) (#24)" This reverts commit 3972070d7c368a844288a86787fa2935ba2391ae. Merge policy: main carries wins only. replay_last_user_on_compaction was an unproven, opt-in feature. Belongs on a branch for evaluation, not on main. --- README.md | 83 --- amplifier_module_context_simple/__init__.py | 277 -------- tests/test_replay_last_user.py | 702 -------------------- 3 files changed, 1062 deletions(-) delete mode 100644 tests/test_replay_last_user.py diff --git a/README.md b/README.md index fc62917..72ae0a8 100644 --- a/README.md +++ b/README.md @@ -638,89 +638,6 @@ computed by the new code. is the seam for it. - **A cleanup sweep.** See above. -## Last-user replay (`replay_last_user_on_compaction`) - -**Opt-in. Default `false`. Default is byte-identical to the behavior -before this feature existed. NOT MEASURED — see "Status" below.** - -```toml -config = { replay_last_user_on_compaction = true } -``` - -### What it does - -The highest-value tier of retained context is the **user's own -verbatims**. A compaction boundary can leave the most recent user -instruction sitting far from the attention-strongest tail position, -behind whatever tool results the ladder chose to keep. - -When this flag is on **and a compaction boundary actually occurs**, the -module appends a copy of the most recent real user message as the last -item before the dynamic tail (the compaction notice), wrapped in a -`` envelope that states -explicitly that it is a repeat and not a new request: - -``` -[... compacted conversation ...] -[replay ] user, ephemeral: -[notice ] user, ephemeral: -``` - -### Why it is shaped this way - -- **Append-only.** Nothing before the append point moves. No `_seq` is - consumed, no sticky decision is touched, `self.messages` is never - modified, and the message is ephemeral (view-only). Append is the - measured cache-**HIT** shape; a shrink or a reorder is a cold rebuild. -- **Both tail items are `ephemeral: true`,** so the Anthropic provider's - trailing-ephemeral walk-back skips them and the cache breakpoint lands - in the same place it did before. -- **Tool-pair integrity is untouched.** The replay applies the same - unanswered-`tool_calls` tail guard the compaction notice uses, and - skips rather than interleaving between a `tool_use` and its - `tool_result`. -- **Once per boundary, not once per request.** The boundary identity is - `(sticky progressive level, summary-absorbed count)`; a request that - merely re-applies an existing sticky decision does not re-emit. - -### When it deliberately does nothing - -| Condition | Why | -|---|---| -| No compaction this request | No boundary, nothing was buried | -| This boundary's replay already went out | Once per boundary | -| View ends on unanswered `tool_calls` | Tool-pair atomicity; retries next request | -| The last real user message is already the tail | A copy would be a pure duplicate | -| The message is a Level-8 stub | A stub is not the user's words | -| No real user message, or no text in it | Nothing to repeat | -| The copy would exceed the budget | Compaction just shed tokens to reach it | - -### `_is_real_user_message` is vendored, not imported - -This module declares **no runtime dependencies**, so the predicate is -implemented locally rather than imported from `amplifier-foundation` — a -soft import would make behavior depend on whether an undeclared package -happens to be installed. - -It is also deliberately **stronger** than foundation's. Measured against -`amplifier-foundation` 1.0.0 (`session/messages.py`): foundation rejects -only content beginning with the **bare** `` tag, so an -**attributed** envelope — including this module's own -`` summary message — passes -foundation's check as a real user turn. Replaying a synthetic envelope as -if it were the user's words is exactly what this feature must never do, -so the local predicate rejects any `=1.0.0` diff --git a/amplifier_module_context_simple/__init__.py b/amplifier_module_context_simple/__init__.py index 919b07c..77ad0c7 100644 --- a/amplifier_module_context_simple/__init__.py +++ b/amplifier_module_context_simple/__init__.py @@ -142,37 +142,6 @@ mutate an already-cached prefix -- which, under a grow-only prompt cache, is a full cold rebuild. A failed write yields a pointer to a missing file (visible, recoverable) rather than a silent byte change. - -Last-User Replay After a Compaction Boundary (opt-in, default off -- see -config `replay_last_user_on_compaction`): - • MOTIVATION (design lens): the highest-value retained tier is the - USER'S OWN VERBATIMS. A compaction boundary can leave the most recent - user instruction sitting far from the attention-strongest tail - position, behind a wall of tool results the ladder chose to keep. - • When enabled AND a compaction boundary actually occurs, this APPENDS - (never moves, never removes) a copy of the most recent real user - message as the last item before the dynamic tail (the compaction - notice), wrapped in a `` - envelope so it reads as a reminder of a standing instruction and is - never mistaken for a NEW request. - • APPEND-ONLY BY CONSTRUCTION -- the measured cache-HIT shape. Nothing - before the append point moves, no `_seq` is consumed, no sticky - decision is touched, and `self.messages` is never modified. The - replay is ephemeral (metadata.ephemeral=True), so it joins the - compaction notice in the Anthropic provider's trailing-ephemeral - exclusion walk and does not displace the cache breakpoint. - • Tool-pair integrity untouched: it uses the SAME tail guard as the - compaction notice and skips entirely when the view ends on an - assistant message with unanswered tool_calls. - • FIRES ONCE PER BOUNDARY, not once per request: the boundary identity - is (sticky progressive level, summary-absorbed count), so a request - that merely re-applies an existing sticky decision does not re-emit. - • NOT MEASURED. This is a mechanism, shipped default-off, with NO - quality evidence behind it: the retention scenario that could - discriminate (S7) does not exist yet, and S5-CRAC is saturated - (40/40 constraints, 20/20 post-compaction in every arm of every - probe 1-6). Do not enable by default, and do not claim a quality - benefit, until a discriminating eval has run. """ # Amplifier module metadata @@ -253,70 +222,6 @@ _SUMMARY_ENVELOPE_SOURCE = "context-summary" _SUMMARY_METADATA_TYPE = "context_summary" -# The last-user-replay envelope's source tag and metadata source marker -# (opt-in `replay_last_user_on_compaction`; see module docstring -# "Last-User Replay After a Compaction Boundary"). -_REPLAY_ENVELOPE_SOURCE = "context-replay" - -# Any `` opener, attributed or bare. See -# _is_real_user_message for why the bare-tag check is not sufficient. -_REMINDER_ENVELOPE_PREFIX = " bool: - """Whether `entry` is a genuine human-authored user turn. - - Mirrors `amplifier_foundation.session.is_real_user_message` (role is - "user", no `tool_call_id`, content not a reminder envelope), plus one - hardening clause. - - VENDORED, NOT IMPORTED, on purpose: this module declares no runtime - dependencies (pyproject `dependencies = []`). A soft import of - foundation would make this predicate's behavior depend on whether an - undeclared package happens to be installed in the host environment -- - two silently different code paths for the same config. Instead the - logic lives here and `tests/test_replay_last_user.py` asserts parity - against the real foundation function whenever foundation IS - importable, so drift is caught by a failing test rather than assumed - away. - - HARDENING, and it is load-bearing: foundation 1.0.0 - (session/messages.py:95, 103) rejects only content beginning with the - BARE `` tag. An ATTRIBUTED envelope -- including this - module's own `` summary - message and the `context-replay` message this feature emits -- does - NOT match that check and is classified by foundation as a real user - turn. (The comment at `_SUMMARY_ENVELOPE_SOURCE` and the docstring of - `_make_summary_message` both assert the opposite; they are wrong on - this point as of foundation 1.0.0.) Replaying a synthetic envelope as - if it were the user's own words -- or worse, replaying a replay -- is - precisely the failure this feature must never produce, so this - predicate rejects any content opening with ` None: self._stubbed_seqs = set() self._sticky_level = 0 self._last_compaction_stats = None - self._last_replayed_boundary = None self._reset_summary_strategy_state() logger.info(f"Restored {len(messages)} messages to context") @@ -1213,7 +1078,6 @@ async def clear(self) -> None: # forked session may still hold a pointer to one; see README). self._tool_name_by_call_id = {} self._spilled_paths = set() - self._last_replayed_boundary = None self._reset_summary_strategy_state() logger.info("Context cleared") @@ -3086,147 +2950,6 @@ def _format_affected_items(self, level: int, stats: dict[str, Any]) -> str: "- If context is critical, consider asking user to clarify their current goal" ) - # ------------------------------------------------------------------ - # Last-user replay (`replay_last_user_on_compaction: true`) - # ------------------------------------------------------------------ - # - # Opt-in, default off, and a complete no-op when off: the ONLY call - # site is guarded by `if self.replay_last_user_on_compaction` inside - # the compaction branch of get_messages_for_request(), so in the - # default configuration none of the code below ever executes and the - # returned view is byte-identical to before this feature existed. - - def _current_compaction_boundary(self) -> tuple[int, int]: - """Identity of the compaction boundary the view currently reflects. - - A "boundary" is a real escalation of what has been shed, not a - request: the progressive ladder's cumulative sticky level, paired - with how many messages the summary strategy has absorbed. Both - are monotonic within a session and both already exist -- this - introduces no new state to keep in sync. - - A request that merely re-applies existing sticky decisions leaves - both components unchanged, which is exactly what makes the replay - fire once per boundary instead of once per request. - """ - return (self._sticky_level, self._summary_absorbed_count) - - @staticmethod - def _replay_text(msg: dict[str, Any]) -> str: - """Plain text of a user message, for string and block-list content. - - Returns "" when there is no text to replay (e.g. an image-only - block list), which the caller treats as "nothing to say, skip". - """ - content = msg.get("content") - if isinstance(content, str): - return content - if isinstance(content, list): - parts = [ - block["text"] - for block in content - if isinstance(block, dict) and isinstance(block.get("text"), str) - ] - return "\n".join(part for part in parts if part) - return "" - - def _maybe_append_last_user_replay( - self, compacted: list[dict[str, Any]], budget: int - ) -> None: - """Append one reminder-wrapped copy of the most recent real user - message to the tail of `compacted`, in place, if all guards pass. - - Guards, and why each exists: - - 1. ONCE PER BOUNDARY -- skip when this boundary's replay already - went out (see _current_compaction_boundary). - 2. TOOL-PAIR ATOMICITY -- skip when the view ends on an assistant - message with unanswered tool_calls, for exactly the reason the - compaction notice does: a user-role message landing between a - tool_use and its tool_result is rejected or mishandled by - providers. Skipping is free; the boundary stays unmarked and - the replay goes out on the next request instead. - 3. NOT ALREADY THE TAIL -- if the last real user message IS the - final item, a copy would be a pure duplicate that says nothing - the model cannot already see in the strongest position. - 4. NOTHING TO SAY -- no real user message in view, or no text in - it. - 5. NOT A STUB -- a Level-8 stub (`_stubbed`) is not the user's - words, it is a placeholder REPLACING them. The envelope below - calls its payload "a verbatim copy of your most recent - instruction"; emitting a stub under that sentence would make - the envelope lie. DEFENCE IN DEPTH, and deliberately so: this - branch is UNREACHABLE as of this commit, because the ladder - protects the last user message from stubbing at every level - (`i != last_user_idx` in _remove_messages_with_protection, and - `first_user_idx != last_user_idx` at Level 8). The guard exists - so this feature's honesty does not silently depend on a - protection rule enforced 600 lines away in code it does not - own. tests/test_replay_last_user.py exercises it directly - rather than pretending a natural fixture reaches it. - 6. BUDGET -- compaction has just finished shedding tokens to hit - `budget`; a large pasted user message (a file, a log) could - push the request straight back over it. Skip rather than - overshoot what compaction just paid for. - - Sourced from the COMPACTED VIEW, not from self.messages, on - purpose: the view is post-sticky-decision, so this can never - resurrect content compaction deliberately shed (e.g. a Level-8 - stub of a first-and-only user message replays as the stub, not as - the original text it replaced). - - Mutates only the caller's local view list. self.messages, `_seq` - allocation, and every sticky decision set are untouched. - """ - boundary = self._current_compaction_boundary() - if boundary == self._last_replayed_boundary: - return - - if not compacted or compacted[-1].get("tool_calls"): - return - - source = next( - (msg for msg in reversed(compacted) if _is_real_user_message(msg)), None - ) - if source is None or source is compacted[-1] or source.get("_stubbed"): - return - - text = self._replay_text(source) - if not text.strip(): - return - - replay = { - "role": "user", - "content": ( - f'\n' - "This is a verbatim copy of your most recent instruction, " - "repeated here because context compaction has moved it far " - "from the end of the conversation. It is NOT a new request " - "-- do not answer it again if it is already handled.\n\n" - f"{text}\n" - "" - ), - "metadata": { - "source": _REPLAY_ENVELOPE_SOURCE, - "ephemeral": True, - }, - } - - projected = self._estimate_tokens(compacted) + self._estimate_tokens([replay]) - if budget > 0 and projected > budget: - logger.debug( - "Skipping last-user replay this request: it would put the " - f"view at ~{projected:,} tokens against a {budget:,} budget " - "that compaction just shed tokens to reach" - ) - return - - compacted.append(replay) - self._last_replayed_boundary = boundary - logger.debug( - f"Appended last-user replay at tail for compaction boundary {boundary}" - ) - # ------------------------------------------------------------------ # Summary compaction strategy (`compaction_strategy: "summary"`) # ------------------------------------------------------------------ diff --git a/tests/test_replay_last_user.py b/tests/test_replay_last_user.py deleted file mode 100644 index 2e88fde..0000000 --- a/tests/test_replay_last_user.py +++ /dev/null @@ -1,702 +0,0 @@ -""" -Tests for `replay_last_user_on_compaction` -- appending a reminder-wrapped -copy of the most recent real user message at the tail once per compaction -boundary. - -The load-bearing tests here, in order of what they protect: - - 1. `test_default_off_is_byte_identical` -- the default configuration must - produce a view byte-identical to one built by a manager that has never - heard of this feature. This is the test that keeps an opt-in flag - honest. - 2. `test_fires_once_per_boundary` -- the replay is keyed to a compaction - BOUNDARY, not to a request. A request that merely re-applies existing - sticky decisions must not re-emit it. - 3. `test_prefix_stability_with_replay_enabled` -- enabling the feature - must not disturb the byte-stable shared prefix the sticky/_seq - machinery exists to protect. - 4. `test_no_duplicate_when_last_message_is_already_the_target` -- a copy - of the tail, at the tail, is pure waste. - 5. `test_never_replays_a_reminder_envelope` / - `test_predicate_matches_foundation_where_foundation_rejects` -- the - replay must carry the USER's words, never a synthetic envelope - (including one of its own). -""" - -import copy -from typing import Any - -import pytest -from amplifier_module_context_simple import ( - _REPLAY_ENVELOPE_SOURCE, - SimpleContextManager, - _is_real_user_message, -) - -BASE_CONFIG: dict[str, Any] = { - "max_tokens": 2000, - "compact_threshold": 0.5, - "target_usage": 0.3, - "protected_recent": 0.2, - "protected_tool_results": 1, - "truncate_chars": 40, - "compaction_notice_enabled": True, - "compaction_notice_min_level": 1, -} - - -def _make_context(**overrides: Any) -> SimpleContextManager: - config = dict(BASE_CONFIG) - config.update(overrides) - return SimpleContextManager(**config) - - -def _padded(i: int, role: str, size: int = 80) -> dict[str, Any]: - return {"role": role, "content": f"{role} message {i} " + ("x" * size)} - - -async def _fill_until_compacted( - context: SimpleContextManager, turns: int = 40 -) -> None: - for i in range(turns): - await context.add_message(_padded(i, "user")) - await context.add_message(_padded(i, "assistant")) - - -def _replays(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: - return [ - m - for m in messages - if (m.get("metadata") or {}).get("source") == _REPLAY_ENVELOPE_SOURCE - ] - - -def _without_timestamps(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Drop the wall-clock timestamp add_message() stamps on every message. - - Two managers fed the same history milliseconds apart get different - timestamps; that difference is pre-existing behavior and has nothing to - do with this feature. Everything else -- roles, content, ordering, every - other metadata key -- is compared byte for byte. - """ - scrubbed = [] - for msg in messages: - meta = msg.get("metadata") - if isinstance(meta, dict) and "timestamp" in meta: - msg = {**msg, "metadata": {k: v for k, v in meta.items() if k != "timestamp"}} - scrubbed.append(msg) - return scrubbed - - -# An ESCALATING fixture: unlike flat text turns (which jump straight to -# level 8 on the first compaction), tool-result turns give the truncation -# levels real work to do, so the sticky level climbs in observable steps. -# Measured with this exact configuration: level 0 through batch 3, level 3 -# at batch 4, unchanged through batch 7, level 5 at batch 8. That gives a -# deterministic "same boundary" window AND a deterministic escalation. -ESCALATING_CONFIG: dict[str, Any] = { - "max_tokens": 4000, - "compact_threshold": 0.5, - "target_usage": 0.3, - "protected_recent": 0.3, - "protected_tool_results": 2, - "truncate_chars": 40, - "compaction_notice_enabled": True, - "compaction_notice_min_level": 1, - "replay_last_user_on_compaction": True, -} - - -async def _add_tool_turn(context: SimpleContextManager, n: int) -> None: - await context.add_message({"role": "user", "content": f"user {n} " + "u" * 60}) - await context.add_message( - { - "role": "assistant", - "content": f"a {n}", - "tool_calls": [{"id": f"c{n}", "function": {"name": "t", "arguments": "{}"}}], - } - ) - await context.add_message( - {"role": "tool", "tool_call_id": f"c{n}", "content": "R" * 800} - ) - await context.add_message({"role": "assistant", "content": f"done {n} " + "d" * 60}) - - -# ---------------------------------------------------------------------- -# 1. Default off: byte-identical -# ---------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_default_is_off(): - """The flag defaults to False on both construction paths.""" - assert SimpleContextManager().replay_last_user_on_compaction is False - assert _make_context().replay_last_user_on_compaction is False - - -@pytest.mark.asyncio -async def test_default_off_is_byte_identical(): - """Two managers fed identical histories -- one constructed without the - flag at all, one with it explicitly False -- must produce byte-identical - views on every call, compaction included. - - Stronger than "the replay is absent": it asserts the whole returned - structure is unchanged, so a stray metadata key or a reordered tail - would fail too. - """ - baseline = _make_context() - explicit_off = _make_context(replay_last_user_on_compaction=False) - - for i in range(40): - for ctx in (baseline, explicit_off): - await ctx.add_message(_padded(i, "user")) - await ctx.add_message(_padded(i, "assistant")) - - got_baseline = await baseline.get_messages_for_request() - got_off = await explicit_off.get_messages_for_request() - assert _without_timestamps(got_baseline) == _without_timestamps(got_off) - - assert baseline._last_compaction_stats is not None, ( - "setup must actually trigger compaction for this test to mean anything" - ) - assert _replays(got_off) == [] - - -@pytest.mark.asyncio -async def test_default_off_never_touches_replay_state(): - """With the flag off, the boundary marker is never written -- proving - the guarded call site is the only thing that can reach this code.""" - context = _make_context() - await _fill_until_compacted(context) - await context.get_messages_for_request() - await context.get_messages_for_request() - assert context._last_replayed_boundary is None - - -# ---------------------------------------------------------------------- -# 2. Fires, and fires once per boundary -# ---------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_replay_appended_on_compaction(): - context = _make_context(replay_last_user_on_compaction=True) - await _fill_until_compacted(context) - - view = await context.get_messages_for_request() - assert context._last_compaction_stats is not None - replays = _replays(view) - assert len(replays) == 1 - - replay = replays[0] - assert replay["role"] == "user" - assert replay["metadata"]["ephemeral"] is True - assert replay["content"].startswith( - f'' - ) - assert replay["content"].rstrip().endswith("") - assert "NOT a new request" in replay["content"] - - # It carries the actual most recent user text. - last_user = [ - m for m in view if m.get("role") == "user" and _is_real_user_message(m) - ][-1] - assert last_user["content"] in replay["content"] - - -@pytest.mark.asyncio -async def test_no_replay_without_compaction(): - """No compaction, no boundary, no replay.""" - context = _make_context(replay_last_user_on_compaction=True) - await context.add_message(_padded(0, "user")) - await context.add_message(_padded(0, "assistant")) - - view = await context.get_messages_for_request() - assert context._last_compaction_stats is None - assert _replays(view) == [] - assert context._last_replayed_boundary is None - - -@pytest.mark.asyncio -async def test_fires_once_per_boundary(): - """Repeated requests at the SAME compaction boundary emit exactly one - replay -- the first one. Subsequent calls, even as history keeps - growing and compaction keeps firing, must not re-emit until a new - boundary actually occurs. - - This is the test that distinguishes "once per boundary" from "once per - request", and it runs on a fixture whose sticky level is measured to - hold steady across the window it checks. - """ - context = SimpleContextManager(**ESCALATING_CONFIG) - - turn = 0 - emissions: list[int] = [] - levels: list[int] = [] - for _ in range(8): - await _add_tool_turn(context, turn) - turn += 1 - view = await context.get_messages_for_request() - emissions.append(len(_replays(view))) - levels.append(context._sticky_level) - - assert any(e == 1 for e in emissions), "the replay never fired at all" - - # Group the requests by the boundary in force at the time, and assert - # at most one emission per boundary. - per_boundary: dict[int, int] = {} - for level, emitted in zip(levels, emissions): - per_boundary[level] = per_boundary.get(level, 0) + emitted - for level, count in per_boundary.items(): - assert count <= 1, ( - f"replay fired {count} times within sticky level {level}: " - f"levels={levels} emissions={emissions}" - ) - - # And specifically: the level-3 boundary is held across several - # consecutive requests in this fixture, with exactly one emission. - assert levels.count(3) >= 2, f"fixture drifted; levels={levels}" - assert per_boundary[3] == 1 - - -@pytest.mark.asyncio -async def test_refires_on_a_new_boundary(): - """A genuinely new boundary (the sticky level escalating) re-arms the - replay -- each escalation re-buries the user's instruction deeper in - the view, which is the whole reason this feature exists.""" - context = SimpleContextManager(**ESCALATING_CONFIG) - - seen_levels: list[int] = [] - emissions: list[int] = [] - for turn in range(12): - await _add_tool_turn(context, turn) - view = await context.get_messages_for_request() - seen_levels.append(context._sticky_level) - emissions.append(len(_replays(view))) - - distinct_compacting_levels = {lv for lv in seen_levels if lv > 0} - assert len(distinct_compacting_levels) >= 2, ( - f"fixture must escalate at least once; levels={seen_levels}" - ) - assert sum(emissions) >= 2, ( - f"a new compaction boundary must re-arm the replay; " - f"levels={seen_levels} emissions={emissions}" - ) - - -@pytest.mark.asyncio -async def test_boundary_marker_resets_on_clear_and_set_messages(): - """A cleared or resumed session must not inherit a stale boundary - marker -- the boundary counters reset to (0, 0), so a stale marker - would silently suppress the first real replay of the new session.""" - context = _make_context(replay_last_user_on_compaction=True) - await _fill_until_compacted(context) - await context.get_messages_for_request() - assert context._last_replayed_boundary is not None - - await context.clear() - assert context._last_replayed_boundary is None - - await _fill_until_compacted(context) - await context.get_messages_for_request() - assert context._last_replayed_boundary is not None - - await context.set_messages([_padded(0, "user")]) - assert context._last_replayed_boundary is None - - -# ---------------------------------------------------------------------- -# 3. Append-only: prefix / _seq / history invariants -# ---------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_prefix_stability_with_replay_enabled(): - """Enabling the replay must not disturb the byte-stable shared prefix. - - Both trailing ephemeral items (the replay and the compaction notice) - are stripped before comparison -- they are the dynamic tail by design. - The prefix in front of them must match byte for byte across a call - where history grew by exactly one turn. - """ - - def strip_trailing_ephemeral(messages: list[dict[str, Any]]) -> list[dict]: - out = list(messages) - while out and (out[-1].get("metadata") or {}).get("ephemeral"): - out.pop() - return out - - context = _make_context(replay_last_user_on_compaction=True) - await _fill_until_compacted(context) - - call1 = strip_trailing_ephemeral(await context.get_messages_for_request()) - - await context.add_message(_padded(9001, "user")) - await context.add_message(_padded(9001, "assistant")) - - call2 = strip_trailing_ephemeral(await context.get_messages_for_request()) - - assert len(call2) >= len(call1) - assert call2[: len(call1)] == call1, ( - "enabling the last-user replay shifted the byte-stable prefix" - ) - - -@pytest.mark.asyncio -async def test_replay_does_not_mutate_history_or_consume_a_seq(): - """The replay is ephemeral: self.messages, `_seq` allocation, and every - sticky decision set must be exactly as they were.""" - context = _make_context(replay_last_user_on_compaction=True) - await _fill_until_compacted(context) - - # Prime past the first compaction so state is settled. - await context.get_messages_for_request() - - history_before = copy.deepcopy(context.messages) - seq_before = context._next_seq - removed_before = set(context._removed_seqs) - truncated_before = set(context._truncated_seqs) - stubbed_before = set(context._stubbed_seqs) - - await context.set_messages(history_before) - await context.get_messages_for_request() - - assert context._next_seq == seq_before - assert len(context.messages) == len(history_before) - # A fresh set_messages resets sticky state, so compare shapes not sets. - assert isinstance(removed_before, set) - assert isinstance(truncated_before, set) - assert isinstance(stubbed_before, set) - - -@pytest.mark.asyncio -async def test_replay_carries_no_internal_seq_metadata(): - """Nothing internal leaks across the module boundary on the replay.""" - context = _make_context(replay_last_user_on_compaction=True) - await _fill_until_compacted(context) - view = await context.get_messages_for_request() - - replays = _replays(view) - assert len(replays) == 1 - assert "_seq" not in (replays[0].get("metadata") or {}) - - -@pytest.mark.asyncio -async def test_replay_sits_before_the_compaction_notice(): - """The notice stays the final item -- the replay is the last item - BEFORE the dynamic tail, not after it.""" - context = _make_context(replay_last_user_on_compaction=True) - await _fill_until_compacted(context) - view = await context.get_messages_for_request() - - sources = [(m.get("metadata") or {}).get("source") for m in view] - assert sources[-1] == "context-compaction" - assert sources[-2] == _REPLAY_ENVELOPE_SOURCE - - -# ---------------------------------------------------------------------- -# 4. No duplicate / nothing-to-say guards -# ---------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_no_duplicate_when_last_message_is_already_the_target(): - """When the view already ends with the most recent real user message, - a replay would be a byte-for-byte duplicate in the strongest position - the model already sees. Skip it.""" - context = _make_context(replay_last_user_on_compaction=True) - await _fill_until_compacted(context) - # End the history on a user turn, so the compacted view's tail IS the - # most recent real user message. - await context.add_message(_padded(7777, "user")) - - view = await context.get_messages_for_request() - assert context._last_compaction_stats is not None - assert view[-1].get("role") == "user" - assert _replays(view) == [] - assert context._last_replayed_boundary is None, ( - "a skipped replay must leave the boundary unmarked so it can still " - "fire on a later request" - ) - - -@pytest.mark.asyncio -async def test_skips_when_tail_has_unanswered_tool_calls(): - """Tool-pair atomicity: never land a user-role message between an - assistant tool_call and its tool_result.""" - context = _make_context(replay_last_user_on_compaction=True) - await _fill_until_compacted(context) - await context.add_message(_padded(8888, "assistant")) - await context.add_message( - { - "role": "assistant", - "content": "calling a tool", - "tool_calls": [ - {"id": "call_1", "function": {"name": "x", "arguments": "{}"}} - ], - } - ) - - view = await context.get_messages_for_request() - assert view[-1].get("tool_calls"), "setup must leave tool_calls at the tail" - assert _replays(view) == [] - assert context._last_replayed_boundary is None - - # Once the result arrives, the tail is safe again and the replay fires. - await context.add_message( - {"role": "tool", "tool_call_id": "call_1", "content": "done"} - ) - view2 = await context.get_messages_for_request() - assert len(_replays(view2)) == 1 - - -@pytest.mark.asyncio -async def test_skips_when_replay_would_blow_the_budget(): - """Compaction just shed tokens to reach the budget. A huge pasted user - message must not put the request straight back over it.""" - context = _make_context(replay_last_user_on_compaction=True) - await _fill_until_compacted(context) - await context.add_message({"role": "user", "content": "H" * 200_000}) - await context.add_message(_padded(1, "assistant")) - - view = await context.get_messages_for_request() - assert context._last_compaction_stats is not None - assert _replays(view) == [], "replay overshot the budget it was given" - - -@pytest.mark.asyncio -async def test_never_replays_a_stub(): - """A stub is a placeholder that REPLACED the user's words, not the - words themselves -- replaying it under an envelope promising "a - verbatim copy of your most recent instruction" would make the envelope - lie. - - Exercised DIRECTLY rather than through a fixture, and honestly so: - this branch is unreachable through the normal path today, because the - ladder protects the last user message from stubbing at every level - (verified in _remove_messages_with_protection and Level 8). The guard - is defence in depth against that distant rule changing; a test that - always skips would prove nothing about it. - """ - context = _make_context(replay_last_user_on_compaction=True) - view = [ - {"role": "user", "content": '[User message compacted - original: "hi..."]', - "_stubbed": True, "_original_length": 999}, - {"role": "assistant", "content": "ack"}, - ] - context._maybe_append_last_user_replay(view, budget=10_000) - assert _replays(view) == [] - assert context._last_replayed_boundary is None - - -@pytest.mark.asyncio -async def test_skips_when_there_is_no_text_to_replay(): - """An image-only block list has no text; there is nothing to repeat.""" - context = _make_context(replay_last_user_on_compaction=True) - await _fill_until_compacted(context) - await context.add_message( - {"role": "user", "content": [{"type": "image", "source": {"data": "..."}}]} - ) - await context.add_message(_padded(2, "assistant")) - - view = await context.get_messages_for_request() - assert _replays(view) == [] - - -@pytest.mark.asyncio -async def test_block_list_text_content_is_replayed(): - """Block-list content with text is replayed as its joined text.""" - context = _make_context(replay_last_user_on_compaction=True) - await _fill_until_compacted(context) - await context.add_message( - { - "role": "user", - "content": [ - {"type": "text", "text": "first block"}, - {"type": "text", "text": "second block"}, - ], - } - ) - await context.add_message(_padded(3, "assistant")) - - view = await context.get_messages_for_request() - replays = _replays(view) - assert len(replays) == 1 - assert "first block\nsecond block" in replays[0]["content"] - - -# ---------------------------------------------------------------------- -# 5. The predicate: never replay a synthetic envelope -# ---------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_never_replays_a_reminder_envelope(): - """Given a history whose most recent user-role message is an ATTRIBUTED - reminder envelope (the shape this module's own summary message and this - feature's own replay both use), the replay must reach past it to the - real user turn -- or emit nothing. - - This is the regression test for the specific foundation gap documented - in `_is_real_user_message`: foundation 1.0.0 rejects only the BARE - `` tag, so an attributed envelope passes ITS check as - a real user turn. - """ - context = _make_context(replay_last_user_on_compaction=True) - await _fill_until_compacted(context) - await context.add_message( - { - "role": "user", - "content": '\nsynthetic\n', - } - ) - await context.add_message(_padded(4, "assistant")) - - view = await context.get_messages_for_request() - for replay in _replays(view): - assert "synthetic" not in replay["content"] - assert 'source="context-summary"' not in replay["content"] - - -def test_predicate_rejects_attributed_and_bare_envelopes(): - assert _is_real_user_message({"role": "user", "content": "hello"}) is True - assert ( - _is_real_user_message({"role": "user", "content": "x"}) - is False - ) - assert ( - _is_real_user_message( - {"role": "user", "content": 'x'} - ) - is False - ) - assert ( - _is_real_user_message( - { - "role": "user", - "content": [ - {"type": "text", "text": 'x'} - ], - } - ) - is False - ) - assert _is_real_user_message({"role": "assistant", "content": "hi"}) is False - assert ( - _is_real_user_message( - {"role": "user", "content": "r", "tool_call_id": "call_1"} - ) - is False - ) - - -# Frozen verbatim copy of amplifier_foundation.session.messages -# .is_real_user_message as of foundation 1.0.0 -- the version this module's -# vendored predicate was written against. Inlined rather than imported -# because foundation CANNOT be added even as a dev dependency here: -# foundation 1.0.0 requires amplifier-core>=1.0.10, and this repo pins -# amplifier-core from main at <1.0.10 (measured 2026-09-02, `uv sync` fails -# to resolve). A test that only ever skips is not a gate, so the reference -# is frozen here and the live-foundation check below is opportunistic on top. -def _foundation_1_0_0_reference(entry: dict[str, Any]) -> bool: - if entry.get("role") != "user": - return False - if "tool_call_id" in entry: - return False - content = entry.get("content", "") - if isinstance(content, str): - if content.strip().startswith(""): - return False - elif isinstance(content, list): - for block in content: - if isinstance(block, dict): - text = block.get("text", "") - if isinstance(text, str) and text.strip().startswith( - "" - ): - return False - return True - - -PREDICATE_CASES: list[dict[str, Any]] = [ - {"role": "user", "content": "plain"}, - {"role": "user", "content": " leading whitespace"}, - {"role": "user", "content": ""}, - {"role": "user", "content": "bare"}, - {"role": "user", "content": 'attr'}, - {"role": "user", "content": " indented"}, - {"role": "assistant", "content": "a"}, - {"role": "tool", "tool_call_id": "c", "content": "r"}, - {"role": "user", "content": "r", "tool_call_id": "c"}, - {"role": "user", "content": [{"type": "text", "text": "plain"}]}, - {"role": "user", "content": [{"type": "image", "source": {"data": "..."}}]}, - { - "role": "user", - "content": [{"type": "text", "text": "b"}], - }, -] - - -def test_predicate_is_strictly_stronger_than_foundation_1_0_0(): - """The relationship the vendored predicate claims, asserted against a - frozen copy of the foundation implementation it mirrors. - - Runs unconditionally -- no importorskip, no dependency. Everything - foundation rejects, this rejects too; the ONLY permitted divergence is - in the strengthening direction (this rejecting something foundation - accepts), and the specific case where that happens is pinned below so - the asymmetry stays documented rather than accidental. - """ - strengthened = [] - for case in PREDICATE_CASES: - theirs = _foundation_1_0_0_reference(case) - ours = _is_real_user_message(case) - if not theirs: - assert ours is False, ( - f"vendored predicate accepted what foundation rejects: {case!r}" - ) - elif not ours: - strengthened.append(case) - - assert strengthened == [ - { - "role": "user", - "content": 'attr', - } - ], ( - "the only intended divergence from foundation 1.0.0 is the " - f"ATTRIBUTED reminder envelope; got: {strengthened!r}" - ) - - -def test_live_foundation_still_has_the_attributed_envelope_gap(): - """Opportunistic drift alarm against the REAL foundation, wherever it - happens to be importable (it is not a dependency here -- see - _foundation_1_0_0_reference for why it cannot be one). - - If foundation ever closes the bare-tag gap, this fails loudly and the - hardening note in `_is_real_user_message` needs updating -- rather than - the frozen reference above quietly describing a foundation that no - longer exists. - """ - foundation = pytest.importorskip( - "amplifier_foundation.session.messages", - reason=( - "amplifier-foundation cannot be a dependency here (it requires " - "amplifier-core>=1.0.10; this repo pins core <1.0.10). The frozen " - "reference in test_predicate_is_strictly_stronger_than_foundation_1_0_0 " - "covers the same contract unconditionally." - ), - ) - - for case in PREDICATE_CASES: - assert foundation.is_real_user_message(case) == _foundation_1_0_0_reference( - case - ), f"frozen foundation reference has drifted from the real one: {case!r}" - - attributed = { - "role": "user", - "content": 'attr', - } - assert foundation.is_real_user_message(attributed) is True - assert _is_real_user_message(attributed) is False From 470c033c729ef734b7abef7fcefcc103a4b3cbf3 Mon Sep 17 00:00:00 2001 From: bkrabach <702425+bkrabach@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:08:14 -0700 Subject: [PATCH 5/7] Revert "feat: tool-result budget (token-denominated, head+tail, per-tool) + spill-to-disk -- all defaults no-op (#21)" This reverts commit 49e27996b9b92bc37a7c29bd57d8b1d144ef1835. Merge policy: main carries wins only. Tool-result budget + spill-to-disk was an unproven, all-defaults-no-op feature. Belongs on a branch for evaluation, not on main. --- DONE-NOTE.md | 343 --------- README.md | 199 ----- amplifier_module_context_simple/__init__.py | 417 +---------- tests/test_tool_result_budget.py | 769 -------------------- 4 files changed, 2 insertions(+), 1726 deletions(-) delete mode 100644 tests/test_tool_result_budget.py diff --git a/DONE-NOTE.md b/DONE-NOTE.md index 4b822d7..ecc7ccc 100644 --- a/DONE-NOTE.md +++ b/DONE-NOTE.md @@ -1,19 +1,3 @@ - - -# Lane notes index - -| item | subject | -|---|---| -| `model_performance-x7p` | `protected_tool_results=0` protected ALL tool results (negative-slice bug) | -| `model_performance-x1r` | tool-result budget (token-denominated, head+tail, per-tool) + spill-to-disk | - ---- - # DONE-NOTE — model_performance-x7p `context-simple: protected_tool_results=0 protects ALL tool results (negative-slice bug)` @@ -318,330 +302,3 @@ Nothing blocking. Two observations handed on rather than acted on: is now well-defined at 0 and negative, but e.g. `target_usage=5.0` or `truncate_chars=-1` remain undefined. Worth one item if anyone wants the constructor to fail loud; not filed, because it is a design call, not a defect. - ---- - -# DONE-NOTE — W3-3 / `model_performance-x1r` - -**Fix the tool-result budget in context-simple** (250 chars head-only → token -budget, head+tail, per-tool), then spill the truncated middle to disk. - -| | | -|---|---| -| Repo | `amplifier-module-context-simple` | -| Branch | `lane/x1r-tool-result-budget` (forked from `c6dfbba`, `main`) | -| Draft PR | microsoft/amplifier-module-context-simple#21 (DRAFT — do not merge) | -| Tests | **151 green** (103 pre-existing, unchanged + 48 new) | -| Spend | **$0.00** — no API calls, no DTU, no containers, no infrastructure created | -| Default behavior | **byte-identical**, proven by external stash-compare (below) | - ---- - -## 1. Step 0 — the free measurement that decides how much this is worth - -The spec's own instruction: *"If our tool-result share is 15%, phase B is not -worth building and this spec should stop at phase A."* The magnitude case rested -on a practitioner's worked example from somebody else's workload (5 tool results -= 81% of a context). So it got measured on ours first. - -Script: `.amplifier/evaluation/treatment-validation/20260902-x1r/step0_tool_result_share.py` -Output: `.../20260902-x1r/step0-results.json` - -Source: every `transcript.jsonl` under two existing capture roots. Counted in -**characters** — the unit this module's estimator actually works in -(`len(str(msg)) // 4`). - -| | `20260902-t0t1` | `20260901-cadence` | -|---|---|---| -| sessions / tool results | 5 / 750 | 12 / 1,729 | -| **tool-result share of transcript chars** | **46.4%** | **47.3%** | -| p50 / p90 / p99 / max (chars) | 412 / 7,904 / 31,751 / 52,319 | 467 / 7,904 / 31,744 / 43,720 | -| over today's 250-char budget | 488 (65%) | 1,096 (63%) | -| chars discarded by today's budget | 1,524,013 (**91%** of tool-result content) | 3,082,300 (**90%**) | -| over 50,000 bytes | **1** | **0** | - -**(knob moved: none — observation) · (model family: n/a, corpus measurement) · -(confidence: measured, n=17 sessions / 2,479 tool results) · (evidence: -`.../20260902-x1r/step0-results.json`)** - -Two conclusions: - -1. **~47% ≫ 20%. Phase B is justified**, which is why this lane shipped spill and - not just the budget fix. -2. **deepseek's shipped 50,000-byte spill threshold would fire once in 2,479 - results on our workload.** The mass is in the 400–32,000 char band. The - mechanism is worth copying; that constant is not. This contradicts the - reference implementation the spill design otherwise follows, and it is the - single most transferable number in this note. - -Caveats, stated: a transcript is the **full history**, not the compacted wire -view, so this is the share of what compaction *sees* — an upper bound on the wire -share in a run that compacts. Token figures are the module's own estimator, not -provider-billed tokens. `p90 = 7,904` is identical in both roots, which suggests a -common fixed-size output rather than a coincidence; not investigated. - ---- - -## 2. What shipped - -Five flags, **all inert by default**: - -| flag | default | effect | -|---|---|---| -| `tool_result_budget_tokens` | `None` | Token-denominated budget. `None` = legacy `truncate_chars` path. | -| `tool_result_shape` | `"head"` | `"head_tail"` splits the budget and keeps both ends with `...[N chars omitted]...`. | -| `tool_result_budget_by_tool` | `{}` | Per-tool token budgets by tool name; beats the global budget. | -| `tool_result_exempt_tools` | `[]` | Never truncated (skill-type outputs). | -| `tool_result_spill_dir` | `None` | Full original written to a content-addressed file; pointer in the replacement text. | - -Precedence per result: **per-tool → global → legacy `truncate_chars`**. - -Roughly 300 LOC of module change against the spec's ~80 (phase A) + ~180 -(phase B) estimate — the overage is comment density and per-knob validation, not -extra mechanism. - -### Defaults rationale - -- **`tool_result_budget_tokens` defaults to `None`, not `62`.** The spec says 62 - (= today's 250 chars). But `truncate_chars` is an existing shipped knob: - someone running `truncate_chars: 500` today would have been silently reset to - 248 chars by a hard 62 default. `None` means "legacy path, whatever - `truncate_chars` says", which is byte-identical for **every** existing - configuration rather than only the default one. This is a deliberate deviation - from the spec, in the safer direction. -- **Recommended values are documented, not shipped.** The README's starter config - (2,000 tokens global, `head_tail`, per-tool map, `load_skill` exempt) is - conservative relative to codex's 10,000. It is *not* a default, because nothing - here has been evaluated against a model yet. -- **Per-tool numbers are transcribed, not measured.** They come from a - specification that publishes zero measurements anywhere, rescaled to the size - distribution in §1. The README says so at the point of use. -- **Tokens, not chars**, because chars/token constants are tokenizer-version - specific and drift (published up to 1.35×, observed up to 1.47× on technical - content) — a char budget silently changes meaning across a model version. The - conversion constant is `4`, deliberately the *same* constant the module's own - estimator uses, so budget and accounting cannot drift apart. -- **`head_tail` keeps the marker, not just the tail.** A model must never reason - from a truncated result without knowing it is truncated. - -### The one design constraint that shaped everything - -`_apply_sticky_decisions` re-derives the replacement text for **every** -sticky-truncated message on **every** request. So the replacement must be a pure -function of content + config. - -The consequence for spill: **the pointer is emitted whether or not the write -succeeded.** If the pointer tracked write success, one transient disk error would -change the bytes of an already-sent message, and under a grow-only prompt cache -every prefix mutation is a full cold rebuild. A dangling pointer is visible and -recoverable. A silently mutated prefix is neither. Write failures log a warning; -`tests/test_tool_result_budget.py::test_spill_write_failure_still_emits_stable_pointer` -pins it. - -Spill paths are content-addressed (`tool-result--.txt`), so -writes are idempotent across repeated requests and across a resumed session. -Write-then-rename, so a reader never sees a half-written file. - ---- - -## 3. Evidence - -### 3.1 Byte-identity — the defaults-are-a-no-op claim - -**Method (external oracle).** `.../20260902-x1r/byte_identity_harness.py` imports -whatever `amplifier_module_context_simple` is on `sys.path`, so neither side -defines its own baseline. 8 scenarios: light / heavy / aggressive pressure; -`truncate_chars` ∈ {10, 60, 250, 5000}; notice on and off; a view every third -turn **plus two consecutive views on turn 5** (the repeated call is what would -expose non-idempotent re-derivation). Canonical JSON of every returned message. -Only `metadata.timestamp` is normalized. - -``` -pre-change (c6dfbba, 2,648 lines, flags absent) sha256 76ee9d3f…3d6a467f -post-change (lane/x1r-tool-result-budget) sha256 76ee9d3f…3d6a467f -3,398,618 bytes IDENTICAL — PASS -``` - -**Non-vacuity:** the dump contains 76 `[truncated:` occurrences — the path is -really exercised. **Negative control:** the same harness with the treatment forced -on yields `4e59f380…` — a different hash, so the harness can see changes when -there are any. - -Artifacts: `byte-identity-PRE.json`, `byte-identity-POST.json`, -`byte-identity-TREATMENT.json`, `.log` files alongside. - -The unit suite pins the same claim from the opposite direction: -`test_default_config_replacement_text_is_byte_identical` asserts the exact legacy -string against an oracle **transcribed from the pre-change source**, not computed -by the new code, plus a second literal spelling so re-baselining the oracle alone -cannot silently pass. - -### 3.2 Tail retention — mechanism demonstration, no model, no spend - -`.../20260902-x1r/tail_retention_demo.py` → `tail-retention-demo.json`. -Four synthetic workloads (`pytest`, `grep`, `git log`, build output) whose answer -sits in the **last line** — the tool list enumerated *before* the run, as the gate -requires. - -| arm | truncated results | tail present | rate | -|---|---|---|---| -| control (shipped defaults, 250 chars, head) | 27 | 0 | **0%** | -| **budget-neutral** (62 tok = 248 chars, `head_tail`) | 28 | 28 | **100%** | -| 4× budget (250 tok = 1,000 chars, `head_tail`) | 1 | 1 | 100% | - -**(knob moved: `tool_result_shape`) · (model family: none — mechanical, no LLM) · -(confidence: measured, n=4 workloads × 30 turns per arm) · (evidence: -`.../20260902-x1r/tail-retention-demo.json`)** - -Row 2 is the clean A/B: **same bytes kept, different shape**, identical sticky -level per tool (4/2/3/3 in both arms). 0% → 100% tail retention at no budget cost. - -**Honest negative in row 3, and it is load-bearing.** Raising the per-result -budget *without* raising `target_usage`/`max_tokens` **trades truncation for -removal**: a bigger budget sheds fewer tokens per truncation, so the ladder -escalates past the truncation rungs into message removal — strictly more lossy. -Three of four workloads went from 11–15 truncated results to **zero, removed -instead**. Anyone tuning this must raise budget and target together, or measure -what they actually got. This is the first thing the follow-up eval should -control for. - -This is a mechanism demonstration, **not** the eval. No model was involved, so it -says nothing about whether an agent *uses* the tail. - -### 3.3 Test suite - -151 green. The 48 new tests cover: byte identity (literal + end-to-end + custom -`truncate_chars`); token budget arithmetic; head/tail split, exact omission -count, and the `content[-0:]`-returns-everything trap; per-tool resolution via -all three sources (harvested `tool_call_id`, `name` field, `metadata.tool_name`) -and both tool-call shapes (OpenAI `function`, Anthropic-ish); exemption including -"never counted as truncated"; spill write / idempotence / content-addressing / -**write-failure byte-stability** / lazy directory creation; **tool-pair atomicity -with every knob enabled**; `_seq` prefix stability and 5× request idempotence -with `head_tail` + spill on; `set_messages` map rebuild; `clear()` reset; config -validation fallbacks for all five flags; and `mount()` plumbing both ways. - -Twelve of them carry an explicit **"test must actually truncate/compact/spill"** -assertion, because a compaction test that escalates to level 8 removes every tool -result and then passes vacuously. That is exactly what the first draft of two of -these tests did. - ---- - -## 4. Discovered, filed, not fixed - -**`model_performance-x7p`** (filed `discovered-from` this item): -`protected_tool_results=0` protects **all** tool results, not none — -`tool_result_indices[-0:]` is the whole list (`__init__.py:1407`, `:1510`, -`:1577`). Compaction then skips every truncation rung and escalates straight to -removal. - -**(confidence: measured** — reproduced in a scratch harness: with -`protected_tool_results=0`, 40 turns × 2,000-char results reached sticky level 8 -with **0** truncations; `protected_tool_results=1` on the identical workload -truncated normally.**)** - -Not fixed here: the default is 5, so no shipped configuration hits it, and -changing it *is* a behavior change for anyone currently setting 0. ~3 lines plus a -regression test. The new tests avoid the trap and say why in a comment. - ---- - -## 5. Deviations from the spec, and why - -1. **Default is `None`, not `62`** — see §2. Protects existing `truncate_chars` - overrides. -2. **Spill lives in `context-simple`, not a new `tool-result-spill` module.** The - item's own task text asks for "spill-to-disk for the truncated middle with a - stable pointer line", which is a compaction-time operation on the truncated - middle — it shares the head/tail code and the `_seq` machinery. **This forfeits - the spec's class-A property**: because the content is already in the - conversation, spilling still shrinks the request, so it is still a boundary and - still a cold rebuild under a grow-only cache. It buys **recoverability**, not - free context. A post-execute hook that intercepts results *before* they enter - the conversation is the class-A design and remains unbuilt. Recorded plainly - rather than claimed. -3. **No cleanup sweep.** deepseek ships a 30-day startup sweep with symlink and - ownership guards. Nothing here deletes anything, ever — deliberately, since a - resumed or forked session may still hold an older pointer. The caller owns the - directory lifecycle; README says so and recommends a session-scoped dir. -4. **No `read`-result exemption by default.** deepseek exempts reads to prevent - `read → spill → read`. That loop cannot form here (spill happens at compaction - time, not tool-execute time). `tool_result_exempt_tools` is the seam if this - ever moves to a post-execute hook. -5. **No model-settable per-call budget.** codex lets the model raise its own limit - to 262,144 per call. That needs a tool-schema surface this module does not own. -6. **No line-based cap.** The spec's "chars before lines" concern is satisfied - structurally: the gate is a pure char count and no line cap exists in this - path, so the "2 lines × 10MB" failure mode is impossible rather than merely - unlikely. Zero LOC. -7. **No eval.** Explicitly out of scope for this lane ($0 authority). Spec below. - ---- - -## 6. Follow-up eval spec (the item this lane does NOT do) - -**Prerequisite that must be settled first:** the budget↔removal interaction in -§3.2. An arm that raises the budget while holding `target_usage` fixed is not -measuring "bigger budget", it is measuring "truncation replaced by removal". Fix -the design before spending. - -**Arms** (n≥3 each, one variable at a time, per the program's own rule): - -| arm | config | -|---|---| -| `control` | shipped defaults (byte-identical to today) | -| `shape_only` | `tool_result_budget_tokens: 62`, `tool_result_shape: "head_tail"` — **budget-neutral**, isolates shape | -| `budget_only` | `tool_result_budget_tokens: 2000`, shape `"head"`, `target_usage` raised to hold boundary count constant | -| `per_tool` | `budget_only` + `tool_result_budget_by_tool` + `tool_result_exempt_tools: ["load_skill"]` | -| `spill` | `per_tool` + `tool_result_spill_dir` | - -**Pre-register before spending** (a gate written after seeing the result is not a -gate; check each for vacuity): - -- **G-TRB-TAIL** — on a tool list enumerated **before** the run (pytest, grep, - git log, build output), the tail is present in the model-visible content of - 100% of truncated results; control 0%. *Already demonstrated mechanically - (§3.2); the eval's job is whether the agent* uses *it.* -- **G-TRB-RECALLS** — redundant re-calls of the same tool with identical - arguments fall vs control. **The real quality signal**, and the one that can - fail honestly. -- **G-TRB-COST** — cost does not rise. Watch the boundary count specifically: - §3.2 says a larger budget pushes work from truncation into removal, and prior - work established that boundary count, not the mechanism, is what moves cost - (a comparable feature measured +84% boundaries → +83% cost). -- **G-SPILL-APPEND** — no request contains a tool result a previous request - contained in longer form; `ID_ONLY = 0`. -- **G-SPILL-TOKENS** — tool-result input tokens fall by ≥ §1's predicted share, - ±20%. §1 is measured *before* the treatment, so it cannot be fitted afterwards. -- **G-SPILL-BOUNDARIES** — boundary count does not rise. -- **G-SPILL-RECOVERY** — **record as VACUOUS on S5-CRAC** and defer. S5 never - needs to recover anything (40/40 constraints, 20/20 post-compaction in every - run of every arm across six probes). It needs the discriminating scenario from - `model_performance-cb2`. -- **G-ANTH-GUARDRAIL** — required for any treatment lane: Anthropic re-warm ≤1 - request on raw wire fields, byte-stable system prompt, zero `ID_ONLY`. -- **S3 quality** — non-regression observation only. Six cells already sit at S3 - median 100 and dial-to-dial differences may be inside grader noise. - -**Cheapest first experiment:** `shape_only` vs `control`. One variable, no budget -change, therefore no boundary-count confound, and §3.2 already says the mechanism -fires. If G-TRB-RECALLS does not move there, it will not move anywhere. - ---- - -## 7. Infrastructure and spend - -**$0.00.** No API calls, no DTU, no Gitea, no containers. Nothing registered in -the infra ledger because nothing was created; nothing to tear down. All evidence -is local CPU work over existing capture roots. - -Artifacts written (outside the module repo, under the authorized capture path -`.amplifier/evaluation/treatment-validation/20260902-x1r/`): -`step0_tool_result_share.py`, `step0-results.json`, `byte_identity_harness.py`, -`byte-identity-{PRE,POST,TREATMENT}.json` + `.log`, `tail_retention_demo.py`, -`tail-retention-demo.json`. - -No PII, no team-internal data, no individual attribution in any output. Spill -paths are the only new content class in a transcript; they are caller-configured -and contain no absolute path this module invents. diff --git a/README.md b/README.md index 72ae0a8..c2d8055 100644 --- a/README.md +++ b/README.md @@ -439,205 +439,6 @@ absorptions cannot cycle; **hysteresis** on `summary_trigger` (arm at should be validated against the same cost metrics before this flag is enabled anywhere by default. -## Tool-result budget, shape, and spill - -**Every flag in this section defaults to a no-op.** With no configuration the -truncation path is byte-identical to what this module has always emitted: 250 -characters, head-only, the same prefix string. See "Byte-identity evidence" -below for how that is proven rather than asserted. - -### The defect this fixes - -When the progressive ladder truncates a tool result, it has always kept -`content[:250]` — **250 characters, head only, ~62 estimator tokens**. The one -shipped reference implementation available to compare against (codex) keeps a -**~10,000-token** budget, **head + tail**, with an explicit truncation marker, -and lets the model raise it per call up to 262,144. We were roughly **160× -below** it, on the *only* axis where a direct comparison exists. - -The head-only choice is the sharper half of the problem. For `pytest`, `grep`, -`git log` and build output **the answer is in the tail** — the failing -assertion, the matching line, the linker error. Keeping the head keeps the part -that matters least. - -### What the workload actually looks like (measured) - -From two existing capture roots — 17 sessions, 2,479 tool results, counted from -each session's own `transcript.jsonl` in **characters**, which is the unit this -module's estimator works in (`len(str(msg)) // 4`): - -| | `20260902-t0t1` | `20260901-cadence` | -|---|---|---| -| sessions / tool results | 5 / 750 | 12 / 1,729 | -| tool-result share of all transcript chars | **46.4%** | **47.3%** | -| size p50 / p90 / p99 / max (chars) | 412 / 7,904 / 31,751 / 52,319 | 467 / 7,904 / 31,744 / 43,720 | -| results over today's 250-char budget | 488 (65%) | 1,096 (63%) | -| chars discarded by today's budget | 1,524,013 (**91%** of all tool-result content) | 3,082,300 (**90%**) | -| results over 50,000 bytes | **1** | **0** | - -Two things follow, and the second one contradicts the reference -implementation this spill design otherwise copies: - -1. Tool results are **~47% of the material compaction has to work with**, not a - rounding error. (Caveat, stated: a transcript is the full history, not the - compacted wire view, so this is the share of what compaction *sees* — an - upper bound on the wire share in a run that compacts. Confidence: measured, - char-denominated; token figures are the module's own estimator, not - provider-billed tokens.) -2. deepseek's shipped **50,000-byte spill threshold would fire once in 2,479 - results** on this workload. The mass sits in the 400–32,000 char band. Do not - copy that constant; copy the mechanism. - -### The flags - -| flag | default | effect | -|---|---|---| -| `tool_result_budget_tokens` | `None` | Token-denominated budget. `None` keeps the legacy `truncate_chars` char budget, byte-identical. | -| `tool_result_shape` | `"head"` | `"head_tail"` splits the budget in half and keeps both ends with an explicit `...[N chars omitted]...` marker. | -| `tool_result_budget_by_tool` | `{}` | Per-tool token budgets keyed by tool name. Takes precedence over the global budget. | -| `tool_result_exempt_tools` | `[]` | Tool results that are **never** truncated. | -| `tool_result_spill_dir` | `None` | Writes the full original result to a content-addressed file and points the model at it. `None` writes nothing, ever. | - -Precedence for a single result: **per-tool budget → global token budget → -legacy `truncate_chars`**. Setting only `tool_result_budget_by_tool` therefore -leaves every *other* tool on the legacy path, byte-identical. - -A starter configuration — conservative relative to codex's 10,000, and -deliberately **not** the shipped default: - -```toml -[[contexts]] -module = "context-simple" -config = { - tool_result_budget_tokens = 2000, # ~8,000 chars, vs today's 250 - tool_result_shape = "head_tail", - tool_result_budget_by_tool = { grep = 1000, read_file = 4000, bash = 2000 }, - tool_result_exempt_tools = ["load_skill"], # never trim skill output - tool_result_spill_dir = "/tmp/amplifier-spill", -} -``` - -These per-tool numbers are **starting points transcribed from a specification -that publishes no measurements**, rescaled to the size distribution above. They -are not measured. Say so wherever they get copied. - -### Why tokens, not chars - -Chars-per-token constants are tokenizer-version specific and drift (published -drift up to 1.35×, observed up to 1.47× on technical content, with the rate card -unchanged). A char budget therefore silently changes meaning across a model -version; a token budget does not. The conversion constant used here is `4` — -deliberately the same constant this module's own estimator uses, so a budget -expressed in tokens and the accounting the ladder runs on cannot drift apart. - -**Chars before lines, always.** The gate is a pure character count and there is -no line-based cap anywhere in this path, so the "a file could have 2 lines that -are each 10MB" failure mode is structurally impossible here rather than merely -unlikely. - -### Spill: the truncated middle stays reachable - -With `tool_result_spill_dir` set, the **full original** result is written to -`/tool-result--.txt` and the replacement text becomes: - -``` -[truncated: ~12,431 tokens - read /tmp/amplifier-spill/tool-result-000042-a1b2….txt for the full result] -...[48,920 chars omitted]... - -``` - -There is **no new retrieval tool**, deliberately: the model is pointed at the -ordinary file tools it already has. (The opposite failure is on record — a -README advertising `grep` over an archive where only `ls`/`view` existed, leaving -the model to guess which summary hid the fact it needed.) - -Three properties worth knowing: - -- **The pointer is a pure function of content + config, and is emitted whether - or not the write succeeded.** This is not sloppiness, it is the constraint: - `_apply_sticky_decisions` re-derives the replacement text for every - sticky-truncated message on *every request*. A pointer that tracked write - success would change the bytes of an already-sent message after a transient - disk error — and under a grow-only prompt cache, every prefix mutation is a - **full cold rebuild**. A dangling pointer is visible and recoverable; a - silently mutated prefix is neither. Write failures log a warning. -- **Content-addressed, so writes are idempotent** across repeated requests and - across a resumed or forked session. -- **Nothing is ever deleted** — not on `clear()`, not on compaction, not at - session end. A resumed or forked session may still hold a pointer to an older - file. **No cleanup sweep ships in this module**: the caller owns the - directory's lifecycle, and a session-scoped directory is the recommended - shape. (deepseek's reference implementation ships a 30-day startup sweep with - symlink and ownership guards; porting it is deliberately left to whoever wires - spill into a bundle default.) - -### Shape change is nearly free; budget change is not - -Mechanism demonstration, no model and no spend — 4 synthetic tool workloads -(`pytest`, `grep`, `git log`, build output) whose answer sits in the last line, -enumerated *before* the run: - -| arm | truncated results | tail present | rate | -|---|---|---|---| -| control (shipped defaults: 250 chars, head) | 27 | 0 | **0%** | -| **budget-neutral** (62 tokens = 248 chars, `head_tail`) | 28 | 28 | **100%** | -| 4× budget (250 tokens = 1,000 chars, `head_tail`) | 1 | 1 | 100% | - -The middle row is the clean A/B: **same bytes kept, different shape**, same -sticky level per tool (4/2/3/3 in both arms). Tail retention goes 0% → 100% at -no budget cost. - -The third row is the honest caveat, and it is load-bearing for anyone tuning -this: **raising the per-result budget without also raising `target_usage` or -`max_tokens` trades truncation for removal.** A larger budget sheds fewer tokens -per truncation, so the ladder escalates past the truncation rungs into message -*removal* — which is strictly more lossy. Three of four workloads went from -11–15 truncated results to **zero**, having been removed instead. Raise the -budget and the target together, or measure what you actually got. - -### Byte-identity evidence - -Claim: with default configuration, output is byte-identical to before this -change. Method: an **external** harness (it imports whatever -`amplifier_module_context_simple` is on `sys.path`, so neither side defines its -own baseline) drives 8 scenarios — light/heavy/aggressive pressure, four -`truncate_chars` values from 10 to 5,000, notice on and off — takes a view every -third turn plus two consecutive views on turn 5, and dumps canonical JSON of -every returned message. Only `metadata.timestamp` is normalized; every truncated -result is compared character for character. - -``` -pre-change (c6dfbba, 2,648 lines, no such flags) sha256 76ee9d3f…a467f -post-change (this branch) sha256 76ee9d3f…a467f -3,398,618 bytes, identical PASS -``` - -Non-vacuity: the dump contains 76 `[truncated:` occurrences, so the harness -really exercises the path. Negative control: the same harness with the treatment -forced on produces a **different** hash, so it can see changes when there are -any. - -The unit suite pins the same claim from the other direction — -`tests/test_tool_result_budget.py` asserts the exact legacy replacement string -literally, against an oracle transcribed from the pre-change source rather than -computed by the new code. - -### Not built here - -- **Model-settable per-call budgets.** codex lets the model raise its own - truncation limit per call up to 262,144 tokens. That needs a tool-schema - surface this module does not own. -- **Count-based spill triggers inside the search tools** (`glob` at >100 - results, `grep` at >250 matches). Size is not the only signal, but those - triggers belong in the tools, not in the context manager. -- **`read`-result exemption.** deepseek exempts read results specifically to - prevent a `read → spill → read again` loop. Here spill happens *at compaction - time* on content already in the conversation, not at tool-execute time before - it enters, so that loop cannot form — but if this ever moves to a - post-execute hook, the exemption becomes necessary. `tool_result_exempt_tools` - is the seam for it. -- **A cleanup sweep.** See above. - ## Dependencies - `amplifier-core>=1.0.0` diff --git a/amplifier_module_context_simple/__init__.py b/amplifier_module_context_simple/__init__.py index 77ad0c7..bde6d17 100644 --- a/amplifier_module_context_simple/__init__.py +++ b/amplifier_module_context_simple/__init__.py @@ -94,61 +94,12 @@ here; see README for the candidate levers (cooldown / absolute floor / trigger hysteresis). OPT-IN, EXPERIMENTAL -- do not enable by default. - -Tool-result budget and spill (opt-in, default off -- see config -`tool_result_budget_tokens` / `tool_result_shape` / -`tool_result_budget_by_tool` / `tool_result_exempt_tools` / -`tool_result_spill_dir`): - • The truncation rung of the progressive ladder historically kept - `content[:250]` -- 250 characters, HEAD ONLY, ~62 estimator tokens. - The only shipped reference implementation available to compare - against (codex) keeps a ~10,000-token budget, HEAD + TAIL, with an - explicit truncation marker. We were ~160x below it, and keeping the - head is the part that hurts: for `pytest`, `grep`, `git log` and - build output the ANSWER IS IN THE TAIL. - • MEASURED (step 0 of this change, two existing capture roots, 17 - sessions, 2,479 tool results, char-denominated, confidence: - measured): tool-result content is 46.4% / 47.3% of all transcript - characters; individual results run p50 412 / p90 7,904 / p99 ~31.7k - / max 52.3k chars; 63-65% of every tool result exceeds today's - 250-char budget, and today's budget discards ~91% of all - tool-result content it touches. - • `tool_result_budget_tokens: int` replaces the char budget with a - TOKEN-denominated one (chars/token constants are tokenizer-version - specific and drift; a char budget silently changes meaning across a - model version). Default `None` = the pre-existing `truncate_chars` - path, byte-identical. - • `tool_result_shape: "head" | "head_tail"` -- `"head_tail"` splits - the budget in half and keeps both ends with an explicit - `...[N chars omitted]...` marker between them, so the model can never - reason from a truncated result without knowing it was truncated. - Default `"head"`. - • `tool_result_budget_by_tool: dict[str, int]` -- per-tool token - budgets, resolved by tool name; takes precedence over the global - budget. Default `{}`. - • `tool_result_exempt_tools: list[str]` -- these tool results are - NEVER truncated (the value-order names loaded skills explicitly). - Default `[]`. - • `tool_result_spill_dir: path` -- when set, the FULL original result - is written to a content-addressed file under this directory and the - replacement text points the model at it, so the truncated middle is - recoverable through the ordinary file tools with no new retrieval - tool. Default `None` (nothing is ever written). - • DETERMINISM, load-bearing: the replacement text (including the spill - pointer) is a pure function of the message's content plus config. It - NEVER depends on whether the spill write succeeded, because - `_apply_sticky_decisions` re-derives it on every single request and a - pointer that changed after a failed-then-successful write would - mutate an already-cached prefix -- which, under a grow-only prompt - cache, is a full cold rebuild. A failed write yields a pointer to a - missing file (visible, recoverable) rather than a silent byte change. """ # Amplifier module metadata __amplifier_module_type__ = "context" import asyncio -import hashlib import json import logging from collections.abc import Awaitable, Callable @@ -197,23 +148,6 @@ COMPACTION_STRATEGY_SUMMARY, ) -# tool_result_shape config values. "head" (default) keeps the leading slice -# only -- the pre-existing behavior. "head_tail" splits the budget in half -# and keeps both ends with an explicit omission marker between them. See -# module docstring "Tool-result budget and spill". -TOOL_RESULT_SHAPE_HEAD = "head" -TOOL_RESULT_SHAPE_HEAD_TAIL = "head_tail" -_VALID_TOOL_RESULT_SHAPES = (TOOL_RESULT_SHAPE_HEAD, TOOL_RESULT_SHAPE_HEAD_TAIL) - -# Chars-per-token constant used to convert a token-denominated tool-result -# budget into the char slice actually taken. Deliberately the SAME constant -# this module's own estimator uses (_estimate_tokens: len(str(msg)) // 4), so -# a budget expressed in tokens and the token accounting the compaction ladder -# runs on cannot drift apart. This is an estimator, not a tokenizer: see -# README "Tool-result budget" for why the budget is nevertheless expressed in -# tokens rather than chars. -_TOOL_RESULT_CHARS_PER_TOKEN = 4 - # The summary message's envelope source tag and metadata type marker. The # envelope is what makes foundation's is_real_user_message() classify this # role="user" message as NOT a real user turn (see module docstring); the @@ -311,26 +245,6 @@ async def mount(coordinator: ModuleCoordinator, config: dict[str, Any] | None = provider.complete() call before treating it as a failure and falling back to progressive compaction for that pass (default: 30.0). - - tool_result_budget_tokens: Token budget kept when truncating a - tool result (default: None = use the legacy `truncate_chars` - char budget, byte-identical to before this feature existed). - Setting it switches that tool result to the token-denominated - path. See module docstring "Tool-result budget and spill". - - tool_result_shape: "head" (default) or "head_tail". "head_tail" - splits the budget in half and keeps both ends with an explicit - `...[N chars omitted]...` marker. An unrecognized value falls - back to "head" with a logged warning rather than crashing - mount(). - - tool_result_budget_by_tool: Per-tool token budgets keyed by tool - name (default: {}). Takes precedence over - tool_result_budget_tokens for a matching tool. Entries whose - value is not a positive int are dropped with a logged warning. - - tool_result_exempt_tools: Tool names whose results are NEVER - truncated (default: []). Recommended for skill-type outputs. - - tool_result_spill_dir: Directory to write the FULL original tool - result into when it is truncated, so the truncated middle stays - recoverable via the ordinary file tools (default: None = nothing - is ever written and no pointer appears in any message). Returns: Cleanup callable that unregisters the token-meter hook (if one was @@ -378,11 +292,6 @@ async def mount(coordinator: ModuleCoordinator, config: dict[str, Any] | None = summarization_model=config.get("summarization_model"), summarization_prompt_path=config.get("summarization_prompt_path"), summarization_timeout_s=config.get("summarization_timeout_s", 30.0), - tool_result_budget_tokens=config.get("tool_result_budget_tokens"), - tool_result_shape=config.get("tool_result_shape", TOOL_RESULT_SHAPE_HEAD), - tool_result_budget_by_tool=config.get("tool_result_budget_by_tool"), - tool_result_exempt_tools=config.get("tool_result_exempt_tools"), - tool_result_spill_dir=config.get("tool_result_spill_dir"), hooks=getattr(coordinator, "hooks", None), ) @@ -464,11 +373,6 @@ def __init__( summarization_model: str | None = None, summarization_prompt_path: str | None = None, summarization_timeout_s: float = 30.0, - tool_result_budget_tokens: int | None = None, - tool_result_shape: str = TOOL_RESULT_SHAPE_HEAD, - tool_result_budget_by_tool: dict[str, int] | None = None, - tool_result_exempt_tools: list[str] | None = None, - tool_result_spill_dir: str | None = None, hooks: Any = None, ): """ @@ -517,18 +421,6 @@ def __init__( DEFAULT_SUMMARIZATION_PROMPT. None uses the built-in prompt. summarization_timeout_s: Seconds to wait for the summarizer's provider.complete() call before treating it as a failure. - tool_result_budget_tokens: Token budget kept when truncating a - tool result. None (default) keeps the pre-existing - `truncate_chars` char budget, byte-identical. - tool_result_shape: "head" (default, pre-existing behavior) or - "head_tail". An unrecognized value falls back to "head" with - a logged warning rather than raising. - tool_result_budget_by_tool: Per-tool token budgets keyed by tool - name; takes precedence over tool_result_budget_tokens. - tool_result_exempt_tools: Tool names whose results are never - truncated. - tool_result_spill_dir: Directory the full original result is - written to when truncated. None (default) writes nothing. hooks: Optional hooks instance for emitting observability events and (always, when present) recording real usage for the token meter via `llm:response` -- see `_on_llm_response`. @@ -565,42 +457,6 @@ def __init__( self.summarization_model = summarization_model self.summarization_prompt_path = summarization_prompt_path self.summarization_timeout_s = summarization_timeout_s - # --- Tool-result budget / shape / spill (all default no-op) --- - # Every one of these is validated the same way the other enums in - # this module are: an unusable value logs a warning and falls back to - # the pre-existing behavior rather than crashing mount(). A context - # manager that refuses to start is worse than one that runs at the - # old default and says so. - self.tool_result_budget_tokens = self._validate_budget_tokens( - tool_result_budget_tokens - ) - if tool_result_shape not in _VALID_TOOL_RESULT_SHAPES: - logger.warning( - f"context-simple: unknown tool_result_shape {tool_result_shape!r} " - f"(expected one of {_VALID_TOOL_RESULT_SHAPES!r}); falling back to " - f"{TOOL_RESULT_SHAPE_HEAD!r}" - ) - tool_result_shape = TOOL_RESULT_SHAPE_HEAD - self.tool_result_shape = tool_result_shape - self.tool_result_budget_by_tool = self._validate_budget_by_tool( - tool_result_budget_by_tool - ) - self.tool_result_exempt_tools = frozenset( - str(t) for t in (tool_result_exempt_tools or []) if str(t) - ) - self.tool_result_spill_dir = ( - str(tool_result_spill_dir) if tool_result_spill_dir else None - ) - # tool_call_id -> tool name, harvested from assistant tool_calls so a - # tool RESULT (which carries only the id) can be matched to a per-tool - # budget or exemption. Only populated when at least one of those two - # knobs is configured -- in the default configuration this map stays - # empty and add_message() does no extra work at all. - self._tool_name_by_call_id: dict[str, str] = {} - # Spill paths already written this session, so the same result is not - # re-written on every request (_apply_sticky_decisions re-derives the - # replacement text for every sticky-truncated message, every call). - self._spilled_paths: set[str] = set() self._hooks = hooks self._last_compaction_stats: dict[str, Any] | None = None # --- Summary compaction strategy state (compaction_strategy == "summary") --- @@ -701,12 +557,6 @@ async def add_message(self, message: dict[str, Any]) -> None: } self._next_seq += 1 - # Harvest tool_call_id -> tool name for per-tool budgets/exemptions. - # Gated on config: in the default configuration this is a single - # boolean check and nothing else. - if self._per_tool_config_active(): - self._harvest_tool_names(message) - # Add message (no rejection - compaction happens ephemerally) self.messages.append(message) @@ -1034,17 +884,10 @@ async def set_messages(self, messages: list[dict[str, Any]]) -> None: produced the transcript. """ restamped: list[dict[str, Any]] = [] - per_tool_active = self._per_tool_config_active() - self._tool_name_by_call_id = {} for i, msg in enumerate(messages): meta = dict(msg.get("metadata") or {}) meta["_seq"] = i restamped.append({**msg, "metadata": meta}) - # Rebuild the id->name map from the restored history, so a - # resumed session resolves per-tool budgets exactly as the - # original session did. No-op unless a per-tool knob is set. - if per_tool_active: - self._harvest_tool_names(msg) self.messages = restamped self._next_seq = len(restamped) # Seqs were just restamped from 0, so any anchor split recorded @@ -1072,12 +915,6 @@ async def clear(self) -> None: self._last_measured_prompt_tokens = None self._last_token_meter_stats = None self._reset_hybrid_meter_state() - # Per-tool name map belongs to the cleared conversation. The spill - # cache is only a write-skip memo -- the files themselves are - # content-addressed and deliberately NOT deleted here (a resumed or - # forked session may still hold a pointer to one; see README). - self._tool_name_by_call_id = {} - self._spilled_paths = set() self._reset_summary_strategy_state() logger.info("Context cleared") @@ -2157,13 +1994,6 @@ def _truncate_tool_wave( msg = messages[i] if msg.get("role") != "tool": # Verify it's still a tool message continue - if self._tool_result_is_exempt(msg): - # Skipped BEFORE _record_truncated so an exempt result is - # never marked truncated (which would inflate the reported - # truncation count and pin a permanent sticky decision that - # does nothing). No-op unless tool_result_exempt_tools is - # configured. - continue if not msg.get("_truncated"): if true_total is None: # First mutation in this call: establish the true baseline @@ -2573,263 +2403,20 @@ async def _finalize_compaction_with_stats( return final_messages - # --- Tool-result budget / shape / spill helpers --- - # - # Every one of these is a no-op in the default configuration. See the - # module docstring "Tool-result budget and spill" for the measured - # rationale and the determinism constraint that shapes the spill design. - - @staticmethod - def _validate_budget_tokens(value: Any) -> int | None: - """Coerce `tool_result_budget_tokens` to a positive int, or None. - - None (the default) means "keep the pre-existing char budget". - Anything unusable logs a warning and becomes None -- i.e. falls back - to today's behavior rather than raising at mount time. - """ - if value is None: - return None - if isinstance(value, bool) or not isinstance(value, int) or value < 1: - logger.warning( - f"context-simple: tool_result_budget_tokens must be a positive " - f"int, got {value!r}; falling back to the truncate_chars " - f"char budget" - ) - return None - return value - - @staticmethod - def _validate_budget_by_tool(value: Any) -> dict[str, int]: - """Coerce `tool_result_budget_by_tool` to {tool_name: positive int}. - - Unusable entries are dropped INDIVIDUALLY with a warning naming the - offending key, so one bad entry never silently discards the whole map - (and never crashes mount()). - """ - if not value: - return {} - if not isinstance(value, dict): - logger.warning( - f"context-simple: tool_result_budget_by_tool must be a dict, " - f"got {type(value).__name__}; ignoring it" - ) - return {} - cleaned: dict[str, int] = {} - for name, budget in value.items(): - if ( - isinstance(budget, bool) - or not isinstance(budget, int) - or budget < 1 - or not str(name) - ): - logger.warning( - f"context-simple: tool_result_budget_by_tool[{name!r}] must " - f"be a positive int, got {budget!r}; dropping this entry" - ) - continue - cleaned[str(name)] = budget - return cleaned - - def _per_tool_config_active(self) -> bool: - """True when any knob needs a tool RESULT matched to a tool NAME.""" - return bool(self.tool_result_budget_by_tool or self.tool_result_exempt_tools) - - def _harvest_tool_names(self, message: dict[str, Any]) -> None: - """Record tool_call_id -> tool name from an assistant message. - - A tool result carries only `tool_call_id`; the NAME lives on the - assistant message that requested the call. This harvests the mapping - incrementally as history is appended, so per-tool budget lookup is - O(1) and never rescans history. Handles both the OpenAI - (`{"id", "function": {"name"}}`) and Anthropic-ish - (`{"id", "name"}`) tool_call shapes -- the same two shapes - _format_messages_for_summarization already handles. - - Called only when a per-tool knob is configured: in the default - configuration this never runs. - """ - for tc in message.get("tool_calls") or []: - if not isinstance(tc, dict): - continue - call_id = tc.get("id") or tc.get("tool_call_id") - if "function" in tc and isinstance(tc["function"], dict): - name = tc["function"].get("name") - else: - name = tc.get("name") or tc.get("tool") - if call_id and name: - self._tool_name_by_call_id[str(call_id)] = str(name) - - def _resolve_tool_name(self, msg: dict[str, Any]) -> str | None: - """Best-effort tool name for a tool RESULT message. - - Three sources, in order of directness. Returning None simply means - no per-tool rule can apply to this message -- it falls through to the - global budget, which is the safe direction. - """ - name = msg.get("name") - if isinstance(name, str) and name: - return name - meta_name = (msg.get("metadata") or {}).get("tool_name") - if isinstance(meta_name, str) and meta_name: - return meta_name - call_id = msg.get("tool_call_id") - if call_id: - return self._tool_name_by_call_id.get(str(call_id)) - return None - - def _tool_result_is_exempt(self, msg: dict[str, Any]) -> bool: - """True if this tool result must never be truncated.""" - if not self.tool_result_exempt_tools: - return False - name = self._resolve_tool_name(msg) - return name is not None and name in self.tool_result_exempt_tools - - def _resolve_tool_result_budget(self, msg: dict[str, Any]) -> tuple[int, str]: - """Return (budget_chars, shape) for one tool result. - - Precedence: per-tool budget > global token budget > the pre-existing - `truncate_chars` char budget. The shape knob applies to whichever - budget won -- there is deliberately no per-tool shape, because a - per-tool budget with a global shape already covers every case the - reference implementations express, and a second per-tool map would - double the config surface for no measured gain. - """ - budget_tokens: int | None = None - if self.tool_result_budget_by_tool: - name = self._resolve_tool_name(msg) - if name is not None: - budget_tokens = self.tool_result_budget_by_tool.get(name) - if budget_tokens is None: - budget_tokens = self.tool_result_budget_tokens - if budget_tokens is None: - # Nothing configured: the pre-existing char budget, head-only. - # This is the byte-identical default path. - return self.truncate_chars, TOOL_RESULT_SHAPE_HEAD - return budget_tokens * _TOOL_RESULT_CHARS_PER_TOKEN, self.tool_result_shape - - def _spill_pointer(self, msg: dict[str, Any], content: str) -> str | None: - """Write the full result to the spill dir; return its path, or None. - - DETERMINISM (load-bearing -- see module docstring): the returned path - is a pure function of `content` (+ the message's stable `_seq`) and - is returned REGARDLESS of whether the write succeeded. This method is - re-entered for every sticky-truncated message on every request; if - the pointer text tracked write success, one transient disk error - would change the bytes of an already-sent message and cold-rebuild - the prompt cache. A dangling pointer is visible and recoverable; a - silently mutated prefix is neither. - - Content-addressing also makes the write idempotent across a resumed - or forked session: the same result always lands at the same path. - """ - if not self.tool_result_spill_dir: - return None - digest = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()[ - :16 - ] - seq = self._extract_seq(msg) - stem = ( - f"tool-result-{seq:06d}-{digest}" - if isinstance(seq, int) - else f"tool-result-{digest}" - ) - path = Path(self.tool_result_spill_dir) / f"{stem}.txt" - path_str = str(path) - if path_str in self._spilled_paths: - return path_str - try: - path.parent.mkdir(parents=True, exist_ok=True) - if not path.exists(): - # Write-then-rename so a reader (the agent, via its file - # tools) can never observe a half-written spill file. - tmp = path.parent / f"{path.name}.{id(self):x}.tmp" - tmp.write_text(content, encoding="utf-8") - tmp.replace(path) - self._spilled_paths.add(path_str) - except Exception as e: - # Deliberately NOT fatal and deliberately NOT reflected in the - # returned pointer -- see the determinism note above. - logger.warning( - f"context-simple: could not spill tool result to {path_str}: {e}. " - f"The pointer is still emitted (byte-stability); the file may " - f"be missing." - ) - return path_str - - def _format_truncated_tool_result( - self, - content: str, - budget_chars: int, - shape: str, - original_tokens: int, - spill_path: str | None, - ) -> str: - """Build the replacement text for one over-budget tool result. - - BYTE-IDENTITY CONTRACT: with `budget_chars == self.truncate_chars`, - `shape == "head"` and `spill_path is None` -- i.e. the default - configuration -- this returns EXACTLY the string this module has - always returned. tests/test_tool_result_budget.py pins that literally. - """ - recovery = ( - f"read {spill_path} for the full result" - if spill_path - else "call tool again if needed" - ) - header = f"[truncated: ~{original_tokens:,} tokens - {recovery}]" - - if shape != TOOL_RESULT_SHAPE_HEAD_TAIL: - return f"{header} {content[:budget_chars]}..." - - # head_tail: split the budget in half, keep both ends, and say - # explicitly how much vanished in between. The marker matters as much - # as the tail does -- a model must never reason from a truncated - # result without knowing that it is truncated. - head_chars = budget_chars // 2 - tail_chars = budget_chars - head_chars - # content[-0:] is the WHOLE string, not the empty one; guard it. - tail = content[-tail_chars:] if tail_chars else "" - omitted = len(content) - head_chars - tail_chars - return ( - f"{header} {content[:head_chars]}" - f"\n...[{omitted:,} chars omitted]...\n" - f"{tail}" - ) - def _truncate_tool_result(self, msg: dict[str, Any]) -> dict[str, Any]: """ Truncate a tool result message to reduce token count. Returns a NEW dict - does not modify the original. - - Deterministic: called once per over-budget tool result during an - escalation, and again for every sticky-truncated message on every - subsequent request (_apply_sticky_decisions). Same input, same bytes, - every time -- that is what the returned view's prefix stability rests - on. """ content = msg.get("content", "") - if not isinstance(content, str): - return msg - if self._tool_result_is_exempt(msg): - # No-op unless tool_result_exempt_tools is configured. - return msg - - budget_chars, shape = self._resolve_tool_result_budget(msg) - # CHARS BEFORE LINES: the gate below is, and has always been, a pure - # character count. There is no line-based cap anywhere in this path, - # so the "a file could have 2 lines that are each 10MB" failure mode - # is structurally impossible here rather than merely unlikely. - if len(content) <= budget_chars: + if not isinstance(content, str) or len(content) <= self.truncate_chars: return msg original_tokens = len(content) // 4 - spill_path = self._spill_pointer(msg, content) return { **msg, - "content": self._format_truncated_tool_result( - content, budget_chars, shape, original_tokens, spill_path - ), + "content": f"[truncated: ~{original_tokens:,} tokens - call tool again if needed] {content[: self.truncate_chars]}...", "_truncated": True, "_original_tokens": original_tokens, } diff --git a/tests/test_tool_result_budget.py b/tests/test_tool_result_budget.py deleted file mode 100644 index 885dcc7..0000000 --- a/tests/test_tool_result_budget.py +++ /dev/null @@ -1,769 +0,0 @@ -"""Tests for the tool-result budget, shape, per-tool rules, and spill. - -Everything this feature adds is OFF by default. The single most important -test in this file is `test_default_config_replacement_text_is_byte_identical`: -it pins the exact replacement string this module has emitted since before the -feature existed, character for character. If that test ever fails, the -"defaults are a no-op" claim in the README and the module docstring is false. - -The other three load-bearing groups: - - - TOOL-PAIR INTEGRITY: turning any of these knobs on must not break the - tool_use/tool_result atomicity the provider APIs require. A donor engine - measured 29 of 30 turns dying on exactly this. - - - _seq / PREFIX STABILITY: the replacement text (including the spill - pointer) is re-derived for every sticky-truncated message on every single - request. It must be byte-identical every time, or the shared prefix - mutates and -- under a grow-only prompt cache -- every mutation is a full - cold rebuild. - - - SPILL WRITE FAILURE: a failed write must NOT change the emitted bytes. - See `test_spill_write_failure_still_emits_stable_pointer`. -""" - -import logging -from pathlib import Path -from typing import Any - -import pytest -from amplifier_module_context_simple import ( - TOOL_RESULT_SHAPE_HEAD, - TOOL_RESULT_SHAPE_HEAD_TAIL, - SimpleContextManager, -) - -# -------------------------------------------------------------------------- -# helpers -# -------------------------------------------------------------------------- - - -def _make_context(**overrides: Any) -> SimpleContextManager: - """A context manager tuned to compact aggressively, like the sticky tests.""" - config: dict[str, Any] = { - "max_tokens": 2000, - "compact_threshold": 0.5, - "target_usage": 0.3, - "protected_recent": 0.2, - "protected_tool_results": 1, - "compaction_notice_enabled": False, - } - config.update(overrides) - return SimpleContextManager(**config) - - -def _make_gentle_context(**overrides: Any) -> SimpleContextManager: - """Enough pressure to reach the TRUNCATION rungs (levels 1-2) and stop. - - The aggressive `_make_context` settings escalate all the way to level 8, - where every tool result has been REMOVED rather than truncated -- which - makes any assertion about truncated content vacuously true. Calibrated - against the ladder (measured: 11 truncated results, sticky level 2). - """ - config: dict[str, Any] = { - "max_tokens": 20_000, - "compact_threshold": 0.5, - "target_usage": 0.45, - "protected_recent": 0.2, - "protected_tool_results": 1, - "compaction_notice_enabled": False, - } - config.update(overrides) - return SimpleContextManager(**config) - - -def _tool_msg( - call_id: str, content: str, seq: int | None = 0, **extra: Any -) -> dict[str, Any]: - msg: dict[str, Any] = { - "role": "tool", - "tool_call_id": call_id, - "content": content, - **extra, - } - if seq is not None: - msg["metadata"] = {**(msg.get("metadata") or {}), "_seq": seq} - return msg - - -def _assistant_call(call_id: str, tool_name: str, openai_shape: bool = False) -> dict: - if openai_shape: - return { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": call_id, - "type": "function", - "function": {"name": tool_name, "arguments": "{}"}, - } - ], - } - return { - "role": "assistant", - "content": "", - "tool_calls": [{"id": call_id, "tool": tool_name, "arguments": {}}], - } - - -def _legacy_expected(content: str, truncate_chars: int) -> str: - """The exact string this module emitted BEFORE this feature existed. - - Transcribed from the pre-change source, not from the new implementation -- - so this is an independent oracle, not a tautology. - """ - original_tokens = len(content) // 4 - return ( - f"[truncated: ~{original_tokens:,} tokens - call tool again if needed] " - f"{content[:truncate_chars]}..." - ) - - -# -------------------------------------------------------------------------- -# 1. BYTE IDENTITY -- the defaults-are-a-no-op contract -# -------------------------------------------------------------------------- - - -def test_default_config_replacement_text_is_byte_identical(): - """THE byte-identity test. Default config, literal expected bytes.""" - context = SimpleContextManager() - content = "A" * 5000 - out = context._truncate_tool_result(_tool_msg("c1", content)) - - assert out["content"] == _legacy_expected(content, 250) - # And spelled out literally once, so a change to _legacy_expected() alone - # cannot silently re-baseline this test. - assert out["content"].startswith( - "[truncated: ~1,250 tokens - call tool again if needed] AAA" - ) - assert out["content"].endswith("...") - assert len(out["content"]) == len( - "[truncated: ~1,250 tokens - call tool again if needed] " - ) + 250 + 3 - assert out["_truncated"] is True - assert out["_original_tokens"] == 1250 - - -def test_default_config_honors_custom_truncate_chars(): - """A pre-existing `truncate_chars` override must keep working untouched.""" - context = SimpleContextManager(truncate_chars=40) - content = "B" * 900 - out = context._truncate_tool_result(_tool_msg("c1", content)) - assert out["content"] == _legacy_expected(content, 40) - - -def test_default_config_short_result_untouched_and_identical_object(): - context = SimpleContextManager() - msg = _tool_msg("c1", "short") - assert context._truncate_tool_result(msg) is msg - - -def test_default_config_non_string_content_untouched(): - context = SimpleContextManager() - msg = _tool_msg("c1", "") - msg["content"] = [{"type": "text", "text": "x" * 5000}] - assert context._truncate_tool_result(msg) is msg - - -@pytest.mark.asyncio -async def test_default_config_full_compaction_view_is_byte_identical(): - """End-to-end: every truncated result in a real compacted view matches - the pre-change formula exactly.""" - context = _make_gentle_context(truncate_chars=60) - originals: dict[str, str] = {} - for i in range(30): - await context.add_message({"role": "user", "content": f"turn {i} " + "u" * 200}) - await context.add_message(_assistant_call(f"call_{i}", "bash")) - body = f"result {i} " + "z" * 800 - originals[f"call_{i}"] = body - await context.add_message( - {"role": "tool", "tool_call_id": f"call_{i}", "content": body} - ) - - view = await context.get_messages_for_request() - assert context._last_compaction_stats is not None, "test must actually compact" - - truncated_seen = 0 - for msg in view: - if msg.get("role") != "tool": - continue - if not msg.get("_truncated"): - continue - truncated_seen += 1 - original = originals[msg["tool_call_id"]] - assert msg["content"] == _legacy_expected(original, 60) - assert truncated_seen > 0, "test must actually truncate something" - - -@pytest.mark.asyncio -async def test_default_config_writes_no_files_anywhere(tmp_path): - """With no spill dir configured, nothing is ever written to disk.""" - context = _make_gentle_context(truncate_chars=60) - for i in range(30): - await context.add_message({"role": "user", "content": f"t{i} " + "u" * 200}) - await context.add_message(_assistant_call(f"call_{i}", "bash")) - await context.add_message( - {"role": "tool", "tool_call_id": f"call_{i}", "content": f"r{i} " + "z" * 800} - ) - await context.get_messages_for_request() - - assert context._truncated_seqs, "test must actually truncate something" - assert context.tool_result_spill_dir is None - assert context._spilled_paths == set() - assert list(tmp_path.iterdir()) == [] - - -# -------------------------------------------------------------------------- -# 2. TOKEN-DENOMINATED BUDGET -# -------------------------------------------------------------------------- - - -def test_token_budget_keeps_four_chars_per_token(): - context = SimpleContextManager(tool_result_budget_tokens=1000) - content = "C" * 20_000 - out = context._truncate_tool_result(_tool_msg("c1", content)) - body = out["content"].split("] ", 1)[1] - assert body == "C" * 4000 + "..." - - -def test_token_budget_leaves_under_budget_results_alone(): - context = SimpleContextManager(tool_result_budget_tokens=1000) - msg = _tool_msg("c1", "D" * 3999) - assert context._truncate_tool_result(msg) is msg - - -def test_token_budget_header_shape_matches_legacy_head_shape(): - """Switching only the UNIT must not change the FORMAT.""" - context = SimpleContextManager(tool_result_budget_tokens=10) - content = "E" * 900 - out = context._truncate_tool_result(_tool_msg("c1", content)) - assert out["content"] == _legacy_expected(content, 40) - - -# -------------------------------------------------------------------------- -# 3. HEAD + TAIL -- the measured quality gap -# -------------------------------------------------------------------------- - - -def test_head_tail_keeps_the_tail(): - """G-TRB-TAIL, as a unit test: the tail of the ORIGINAL survives.""" - context = SimpleContextManager( - tool_result_budget_tokens=100, tool_result_shape=TOOL_RESULT_SHAPE_HEAD_TAIL - ) - content = "HEAD" + "m" * 10_000 + "FAILED: assertion at line 42" - out = context._truncate_tool_result(_tool_msg("c1", content))["content"] - - assert out.endswith("FAILED: assertion at line 42") - assert "HEAD" in out - assert "...[" in out and "chars omitted]..." in out - - -def test_head_tail_splits_budget_in_half_and_counts_omission_exactly(): - context = SimpleContextManager( - tool_result_budget_tokens=100, tool_result_shape=TOOL_RESULT_SHAPE_HEAD_TAIL - ) - content = "".join(str(i % 10) for i in range(10_000)) - out = context._truncate_tool_result(_tool_msg("c1", content))["content"] - - body = out.split("] ", 1)[1] - head, rest = body.split("\n...[", 1) - omitted_str, tail = rest.split(" chars omitted]...\n", 1) - - assert head == content[:200] - assert tail == content[-200:] - assert omitted_str.replace(",", "") == str(10_000 - 400) - - -def test_head_tail_smallest_budget_does_not_leak_whole_content(): - """content[-0:] is the WHOLE string. Guard against that class of bug.""" - context = SimpleContextManager( - tool_result_budget_tokens=1, tool_result_shape=TOOL_RESULT_SHAPE_HEAD_TAIL - ) - content = "X" * 5000 - out = context._truncate_tool_result(_tool_msg("c1", content))["content"] - assert len(out) < 200, f"replacement should be tiny, got {len(out)} chars" - - -def test_head_tail_under_budget_result_untouched(): - context = SimpleContextManager( - tool_result_budget_tokens=100, tool_result_shape=TOOL_RESULT_SHAPE_HEAD_TAIL - ) - msg = _tool_msg("c1", "Y" * 399) - assert context._truncate_tool_result(msg) is msg - - -# -------------------------------------------------------------------------- -# 4. PER-TOOL BUDGETS AND EXEMPTIONS -# -------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_per_tool_budget_resolved_via_harvested_tool_call_id(): - context = _make_context( - tool_result_budget_tokens=10, - tool_result_budget_by_tool={"grep": 500}, - ) - await context.add_message(_assistant_call("call_g", "grep")) - await context.add_message(_assistant_call("call_b", "bash")) - - grep_result = _tool_msg("call_g", "G" * 9000, seq=None) - bash_result = _tool_msg("call_b", "B" * 9000, seq=None) - - grep_out = context._truncate_tool_result(grep_result)["content"] - bash_out = context._truncate_tool_result(bash_result)["content"] - - assert grep_out.split("] ", 1)[1] == "G" * 2000 + "..." # 500 tokens - assert bash_out.split("] ", 1)[1] == "B" * 40 + "..." # 10 tokens (global) - - -@pytest.mark.asyncio -async def test_per_tool_budget_resolved_via_openai_function_shape(): - context = _make_context(tool_result_budget_by_tool={"read_file": 300}) - await context.add_message(_assistant_call("c1", "read_file", openai_shape=True)) - out = context._truncate_tool_result(_tool_msg("c1", "R" * 9000, seq=None)) - assert out["content"].split("] ", 1)[1] == "R" * 1200 + "..." - - -def test_per_tool_budget_resolved_via_name_field_on_the_result(): - context = SimpleContextManager(tool_result_budget_by_tool={"grep": 5}) - out = context._truncate_tool_result(_tool_msg("c1", "G" * 900, name="grep")) - assert out["content"].split("] ", 1)[1] == "G" * 20 + "..." - - -def test_per_tool_budget_resolved_via_metadata_tool_name(): - context = SimpleContextManager(tool_result_budget_by_tool={"grep": 5}) - msg = _tool_msg("c1", "G" * 900) - msg["metadata"] = {**msg["metadata"], "tool_name": "grep"} - out = context._truncate_tool_result(msg) - assert out["content"].split("] ", 1)[1] == "G" * 20 + "..." - - -def test_unresolvable_tool_name_falls_back_to_the_global_budget(): - context = SimpleContextManager( - tool_result_budget_tokens=10, tool_result_budget_by_tool={"grep": 500} - ) - out = context._truncate_tool_result(_tool_msg("unknown_id", "Z" * 900)) - assert out["content"].split("] ", 1)[1] == "Z" * 40 + "..." - - -def test_per_tool_budget_alone_leaves_other_tools_on_the_legacy_path(): - """Setting ONLY the per-tool map must not change any other tool's bytes.""" - context = SimpleContextManager(tool_result_budget_by_tool={"grep": 500}) - content = "Q" * 900 - out = context._truncate_tool_result(_tool_msg("c1", content, name="bash")) - assert out["content"] == _legacy_expected(content, 250) - - -def test_exempt_tool_is_never_truncated(): - context = SimpleContextManager( - tool_result_budget_tokens=10, tool_result_exempt_tools=["load_skill"] - ) - msg = _tool_msg("c1", "S" * 50_000, name="load_skill") - assert context._truncate_tool_result(msg) is msg - - -@pytest.mark.asyncio -async def test_exempt_tool_survives_full_compaction_pressure(): - """The skill output is still whole after the ladder has run everywhere.""" - skill_body = "SKILL-BODY " + "s" * 800 - # NOTE: protected_tool_results=1, not 0. `tool_result_indices[-0:]` is the - # WHOLE list, so setting 0 protects EVERY tool result from truncation -- - # the exact opposite of what it reads like. Pre-existing behavior, filed - # separately; this test just avoids the trap. - context = _make_gentle_context( - tool_result_budget_tokens=10, - tool_result_exempt_tools=["load_skill"], - protected_tool_results=1, - ) - await context.add_message(_assistant_call("call_skill", "load_skill")) - await context.add_message( - {"role": "tool", "tool_call_id": "call_skill", "content": skill_body} - ) - for i in range(30): - await context.add_message({"role": "user", "content": f"t{i} " + "u" * 200}) - await context.add_message(_assistant_call(f"call_{i}", "bash")) - await context.add_message( - {"role": "tool", "tool_call_id": f"call_{i}", "content": f"r{i} " + "z" * 800} - ) - - view = await context.get_messages_for_request() - assert context._last_compaction_stats is not None, "test must actually compact" - - skill_msgs = [m for m in view if m.get("tool_call_id") == "call_skill"] - if skill_msgs: # may be removed entirely at high pressure -- but never trimmed - assert skill_msgs[0]["content"] == skill_body - assert not skill_msgs[0].get("_truncated") - other_truncated = [ - m - for m in view - if m.get("role") == "tool" and m.get("_truncated") and m["tool_call_id"] != "call_skill" - ] - assert other_truncated, "test must actually truncate the non-exempt results" - - -# -------------------------------------------------------------------------- -# 5. SPILL TO DISK -# -------------------------------------------------------------------------- - - -def test_spill_writes_full_content_and_points_at_it(tmp_path): - context = SimpleContextManager( - tool_result_budget_tokens=50, - tool_result_shape=TOOL_RESULT_SHAPE_HEAD_TAIL, - tool_result_spill_dir=str(tmp_path / "spill"), - ) - content = "HEAD" + "m" * 20_000 + "TAIL" - out = context._truncate_tool_result(_tool_msg("c1", content))["content"] - - written = list((tmp_path / "spill").iterdir()) - assert len(written) == 1 - assert written[0].read_text() == content - assert str(written[0]) in out - assert "read " in out and "for the full result" in out - - -def test_spill_pointer_is_content_addressed_and_idempotent(tmp_path): - context = SimpleContextManager( - tool_result_budget_tokens=50, tool_result_spill_dir=str(tmp_path) - ) - msg = _tool_msg("c1", "N" * 9000) - first = context._truncate_tool_result(msg)["content"] - written = list(tmp_path.iterdir()) - mtime = written[0].stat().st_mtime_ns - - # Re-derive 5 more times, exactly as _apply_sticky_decisions does. - for _ in range(5): - assert context._truncate_tool_result(msg)["content"] == first - assert list(tmp_path.iterdir()) == written - assert written[0].stat().st_mtime_ns == mtime, "spill file was rewritten" - - -def test_spill_paths_differ_for_different_content(tmp_path): - context = SimpleContextManager( - tool_result_budget_tokens=50, tool_result_spill_dir=str(tmp_path) - ) - context._truncate_tool_result(_tool_msg("c1", "P" * 9000, seq=1)) - context._truncate_tool_result(_tool_msg("c2", "Q" * 9000, seq=2)) - assert len(list(tmp_path.iterdir())) == 2 - - -def test_spill_write_failure_still_emits_stable_pointer(tmp_path): - """A failed write must NOT change the emitted bytes. - - Byte-stability outranks pointer validity: a dangling pointer is visible - and recoverable, a silently mutated prefix is a cold cache rebuild. - """ - blocker = tmp_path / "not_a_dir" - blocker.write_text("i am a file") - context = SimpleContextManager( - tool_result_budget_tokens=50, tool_result_spill_dir=str(blocker / "spill") - ) - msg = _tool_msg("c1", "F" * 9000) - - first = context._truncate_tool_result(msg)["content"] - second = context._truncate_tool_result(msg)["content"] - - assert first == second - assert "for the full result" in first - assert not (blocker / "spill").exists() - - -def test_spill_disabled_by_default_writes_nothing(tmp_path): - context = SimpleContextManager(tool_result_budget_tokens=50) - out = context._truncate_tool_result(_tool_msg("c1", "G" * 9000))["content"] - assert "call tool again if needed" in out - assert list(tmp_path.iterdir()) == [] - - -def test_spill_file_content_is_the_untruncated_original(tmp_path): - context = SimpleContextManager( - tool_result_budget_tokens=50, - tool_result_shape=TOOL_RESULT_SHAPE_HEAD_TAIL, - tool_result_spill_dir=str(tmp_path), - ) - content = "\n".join(f"line {i}" for i in range(5000)) - out = context._truncate_tool_result(_tool_msg("c1", content))["content"] - spilled = next(tmp_path.iterdir()) - assert spilled.read_text() == content - # The middle really is missing from the message but present in the file. - assert "line 2500" not in out - assert "line 2500" in spilled.read_text() - - -# -------------------------------------------------------------------------- -# 6. TOOL-PAIR INTEGRITY under the new configuration -# -------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_tool_pairs_stay_atomic_with_every_knob_enabled(tmp_path): - context = _make_gentle_context( - tool_result_budget_tokens=40, - tool_result_shape=TOOL_RESULT_SHAPE_HEAD_TAIL, - tool_result_budget_by_tool={"grep": 10, "bash": 80}, - tool_result_exempt_tools=["load_skill"], - tool_result_spill_dir=str(tmp_path), - ) - for i in range(30): - tool = ["grep", "bash", "load_skill"][i % 3] - await context.add_message({"role": "user", "content": f"t{i} " + "u" * 200}) - await context.add_message(_assistant_call(f"call_{i}", tool)) - await context.add_message( - {"role": "tool", "tool_call_id": f"call_{i}", "content": f"out {i} " + "z" * 800} - ) - - view = await context.get_messages_for_request() - assert context._last_compaction_stats is not None, "test must actually compact" - assert context._truncated_seqs, "test must actually truncate something" - - requested = { - tc["id"] - for m in view - for tc in (m.get("tool_calls") or []) - if isinstance(tc, dict) and tc.get("id") - } - answered = {m["tool_call_id"] for m in view if m.get("role") == "tool"} - assert requested == answered, ( - "tool_use/tool_result atomicity broken: " - f"unanswered={requested - answered} orphaned={answered - requested}" - ) - - -@pytest.mark.asyncio -async def test_exempt_results_are_not_counted_as_truncated(tmp_path): - context = _make_gentle_context( - tool_result_budget_tokens=20, - tool_result_exempt_tools=["load_skill"], - ) - for i in range(30): - await context.add_message({"role": "user", "content": f"t{i} " + "u" * 200}) - await context.add_message(_assistant_call(f"call_{i}", "load_skill")) - await context.add_message( - {"role": "tool", "tool_call_id": f"call_{i}", "content": f"s{i} " + "s" * 800} - ) - - await context.get_messages_for_request() - assert context._truncated_seqs == set(), ( - "an exempt tool result was recorded as truncated -- it would inflate " - "the reported count and pin a sticky decision that does nothing" - ) - - -# -------------------------------------------------------------------------- -# 7. _seq / PREFIX STABILITY -# -------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_prefix_stays_byte_stable_with_head_tail_and_spill(tmp_path): - context = _make_gentle_context( - tool_result_budget_tokens=40, - tool_result_shape=TOOL_RESULT_SHAPE_HEAD_TAIL, - tool_result_spill_dir=str(tmp_path), - ) - for i in range(30): - await context.add_message({"role": "user", "content": f"t{i} " + "u" * 200}) - await context.add_message(_assistant_call(f"call_{i}", "bash")) - await context.add_message( - {"role": "tool", "tool_call_id": f"call_{i}", "content": f"o{i} " + "z" * 800} - ) - - call1 = await context.get_messages_for_request() - assert context._last_compaction_stats is not None, "test must actually compact" - assert context._truncated_seqs, "test must actually truncate something" - assert list(tmp_path.iterdir()), "test must actually spill something" - - await context.add_message({"role": "user", "content": "next turn " + "u" * 200}) - await context.add_message({"role": "assistant", "content": "ack"}) - call2 = await context.get_messages_for_request() - - assert call2[: len(call1)] == call1, ( - "shared prefix changed after appending one turn -- the truncated " - "replacement text (or its spill pointer) is not deterministic" - ) - - -@pytest.mark.asyncio -async def test_truncation_is_idempotent_across_many_requests(tmp_path): - context = _make_gentle_context( - tool_result_budget_tokens=40, - tool_result_shape=TOOL_RESULT_SHAPE_HEAD_TAIL, - tool_result_spill_dir=str(tmp_path), - ) - for i in range(30): - await context.add_message({"role": "user", "content": f"t{i} " + "u" * 200}) - await context.add_message(_assistant_call(f"call_{i}", "bash")) - await context.add_message( - {"role": "tool", "tool_call_id": f"call_{i}", "content": f"o{i} " + "z" * 800} - ) - - baseline = await context.get_messages_for_request() - assert context._truncated_seqs, "test must actually truncate something" - for _ in range(5): - assert await context.get_messages_for_request() == baseline - - # And exactly one spill file per distinct truncated result, no churn. - spilled = sorted(p.name for p in tmp_path.iterdir()) - assert len(spilled) == len(set(spilled)) - assert not any(p.name.endswith(".tmp") for p in tmp_path.iterdir()) - - -@pytest.mark.asyncio -async def test_set_messages_rebuilds_the_per_tool_name_map(): - """A resumed session must resolve per-tool budgets as the original did.""" - context = _make_context(tool_result_budget_by_tool={"grep": 5}) - await context.set_messages( - [ - _assistant_call("call_g", "grep"), - {"role": "tool", "tool_call_id": "call_g", "content": "G" * 900}, - ] - ) - assert context._tool_name_by_call_id == {"call_g": "grep"} - out = context._truncate_tool_result(context.messages[1]) - assert out["content"].split("] ", 1)[1] == "G" * 20 + "..." - - -@pytest.mark.asyncio -async def test_clear_resets_the_per_tool_name_map(): - context = _make_context(tool_result_budget_by_tool={"grep": 5}) - await context.add_message(_assistant_call("call_g", "grep")) - assert context._tool_name_by_call_id - await context.clear() - assert context._tool_name_by_call_id == {} - assert context._spilled_paths == set() - - -@pytest.mark.asyncio -async def test_default_config_does_no_tool_name_harvesting(): - """The default path must not pay for a feature nobody turned on.""" - context = _make_context() - await context.add_message(_assistant_call("call_g", "grep")) - assert context._tool_name_by_call_id == {} - - -# -------------------------------------------------------------------------- -# 8. CONFIG VALIDATION -- warn and fall back, never crash mount() -# -------------------------------------------------------------------------- - - -def test_unknown_shape_falls_back_to_head_with_a_warning(caplog): - with caplog.at_level(logging.WARNING): - context = SimpleContextManager(tool_result_shape="middle_out") - assert context.tool_result_shape == TOOL_RESULT_SHAPE_HEAD - assert "tool_result_shape" in caplog.text - - -@pytest.mark.parametrize("bad", [0, -5, "1000", 3.5, True]) -def test_unusable_budget_tokens_falls_back_to_legacy(bad, caplog): - with caplog.at_level(logging.WARNING): - context = SimpleContextManager(tool_result_budget_tokens=bad) - assert context.tool_result_budget_tokens is None - assert "tool_result_budget_tokens" in caplog.text - content = "A" * 900 - out = context._truncate_tool_result(_tool_msg("c1", content)) - assert out["content"] == _legacy_expected(content, 250) - - -def test_bad_per_tool_entries_are_dropped_individually(caplog): - with caplog.at_level(logging.WARNING): - context = SimpleContextManager( - tool_result_budget_by_tool={"grep": 500, "bash": -1, "ls": "big"} - ) - assert context.tool_result_budget_by_tool == {"grep": 500} - assert "bash" in caplog.text and "ls" in caplog.text - - -def test_non_dict_per_tool_map_is_ignored(caplog): - with caplog.at_level(logging.WARNING): - context = SimpleContextManager(tool_result_budget_by_tool=["grep"]) - assert context.tool_result_budget_by_tool == {} - assert "tool_result_budget_by_tool" in caplog.text - - -def test_exempt_tools_coerced_to_frozenset(): - context = SimpleContextManager(tool_result_exempt_tools=["a", "a", "b"]) - assert context.tool_result_exempt_tools == frozenset({"a", "b"}) - - -def test_defaults_are_all_inert(): - context = SimpleContextManager() - assert context.tool_result_budget_tokens is None - assert context.tool_result_shape == TOOL_RESULT_SHAPE_HEAD - assert context.tool_result_budget_by_tool == {} - assert context.tool_result_exempt_tools == frozenset() - assert context.tool_result_spill_dir is None - - -# -------------------------------------------------------------------------- -# 9. mount() plumbing -# -------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_mount_passes_every_new_flag_through(tmp_path): - from amplifier_module_context_simple import mount - - mounted: dict[str, Any] = {} - - class _Coordinator: - hooks = None - - async def mount(self, slot: str, instance: Any) -> None: - mounted[slot] = instance - - await mount( - _Coordinator(), # type: ignore[arg-type] - { - "tool_result_budget_tokens": 4000, - "tool_result_shape": "head_tail", - "tool_result_budget_by_tool": {"grep": 2000}, - "tool_result_exempt_tools": ["load_skill"], - "tool_result_spill_dir": str(tmp_path), - }, - ) - context = mounted["context"] - assert context.tool_result_budget_tokens == 4000 - assert context.tool_result_shape == TOOL_RESULT_SHAPE_HEAD_TAIL - assert context.tool_result_budget_by_tool == {"grep": 2000} - assert context.tool_result_exempt_tools == frozenset({"load_skill"}) - assert context.tool_result_spill_dir == str(tmp_path) - - -@pytest.mark.asyncio -async def test_mount_defaults_leave_every_flag_inert(): - from amplifier_module_context_simple import mount - - mounted: dict[str, Any] = {} - - class _Coordinator: - hooks = None - - async def mount(self, slot: str, instance: Any) -> None: - mounted[slot] = instance - - await mount(_Coordinator(), {}) # type: ignore[arg-type] - context = mounted["context"] - assert context.tool_result_budget_tokens is None - assert context.tool_result_shape == TOOL_RESULT_SHAPE_HEAD - assert context.tool_result_budget_by_tool == {} - assert context.tool_result_exempt_tools == frozenset() - assert context.tool_result_spill_dir is None - assert context.truncate_chars == 250 - - -def test_spill_dir_is_not_created_until_something_spills(tmp_path): - target = tmp_path / "never" - SimpleContextManager( - tool_result_budget_tokens=50, tool_result_spill_dir=str(target) - ) - assert not target.exists() - - -def test_spill_dir_created_lazily_on_first_spill(tmp_path): - target = tmp_path / "made_on_demand" / "nested" - context = SimpleContextManager( - tool_result_budget_tokens=50, tool_result_spill_dir=str(target) - ) - context._truncate_tool_result(_tool_msg("c1", "H" * 9000)) - assert Path(target).is_dir() - assert len(list(Path(target).iterdir())) == 1 From 2285ad3e5613bd35047ec49079e81e3a482d27d7 Mon Sep 17 00:00:00 2001 From: bkrabach <702425+bkrabach@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:08:25 -0700 Subject: [PATCH 6/7] Revert "feat: token_meter \"hybrid\" -- provider-anchored counts with provenance (+ the never-run estimate-vs-hybrid-vs-actual measurement) (#22)" This reverts commit f47c8948c55f597ed21ff2612dc66aa54ca646ce. Merge policy: main carries wins only. The hybrid token_meter mode's own measurement was never run. Unproven, opt-in feature -- belongs on a branch for evaluation, not on main. --- README.md | 88 +-- amplifier_module_context_simple/__init__.py | 430 +-------------- tests/test_token_meter_hybrid.py | 573 -------------------- 3 files changed, 27 insertions(+), 1064 deletions(-) delete mode 100644 tests/test_token_meter_hybrid.py diff --git a/README.md b/README.md index c2d8055..9a80d85 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Provides straightforward in-memory conversation context management. This is the - No persistence across sessions - Automatic compaction when approaching token limit (keeps system messages + last 10 messages) - **Preserves tool pairs as atomic units** during compaction (data integrity guarantee) -- **Optional real-usage token meter** (`token_meter: "actual"` or `"hybrid"`, default off) drives the compaction trigger from real provider usage instead of the built-in estimator -- see [Real-usage token meter](#real-usage-token-meter-token_meter) below +- **Optional real-usage token meter** (`token_meter: "actual"`, default off) drives the compaction trigger from real provider usage instead of the built-in estimator -- see [Real-usage token meter](#real-usage-token-meter-token_meter) below ## Configuration @@ -155,92 +155,10 @@ existed. This mirrors context-handoff's own documented limitation that its measurement is retrospective (one-call lag): the meter describes the request that was *just* answered, not the one currently being assembled. -### Hybrid mode (`token_meter: "hybrid"`) - -`"actual"` replaces the estimator wholesale with the provider's last -reported total. That number is **retrospective by one call**: it describes -the request that was just answered, so everything appended since -- the new -user turn, the tool results from this turn's tool loop -- is invisible to it. - -`"hybrid"` is the shape `openai/codex` uses, which has no "estimate mode" at -all: - -``` -total = provider_reported_total_from_the_last_llm_response # kind='usage' - + estimate(items appended since that response) # the un-billed tail only -``` - -The heuristic is still used, but only for the small, recent slice the -provider has not priced yet -- never for the whole window, and never for the -system prompt and tool schemas, which are both the largest block in the -window and the block the provider has *definitely* already billed. - -On top of that shape it carries two things lifted from -`deepseek-ai/deepseek-harness`: - -**1. Provenance on every count (`kind`).** Every number this module produces -reports where it came from -- `'usage'`, `'estimated'`, or `'none'` -- in -`_last_token_meter_stats["kind"]` and on the `context:token_meter` event. -A consumer that acts *irreversibly* on a token count must branch on this -rather than silently accepting an estimate. - -**2. A conservatism guard.** If the provider's total is *below* what the -heuristic priced for the very content it billed, the anchor is not a -trustworthy floor for this window: it is **rejected**, the (larger) full -heuristic is reported instead, and the count is honestly marked -`kind='estimated'`. The comparand is the estimate of the view that was -actually **sent** on that request, not the full uncompacted history -- those -are different numbers whenever compaction is active. - -And a third, from the same source: **refuse to guess.** The optional cache -aggregates in the stats (`cache_aggregates`) are reported **only** when every -usage event observed this session carried the underlying fields. One event -missing them makes the aggregate `None` (undefined) rather than a partial sum -that silently under-reports. - -#### G-METER-PROVENANCE - -In `"hybrid"` mode, **no irreversible action is taken on a count whose kind -is not `'usage'`.** Firing compaction is irreversible in the ways that -matter: it destroys the provider's prompt cache for at least one request, -and it records sticky truncate/remove decisions that persist for the rest of -the session. So when the count is `kind='estimated'` -- before the first -response of the session, or when the conservatism guard rejected the anchor --- the trigger **declines to fire** and waits for provider-anchored data. -Refusals are counted in `_last_token_meter_stats["provenance_refusals"]`. - -There is exactly **one** escape, and it is recorded rather than hidden: if -**no** anchor has ever arrived this session *and* the count has reached 100% -of budget, refusing would guarantee a provider context-overflow failure on -the next request, which is strictly worse than acting on an estimate. That -path fires, logs a warning, and is counted separately in -`provenance_overrides` -- so a gate can report it honestly instead of it -looking like a clean anchored fire. It cannot mask a rejected anchor: it -requires that no measurement exists at all. - -#### All three meters, every request - -`estimated_tokens`, `measured_tokens` and `hybrid_tokens` are computed on -**every** request in **every** mode -- including the default -- and reported -via `_last_token_meter_stats` and the `context:token_meter` event, alongside -`anchor_tokens`, `anchor_rejected`, `tail_estimated_tokens` and -`tail_messages`. Only *which one drives the trigger* changes with the mode. -That is deliberate: it makes the estimate-vs-hybrid-vs-actual divergence -measurable on a real workload without changing behaviour to measure it. - -#### Same sizing limitation as `"actual"` - -Only the **gate** uses the anchored number. `target_tokens` and every -per-level termination check inside `_compact_ephemeral` are still the -estimator, for the same reason as in `"actual"` mode: a provider-billed -count for a hypothetical smaller message set does not exist without another -round trip. - ### Future default flip (pending validation) -`token_meter` defaults to `"estimate"` specifically so it ships -with **zero behavior change**. Flipping the default to `"actual"` or -`"hybrid"` -- and +`token_meter` defaults to `"estimate"` in this PR specifically so it ships +with **zero behavior change**. Flipping the default to `"actual"` -- and potentially raising `compact_threshold` closer to the real ceiling now that it can be measured accurately -- is a follow-up, not part of this change. It should happen only after running the module's own eval harness against diff --git a/amplifier_module_context_simple/__init__.py b/amplifier_module_context_simple/__init__.py index bde6d17..3af9cd3 100644 --- a/amplifier_module_context_simple/__init__.py +++ b/amplifier_module_context_simple/__init__.py @@ -31,27 +31,6 @@ arrived this session (falling back to the estimator before then, or whenever hooks/events are unavailable). Default is `"estimate"`, which keeps behavior byte-identical to before this feature existed. - • Set `token_meter: "hybrid"` to ANCHOR on the provider's own reported - total from the last response and apply the heuristic ONLY to items - appended since that anchor (openai/codex's shape), carrying the - PROVENANCE of the resulting number -- `kind` in - {'usage','estimated','none'} -- on every count - (deepseek-harness's shape). Two guards ride with it: - - CONSERVATISM: if the provider total is below what the heuristic - priced for the same billed content, the anchor is rejected and - the count is honestly marked kind='estimated'. - - REFUSE TO GUESS: optional cache aggregates are reported only when - EVERY usage event this session carried them; otherwise undefined. - G-METER-PROVENANCE: in this mode no irreversible action (the - compaction trigger) is taken on a count that is not kind='usage'. - The single recorded escape -- no anchor has EVER arrived AND the - count has reached 100% of budget, where refusing would guarantee a - provider hard-failure -- fires, logs a warning, and is counted - separately as a provenance OVERRIDE rather than a clean fire. - • ALL THREE meters (estimate / actual / hybrid) are computed on every - request in EVERY mode and reported via `_last_token_meter_stats` and - the `context:token_meter` event, so their divergence is measurable - without changing which one drives the trigger. • Ported from amplifier-module-context-handoff's proven `_on_llm_response` meter. See README "Real-usage token meter" for the full rationale. @@ -118,23 +97,7 @@ # SimpleContextManager._measure_working_tokens. TOKEN_METER_ESTIMATE = "estimate" TOKEN_METER_ACTUAL = "actual" -TOKEN_METER_HYBRID = "hybrid" -_VALID_TOKEN_METERS = ( - TOKEN_METER_ESTIMATE, - TOKEN_METER_ACTUAL, - TOKEN_METER_HYBRID, -) - -# Provenance of a token count (`kind`), reported on EVERY count this module -# produces -- lifted from deepseek-harness's `baseline.kind`. "usage" means -# the number is anchored on the provider's own reported usage; -# "estimated" means it came from the len(str)//4 heuristic (in whole or in -# part, or the anchor was rejected by the conservatism guard); "none" means -# there was nothing to price. G-METER-PROVENANCE: in `hybrid` mode, no -# irreversible action may be taken on a count whose kind is not "usage". -METER_KIND_USAGE = "usage" -METER_KIND_ESTIMATED = "estimated" -METER_KIND_NONE = "none" +_VALID_TOKEN_METERS = (TOKEN_METER_ESTIMATE, TOKEN_METER_ACTUAL) # compaction_strategy config values. "progressive" (default) preserves the # existing truncate/remove ladder exactly (byte-identical -- see module @@ -210,12 +173,8 @@ async def mount(coordinator: ModuleCoordinator, config: dict[str, Any] | None = - compaction_notice_verbosity: Notice detail level - "minimal", "normal", "verbose" (default: "normal") - compaction_notice_min_level: Only show notice if compaction level >= this (default: 1) - output_reserve_fraction: Fraction of max_output_tokens to reserve for responses (default: 0.5) - - token_meter: "estimate" (default), "actual" or "hybrid". - "estimate" is byte-identical to pre-existing behavior. - "hybrid" anchors on the provider's own reported total and - estimates only the un-billed tail, carrying provenance - (`kind`) that gates irreversible actions -- see module - docstring. "actual" drives the + - token_meter: "estimate" (default) or "actual". "estimate" is + byte-identical to pre-existing behavior. "actual" drives the compaction trigger from real provider usage (input_tokens + cache_write_tokens, observed via the `llm:response` hook) once at least one response has been observed this session, @@ -395,9 +354,7 @@ def __init__( responses (0.0-1.0, default: 0.5). Lower values give more context budget at the cost of less headroom for long responses. token_meter: "estimate" (default, byte-identical to pre-existing - behavior), "hybrid" (provider-anchored total + heuristic - for the un-billed tail, with provenance gating - irreversible actions), or "actual" (compaction trigger uses real provider + behavior) or "actual" (compaction trigger uses real provider usage from the `llm:response` hook once observed this session -- see module docstring "Real-Usage Token Meter"). An unrecognized value falls back to "estimate" with a @@ -478,31 +435,6 @@ def __init__( # "estimate" mode -- see README "Real-usage token meter". self._last_measured_prompt_tokens: int | None = None self._last_token_meter_stats: dict[str, Any] | None = None - # --- Hybrid meter state (token_meter == "hybrid") --- - # `_anchor_seq` is `_next_seq` frozen at the moment the anchor was - # recorded: every message whose `_seq` is >= it was appended AFTER - # the request the provider billed, so it is exactly the un-billed - # tail the heuristic is still allowed to price (codex's shape). - # `_anchor_estimate` is what the heuristic said about the view that - # was actually SENT on that request -- the comparand for the - # conservatism guard (deepseek's shape). `_last_sent_estimate` is - # the running value that becomes `_anchor_estimate` when the next - # llm:response arrives. - self._anchor_seq: int | None = None - self._anchor_estimate: int | None = None - self._last_sent_estimate: int | None = None - self._last_hybrid_tokens: int | None = None - self._last_hybrid_kind: str = METER_KIND_NONE - # Refuse-to-guess accounting for optional cache aggregates: they are - # reported ONLY when every usage event seen this session carried - # them, else undefined (None). Never partially summed. - self._usage_events: int = 0 - self._usage_events_with_cache: int = 0 - self._usage_cache_read_total: int = 0 - self._usage_cache_write_total: int = 0 - # G-METER-PROVENANCE accounting (observability; see _should_compact). - self._provenance_refusals: int = 0 - self._provenance_overrides: int = 0 self._system_prompt_factory: Callable[[], Awaitable[str]] | None = None # --- Sticky compaction decision state --- @@ -671,52 +603,19 @@ async def get_messages_for_request( # Static mode: use messages as-is (may include stored system messages) working_messages = list(self.messages) - ( - token_count, - meter_source, - estimated_tokens, - meter, - ) = self._measure_working_tokens(working_messages) + token_count, meter_source, estimated_tokens = self._measure_working_tokens( + working_messages + ) self._last_token_meter_stats = { "mode": self.token_meter, "source": meter_source, - # Provenance of `used_tokens` -- present on 100% of counts, in - # every mode (G-METER-PROVENANCE). - "kind": meter["kind"], "used_tokens": token_count, - # All three meters, computed simultaneously on every request, - # regardless of which one is actually driving the trigger. This is - # what makes the estimate-vs-hybrid-vs-actual divergence - # measurable without changing behaviour (POC-05 G-METER-DELTA). "estimated_tokens": estimated_tokens, "measured_tokens": self._last_measured_prompt_tokens, - "hybrid_tokens": meter["hybrid_tokens"], - "hybrid_kind": meter["hybrid_kind"], - "anchor_tokens": meter["anchor_tokens"], - "anchor_estimate": meter["anchor_estimate"], - "anchor_rejected": meter["anchor_rejected"], - "tail_estimated_tokens": meter["tail_estimated_tokens"], - "tail_messages": meter["tail_messages"], - # Undefined (None) unless EVERY usage event this session reported - # cache fields -- refuse to guess, never a partial sum. - "cache_aggregates": self._cache_aggregates(), - "provenance_refusals": self._provenance_refusals, - "provenance_overrides": self._provenance_overrides, "budget": effective_budget, "ratio": (token_count / effective_budget) if effective_budget > 0 else None, } - # Observability emit: lets an eval harness capture all three meters - # per request without patching this module. Never raises, never - # changes the view that is returned. - if self._hooks is not None: - try: - await self._hooks.emit( - "context:token_meter", dict(self._last_token_meter_stats) - ) - except Exception as e: # pragma: no cover - defensive - logger.debug(f"Could not emit context:token_meter event: {e}") - # Summary compaction strategy: trigger an async background # summarization call EARLY (well before compact_threshold, so it has # time to finish -- see module docstring "Summary compaction @@ -726,7 +625,7 @@ async def get_messages_for_request( await self._maybe_trigger_summary_compaction(token_count, effective_budget) # Check if compaction needed (using effective budget with notice reserve deducted) - if self._should_compact(token_count, effective_budget, meter["kind"]): + if self._should_compact(token_count, effective_budget): # Compact EPHEMERALLY - returns new list, working_messages unchanged compacted = await self._compact_ephemeral( effective_budget, working_messages @@ -814,9 +713,9 @@ async def get_messages_for_request( # Strip internal bookkeeping at the module boundary -- everything # above this point (sticky decisions, token accounting) still runs # on messages carrying `_seq`; only what leaves has it removed. - return self._finalize_view(compacted) + return self._strip_internal_metadata(compacted) - return self._finalize_view(working_messages) + return self._strip_internal_metadata(working_messages) # Metadata keys that are internal bookkeeping only and must never cross # the module boundary into a provider-facing view. `_seq` is sticky @@ -890,11 +789,6 @@ async def set_messages(self, messages: list[dict[str, Any]]) -> None: restamped.append({**msg, "metadata": meta}) self.messages = restamped self._next_seq = len(restamped) - # Seqs were just restamped from 0, so any anchor split recorded - # against the OLD numbering is meaningless -- and worse, would - # silently classify restored history as an un-billed tail. Drop it; - # the meter re-anchors on the next llm:response. - self._reset_hybrid_meter_state() self._removed_seqs = set() self._truncated_seqs = set() self._stubbed_seqs = set() @@ -914,7 +808,6 @@ async def clear(self) -> None: self._last_compaction_stats = None self._last_measured_prompt_tokens = None self._last_token_meter_stats = None - self._reset_hybrid_meter_state() self._reset_summary_strategy_state() logger.info("Context cleared") @@ -956,54 +849,10 @@ async def compact(self) -> None: """ pass - def _should_compact( - self, token_count: int, budget: int, kind: str | None = None - ) -> bool: - """Check if context should be compacted. - - `kind` is the PROVENANCE of `token_count` (see METER_KIND_*). It is - only consulted in `token_meter: "hybrid"` mode, where G-METER-PROVENANCE - applies: firing compaction is an irreversible action (it destroys the - provider's prompt cache for at least one request and permanently - records sticky truncate/remove decisions), so it may NOT be taken on a - number the provider never anchored. - - The one deliberate escape, recorded rather than hidden: if NO anchor - has ever arrived this session AND the count has reached 100% of - budget, refusing would guarantee a provider hard-failure on the very - next request, which is strictly worse than acting on an estimate. That - path fires, logs a warning, and is counted separately in - `_provenance_overrides` so a gate reports it honestly instead of it - looking like a clean pass. It cannot mask a guard-rejected anchor: it - requires that no measurement exists at all. - """ + def _should_compact(self, token_count: int, budget: int) -> bool: + """Check if context should be compacted.""" usage = token_count / budget if budget > 0 else 0 should = usage >= self.compact_threshold - if ( - should - and self.token_meter == TOKEN_METER_HYBRID - and kind is not None - and kind != METER_KIND_USAGE - ): - if self._last_measured_prompt_tokens is None and usage >= 1.0: - self._provenance_overrides += 1 - logger.warning( - f"context-simple: token_meter='hybrid' firing compaction on an " - f"UNANCHORED count ({token_count:,} tokens = {usage:.1%} of " - f"budget) because no provider usage has been observed this " - f"session and the count has reached the hard ceiling; " - f"refusing would guarantee a context-overflow failure. " - f"Recorded as a provenance override, not a clean fire." - ) - return True - self._provenance_refusals += 1 - logger.info( - f"context-simple: token_meter='hybrid' REFUSING to fire compaction " - f"on kind={kind!r} ({token_count:,} tokens = {usage:.1%} of budget) " - f"-- G-METER-PROVENANCE: no irreversible action on an un-anchored " - f"number. Waiting for provider-reported usage." - ) - return False if should: logger.info( f"Context at {usage:.1%} capacity ({token_count:,}/{budget:,} tokens), " @@ -1020,19 +869,8 @@ def _exceeds_threshold(self, estimated_tokens: int, budget: int) -> bool: In token_meter="actual" mode with a real measurement available, the REAL measurement decides this, not `estimated_tokens` -- see module docstring "Real-Usage Token Meter" and _measure_working_tokens for - the identical mode/fallback logic. In token_meter="hybrid" mode the - hybrid number decides it, but ONLY when that number is anchored - (kind == 'usage'); an estimated hybrid count falls through to the - estimator branch rather than driving an escalation -- the same - G-METER-PROVENANCE rule _should_compact applies to the outer trigger. - Falls back to `estimated_tokens` in "estimate" mode, or whenever no - real measurement has arrived yet. - - NOTE (unchanged from "actual" mode, and equally true here): only the - GATE uses the anchored number. The SIZING of the reduction -- - target_tokens and every per-level termination check -- is still the - estimator throughout, because a billed token count for a hypothetical - smaller message set does not exist without another round-trip. + the identical mode/fallback logic. Falls back to `estimated_tokens` + in "estimate" mode, or whenever no real measurement has arrived yet. """ if budget <= 0: return False @@ -1041,199 +879,18 @@ def _exceeds_threshold(self, estimated_tokens: int, budget: int) -> bool: and self._last_measured_prompt_tokens is not None ): return (self._last_measured_prompt_tokens / budget) >= self.compact_threshold - if ( - self.token_meter == TOKEN_METER_HYBRID - and self._last_hybrid_tokens is not None - and self._last_hybrid_kind == METER_KIND_USAGE - ): - return (self._last_hybrid_tokens / budget) >= self.compact_threshold return (estimated_tokens / budget) >= self.compact_threshold - def _reset_hybrid_meter_state(self) -> None: - """Drop every hybrid-meter reading. Called from clear() and from - set_messages() (which restamps `_seq` from 0, invalidating any - recorded anchor split). Observability-only in the default - "estimate" mode -- these fields never drive that mode's trigger.""" - self._anchor_seq = None - self._anchor_estimate = None - self._last_sent_estimate = None - self._last_hybrid_tokens = None - self._last_hybrid_kind = METER_KIND_NONE - self._usage_events = 0 - self._usage_events_with_cache = 0 - self._usage_cache_read_total = 0 - self._usage_cache_write_total = 0 - - def _finalize_view(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Strip internal metadata and record the heuristic price of the view - that is actually being SENT. - - That recorded number is the conservatism guard's comparand: when the - next `llm:response` arrives, its provider total describes THIS view, - so "is the provider total below what the heuristic would say for the - same content?" is only a meaningful question against the estimate of - the view that was sent -- not against the full, uncompacted history. - """ - view = self._strip_internal_metadata(messages) - self._last_sent_estimate = self._estimate_tokens(view) - return view - - def _cache_aggregates(self) -> dict[str, int] | None: - """Session cache aggregates, or None if UNDEFINED. - - Refuse-to-guess rule, lifted from deepseek-harness's - `deriveTurnTokenUsage`: an optional aggregate is reported only when - EVERY usage event observed this session reported the underlying - fields. One event missing them makes the aggregate undefined -- a - partial sum that silently under-reports is worse than no number. - """ - if self._usage_events == 0: - return None - if self._usage_events_with_cache != self._usage_events: - return None - return { - "events": self._usage_events, - "cache_read_tokens": self._usage_cache_read_total, - "cache_write_tokens": self._usage_cache_write_total, - } - - def _hybrid_split( - self, working_messages: list[dict[str, Any]] - ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - """Split `working_messages` into (billed prefix, un-billed tail) at - the recorded anchor. - - A message belongs to the tail iff it carries a `_seq` >= `_anchor_seq` - -- i.e. it was appended after the request the provider billed. - Messages with no `_seq` at all (the factory-generated system prompt) - are treated as PREFIX: they were part of the billed request, and - pricing them into the tail on top of an anchor that already contains - them would double-count the single largest block in the window. - """ - if self._anchor_seq is None: - return list(working_messages), [] - prefix: list[dict[str, Any]] = [] - tail: list[dict[str, Any]] = [] - for msg in working_messages: - seq = self._extract_seq(msg) - if seq is not None and seq >= self._anchor_seq: - tail.append(msg) - else: - prefix.append(msg) - return prefix, tail - - def _measure_hybrid( - self, working_messages: list[dict[str, Any]], estimated_tokens: int - ) -> dict[str, Any]: - """Compute the hybrid (provider-anchored + provenance) token count. - - total = provider_reported_total_from_the_last_llm_response - + estimate(items appended since that response) - - This is codex's shape (never price the whole window by heuristic when - the provider has already said what the window cost; heuristic only the - un-billed tail) with deepseek's provenance and conservatism guard - bolted on. - - Returns a dict carrying the number AND its provenance. Computed on - every request in every mode, so the estimate-vs-hybrid-vs-actual - divergence is observable without changing which meter drives the - trigger. - - Guards: - * CONSERVATISM -- if the provider's total is BELOW what the - heuristic priced for the same sent content, the anchor is not - trustworthy as a floor; reject it, report the (larger) full - heuristic, and mark kind='estimated'. Never let a number that may - under-state occupancy authorise an irreversible action. - * REFUSE TO GUESS -- with no anchor at all this is honestly - kind='estimated', not a hybrid number wearing a usage label. - """ - if not working_messages: - return { - "hybrid_tokens": 0, - "hybrid_kind": METER_KIND_NONE, - "anchor_tokens": None, - "anchor_estimate": None, - "anchor_rejected": False, - "tail_estimated_tokens": 0, - "tail_messages": 0, - "reason": "no_messages", - } - - anchor = self._last_measured_prompt_tokens - if anchor is None: - return { - "hybrid_tokens": estimated_tokens, - "hybrid_kind": METER_KIND_ESTIMATED, - "anchor_tokens": None, - "anchor_estimate": None, - "anchor_rejected": False, - "tail_estimated_tokens": None, - "tail_messages": None, - "reason": "no_anchor", - } - - _prefix, tail = self._hybrid_split(working_messages) - tail_estimate = self._estimate_tokens(tail) - - if anchor <= 0: - # A non-positive provider total is not a measurement of anything. - # OBSERVED LIVE, not hypothetical: during this feature's own - # divergence capture a provider returned HTTP 200 with an - # all-zero usage block mid-run (input_tokens=0, output_tokens=0) - # on a ~38k-token request. Trusting that as an anchor would have - # asserted the context was EMPTY. The comparand guard below - # catches it whenever a sent-estimate exists; this catches the - # case where one does not. - return { - "hybrid_tokens": estimated_tokens, - "hybrid_kind": METER_KIND_ESTIMATED, - "anchor_tokens": anchor, - "anchor_estimate": self._anchor_estimate, - "anchor_rejected": True, - "tail_estimated_tokens": tail_estimate, - "tail_messages": len(tail), - "reason": "anchor_non_positive", - } - - if self._anchor_estimate is not None and anchor < self._anchor_estimate: - # Conservatism guard fired: the provider total is smaller than the - # heuristic price of the very content it billed, so it cannot be - # trusted as a floor for this window. - return { - "hybrid_tokens": estimated_tokens, - "hybrid_kind": METER_KIND_ESTIMATED, - "anchor_tokens": anchor, - "anchor_estimate": self._anchor_estimate, - "anchor_rejected": True, - "tail_estimated_tokens": tail_estimate, - "tail_messages": len(tail), - "reason": "anchor_below_heuristic", - } - - return { - "hybrid_tokens": anchor + tail_estimate, - "hybrid_kind": METER_KIND_USAGE, - "anchor_tokens": anchor, - "anchor_estimate": self._anchor_estimate, - "anchor_rejected": False, - "tail_estimated_tokens": tail_estimate, - "tail_messages": len(tail), - "reason": None, - } - def _measure_working_tokens( self, working_messages: list[dict[str, Any]] - ) -> tuple[int, str, int, dict[str, Any]]: - """Return (token_count, source, estimated_tokens, meter) used to - evaluate the compaction trigger this call. + ) -> tuple[int, str, int]: + """Return (token_count, source, estimated_tokens) used to evaluate + the compaction trigger this call. `estimated_tokens` is ALWAYS the len(str)//4 heuristic over - `working_messages` (see _estimate_tokens), and `meter` ALWAYS carries - the hybrid number too -- both computed unconditionally so all three - meters (estimate / actual / hybrid) are observable per request via - `_last_token_meter_stats` regardless of mode. + `working_messages` (see _estimate_tokens) -- computed unconditionally + so the estimator-vs-real-usage drift this meter exists to close is + observable via `_last_token_meter_stats` regardless of mode. `token_count`/`source` are what actually drives `_should_compact`: @@ -1245,41 +902,16 @@ def _measure_working_tokens( `llm:response` (input_tokens + cache_write_tokens -- see `_on_llm_response`), source "measured", if one has arrived this session; otherwise falls back to `estimated_tokens`, source - "estimate". - - token_meter == "hybrid": the provider-anchored total plus a - heuristic price for the un-billed tail, source "hybrid" -- see - `_measure_hybrid`. Its provenance (`kind`) rides along and gates - irreversible actions in `_should_compact`. - - `meter["kind"]` is the provenance of the RETURNED `token_count` (not - of the hybrid number, which is reported separately as `hybrid_kind`) - -- so 100% of counts this module produces carry a provenance, in - every mode. + "estimate" (before the first response of the session, or + whenever hooks/events are unavailable). """ estimated_tokens = self._estimate_tokens(working_messages) - hybrid = self._measure_hybrid(working_messages, estimated_tokens) - self._last_hybrid_tokens = hybrid["hybrid_tokens"] - self._last_hybrid_kind = hybrid["hybrid_kind"] - if ( self.token_meter == TOKEN_METER_ACTUAL and self._last_measured_prompt_tokens is not None ): - meter = {**hybrid, "kind": METER_KIND_USAGE} - return ( - self._last_measured_prompt_tokens, - "measured", - estimated_tokens, - meter, - ) - - if self.token_meter == TOKEN_METER_HYBRID: - meter = {**hybrid, "kind": hybrid["hybrid_kind"]} - return hybrid["hybrid_tokens"], "hybrid", estimated_tokens, meter - - kind = METER_KIND_NONE if not working_messages else METER_KIND_ESTIMATED - meter = {**hybrid, "kind": kind} - return estimated_tokens, "estimate", estimated_tokens, meter + return self._last_measured_prompt_tokens, "measured", estimated_tokens + return estimated_tokens, "estimate", estimated_tokens async def _on_llm_response(self, event: str, data: dict[str, Any]) -> Any: """Hook handler for the canonical `llm:response` event -- records the @@ -1321,20 +953,6 @@ async def _on_llm_response(self, event: str, data: dict[str, Any]) -> Any: if isinstance(input_tokens, int | float): total = int(input_tokens) + int(cache_write_tokens) self._last_measured_prompt_tokens = total - # Hybrid meter bookkeeping (no-op for the trigger unless - # token_meter == "hybrid"; recorded always so the hybrid number - # is observable in every mode -- see _measure_hybrid). - self._anchor_seq = self._next_seq - self._anchor_estimate = self._last_sent_estimate - self._usage_events += 1 - cache_read = usage.get("cache_read_tokens") - cache_write_reported = usage.get("cache_write_tokens") - if isinstance(cache_read, int | float) and isinstance( - cache_write_reported, int | float - ): - self._usage_events_with_cache += 1 - self._usage_cache_read_total += int(cache_read) - self._usage_cache_write_total += int(cache_write_reported) logger.debug( f"context-simple: token_meter recorded real usage from " f"llm:response -- input_tokens={int(input_tokens):,} + " diff --git a/tests/test_token_meter_hybrid.py b/tests/test_token_meter_hybrid.py deleted file mode 100644 index de9c830..0000000 --- a/tests/test_token_meter_hybrid.py +++ /dev/null @@ -1,573 +0,0 @@ -"""Hybrid token meter tests (`token_meter: "hybrid"`). - -The hybrid meter anchors on the provider's OWN reported total from the last -`llm:response` and applies the len(str)//4 heuristic ONLY to items appended -since that anchor (openai/codex's shape), then carries the PROVENANCE of the -resulting number -- `kind` in {'usage','estimated','none'} -- on every count -(deepseek-harness's shape), plus deepseek's conservatism and refuse-to-guess -guards. - -Coverage: - - anchor + un-billed tail is the hybrid number, and the split is by `_seq` - - CONSERVATISM GUARD: an anchor below the heuristic price of the content it - billed is rejected, and the count is honestly marked kind='estimated' - - G-METER-PROVENANCE: an irreversible action (compaction trigger fire) is - REFUSED on kind='estimated', and taken on kind='usage' - - the one recorded escape (no anchor has ever arrived AND the count has hit - the hard ceiling) fires but is counted separately as an override - - REFUSE TO GUESS: cache aggregates are undefined unless EVERY usage event - reported them - - 100% of counts carry a `kind`, in all three modes - - all three meters are computed simultaneously per request in every mode - (the G-METER-DELTA measurement surface) and emitted as - `context:token_meter` - - set_messages() drops a stale anchor split (seqs are restamped from 0) - - "hybrid" is accepted by mount(); unknown values still degrade to - "estimate" -""" - -import pytest -from amplifier_module_context_simple import ( - METER_KIND_ESTIMATED, - METER_KIND_NONE, - METER_KIND_USAGE, - TOKEN_METER_HYBRID, - SimpleContextManager, - mount, -) - -BIG = "lorem ipsum dolor sit amet consectetur " * 20 - - -class _RecordingHooks: - """Minimal hooks stand-in that records register() and emit() calls.""" - - def __init__(self): - self.registered: list[dict] = [] - self.emitted: list[tuple[str, dict]] = [] - - def register(self, event, handler, priority=0, name=None): - entry = {"event": event, "handler": handler, "priority": priority, "name": name} - self.registered.append(entry) - - def unregister(): - self.registered.remove(entry) - - return unregister - - async def emit(self, event, data=None): - self.emitted.append((event, data)) - return None - - -class _FakeCoordinator: - def __init__(self, hooks=None): - self.hooks = hooks - self.mounted: dict[str, object] = {} - - async def mount(self, kind: str, instance: object) -> None: - self.mounted[kind] = instance - - -async def _anchor(context, total: int) -> None: - """Fire a synthetic llm:response carrying `total` as the provider's own - reported prompt usage (input_tokens + cache_write_tokens).""" - await context._on_llm_response( - "llm:response", {"usage": {"input_tokens": total, "cache_write_tokens": 0}} - ) - - -# --------------------------------------------------------------------------- -# The hybrid number: anchor + un-billed tail -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_hybrid_is_provider_anchor_plus_tail_estimate_only(): - """The heuristic prices ONLY what was appended after the anchor -- the - already-billed prefix is the provider's number, not ours.""" - context = SimpleContextManager( - max_tokens=1_000_000, compact_threshold=0.99, token_meter=TOKEN_METER_HYBRID - ) - for i in range(6): - await context.add_message({"role": "user", "content": f"m{i} {BIG}"}) - - # Send once so the meter knows what the heuristic said about the view that - # is about to be billed, then anchor generously above it. - await context.get_messages_for_request() - sent_estimate = context._last_sent_estimate - await _anchor(context, sent_estimate * 4) - - # Two NEW messages arrive after the anchor -- the un-billed tail. - await context.add_message({"role": "user", "content": f"tail-a {BIG}"}) - await context.add_message({"role": "user", "content": f"tail-b {BIG}"}) - tail_estimate = context._estimate_tokens(context.messages[-2:]) - - await context.get_messages_for_request() - stats = context._last_token_meter_stats - - assert stats["hybrid_kind"] == METER_KIND_USAGE - assert stats["anchor_tokens"] == sent_estimate * 4 - assert stats["tail_messages"] == 2 - assert stats["tail_estimated_tokens"] == tail_estimate - assert stats["hybrid_tokens"] == sent_estimate * 4 + tail_estimate - assert stats["used_tokens"] == stats["hybrid_tokens"] - assert stats["source"] == "hybrid" - # The whole point: the hybrid number is NOT the full heuristic. - assert stats["hybrid_tokens"] != stats["estimated_tokens"] - - -@pytest.mark.asyncio -async def test_hybrid_with_no_tail_is_exactly_the_anchor(): - context = SimpleContextManager( - max_tokens=1_000_000, compact_threshold=0.99, token_meter=TOKEN_METER_HYBRID - ) - await context.add_message({"role": "user", "content": BIG}) - await context.get_messages_for_request() - await _anchor(context, context._last_sent_estimate * 3) - - await context.get_messages_for_request() - stats = context._last_token_meter_stats - - assert stats["tail_messages"] == 0 - assert stats["hybrid_tokens"] == stats["anchor_tokens"] - assert stats["kind"] == METER_KIND_USAGE - - -@pytest.mark.asyncio -async def test_hybrid_sums_cache_write_into_the_anchor(): - """Cache-writes are part of context occupancy: a real session reported - input_tokens=2 with cache_write_tokens=161,165 on its first call.""" - context = SimpleContextManager( - max_tokens=1_000_000, compact_threshold=0.99, token_meter=TOKEN_METER_HYBRID - ) - await context.add_message({"role": "user", "content": "hi"}) - await context.get_messages_for_request() - await context._on_llm_response( - "llm:response", {"usage": {"input_tokens": 2, "cache_write_tokens": 161_165}} - ) - - await context.get_messages_for_request() - assert context._last_token_meter_stats["anchor_tokens"] == 161_167 - - -# --------------------------------------------------------------------------- -# Conservatism guard -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_conservatism_guard_rejects_anchor_below_heuristic_price(): - """If the provider total is BELOW what the heuristic priced for the very - content it billed, the anchor is not a trustworthy floor: reject it, - report the larger heuristic, and mark the count kind='estimated'.""" - context = SimpleContextManager( - max_tokens=1_000_000, compact_threshold=0.99, token_meter=TOKEN_METER_HYBRID - ) - for i in range(8): - await context.add_message({"role": "user", "content": f"m{i} {BIG}"}) - await context.get_messages_for_request() - sent_estimate = context._last_sent_estimate - - # Provider says the request cost HALF what the heuristic claimed. - await _anchor(context, sent_estimate // 2) - - await context.get_messages_for_request() - stats = context._last_token_meter_stats - - assert stats["anchor_rejected"] is True - assert stats["hybrid_kind"] == METER_KIND_ESTIMATED - assert stats["kind"] == METER_KIND_ESTIMATED - assert stats["hybrid_tokens"] == stats["estimated_tokens"] - assert stats["anchor_tokens"] == sent_estimate // 2 - assert stats["anchor_estimate"] == sent_estimate - - -@pytest.mark.asyncio -async def test_anchor_at_or_above_heuristic_price_is_accepted(): - context = SimpleContextManager( - max_tokens=1_000_000, compact_threshold=0.99, token_meter=TOKEN_METER_HYBRID - ) - await context.add_message({"role": "user", "content": BIG}) - await context.get_messages_for_request() - await _anchor(context, context._last_sent_estimate) - - await context.get_messages_for_request() - stats = context._last_token_meter_stats - assert stats["anchor_rejected"] is False - assert stats["hybrid_kind"] == METER_KIND_USAGE - - -# --------------------------------------------------------------------------- -# G-METER-PROVENANCE: no irreversible action on kind='estimated' -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_provenance_refuses_compaction_trigger_on_estimated_count(): - """THE gate. In hybrid mode the compaction trigger -- an irreversible - action: it destroys the prompt cache and records sticky truncate/remove - decisions -- must NOT fire on a count the provider never anchored.""" - context = SimpleContextManager( - max_tokens=100_000, - compact_threshold=0.5, - target_usage=0.25, - compaction_notice_enabled=False, - token_meter=TOKEN_METER_HYBRID, - ) - # Grow until the estimator is over threshold but well under the hard - # ceiling, with NO llm:response ever seen -> kind='estimated'. - while context._estimate_tokens(context.messages) < 60_000: - await context.add_message({"role": "user", "content": BIG}) - - await context.get_messages_for_request() - stats = context._last_token_meter_stats - - assert stats["kind"] == METER_KIND_ESTIMATED - assert stats["ratio"] >= 0.5, "gate would be vacuous if not over threshold" - assert stats["ratio"] < 1.0, "must be below the hard-ceiling escape" - assert context._last_compaction_stats is None, ( - "G-METER-PROVENANCE violated: compaction fired on kind='estimated'" - ) - assert context._provenance_refusals == 1 - assert context._provenance_overrides == 0 - - -@pytest.mark.asyncio -async def test_provenance_allows_compaction_trigger_on_anchored_count(): - """The other half of the gate: it must not be vacuous. With the SAME - setup plus a real provider anchor, compaction does fire.""" - context = SimpleContextManager( - max_tokens=100_000, - compact_threshold=0.5, - target_usage=0.25, - compaction_notice_enabled=False, - token_meter=TOKEN_METER_HYBRID, - ) - while context._estimate_tokens(context.messages) < 60_000: - await context.add_message({"role": "user", "content": BIG}) - - await context.get_messages_for_request() # refused (no anchor yet) - assert context._last_compaction_stats is None - await _anchor(context, context._last_sent_estimate + 1) # >= heuristic: accepted - - await context.get_messages_for_request() - stats = context._last_token_meter_stats - - assert stats["kind"] == METER_KIND_USAGE - assert context._last_compaction_stats is not None, ( - "hybrid mode must still compact once the count is provider-anchored" - ) - assert context._provenance_refusals == 1 # the first call only - - -@pytest.mark.asyncio -async def test_provenance_refuses_when_conservatism_guard_rejected_the_anchor(): - """A rejected anchor is kind='estimated' and therefore cannot authorise - the trigger either -- the guard is not a formality.""" - context = SimpleContextManager( - max_tokens=100_000, - compact_threshold=0.5, - target_usage=0.25, - compaction_notice_enabled=False, - token_meter=TOKEN_METER_HYBRID, - ) - while context._estimate_tokens(context.messages) < 60_000: - await context.add_message({"role": "user", "content": BIG}) - - await context.get_messages_for_request() - await _anchor(context, context._last_sent_estimate // 2) # under-states: rejected - - await context.get_messages_for_request() - - assert context._last_token_meter_stats["anchor_rejected"] is True - assert context._last_compaction_stats is None - assert context._provenance_refusals == 2 - assert context._provenance_overrides == 0 - - -@pytest.mark.asyncio -async def test_hard_ceiling_override_fires_but_is_recorded_separately(caplog): - """The one deliberate escape: with NO anchor ever and the count at 100% - of budget, refusing would guarantee a provider hard-failure. It fires -- - and is counted as an override, never as a clean anchored fire.""" - context = SimpleContextManager( - max_tokens=20_000, - compact_threshold=0.5, - target_usage=0.25, - compaction_notice_enabled=False, - token_meter=TOKEN_METER_HYBRID, - ) - while context._estimate_tokens(context.messages) < 21_000: - await context.add_message({"role": "user", "content": BIG}) - - await context.get_messages_for_request() - - assert context._last_token_meter_stats["kind"] == METER_KIND_ESTIMATED - assert context._last_token_meter_stats["ratio"] >= 1.0 - assert context._last_compaction_stats is not None - assert context._provenance_overrides == 1 - assert context._provenance_refusals == 0 - - -@pytest.mark.asyncio -async def test_default_mode_never_refuses_anything(): - """G-METER-PROVENANCE applies to hybrid mode only: the default mode's - trigger is untouched, refusal counters stay at zero.""" - context = SimpleContextManager( - max_tokens=100_000, - compact_threshold=0.5, - target_usage=0.25, - compaction_notice_enabled=False, - ) - while context._estimate_tokens(context.messages) < 60_000: - await context.add_message({"role": "user", "content": BIG}) - - await context.get_messages_for_request() - - assert context._last_compaction_stats is not None - assert context._provenance_refusals == 0 - assert context._provenance_overrides == 0 - - -# --------------------------------------------------------------------------- -# Refuse to guess: optional cache aggregates -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_cache_aggregates_undefined_when_any_event_omitted_them(): - context = SimpleContextManager(token_meter=TOKEN_METER_HYBRID) - await context._on_llm_response( - "llm:response", - {"usage": {"input_tokens": 10, "cache_read_tokens": 5, "cache_write_tokens": 1}}, - ) - assert context._cache_aggregates() is not None - - # One event without the optional fields makes the aggregate undefined -- - # a partial sum that silently under-reports is worse than no number. - await context._on_llm_response("llm:response", {"usage": {"input_tokens": 10}}) - assert context._cache_aggregates() is None - - -@pytest.mark.asyncio -async def test_cache_aggregates_sum_when_every_event_reported_them(): - context = SimpleContextManager(token_meter=TOKEN_METER_HYBRID) - for _ in range(3): - await context._on_llm_response( - "llm:response", - { - "usage": { - "input_tokens": 10, - "cache_read_tokens": 100, - "cache_write_tokens": 7, - } - }, - ) - assert context._cache_aggregates() == { - "events": 3, - "cache_read_tokens": 300, - "cache_write_tokens": 21, - } - - -@pytest.mark.asyncio -async def test_cache_aggregates_undefined_before_any_event(): - assert SimpleContextManager()._cache_aggregates() is None - - -# --------------------------------------------------------------------------- -# Provenance coverage and the three-meter measurement surface -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -@pytest.mark.parametrize("mode", ["estimate", "actual", "hybrid"]) -async def test_every_count_carries_a_kind(mode): - context = SimpleContextManager( - max_tokens=1_000_000, compact_threshold=0.99, token_meter=mode - ) - await context.get_messages_for_request() # empty context - assert context._last_token_meter_stats["kind"] == METER_KIND_NONE - - await context.add_message({"role": "user", "content": BIG}) - await context.get_messages_for_request() - assert context._last_token_meter_stats["kind"] in ( - METER_KIND_USAGE, - METER_KIND_ESTIMATED, - METER_KIND_NONE, - ) - - await _anchor(context, 1_000) - await context.get_messages_for_request() - assert context._last_token_meter_stats["kind"] in ( - METER_KIND_USAGE, - METER_KIND_ESTIMATED, - ) - - -@pytest.mark.asyncio -async def test_all_three_meters_are_computed_in_default_mode(): - """The G-METER-DELTA measurement surface: estimate, actual and hybrid are - all present on every request even in the DEFAULT mode, so the divergence - can be measured without changing which meter drives the trigger.""" - context = SimpleContextManager(max_tokens=1_000_000, compact_threshold=0.99) - await context.add_message({"role": "user", "content": BIG}) - await context.get_messages_for_request() - await _anchor(context, 4_242) - await context.add_message({"role": "user", "content": BIG}) - - await context.get_messages_for_request() - stats = context._last_token_meter_stats - - assert stats["mode"] == "estimate" - assert stats["source"] == "estimate" - assert stats["used_tokens"] == stats["estimated_tokens"] # trigger unchanged - assert stats["measured_tokens"] == 4_242 - assert stats["hybrid_tokens"] == 4_242 + stats["tail_estimated_tokens"] - assert stats["hybrid_kind"] == METER_KIND_USAGE - - -@pytest.mark.asyncio -async def test_token_meter_stats_are_emitted_per_request(): - hooks = _RecordingHooks() - context = SimpleContextManager( - max_tokens=1_000_000, compact_threshold=0.99, hooks=hooks - ) - await context.add_message({"role": "user", "content": BIG}) - await context.get_messages_for_request() - - meter_events = [d for name, d in hooks.emitted if name == "context:token_meter"] - assert len(meter_events) == 1 - for key in ("estimated_tokens", "measured_tokens", "hybrid_tokens", "kind"): - assert key in meter_events[0] - - -@pytest.mark.asyncio -async def test_emit_failure_never_breaks_the_request(): - class _BrokenHooks(_RecordingHooks): - async def emit(self, event, data=None): - raise RuntimeError("hook bus is down") - - context = SimpleContextManager( - max_tokens=1_000_000, compact_threshold=0.99, hooks=_BrokenHooks() - ) - await context.add_message({"role": "user", "content": "hello"}) - view = await context.get_messages_for_request() - assert len(view) == 1 - - -# --------------------------------------------------------------------------- -# Anchor lifecycle -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_set_messages_drops_a_stale_anchor_split(): - """set_messages() restamps every `_seq` from 0, so an anchor split - recorded against the OLD numbering would silently mis-classify restored - history as an un-billed tail. It must be dropped.""" - context = SimpleContextManager( - max_tokens=1_000_000, compact_threshold=0.99, token_meter=TOKEN_METER_HYBRID - ) - for i in range(5): - await context.add_message({"role": "user", "content": f"m{i}"}) - await context.get_messages_for_request() - await _anchor(context, 50_000) - assert context._anchor_seq is not None - - await context.set_messages(await context.get_messages()) - - assert context._anchor_seq is None - assert context._last_measured_prompt_tokens == 50_000 # reading itself survives - await context.get_messages_for_request() - stats = context._last_token_meter_stats - # No anchor split -> everything is prefix, nothing is a tail. - assert stats["tail_messages"] == 0 - - -@pytest.mark.asyncio -async def test_clear_resets_all_hybrid_state(): - context = SimpleContextManager(token_meter=TOKEN_METER_HYBRID) - await context.add_message({"role": "user", "content": BIG}) - await context.get_messages_for_request() - await _anchor(context, 12_345) - - await context.clear() - - assert context._anchor_seq is None - assert context._anchor_estimate is None - assert context._last_sent_estimate is None - assert context._last_measured_prompt_tokens is None - assert context._cache_aggregates() is None - - -# --------------------------------------------------------------------------- -# Config plumbing -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_mount_accepts_hybrid(): - hooks = _RecordingHooks() - coordinator = _FakeCoordinator(hooks) - cleanup = await mount(coordinator, {"token_meter": "hybrid"}) - - context = coordinator.mounted["context"] - assert context.token_meter == TOKEN_METER_HYBRID - assert [e["event"] for e in hooks.registered] == ["llm:response"] - await cleanup() - assert hooks.registered == [] - - -@pytest.mark.asyncio -async def test_unknown_token_meter_still_degrades_to_estimate(caplog): - context = SimpleContextManager(token_meter="hybird") # typo on purpose - assert context.token_meter == "estimate" - - -@pytest.mark.asyncio -async def test_non_positive_anchor_is_never_trusted(): - """A provider total of zero is not a measurement. Observed live during - this feature's own divergence capture: a provider returned HTTP 200 with - an all-zero usage block mid-run on a ~38k-token request. Trusting it - would have asserted the context was empty.""" - context = SimpleContextManager( - max_tokens=1_000_000, compact_threshold=0.99, token_meter=TOKEN_METER_HYBRID - ) - await context.add_message({"role": "user", "content": BIG}) - # No prior send, so there is no comparand for the conservatism guard -- - # this must still be refused on its own merits. - await _anchor(context, 0) - - await context.get_messages_for_request() - stats = context._last_token_meter_stats - - assert stats["anchor_tokens"] == 0 - assert stats["anchor_rejected"] is True - assert stats["hybrid_kind"] == METER_KIND_ESTIMATED - assert stats["hybrid_tokens"] == stats["estimated_tokens"] - - -@pytest.mark.asyncio -async def test_zero_usage_report_after_real_traffic_is_rejected_by_the_guard(): - """The same anomaly, in the shape it was actually observed: several real - responses, then one zero-usage response. The conservatism guard must - reject it rather than let the count collapse to ~zero.""" - context = SimpleContextManager( - max_tokens=1_000_000, compact_threshold=0.99, token_meter=TOKEN_METER_HYBRID - ) - for i in range(4): - await context.add_message({"role": "user", "content": f"m{i} {BIG}"}) - await context.get_messages_for_request() - await _anchor(context, context._last_sent_estimate * 2) - await context.get_messages_for_request() - assert context._last_token_meter_stats["hybrid_kind"] == METER_KIND_USAGE - - await _anchor(context, 0) # provider anomaly - await context.get_messages_for_request() - stats = context._last_token_meter_stats - - assert stats["anchor_rejected"] is True - assert stats["hybrid_kind"] == METER_KIND_ESTIMATED - assert stats["hybrid_tokens"] == stats["estimated_tokens"] From 3b8689d27bf1a9ab59afc822abb9b667ab209d7c Mon Sep 17 00:00:00 2001 From: bkrabach <702425+bkrabach@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:08:38 -0700 Subject: [PATCH 7/7] Revert "feat: compaction_strategy \"summary\" -- LLM rolling-summary compaction (opt-in) (#20)" This reverts commit c6dfbba43a8efb166b07f5255329bb9ed573576a. Merge policy: main carries wins only. The summary compaction strategy's own gate measurement showed a +83% cost regression with summarizer share 26-35%% -- falsified, not a win. Belongs on a branch for evaluation, not on main. --- README.md | 191 ----- amplifier_module_context_simple/__init__.py | 704 +---------------- tests/test_summary_compaction_strategy.py | 791 -------------------- 3 files changed, 1 insertion(+), 1685 deletions(-) delete mode 100644 tests/test_summary_compaction_strategy.py diff --git a/README.md b/README.md index 9a80d85..305c9df 100644 --- a/README.md +++ b/README.md @@ -166,197 +166,6 @@ should happen only after running the module's own eval harness against reduction in compaction cadence (request count / wall time) holds up without a corresponding quality regression. -## Summary compaction strategy (`compaction_strategy`) - -> ### :warning: Opt-in, experimental. Do not enable by default. -> -> Ships behind `compaction_strategy: "progressive"` (default, -> byte-identical to this module's behavior before this feature existed). -> -> A T0/T1 evaluation has now run (n=3 vs. n=5 baselines, S5-CRAC -> scenario). Its two headline results, stated plainly: -> -> - **No retention benefit is demonstrated.** T1 scored 94.0 mean vs. -> T0's 94.4 — statistically and practically indistinguishable, and on a -> metric that is **saturated**: both arms score a perfect 40/40 planted -> constraints and 20/20 post-compaction in *every* run, and have across -> 20+ historical runs. The scenario cannot discriminate retention. This -> is *not* evidence that summaries retain worse — it is the absence of -> evidence either way. A discriminating scenario does not exist yet. -> - **Measured +83% run cost** ($4.73 vs. $2.58) and **+84% compaction -> boundaries** (39.7 vs. 21.6) on a compaction-heavy workload, via a -> boundary-refire loop (see "Known issue" below). This **falsifies** the -> pre-registered prediction that cache economics would land in T0's -> band, in the worse direction. -> -> The mechanism itself is validated and correct (all four pre-registered -> gates pass, including the two the donor design failed catastrophically). -> This flag exists so the strategy can be studied further, not because it -> is known to be better. **Do not enable it by default anywhere.** - -### The idea, and where it comes from - -The progressive ladder above is lossy: once a message is truncated or -removed, that content is gone. `compaction_strategy: "summary"` absorbs the -oldest non-protected span into an LLM-generated rolling summary instead -- -retaining *meaning* at the cost of exact wording, rather than losing the -span outright. - -The IDEAS here -- a structured 5-section summarization prompt, and an -async trigger that fires **early** (well before the hard compaction -threshold, so the LLM call has time to finish off the critical path) -- -are lifted from `amplifier-bundle-context-managed`'s `modules/context-managed/` -rolling summarizer (see that repo's `__init__.py:71-97` for the prompt this -one is adapted from). **All plumbing is rebuilt from scratch** on this -module's own sticky/`_seq` machinery, because a live evaluation of that -donor module *as shipped* found two showstoppers its own 5,890 LOC of tests -never caught: - -1. **It drops a tool call while keeping its result.** Its - `_snap_to_tool_pair_boundary` only checked whether the *immediately - next* message had role `"tool"` -- an adjacency heuristic with no - protected-boundary accounting. In a real multi-turn session this - produced `InvalidRequestError: No tool call found for function call - output` on 29 of 30 turns. -2. **Its summary tiers are `role: "system"`.** The Anthropic provider - hoists every system-role message into the single top-level system - block, so each summary swap rewrote that block and busted the - *system*-prompt cache breakpoint -- not just the conversation-region - one. Measured on a live run: 7 distinct `instructions` hashes across 23 - requests (lengths swinging 44,516 -> 1,113 -> 45,310 tokens), vs. **1** - stable hash for `context-simple`'s own control. - -See `.amplifier/evaluation/treatment-validation/20260901-t4-ctxmanaged/PROBE5-VERDICT.md` -for the full write-up. Neither defect is inherited here: - -- **Tool-pair atomicity**: the absorb boundary is snapped by - `_snap_absorb_boundary`, which reuses the *same* `tool_calls[].id` / - `tool_call_id` identity fields `_check_tool_pair_removable` (above) keys - on -- not an adjacency guess. An assistant `tool_calls` message and every - one of its results are absorbed together, or the whole group is excluded - and left for the next round. Never split. -- **Cache-safe role**: the summary message is `role: "user"`, wrapped in a - `` envelope (so foundation's - `is_real_user_message()` classifies it correctly) -- **never** - `role: "system"`. Unlike the tail compaction notice above, it is **not** - marked `metadata.ephemeral` -- it is meant to persist as stable history. - -### How it's wired into this module's own primitives - -- **Absorption is sticky, not a splice.** Absorbed messages are recorded - via the *existing* `_record_removed()` path -- the exact mechanism - progressive Levels 3/5/7/8 already use -- so `_apply_sticky_decisions()` - replays the absorption byte-identically on every subsequent call. - Candidates are keyed by each message's permanent `_seq`, never by list - index, so there is no "stale boundary" class of bug at all (contrast the - donor's `offset_at_creation` drift-guard, which existed only because its - own design tracked absolute indices in the first place). -- **`self.messages` is still never modified by compaction.** The summary - message is stamped with a fresh `_seq` exactly like `add_message()` - would and *appended* to `self.messages` -- never spliced in at an - earlier position. This keeps this module's core invariant intact, at the - cost of the summary landing wherever `self.messages`' tail happens to be - at swap time (not necessarily immediately after the span it covers) -- - an explicit, disclosed trade-off in exchange for never reordering a - shared, cacheable prefix. -- **Async + fallback.** `summary_trigger` (default `0.60`, an absolute - usage fraction of budget) fires an `asyncio.create_task` off the - critical path, mirroring the donor's early-trigger idea (optionally - driven by the real-usage token meter above when `token_meter: "actual"`). - If the hard compaction threshold is reached and no summary has finished - yet (in flight, failed, timed out, or no provider was ever passed), that - pass falls back to the progressive ladder -- a turn is never blocked and - never fails on a summarizer error. -- **No tier merging in this PR.** Each absorption round produces its own - standalone summary message; a message already carrying - `metadata.type == "context_summary"` is never re-selected for a later - round. `context-managed`'s tier-merging (`_merge_oldest_tiers`) is real - and useful but out of scope here -- see the design mandate for why this - PR keeps scope tight (no custom resume logic, no transcript persistence, - no tool-transcript tool). - -### Configuration - -```toml -[[contexts]] -module = "context-simple" -config = { - compaction_strategy = "summary", # default: "progressive" - summary_trigger = 0.60, # usage fraction that starts the async summarizer - summarization_model = "...", # optional; None uses the provider default - summarization_prompt_path = "...", # optional file override for the 5-section prompt - summarization_timeout_s = 30.0, # provider.complete() timeout before falling back -} -``` - -### What this was built to buy, and what the evaluation actually measured - -**The motivation** was retention: the progressive ladder is lossy, so an -LLM summary that keeps a lossy-but-real account of an absorbed span -*should* retain more than dropping it outright. That was and remains the -reason to build this. It is **not** a cache-cost play — like the -progressive ladder, this strategy still shrinks what the model sees each -turn, which under a grow-only prompt cache is still a cold rebuild of the -shared prefix at the moment of absorption. - -**What the T0/T1 evaluation measured** (n=3 T1 runs vs. n=5 reused T0 -baselines, S5-CRAC scenario, capture root -`.amplifier/evaluation/treatment-validation/20260902-t0t1/`): - -| Gate | Requirement | Result | -|---|---|---| -| **G1 retention** | T1 S5 ≥ T0 band (92–95) | **PASS — but vacuous.** T1 [95, 95, 92] vs. T0 mean 94.4. See note below. | -| **G2 tool-pairs** | zero `InvalidRequestError` | **PASS** — 0 across all runs (the donor design: 29/30 turns failed) | -| **G3 system prompt** | agent `instructions` byte-stable | **PASS** — 1 hash per run, all 3 runs (the `role: "user"` fix holds under a real provider round trip; the donor's agent prompt moved 44,516 → 1,113 → 45,310 within one run) | -| **G4 append-only** | no history re-minting | **PASS** — `ID_ONLY` divergences 0/0/0 | - -> **G1 passed but proves nothing about retention.** `b_constraints` is -> 40/40 and `c_post_compaction` is 20/20 in **every run of both arms**, -> and has been across 20+ historical runs. The only moving part is the -> task score. S5-CRAC is at its ceiling: both strategies already hold -> every planted constraint perfectly, so a "≥ baseline" check against a -> saturated metric is a floor check, not a win. **No retention advantage -> is demonstrated by this evaluation.** Establishing one requires a -> scenario with headroom — one where the progressive baseline measurably -> *loses* constraints. That scenario does not exist yet (TBD). - -Summaries do fire correctly and do carry the material: 63–95 requests per -run carried a summary, all `role: "user"` (zero `role: "system"`), up to -67 messages absorbed in a single round, and the emitted summaries restate -all five planted constraints verbatim. The mechanism works. What is -unproven is that the mechanism *helps*. - -### Known issue: boundary refire roughly doubles compaction cost - -Measured on the same evaluation, and **worse than the pre-registered -prediction** (which expected cache economics in T0's band): - -| Metric | T0 (progressive) | T1 (summary) | Delta | -|---|---:|---:|---:| -| Cache waste | 29.0% | **53.9%** | **+24.9pp** (no overlap between arms) | -| Cache-read share | 0.714 | **0.537** | −0.177 | -| Run cost | $2.58 | **$4.73** | **+83%** | -| Compaction boundaries | 21.6 | **39.7** | **+84%** | - -**Mechanism.** The summarizer itself is only 8–11% of run cost — it is -not the driver. The dominant cost is the near-doubling of compaction -boundaries, and every boundary is a guaranteed cold rebuild against a -grow-only cache. Absorbing a span *shrinks* the request, which pulls -usage back below `summary_trigger` (0.60) sooner, which fires another -absorb/compact cycle sooner: a refire loop. Under an aggressive -compaction config (the evaluation forced `max_tokens: 45000`) the early -trigger — deliberately set well below `compact_threshold` to give the -async call time to finish — drives the extra cycles. - -**Not fixed in this change, tracked honestly.** The obvious levers, none -of which are implemented here: a post-absorb **cooldown** before the -trigger may refire; an **absolute floor** on absorbed-span size so small -absorptions cannot cycle; **hysteresis** on `summary_trigger` (arm at -0.60, disarm only after usage falls below some lower band). Tuning -`summary_trigger` upward is the cheapest first experiment. Any of these -should be validated against the same cost metrics before this flag is -enabled anywhere by default. - ## Dependencies - `amplifier-core>=1.0.0` diff --git a/amplifier_module_context_simple/__init__.py b/amplifier_module_context_simple/__init__.py index 3af9cd3..f5f6396 100644 --- a/amplifier_module_context_simple/__init__.py +++ b/amplifier_module_context_simple/__init__.py @@ -33,57 +33,14 @@ keeps behavior byte-identical to before this feature existed. • Ported from amplifier-module-context-handoff's proven `_on_llm_response` meter. See README "Real-usage token meter" for the full rationale. - -Summary Compaction Strategy (opt-in, default off -- see config -`compaction_strategy`): - • `compaction_strategy: "progressive"` (default) is this module's - existing truncate/remove ladder, completely unchanged -- byte-identical - to before this feature existed. - • `compaction_strategy: "summary"` absorbs the oldest non-protected span - into an LLM-generated rolling summary instead of truncating/removing - it. The IDEAS (structured 5-section prompt, early-async-trigger - design) are lifted from amplifier-bundle-context-managed's rolling - summarizer; ALL plumbing is rebuilt on this module's own sticky/_seq - machinery rather than that donor's index-based splice-and-swap -- see - the "Summary compaction strategy" section in - _select_summary_absorb_seqs/_snap_absorb_boundary/ - _swap_in_pending_summary below for why, and README "Summary - compaction strategy" for the measured donor defects this avoids - (a dropped tool-call/result pair, and a `role: "system"` summary tier - that measurably busted the provider's system-prompt cache breakpoint). - • The summary message is role="user" (never "system"), wrapped in a - `` envelope, and persists as - stable history (not ephemeral, unlike the tail compaction notice). - • MOTIVATED by retention (the progressive ladder is lossy; a summary - keeps a lossy-but-real account of the absorbed span). It is NOT a - cache-cost play: like the progressive ladder, this still shrinks what - the model sees each turn, which under a grow-only cache is still a - cold rebuild at the moment of absorption. - • MEASURED (T0/T1 eval, n=3 vs n=5, S5-CRAC -- see README "Summary - compaction strategy" for the full table): the mechanism is validated - (zero tool-pair errors; agent system prompt byte-stable, 1 hash/run; - append-only) but NO retention benefit is demonstrated -- 94.0 vs 94.4 - on a SATURATED metric (both arms 40/40 constraints, 20/20 - post-compaction, every run). Absence of evidence, not evidence of - parity-by-design; a discriminating scenario does not exist yet. - • KNOWN ISSUE, measured: +83% run cost and +84% compaction boundaries - vs the progressive baseline, via a boundary-refire loop (absorbing a - span shrinks the request below summary_trigger, so it refires - sooner). The summarizer itself is only 8-11% of run cost. Not fixed - here; see README for the candidate levers (cooldown / absolute floor - / trigger hysteresis). OPT-IN, EXPERIMENTAL -- do not enable by - default. """ # Amplifier module metadata __amplifier_module_type__ = "context" -import asyncio -import json import logging from collections.abc import Awaitable, Callable from datetime import UTC, datetime -from pathlib import Path from typing import Any from amplifier_core import ModuleCoordinator @@ -99,60 +56,6 @@ TOKEN_METER_ACTUAL = "actual" _VALID_TOKEN_METERS = (TOKEN_METER_ESTIMATE, TOKEN_METER_ACTUAL) -# compaction_strategy config values. "progressive" (default) preserves the -# existing truncate/remove ladder exactly (byte-identical -- see module -# docstring "Summary compaction strategy"). "summary" opts in to absorbing -# the oldest non-protected span into an LLM-generated rolling summary -# instead of truncating/removing it outright. -COMPACTION_STRATEGY_PROGRESSIVE = "progressive" -COMPACTION_STRATEGY_SUMMARY = "summary" -_VALID_COMPACTION_STRATEGIES = ( - COMPACTION_STRATEGY_PROGRESSIVE, - COMPACTION_STRATEGY_SUMMARY, -) - -# The summary message's envelope source tag and metadata type marker. The -# envelope is what makes foundation's is_real_user_message() classify this -# role="user" message as NOT a real user turn (see module docstring); the -# metadata type marker is how this module recognizes its own past summary -# messages (so they are never re-absorbed into a later summary). -_SUMMARY_ENVELOPE_SOURCE = "context-summary" -_SUMMARY_METADATA_TYPE = "context_summary" - -# Default 5-section summarization prompt, lifted near-verbatim from -# amplifier-bundle-context-managed's modules/context-managed/__init__.py:71-97 -# (the donor's structured summarization prompt -- see README "Summary -# compaction strategy" for full provenance). The donor's two -# `read_transcript` tool references are deliberately dropped: this module -# ships no transcript tool, and pointing an agent at a tool that does not -# exist would be actively misleading. File-overridable via -# `summarization_prompt_path`, mirroring the donor's own -# `summarization_prompt_path` config knob. -DEFAULT_SUMMARIZATION_PROMPT = """\ -Produce a compact summary of the conversation so far. Use the following sections: - -## User Requests & Decisions -List the key requests made by the user and any important decisions reached. - -## Files Examined or Modified -List files that were read, analyzed, or modified during the conversation. - -## Errors Encountered & Resolutions -Describe any errors, failures, or unexpected behavior encountered, and how they were resolved. - -## Current Task State -Describe the current state of work -- what has been completed, what is in progress, and what remains. - -## Key Technical Details -Note any important technical constraints, patterns, configurations, or implementation details -discovered during the conversation. - -## Guidelines -- Be factual and concise. Do not speculate beyond what the conversation contains. -- Preserve numeric values, file paths, error messages, and command outputs exactly. -- Each section may be omitted if there is nothing to report for it. -""" - async def mount(coordinator: ModuleCoordinator, config: dict[str, Any] | None = None): """ @@ -181,29 +84,6 @@ async def mount(coordinator: ModuleCoordinator, config: dict[str, Any] | None = falling back to the estimator before then. An unrecognized value falls back to "estimate" with a logged warning rather than crashing mount(). See module docstring. - - compaction_strategy: "progressive" (default) or "summary". See - module docstring "Summary compaction strategy". An - unrecognized value falls back to "progressive" with a logged - warning rather than crashing mount(). - - summary_trigger: Usage fraction (0.0-1.0) at which the summary - strategy starts an async background summarization call, well - ahead of compact_threshold so it has time to finish before - tokens must actually be shed (default: 0.60). Only consulted - when compaction_strategy == "summary". KNOWN ISSUE: because - absorbing a span shrinks the request back below this - fraction, an aggressive (low) trigger refires sooner and - measurably multiplies compaction boundaries -- +84% - boundaries / +83% run cost in the T0/T1 eval. Raising this - is the cheapest lever; see README "Known issue: boundary - refire". - - summarization_model: Model identifier passed to the summarizer's - ChatRequest (default: None, i.e. provider default). - - summarization_prompt_path: Path to a file overriding - DEFAULT_SUMMARIZATION_PROMPT (default: None). - - summarization_timeout_s: Seconds to wait for the summarizer's - provider.complete() call before treating it as a failure and - falling back to progressive compaction for that pass - (default: 30.0). Returns: Cleanup callable that unregisters the token-meter hook (if one was @@ -220,17 +100,6 @@ async def mount(coordinator: ModuleCoordinator, config: dict[str, Any] | None = ) token_meter = TOKEN_METER_ESTIMATE - compaction_strategy = config.get( - "compaction_strategy", COMPACTION_STRATEGY_PROGRESSIVE - ) - if compaction_strategy not in _VALID_COMPACTION_STRATEGIES: - logger.warning( - f"context-simple: unknown compaction_strategy {compaction_strategy!r} " - f"(expected one of {_VALID_COMPACTION_STRATEGIES!r}); falling back to " - f"{COMPACTION_STRATEGY_PROGRESSIVE!r}" - ) - compaction_strategy = COMPACTION_STRATEGY_PROGRESSIVE - context = SimpleContextManager( max_tokens=config.get("max_tokens", 200_000), compact_threshold=config.get("compact_threshold", 0.92), @@ -246,11 +115,6 @@ async def mount(coordinator: ModuleCoordinator, config: dict[str, Any] | None = compaction_notice_min_level=config.get("compaction_notice_min_level", 1), output_reserve_fraction=config.get("output_reserve_fraction", 0.5), token_meter=token_meter, - compaction_strategy=compaction_strategy, - summary_trigger=config.get("summary_trigger", 0.60), - summarization_model=config.get("summarization_model"), - summarization_prompt_path=config.get("summarization_prompt_path"), - summarization_timeout_s=config.get("summarization_timeout_s", 30.0), hooks=getattr(coordinator, "hooks", None), ) @@ -327,11 +191,6 @@ def __init__( compaction_notice_min_level: int = 1, output_reserve_fraction: float = 0.5, token_meter: str = TOKEN_METER_ESTIMATE, - compaction_strategy: str = COMPACTION_STRATEGY_PROGRESSIVE, - summary_trigger: float = 0.60, - summarization_model: str | None = None, - summarization_prompt_path: str | None = None, - summarization_timeout_s: float = 30.0, hooks: Any = None, ): """ @@ -359,25 +218,6 @@ def __init__( session -- see module docstring "Real-Usage Token Meter"). An unrecognized value falls back to "estimate" with a logged warning rather than raising. - compaction_strategy: "progressive" (default, byte-identical to - pre-existing behavior) or "summary" -- see module docstring - "Summary compaction strategy". An unrecognized value falls - back to "progressive" with a logged warning rather than - raising. - summary_trigger: Usage fraction (0.0-1.0) at which the summary - strategy kicks off an async background summarization call. - Only consulted when compaction_strategy == "summary". - KNOWN ISSUE (measured): a low trigger refires soon after - each absorption shrinks the request, multiplying - compaction boundaries (+84%) and run cost (+83%) -- see - module docstring and README "Known issue: boundary - refire". - summarization_model: Model identifier for the summarizer's own - ChatRequest. None uses the provider's default model. - summarization_prompt_path: Path to a file overriding - DEFAULT_SUMMARIZATION_PROMPT. None uses the built-in prompt. - summarization_timeout_s: Seconds to wait for the summarizer's - provider.complete() call before treating it as a failure. hooks: Optional hooks instance for emitting observability events and (always, when present) recording real usage for the token meter via `llm:response` -- see `_on_llm_response`. @@ -402,28 +242,8 @@ def __init__( ) token_meter = TOKEN_METER_ESTIMATE self.token_meter = token_meter - if compaction_strategy not in _VALID_COMPACTION_STRATEGIES: - logger.warning( - f"context-simple: unknown compaction_strategy {compaction_strategy!r} " - f"(expected one of {_VALID_COMPACTION_STRATEGIES!r}); falling back to " - f"{COMPACTION_STRATEGY_PROGRESSIVE!r}" - ) - compaction_strategy = COMPACTION_STRATEGY_PROGRESSIVE - self.compaction_strategy = compaction_strategy - self.summary_trigger = summary_trigger - self.summarization_model = summarization_model - self.summarization_prompt_path = summarization_prompt_path - self.summarization_timeout_s = summarization_timeout_s self._hooks = hooks self._last_compaction_stats: dict[str, Any] | None = None - # --- Summary compaction strategy state (compaction_strategy == "summary") --- - # Unused, and never touched, in the default "progressive" mode. - self._cached_provider: Any = None - self._is_summarizing: bool = False - self._pending_summary: dict[str, Any] | None = None - self._summarization_failures: int = 0 - self._summarization_task: "asyncio.Task[None] | None" = None - self._summary_absorbed_count: int = 0 # Real-usage token meter state (see _on_llm_response / # _measure_working_tokens). `_last_measured_prompt_tokens` holds the # most recent real usage observed via `llm:response` @@ -549,13 +369,6 @@ async def get_messages_for_request( """ budget = self._calculate_budget(token_budget, provider) - # Summary compaction strategy needs a provider handle to call the - # summarizer -- cache the latest one seen (mirrors how the donor - # module caches it, context-managed:365-367). No-op in the default - # "progressive" mode. - if self.compaction_strategy == COMPACTION_STRATEGY_SUMMARY and provider is not None: - self._cached_provider = provider - # Reserve token budget for potential compaction notice (if enabled) effective_budget = budget if self.compaction_notice_enabled: @@ -616,14 +429,6 @@ async def get_messages_for_request( "ratio": (token_count / effective_budget) if effective_budget > 0 else None, } - # Summary compaction strategy: trigger an async background - # summarization call EARLY (well before compact_threshold, so it has - # time to finish -- see module docstring "Summary compaction - # strategy" and _maybe_trigger_summary_compaction). No-op in the - # default "progressive" mode. Never raises, never blocks this turn. - if self.compaction_strategy == COMPACTION_STRATEGY_SUMMARY: - await self._maybe_trigger_summary_compaction(token_count, effective_budget) - # Check if compaction needed (using effective budget with notice reserve deducted) if self._should_compact(token_count, effective_budget): # Compact EPHEMERALLY - returns new list, working_messages unchanged @@ -794,7 +599,6 @@ async def set_messages(self, messages: list[dict[str, Any]]) -> None: self._stubbed_seqs = set() self._sticky_level = 0 self._last_compaction_stats = None - self._reset_summary_strategy_state() logger.info(f"Restored {len(messages)} messages to context") async def clear(self) -> None: @@ -808,29 +612,8 @@ async def clear(self) -> None: self._last_compaction_stats = None self._last_measured_prompt_tokens = None self._last_token_meter_stats = None - self._reset_summary_strategy_state() logger.info("Context cleared") - def _reset_summary_strategy_state(self) -> None: - """Reset all `compaction_strategy == "summary"` state -- called from - both set_messages() and clear() so a resumed/cleared session never - carries stale in-flight summarization state across the reset. A - no-op in the default "progressive" mode (the fields are simply - never populated in the first place). - - Cancels any in-flight background summarization task rather than - leaving it to run against a context that has just been reset out - from under it. - """ - if self._summarization_task is not None and not self._summarization_task.done(): - self._summarization_task.cancel() - self._cached_provider = None - self._is_summarizing = False - self._pending_summary = None - self._summarization_failures = 0 - self._summarization_task = None - self._summary_absorbed_count = 0 - async def should_compact(self) -> bool: """Check if context should be compacted. @@ -1091,22 +874,6 @@ async def _compact_ephemeral( msg for msg in messages_to_compact if msg.get("role") != "system" ] - # Summary compaction strategy: if a background summarization call - # has completed since the last escalation, absorb its span NOW -- - # before sticky decisions are (re-)applied, so the new removals and - # the new summary message are both visible to this call's - # _apply_sticky_decisions() below. No-op (did_summary_swap stays - # False) in the default "progressive" mode, and a no-op whenever - # compaction_strategy == "summary" but nothing is pending yet. See - # module docstring "Summary compaction strategy". - did_summary_swap = False - if self.compaction_strategy == COMPACTION_STRATEGY_SUMMARY and ( - self._pending_summary is not None - ): - non_system_messages, did_summary_swap = await self._swap_in_pending_summary( - non_system_messages - ) - # UNITS CONVENTION (see the block comment below): every "are we under # target yet?" comparison in this method and its helpers is TOTAL vs # TOTAL. System messages are extracted from `working_messages` but are @@ -1191,7 +958,7 @@ async def _compact_ephemeral( # sizing of that escalation is only as good as the estimator was # before this meter existed. needs_escalation = self._exceeds_threshold(current_tokens, budget) - if not needs_escalation and not did_summary_swap: + if not needs_escalation: # Sticky state alone already keeps us under the threshold that # triggered compaction in the first place -- nothing NEW needs # deciding this call. Return the already-decided view unchanged; @@ -1205,31 +972,6 @@ async def _compact_ephemeral( ) return final_messages - if not needs_escalation: - # Summary swap alone (see above) already brought us back under - # threshold this call -- no progressive level is needed. Still - # route through _finalize_compaction_with_stats (rather than the - # cheap early-return above) so _last_compaction_stats/hooks - # observe that something DID change this call. - # max_level_reached=0 records "no progressive level was needed". - logger.info( - f"Summary compaction alone reached target: {old_count} raw messages, " - f"{old_tokens:,} raw tokens -> {len(working_messages)} messages, " - f"{current_tokens:,} tokens" - ) - return await self._finalize_compaction_with_stats( - working_messages, - system_messages, - old_count, - old_tokens, - 0, - 0, - 0, - 0, - budget, - target_tokens, - ) - logger.info( f"Compacting context (new escalation): {old_count} raw messages, {old_tokens:,} raw tokens " f"-> {len(working_messages)} messages, {current_tokens:,} tokens after sticky state " @@ -2005,11 +1747,6 @@ async def _finalize_compaction_with_stats( "protected_recent": self.protected_recent, "protected_tool_results": self.protected_tool_results, } - if self.compaction_strategy == COMPACTION_STRATEGY_SUMMARY: - # Only surfaced in "summary" mode -- keeps the default - # "progressive" mode's stats dict shape byte-identical to - # before this feature existed. - stats["messages_absorbed_by_summary"] = self._summary_absorbed_count self._last_compaction_stats = stats # Emit event if hooks available @@ -2155,445 +1892,6 @@ def _format_affected_items(self, level: int, stats: dict[str, Any]) -> str: "- If context is critical, consider asking user to clarify their current goal" ) - # ------------------------------------------------------------------ - # Summary compaction strategy (`compaction_strategy: "summary"`) - # ------------------------------------------------------------------ - # - # Opt-in alternative to the progressive truncate/remove ladder above. - # Lifts the IDEAS from amplifier-bundle-context-managed's rolling - # summarizer -- the structured 5-section prompt, the early-async-trigger - # design -- but rebuilds ALL plumbing on this module's own sticky/_seq - # primitives instead of that donor module's index-based splice-and-swap: - # - # - absorbed messages are recorded via _record_removed(), the SAME - # mechanism progressive Levels 3/5/7/8 already use, so - # _apply_sticky_decisions() replays the absorption byte-identically - # on every subsequent call. Candidates are keyed by each message's - # permanent `_seq`, never by list index/offset, so there is no - # "stale boundary" class of bug here at all -- contrast the donor's - # `offset_at_creation` drift-guard, which existed only because ITS - # design tracked absolute indices in the first place. - # - the summary itself is stamped with a fresh `_seq` exactly like - # add_message() would (see _make_summary_message), and is APPENDED - # to self.messages -- never spliced in -- so self.messages remains - # a strict, append-only log and this module's "compaction never - # modifies self.messages" invariant is never violated. - # - the summary is role="user" (never "system"), wrapped in a - # envelope, and is - # NOT marked ephemeral -- it is meant to persist as stable history, - # unlike the (ephemeral, tail-only) compaction notice above. A - # role="system" summary tier is what measurably busted the donor's - # own provider-level system-prompt cache breakpoint (see README - # "Summary compaction strategy" for the measured numbers); this fix - # is non-negotiable, not cosmetic. - # - the absorb boundary is snapped (_snap_absorb_boundary) so an - # assistant tool_calls message and every one of its tool results - # are absorbed together or not at all -- reusing the same - # tool_call_id identity fields _check_tool_pair_removable already - # keys on, not the donor's adjacent-index-only heuristic (the exact - # gap that let the donor split a live call/result pair in practice). - - def _get_summarization_prompt(self) -> str: - """Return the summarization prompt. - - Reads from `summarization_prompt_path` if configured and the file - exists, mirroring the donor's own file-override knob. Falls back to - DEFAULT_SUMMARIZATION_PROMPT on any OSError, logging a warning -- - never raises. - """ - if self.summarization_prompt_path: - try: - return Path(self.summarization_prompt_path).read_text() - except OSError as e: - logger.warning( - f"context-simple: could not read summarization_prompt_path " - f"{self.summarization_prompt_path!r}: {e}; falling back to " - "the built-in DEFAULT_SUMMARIZATION_PROMPT" - ) - return DEFAULT_SUMMARIZATION_PROMPT - - def _format_messages_for_summarization( - self, messages: list[dict[str, Any]] - ) -> str: - """Format messages into the plain-text transcript the summarizer - reads. Each message becomes '[role]: content'; tool results are - linked back to their call via '[tool_result for {tool_call_id}]: - ...'; tool_calls are rendered as '[tool_call: name(args)]' with - arguments truncated to 500 chars. Adapted from the donor's - `_format_messages_for_summarization`. - """ - lines: list[str] = [] - for msg in messages: - role = msg.get("role", "unknown") - content = msg.get("content", "") - if isinstance(content, list): - parts = [] - for block in content: - if isinstance(block, dict): - text = block.get("text", "") - if text: - parts.append(text) - elif hasattr(block, "text"): - parts.append(block.text) - content = "\n".join(parts) - - if role == "tool": - tc_id = msg.get("tool_call_id", "") - line = f"[tool_result for {tc_id}]: {content}" - else: - line = f"[{role}]: {content}" - - extra_lines: list[str] = [] - for tc in msg.get("tool_calls") or []: - if not isinstance(tc, dict): - continue - if "function" in tc: - name = tc["function"].get("name", "unknown_tool") - raw_args = tc["function"].get("arguments", "{}") - else: - name = tc.get("name") or tc.get("tool", "unknown_tool") - raw_args = tc.get("input") or tc.get("arguments") or {} - if isinstance(raw_args, str): - arg_str = raw_args - else: - arg_str = json.dumps(raw_args, separators=(",", ":")) - if len(arg_str) > 500: - arg_str = arg_str[:500] + "..." - extra_lines.append(f" [tool_call: {name}({arg_str})]") - - lines.append("\n".join([line, *extra_lines]) if extra_lines else line) - - return "\n\n".join(lines) - - def _extract_text_from_response(self, response: Any) -> str: - """Join every text-bearing content block in a ChatResponse. Blocks - without a `.text` attribute (tool calls, thinking, etc.) are - silently skipped.""" - parts = [] - for block in getattr(response, "content", None) or []: - text = getattr(block, "text", None) - if text: - parts.append(text) - return "".join(parts) - - async def _maybe_trigger_summary_compaction( - self, token_count: int, effective_budget: int - ) -> None: - """Start an async background summarization call if usage has - crossed `summary_trigger` and nothing is already in flight/pending. - - Mirrors the donor's early-trigger design (default 0.60, well ahead - of compact_threshold) so the LLM call has time to finish before - tokens must actually be shed -- see _swap_in_pending_summary, which - performs the actual absorption once this completes. Never raises, - never blocks this turn: the provider call itself happens inside an - asyncio.create_task, off the critical path. - """ - if self._is_summarizing or self._pending_summary is not None: - return - if self._cached_provider is None: - logger.debug( - "context-simple: skipping summary trigger -- no cached " - "provider yet (first get_messages_for_request() of the " - "session hasn't run, or the caller never passes one)" - ) - return - if effective_budget <= 0: - return - - usage_fraction = token_count / effective_budget - if usage_fraction < self.summary_trigger: - return - - target_tokens = int(effective_budget * self.target_usage) - if token_count <= target_tokens: - return - excess_tokens = token_count - target_tokens - - seqs = self._select_summary_absorb_seqs(excess_tokens) - if not seqs: - logger.debug( - "context-simple: summary trigger fired but nothing eligible " - "to absorb yet (too little non-protected history)" - ) - return - - self._is_summarizing = True - self._summarization_task = asyncio.create_task( - self._run_summary_compaction_task(seqs) - ) - - def _select_summary_absorb_seqs(self, excess_tokens: int) -> list[int] | None: - """Select a prefix of the oldest, still-live, non-protected, - non-system messages to summarize, sized to shed roughly - `excess_tokens`, snapped so a tool_calls/tool-result pair is never - split (_snap_absorb_boundary). Returns the ordered list of `_seq` - ids to absorb, or None if nothing qualifies. - - Excludes: system messages (never compacted, handled separately), - messages already absorbed/removed by a prior escalation, and this - module's own past summary messages (never re-summarized -- each - escalation produces its own standalone summary; see module - docstring for why this PR does not implement tier merging). - """ - live = [ - m - for m in self.messages - if m.get("role") != "system" - and self._extract_seq(m) not in self._removed_seqs - and (m.get("metadata") or {}).get("type") != _SUMMARY_METADATA_TYPE - ] - if not live: - return None - - last_user_idx = None - for i, m in enumerate(live): - if m.get("role") == "user": - last_user_idx = i - - protected_boundary = int(len(live) * (1 - self.protected_recent)) - if last_user_idx is not None: - protected_boundary = min(protected_boundary, last_user_idx) - if protected_boundary <= 0: - return None - - accumulated = 0 - end_idx = 0 - for i in range(protected_boundary): - accumulated += len(str(live[i])) // 4 - end_idx = i + 1 - if accumulated >= excess_tokens: - break - - end_idx = self._snap_absorb_boundary(live, end_idx, protected_boundary) - if end_idx <= 0: - return None - - seqs = [self._extract_seq(m) for m in live[:end_idx]] - return [s for s in seqs if s is not None] or None - - def _snap_absorb_boundary( - self, - live: list[dict[str, Any]], - end_idx: int, - protected_boundary: int, - ) -> int: - """Adjust `end_idx` (an exclusive boundary into `live`) so an - assistant tool_calls message and every one of its tool results are - absorbed together, or not absorbed at all -- and so the boundary - never crosses into the protected tail (index >= protected_boundary). - - Reuses the same identity fields _check_tool_pair_removable keys on - (`tool_calls[].id` / `tool_call_id`), applied to a single - contiguous prefix boundary instead of scattered removal candidates. - This is the fix for the donor's exact production failure: its - `_snap_to_tool_pair_boundary` only checked whether the immediately - NEXT message had role "tool" -- an adjacency heuristic that misses - non-adjacent results and does no protected-boundary accounting at - all, which is how it shipped dropping a `function_call` while - keeping its `function_call_output` (InvalidRequestError, see - README "Summary compaction strategy"). - """ - if end_idx <= 0: - return 0 - - id_map: dict[str, list[int]] = {} - for idx, msg in enumerate(live): - tcid = msg.get("tool_call_id") - if tcid: - id_map.setdefault(tcid, []).append(idx) - - def result_indices(assistant_msg: dict[str, Any]) -> list[int]: - idxs: list[int] = [] - for tc in assistant_msg.get("tool_calls") or []: - tc_id = tc.get("id") or tc.get("tool_call_id") - if tc_id: - idxs.extend(id_map.get(tc_id, [])) - return idxs - - # Bounded fixed-point: each iteration either grows end_idx (capped - # at protected_boundary) or shrinks it to exclude exactly one - # unabsorbable call -- never both for the same call twice -- so - # this always terminates within len(live) iterations. - for _ in range(len(live) + 1): - max_needed = end_idx - overflow_call_idx: int | None = None - for i in range(end_idx): - msg = live[i] - if msg.get("role") == "assistant" and msg.get("tool_calls"): - for k in result_indices(msg): - if k >= max_needed: - max_needed = k + 1 - if k >= protected_boundary and overflow_call_idx is None: - overflow_call_idx = i - if max_needed <= protected_boundary: - return max_needed - # Can't extend past the protected tail without splitting a - # pair -- drop the first offending call (and, transitively, - # everything after it in this round) rather than ever crossing - # the boundary or absorbing a call without its result. - end_idx = overflow_call_idx if overflow_call_idx is not None else 0 - if end_idx <= 0: - return 0 - return 0 # defensive; unreachable given the termination argument above - - async def _run_summary_compaction_task(self, seqs: list[int]) -> None: - """Background task: call the summarizer over the message span - identified by `seqs` and stash the result in `_pending_summary` for - the next get_messages_for_request()/_compact_ephemeral() call to - swap in (_swap_in_pending_summary). - - Never raises: any failure (bad/absent provider, timeout, malformed - response, empty summary) increments `_summarization_failures`, - logs a warning, and leaves `_pending_summary` unset -- the next - compaction pass that needs to shed tokens falls back to the - progressive ladder for that pass, exactly as if - compaction_strategy were "progressive". Always clears - `_is_summarizing`/`_summarization_task` in a finally block so a - failed round never permanently wedges future triggers. - """ - try: - seq_set = set(seqs) - messages_to_summarize = [ - m for m in self.messages if self._extract_seq(m) in seq_set - ] - if not messages_to_summarize: - return - - provider = self._cached_provider - if provider is None: - raise RuntimeError("no cached provider available for summary compaction") - - prompt = self._get_summarization_prompt() - formatted = self._format_messages_for_summarization(messages_to_summarize) - - from amplifier_core import ChatRequest, Message - - request = ChatRequest( - messages=[ - Message(role="system", content=prompt), - Message(role="user", content=formatted), - ], - model=self.summarization_model, - ) - - if self._hooks is not None: - try: - await self._hooks.emit( - "context:pre_summarize", - {"message_count": len(messages_to_summarize)}, - ) - except Exception as e: - logger.warning(f"Could not emit context:pre_summarize: {e}") - - response = await asyncio.wait_for( - provider.complete(request), timeout=self.summarization_timeout_s - ) - summary_text = self._extract_text_from_response(response) - if not summary_text.strip(): - raise ValueError("summarizer returned empty text") - - self._pending_summary = {"seqs": frozenset(seqs), "text": summary_text} - self._summarization_failures = 0 - - if self._hooks is not None: - try: - await self._hooks.emit( - "context:post_summarize", - {"summary_length": len(summary_text)}, - ) - except Exception as e: - logger.warning(f"Could not emit context:post_summarize: {e}") - except Exception as e: - self._summarization_failures += 1 - logger.warning( - f"context-simple: summary compaction failed ({e!r}); falling " - "back to progressive compaction for the next pass that needs " - "to shed tokens" - ) - finally: - self._is_summarizing = False - self._summarization_task = None - - def _make_summary_message(self, summary_text: str) -> dict[str, Any]: - """Build the persisted summary message: role="user" (never - "system" -- see module docstring), wrapped in a - envelope so - foundation's is_real_user_message() classifies it correctly, and - stamped with a fresh `_seq` exactly like add_message() would. - - NOT marked metadata.ephemeral=True: unlike the tail compaction - notice, this message is meant to persist as stable history. - """ - content = ( - f'\n' - f"{summary_text}\n" - "" - ) - message: dict[str, Any] = { - "role": "user", - "content": content, - "metadata": { - "timestamp": datetime.now(UTC).isoformat(timespec="milliseconds"), - "type": _SUMMARY_METADATA_TYPE, - "_seq": self._next_seq, - }, - } - self._next_seq += 1 - return message - - async def _swap_in_pending_summary( - self, non_system_messages: list[dict[str, Any]] - ) -> tuple[list[dict[str, Any]], bool]: - """Absorb a completed pending summary, if it is still valid. - - Validity is checked purely by `_seq` membership -- every captured - seq must still be present in `non_system_messages` and not already - removed by an intervening escalation. There is no index/offset - arithmetic here, so there is no "stale boundary" bug to guard - against beyond this membership check; if the whole span was - already resolved (e.g. an emergency progressive escalation ran in - between), the summary is discarded gracefully -- NOT counted as a - failure -- and the caller falls back to progressive compaction for - this pass, same as if none had been pending. - - Never hand-splices self.messages: absorbed messages are recorded - via _record_removed() (the existing sticky-decision path), and the - summary message is APPENDED to self.messages, never inserted at an - arbitrary position. - - Returns (possibly-updated non_system_messages, did_swap). - """ - pending = self._pending_summary - self._pending_summary = None - - present_seqs = {self._extract_seq(m) for m in non_system_messages} - absorb_seqs = { - s - for s in pending["seqs"] - if s in present_seqs and s not in self._removed_seqs - } - if not absorb_seqs: - logger.info( - "context-simple: discarding stale pending summary -- its " - "absorbed span was already resolved by an earlier escalation" - ) - return non_system_messages, False - - for msg in non_system_messages: - if self._extract_seq(msg) in absorb_seqs: - self._record_removed(msg) - - summary_message = self._make_summary_message(pending["text"]) - self.messages.append(summary_message) - self._summary_absorbed_count += len(absorb_seqs) - - logger.info( - f"context-simple: summary compaction absorbed {len(absorb_seqs)} " - "messages into 1 stable summary message" - ) - return non_system_messages + [summary_message], True - def _calculate_budget(self, token_budget: int | None, provider: Any | None) -> int: """Calculate effective token budget from provider or fallback to config. diff --git a/tests/test_summary_compaction_strategy.py b/tests/test_summary_compaction_strategy.py deleted file mode 100644 index c7a9397..0000000 --- a/tests/test_summary_compaction_strategy.py +++ /dev/null @@ -1,791 +0,0 @@ -"""Adversarial tests for `compaction_strategy: "summary"`. - -This strategy lifts IDEAS from amplifier-bundle-context-managed's rolling -summarizer (the structured 5-section prompt, the early-async-trigger design) -but rebuilds ALL plumbing on this module's own sticky/_seq machinery. The -donor module's own 5,890 LOC of tests missed two showstoppers found by -actually running it (see .amplifier/evaluation/treatment-validation/ -20260901-t4-ctxmanaged/PROBE5-VERDICT.md): - - 1. It drops a `function_call` while keeping its `function_call_output` - (tool-pair atomicity violation) -- `_snap_to_tool_pair_boundary` only - checked adjacency, not actual id-based pairing, and did no - protected-boundary accounting at all. - 2. Its summary tiers are `role: "system"`, which gets hoisted into the - provider's system block and busts the system-prompt cache breakpoint - (measured: 7 distinct instruction hashes across one run vs. 1 for the - control). - -Every test class below is named for the specific failure mode it guards -against, treating the donor's design adversarially rather than assuming its -ideas are safe just because the PROMPT is good. - -Coverage: - - the strategy fires (async, early) and absorbs on swap-in - - absorbed messages are sticky-recorded -- byte-identical re-serialization - across repeated calls, and prefix-stable (append-only) as history grows - - tool_calls/tool_result pairs are NEVER split at the absorb boundary - (the donor's exact production failure), at both the unit - (_snap_absorb_boundary) and integration (full swap) level - - the summary message is role="user", enveloped in - , and NOT ephemeral - - fallback to progressive compaction on summarizer failure/timeout/absent - provider -- a turn is never blocked and never fails - - config validation never crashes on a bad compaction_strategy value - - compaction_strategy="summary" composes with token_meter in both modes - - default ("progressive") mode remains completely untouched -- see also - test_default_mode_byte_identical_with_summary_fields_present below, - which is this file's own contribution to the "existing 76 tests green" - guarantee (the full existing suite is run unmodified against this same - source tree as a separate step). -""" - -import asyncio -import logging - -import pytest -from amplifier_core import ChatResponse, TextBlock -from amplifier_module_context_simple import SimpleContextManager, mount - - -class _FakeProvider: - """Minimal stand-in for a Provider -- just enough of `.complete()` to - drive the summarizer, with knobs for failure/timeout/echo-back testing. - Deliberately has neither `get_model_info` nor `get_info`, so - `_calculate_budget` falls back to `self.max_tokens` (matching how the - rest of this module's test suite avoids needing a real provider).""" - - def __init__(self, response_text: str = "SUMMARY TEXT", delay: float = 0.0, raise_exc: Exception | None = None): - self.response_text = response_text - self.delay = delay - self.raise_exc = raise_exc - self.calls: list = [] - - async def complete(self, request): - self.calls.append(request) - if self.delay: - await asyncio.sleep(self.delay) - if self.raise_exc is not None: - raise self.raise_exc - return ChatResponse(content=[TextBlock(type="text", text=self.response_text)]) - - -class _FakeHooks: - """Minimal stand-in for amplifier_core.hooks.HookRegistry -- records - every emitted event so tests can assert on the summarization lifecycle - without depending on real HookRegistry internals.""" - - def __init__(self): - self.emitted: list[tuple[str, dict]] = [] - - async def emit(self, event, data): - self.emitted.append((event, data)) - - -def _tool_call(call_id: str, tool: str = "bash") -> dict: - return { - "role": "assistant", - "content": "", - "tool_calls": [{"id": call_id, "tool": tool, "arguments": {}}], - } - - -def _tool_result(call_id: str, content: str = "result") -> dict: - return {"role": "tool", "tool_call_id": call_id, "content": content} - - -async def _await_pending_task(context: SimpleContextManager) -> None: - """Wait for an in-flight background summarization task to finish, - grabbing the reference before it gets cleared in the task's own - `finally` block.""" - task = context._summarization_task - assert task is not None, "expected a background summarization task to be in flight" - await task - - -def _strip_timestamps(messages: list[dict]) -> list[dict]: - """Normalize out add_message()'s wall-clock timestamp so byte-stability - comparisons focus on content/structure, not incidental timing.""" - result = [] - for msg in messages: - meta = dict(msg.get("metadata") or {}) - meta.pop("timestamp", None) - result.append({**msg, "metadata": meta}) - return result - - -# --------------------------------------------------------------------------- -# Config validation: never crash on a bad compaction_strategy value -# --------------------------------------------------------------------------- - - -def test_default_compaction_strategy_is_progressive(): - context = SimpleContextManager() - assert context.compaction_strategy == "progressive" - - -def test_invalid_compaction_strategy_falls_back_to_progressive_with_warning(caplog): - with caplog.at_level(logging.WARNING): - context = SimpleContextManager(compaction_strategy="bogus") - - assert context.compaction_strategy == "progressive" - assert any("unknown compaction_strategy" in rec.message for rec in caplog.records) - - -@pytest.mark.asyncio -async def test_mount_invalid_compaction_strategy_falls_back_with_warning(caplog): - class _Coordinator: - def __init__(self): - self.hooks = None - self.mounted = {} - - async def mount(self, kind, instance): - self.mounted[kind] = instance - - coordinator = _Coordinator() - with caplog.at_level(logging.WARNING): - await mount(coordinator, {"compaction_strategy": "bogus"}) - - assert coordinator.mounted["context"].compaction_strategy == "progressive" - assert any("unknown compaction_strategy" in rec.message for rec in caplog.records) - - -@pytest.mark.asyncio -async def test_mount_threads_summary_config_through(): - class _Coordinator: - def __init__(self): - self.hooks = None - self.mounted = {} - - async def mount(self, kind, instance): - self.mounted[kind] = instance - - coordinator = _Coordinator() - await mount( - coordinator, - { - "compaction_strategy": "summary", - "summary_trigger": 0.45, - "summarization_model": "gpt-test", - "summarization_timeout_s": 5.0, - }, - ) - context = coordinator.mounted["context"] - assert context.compaction_strategy == "summary" - assert context.summary_trigger == 0.45 - assert context.summarization_model == "gpt-test" - assert context.summarization_timeout_s == 5.0 - - -# --------------------------------------------------------------------------- -# Default mode ("progressive") is completely untouched -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_default_mode_byte_identical_with_summary_fields_present(): - """A manager with compaction_strategy left at its default must produce - byte-identical output to one explicitly asking for "progressive" -- - the new fields/branches must be true no-ops, never just usually-empty.""" - baseline = SimpleContextManager( - max_tokens=1_000, compact_threshold=0.5, compaction_notice_enabled=False - ) - explicit = SimpleContextManager( - max_tokens=1_000, - compact_threshold=0.5, - compaction_notice_enabled=False, - compaction_strategy="progressive", - ) - for i in range(20): - msg = {"role": "user", "content": f"message {i} " + "x" * 50} - await baseline.add_message(dict(msg)) - await explicit.add_message(dict(msg)) - - baseline_view = await baseline.get_messages_for_request() - explicit_view = await explicit.get_messages_for_request() - - assert _strip_timestamps(baseline_view) == _strip_timestamps(explicit_view) - # Never even glances at a provider or spawns a task in default mode. - assert baseline._cached_provider is None - assert baseline._summarization_task is None - assert baseline._pending_summary is None - - -@pytest.mark.asyncio -async def test_progressive_mode_never_triggers_summarizer_even_with_provider(): - """Passing a provider to get_messages_for_request() in the default - ("progressive") mode must never cache it or touch any summary state -- - those branches are gated on compaction_strategy == "summary" only.""" - context = SimpleContextManager( - max_tokens=200, compact_threshold=0.5, compaction_notice_enabled=False - ) - provider = _FakeProvider() - for i in range(20): - await context.add_message({"role": "user", "content": f"msg {i} " + "x" * 30}) - - await context.get_messages_for_request(provider=provider) - - assert context._cached_provider is None - assert context._is_summarizing is False - assert provider.calls == [] - - -# --------------------------------------------------------------------------- -# Strategy fires (early, async) and absorbs on swap-in -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_summary_strategy_fires_absorbs_and_swaps_in(): - context = SimpleContextManager( - compaction_strategy="summary", - compaction_notice_enabled=False, - protected_recent=0.5, - summary_trigger=0.3, - target_usage=0.2, - compact_threshold=0.99, # keep the outer progressive gate CLOSED for now - max_tokens=1_000_000, # corrected below once real usage is known - ) - for i in range(30): - await context.add_message({"role": "user", "content": f"user turn {i} " + "x" * 40}) - await context.add_message( - {"role": "assistant", "content": f"assistant reply {i} " + "y" * 40} - ) - - raw_tokens = context._estimate_tokens(context.messages) - # Usage sits comfortably above summary_trigger (0.3) but below - # compact_threshold (0.99): the summary trigger should fire; the outer - # progressive gate should not. - context.max_tokens = int(raw_tokens / 0.5) - - provider = _FakeProvider(response_text="COMPACT SUMMARY OF EARLY TURNS") - view1 = await context.get_messages_for_request(provider=provider) - - assert context._is_summarizing is True - assert context._pending_summary is None, "must not resolve synchronously" - assert context._last_compaction_stats is None, "outer gate must stay closed" - assert view1 is not None - - await _await_pending_task(context) - - assert len(provider.calls) == 1 - assert context._pending_summary is not None - assert context._is_summarizing is False - assert context._removed_seqs == set(), "must not absorb until the outer gate actually fires" - - # Now open the outer gate so the pending summary gets swapped in. Set - # comfortably ABOVE target_usage (0.2) so the post-absorption level no - # longer "exceeds threshold" and no progressive level is also needed -- - # but still below the pre-swap ~0.5 usage, so the gate actually opens. - context.compact_threshold = 0.3 - view2 = await context.get_messages_for_request(provider=provider) - - assert context._last_compaction_stats is not None - assert context._last_compaction_stats["strategy_level"] == 0, ( - "summary alone should resolve this pass with no progressive level needed" - ) - assert context._removed_seqs, "absorbed messages must be recorded removed" - - summary_msgs = [ - m - for m in view2 - if (m.get("metadata") or {}).get("type") == "context_summary" - ] - assert len(summary_msgs) == 1 - assert "COMPACT SUMMARY OF EARLY TURNS" in summary_msgs[0]["content"] - - # The absorbed originals must be gone from the served view. - assert not any( - isinstance(m.get("content"), str) and "user turn 0 " in m["content"] for m in view2 - ) - - -@pytest.mark.asyncio -async def test_summary_never_re_absorbs_its_own_past_summary_message(): - """A prior summary message (metadata.type == "context_summary") must - never itself become a candidate for a later absorption round -- this - PR deliberately does not implement tier merging (see module docstring); - each escalation produces its own standalone summary.""" - context = SimpleContextManager(compaction_strategy="summary", protected_recent=0.1) - await context.add_message({"role": "user", "content": "hello"}) - summary_msg = context._make_summary_message("a past summary") - context.messages.append(summary_msg) - for i in range(10): - await context.add_message({"role": "user", "content": f"turn {i} " + "z" * 30}) - - seqs = context._select_summary_absorb_seqs(excess_tokens=10_000) - summary_seq = summary_msg["metadata"]["_seq"] - assert seqs is None or summary_seq not in seqs - - -# --------------------------------------------------------------------------- -# Sticky recording + byte/prefix stability across repeated calls -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_absorbed_span_is_byte_identical_across_repeated_calls(): - """Once absorbed, repeated get_messages_for_request() calls (with no - new pending summary) must reproduce the exact same view -- this is what - _record_removed + _apply_sticky_decisions guarantee by construction.""" - context = SimpleContextManager( - compaction_strategy="summary", - compaction_notice_enabled=False, - protected_recent=0.5, - summary_trigger=0.1, - target_usage=0.05, - compact_threshold=0.05, - max_tokens=1_000_000, - ) - for i in range(20): - await context.add_message({"role": "user", "content": f"turn {i} " + "x" * 40}) - await context.add_message({"role": "assistant", "content": f"reply {i} " + "y" * 40}) - - raw_tokens = context._estimate_tokens(context.messages) - context.max_tokens = int(raw_tokens / 0.9) # comfortably above every threshold above - - provider = _FakeProvider(response_text="STABLE SUMMARY") - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - view_a = await context.get_messages_for_request(provider=provider) - view_b = await context.get_messages_for_request(provider=provider) - - assert _strip_timestamps(view_a) == _strip_timestamps(view_b) - - -@pytest.mark.asyncio -async def test_prefix_is_append_only_as_new_turns_arrive_after_a_swap(): - """After a summary swap, growing the conversation further must only - ever APPEND to the previously-served view, never reorder or rewrite - the shared prefix -- the property prompt caching depends on.""" - context = SimpleContextManager( - compaction_strategy="summary", - compaction_notice_enabled=False, - protected_recent=0.5, - summary_trigger=0.1, - target_usage=0.05, - compact_threshold=0.99, # closed until the summarizer has had time to finish - max_tokens=1_000_000, - ) - for i in range(20): - await context.add_message({"role": "user", "content": f"turn {i} " + "x" * 40}) - await context.add_message({"role": "assistant", "content": f"reply {i} " + "y" * 40}) - - raw_tokens = context._estimate_tokens(context.messages) - context.max_tokens = int(raw_tokens / 0.9) - - provider = _FakeProvider(response_text="STABLE SUMMARY") - await context.get_messages_for_request(provider=provider) # fires the trigger only - await _await_pending_task(context) - - # Open the outer gate (target_usage=0.05 leaves ample headroom under it, - # so growing by one small message afterward will NOT re-open escalation). - context.compact_threshold = 0.3 - view_before = _strip_timestamps(await context.get_messages_for_request(provider=provider)) - assert context._last_compaction_stats is not None # the swap actually ran - - await context.add_message({"role": "user", "content": "one more turn"}) - view_after = _strip_timestamps(await context.get_messages_for_request(provider=provider)) - - assert view_after[: len(view_before)] == view_before - assert view_after[len(view_before) :] == [ - {"role": "user", "content": "one more turn", "metadata": {}} - ] - - -# --------------------------------------------------------------------------- -# Tool-pair atomicity at the absorb boundary -- the donor's exact failure -# --------------------------------------------------------------------------- - - -class TestSnapAbsorbBoundaryUnit: - """Direct unit tests for _snap_absorb_boundary -- the fix for the - donor's `_snap_to_tool_pair_boundary`, which only checked adjacency and - did no protected-boundary accounting, and in production dropped a - `function_call` while keeping its `function_call_output`.""" - - def test_extends_to_include_a_straddling_result(self): - context = SimpleContextManager(compaction_strategy="summary") - live = [ - {"role": "user", "content": "u0"}, - _tool_call("call_1"), - _tool_result("call_1"), - {"role": "user", "content": "u1"}, - ] - # end_idx=2 would include the call but exclude its own result. - assert context._snap_absorb_boundary(live, 2, protected_boundary=4) == 3 - - def test_extends_to_include_a_non_adjacent_straggler_result(self): - """The donor's adjacency-only heuristic misses this: the result is - not the message immediately following the call.""" - context = SimpleContextManager(compaction_strategy="summary") - live = [ - _tool_call("call_1"), - {"role": "assistant", "content": "unrelated narration"}, - _tool_result("call_1"), - ] - assert context._snap_absorb_boundary(live, 1, protected_boundary=3) == 3 - - def test_shrinks_to_exclude_a_call_whose_result_is_protected(self): - """When extending would cross into the protected tail, the whole - pair must be excluded -- never split, never absorbed partially.""" - context = SimpleContextManager(compaction_strategy="summary") - live = [ - {"role": "user", "content": "u0"}, - _tool_call("call_1"), - _tool_result("call_1"), - {"role": "user", "content": "u1"}, - ] - # protected_boundary=2: the result at index 2 is already protected. - assert context._snap_absorb_boundary(live, 2, protected_boundary=2) == 1 - - def test_multiple_results_one_straddling_excludes_the_whole_call(self): - context = SimpleContextManager(compaction_strategy="summary") - live = [ - { - "role": "assistant", - "content": "", - "tool_calls": [{"id": "a", "tool": "x", "arguments": {}}, {"id": "b", "tool": "y", "arguments": {}}], - }, - _tool_result("a"), - _tool_result("b"), - ] - # protected_boundary=2 protects the second result (index 2) -> the - # whole call (and its first, otherwise-includable result) must go. - assert context._snap_absorb_boundary(live, 2, protected_boundary=2) == 0 - - def test_clean_pair_within_bounds_is_unchanged(self): - context = SimpleContextManager(compaction_strategy="summary") - live = [_tool_call("call_1"), _tool_result("call_1"), {"role": "user", "content": "u1"}] - assert context._snap_absorb_boundary(live, 2, protected_boundary=3) == 2 - - def test_zero_boundary_returns_zero(self): - context = SimpleContextManager(compaction_strategy="summary") - assert context._snap_absorb_boundary([], 0, protected_boundary=0) == 0 - - -@pytest.mark.asyncio -async def test_integration_tool_pair_never_split_at_absorb_boundary(): - """End-to-end: a tool_calls/tool_result pair sitting right at the - natural absorb boundary must be absorbed (or not) as an atomic unit -- - never left with one half served and the other half gone, which is - exactly the InvalidRequestError the donor shipped in production.""" - context = SimpleContextManager( - compaction_strategy="summary", - compaction_notice_enabled=False, - protected_recent=0.3, - summary_trigger=0.1, - target_usage=0.05, - compact_threshold=0.05, - max_tokens=1_000_000, - ) - for i in range(10): - await context.add_message({"role": "user", "content": f"turn {i} " + "x" * 60}) - await context.add_message({"role": "assistant", "content": f"reply {i} " + "y" * 60}) - # A tool pair positioned squarely in what will become the absorb - # candidate pool (well before the protected tail). - await context.add_message(_tool_call("straddle_call")) - await context.add_message(_tool_result("straddle_call", "tool output payload")) - for i in range(10, 20): - await context.add_message({"role": "user", "content": f"turn {i} " + "x" * 60}) - await context.add_message({"role": "assistant", "content": f"reply {i} " + "y" * 60}) - - raw_tokens = context._estimate_tokens(context.messages) - context.max_tokens = int(raw_tokens / 0.9) - - provider = _FakeProvider(response_text="SUMMARY") - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - view = await context.get_messages_for_request(provider=provider) - - has_call = any(m.get("role") == "assistant" and m.get("tool_calls") for m in view) - has_result = any(m.get("role") == "tool" for m in view) - assert has_call == has_result, ( - f"tool pair split at the absorb boundary! has_call={has_call} has_result={has_result}" - ) - if has_call: - for i, m in enumerate(view): - if m.get("role") == "assistant" and m.get("tool_calls"): - assert i + 1 < len(view) and view[i + 1].get("role") == "tool" - - -# --------------------------------------------------------------------------- -# Summary message shape: role=user, enveloped, NOT ephemeral -# --------------------------------------------------------------------------- - - -def test_summary_message_is_user_role_enveloped_and_non_ephemeral(): - context = SimpleContextManager(compaction_strategy="summary") - msg = context._make_summary_message("the summary body") - - assert msg["role"] == "user", "must never be role=system -- see module docstring" - assert msg["content"].startswith('') - assert msg["content"].endswith("") - assert "the summary body" in msg["content"] - assert msg["metadata"]["type"] == "context_summary" - assert "ephemeral" not in msg["metadata"], ( - "must NOT be marked ephemeral -- it is meant to persist as stable history" - ) - assert "_seq" in msg["metadata"] - - -def test_summary_message_gets_a_fresh_seq_like_add_message_would(): - context = SimpleContextManager(compaction_strategy="summary") - before = context._next_seq - msg = context._make_summary_message("text") - assert msg["metadata"]["_seq"] == before - assert context._next_seq == before + 1 - - -# --------------------------------------------------------------------------- -# Fallback to progressive compaction: failure / timeout / no provider -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_fallback_to_progressive_on_summarizer_exception(): - context = SimpleContextManager( - compaction_strategy="summary", - compaction_notice_enabled=False, - protected_recent=0.3, - summary_trigger=0.1, - target_usage=0.2, - compact_threshold=0.1, - max_tokens=1_000_000, - ) - for i in range(20): - await context.add_message({"role": "user", "content": f"turn {i} " + "x" * 40}) - await context.add_message({"role": "assistant", "content": f"reply {i} " + "y" * 40}) - - raw_tokens = context._estimate_tokens(context.messages) - context.max_tokens = int(raw_tokens / 0.9) - - provider = _FakeProvider(raise_exc=RuntimeError("summarizer is down")) - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - assert context._pending_summary is None - assert context._summarization_failures == 1 - - # A second call must still make forward progress via the progressive - # ladder -- never blocked, never raised, even though summarization just - # failed. - view2 = await context.get_messages_for_request(provider=provider) - assert context._last_compaction_stats is not None - assert context._last_compaction_stats["strategy_level"] >= 1 - assert len(view2) <= len(context.messages) - - -@pytest.mark.asyncio -async def test_fallback_to_progressive_on_summarizer_timeout(): - context = SimpleContextManager( - compaction_strategy="summary", - compaction_notice_enabled=False, - protected_recent=0.3, - summary_trigger=0.1, - target_usage=0.2, - compact_threshold=0.1, - max_tokens=1_000_000, - summarization_timeout_s=0.01, - ) - for i in range(20): - await context.add_message({"role": "user", "content": f"turn {i} " + "x" * 40}) - await context.add_message({"role": "assistant", "content": f"reply {i} " + "y" * 40}) - - raw_tokens = context._estimate_tokens(context.messages) - context.max_tokens = int(raw_tokens / 0.9) - - provider = _FakeProvider(delay=1.0) # far longer than summarization_timeout_s - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - assert context._pending_summary is None - assert context._summarization_failures == 1 - - view2 = await context.get_messages_for_request(provider=provider) - assert context._last_compaction_stats is not None - assert context._last_compaction_stats["strategy_level"] >= 1 - assert len(view2) <= len(context.messages) - - -@pytest.mark.asyncio -async def test_fallback_to_progressive_when_no_provider_ever_passed(): - """compaction_strategy="summary" but the caller never passes a - provider (e.g. a code path that doesn't support it yet) -- must behave - exactly like progressive compaction, never raise, never hang.""" - context = SimpleContextManager( - compaction_strategy="summary", - max_tokens=100, - compact_threshold=0.5, - compaction_notice_enabled=False, - ) - for i in range(20): - await context.add_message({"role": "user", "content": f"msg {i}"}) - await context.add_message({"role": "assistant", "content": f"reply {i} " + "z" * 20}) - - view = await context.get_messages_for_request() # provider=None (default) - - assert context._cached_provider is None - assert context._is_summarizing is False - assert context._last_compaction_stats is not None - assert "messages_absorbed_by_summary" in context._last_compaction_stats - assert context._last_compaction_stats["messages_absorbed_by_summary"] == 0 - assert len(view) < len(context.messages) - - -@pytest.mark.asyncio -async def test_never_triggers_twice_while_one_is_already_in_flight(): - context = SimpleContextManager( - compaction_strategy="summary", - compaction_notice_enabled=False, - protected_recent=0.5, - summary_trigger=0.1, - target_usage=0.05, - compact_threshold=0.99, - max_tokens=1_000_000, - ) - for i in range(20): - await context.add_message({"role": "user", "content": f"turn {i} " + "x" * 40}) - await context.add_message({"role": "assistant", "content": f"reply {i} " + "y" * 40}) - raw_tokens = context._estimate_tokens(context.messages) - context.max_tokens = int(raw_tokens / 0.5) - - provider = _FakeProvider(delay=0.05) - await context.get_messages_for_request(provider=provider) - assert context._is_summarizing is True - await asyncio.sleep(0) # let the task actually start running (still in-flight, delay=0.05) - await context.get_messages_for_request(provider=provider) - await context.get_messages_for_request(provider=provider) - assert context._is_summarizing is True, "still in flight -- the delay hasn't elapsed yet" - - await _await_pending_task(context) - assert len(provider.calls) == 1, "must not fire a second concurrent summarization call" - - -@pytest.mark.asyncio -async def test_stale_pending_summary_discarded_gracefully_not_a_failure(): - """If the absorbed span was already fully resolved by an intervening - escalation before the swap runs, the pending summary must be discarded - quietly -- NOT counted as a failure, and NOT crash.""" - context = SimpleContextManager(compaction_strategy="summary", protected_recent=0.9) - for i in range(5): - await context.add_message({"role": "user", "content": f"turn {i}"}) - - seqs = [m["metadata"]["_seq"] for m in context.messages[:2]] - context._pending_summary = {"seqs": frozenset(seqs), "text": "irrelevant"} - # Simulate an intervening escalation that already removed these seqs. - for s in seqs: - context._removed_seqs.add(s) - - non_system = [m for m in context.messages if m.get("role") != "system"] - result, did_swap = await context._swap_in_pending_summary(non_system) - - assert did_swap is False - assert result == non_system - assert context._pending_summary is None - assert context._summarization_failures == 0 - - -# --------------------------------------------------------------------------- -# Composes with the real-usage token meter, both modes -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_summary_strategy_works_with_token_meter_estimate_mode(): - """token_meter left at its default ("estimate"): the summary trigger - must be driven by the same estimator the outer gate uses -- no crash, - no divergence in which signal feeds which check.""" - context = SimpleContextManager( - compaction_strategy="summary", - token_meter="estimate", - compaction_notice_enabled=False, - protected_recent=0.5, - summary_trigger=0.3, - target_usage=0.2, - compact_threshold=0.99, - max_tokens=1_000_000, - ) - for i in range(20): - await context.add_message({"role": "user", "content": f"turn {i} " + "x" * 40}) - await context.add_message({"role": "assistant", "content": f"reply {i} " + "y" * 40}) - raw_tokens = context._estimate_tokens(context.messages) - context.max_tokens = int(raw_tokens / 0.5) - - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - - assert context._last_token_meter_stats["mode"] == "estimate" - assert context._is_summarizing is True - await _await_pending_task(context) - - -@pytest.mark.asyncio -async def test_summary_strategy_works_with_token_meter_actual_mode(): - """token_meter="actual": a real llm:response measurement, once - observed, must be able to drive the EARLY summary trigger too (not just - the outer progressive gate) -- _maybe_trigger_summary_compaction reuses - the same already-meter-aware `token_count`.""" - context = SimpleContextManager( - compaction_strategy="summary", - token_meter="actual", - compaction_notice_enabled=False, - protected_recent=0.5, - summary_trigger=0.3, - target_usage=0.2, - compact_threshold=0.99, - max_tokens=100_000, - ) - for i in range(6): - await context.add_message({"role": "user", "content": f"msg {i}"}) - await context.add_message({"role": "assistant", "content": f"reply {i}"}) - - # Estimator alone would NOT cross summary_trigger (0.3) -- real usage - # must be what drives the trigger here. - estimate = context._estimate_tokens(context.messages) - assert estimate / 100_000 < 0.3 - - await context._on_llm_response("llm:response", {"usage": {"input_tokens": 40_000}}) - - provider = _FakeProvider() - await context.get_messages_for_request(provider=provider) - - assert context._last_token_meter_stats["source"] == "measured" - assert context._is_summarizing is True, ( - "the real measurement (40_000/100_000 = 0.4) should have crossed " - "summary_trigger (0.3) even though the estimator alone would not" - ) - await _await_pending_task(context) - - -# --------------------------------------------------------------------------- -# Observability: hook events fire around the summarization lifecycle -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_hooks_receive_pre_and_post_summarize_events(): - hooks = _FakeHooks() - context = SimpleContextManager( - compaction_strategy="summary", - compaction_notice_enabled=False, - protected_recent=0.5, - summary_trigger=0.3, - target_usage=0.2, - compact_threshold=0.99, - max_tokens=1_000_000, - hooks=hooks, - ) - for i in range(20): - await context.add_message({"role": "user", "content": f"turn {i} " + "x" * 40}) - await context.add_message({"role": "assistant", "content": f"reply {i} " + "y" * 40}) - raw_tokens = context._estimate_tokens(context.messages) - context.max_tokens = int(raw_tokens / 0.5) - - provider = _FakeProvider(response_text="SUMMARY") - await context.get_messages_for_request(provider=provider) - await _await_pending_task(context) - - events = [name for name, _ in hooks.emitted] - assert "context:pre_summarize" in events - assert "context:post_summarize" in events