context: summarize-on-evict callback + summary block (Phase 5 commit #2)

Phase 5 commit #2 per docs/PHASE5.md §3 / §6.

Context.new opts additions:
  - summarize_fn(prior_summary, evicted_turns) -> string|nil callback
    per R-B1 canonical signature:
      (nil, [turns])  → first-time summarize
      (str, [turns])  → additive: extend prior summary
      (str, nil)      → compress: re-summarize the prior
    nil return → silent eviction (Phase 0 behavior preserved)
  - max_summary_chars (default 2000) — when ctx.summary grows past
    this, the callback is invoked AGAIN with the compress signal
    so the summary stays bounded across long sessions

Context.summary (string|nil) is the rolling summary state. Composed
into the SYSTEM MESSAGE (not as a turns[] entry — A3 resolution
avoids system/system back-to-back). compose_summary() emits:

  [earlier conversation summary]
  <ctx.summary>

between [background] and the NORRIS suffix. Both [background] and
[earlier summary] are SUPPRESSED when ctx.norris_active (R-C4 —
mirrors R-C1 from Phase 4; planner stays focused on its goal).

enforce_budget() rewrite:
  - Collects the evicted pair before removing.
  - Calls summarize_fn(self.summary, pair) under pcall — wraps any
    callback error so a broken summarizer can't crash the REPL.
  - Updates self.summary if callback returned non-empty string.
  - If new summary exceeds max_summary_chars, invokes compress pass
    (callback with evicted=nil).
  - Removes pair from turns (same final state as Phase 0).

Context:reset() clears the summary alongside turns + pending_exec_output.

Smoke-tested with a mock summarizer over a 10-turn context with
max_turns=4 and max_summary_chars=80:
  - 6 turns evicted to bring count down to 4
  - Callback fired 4 times (3 additive + 1 compress when summary
    crossed 80 chars)
  - to_messages includes [earlier conversation summary] block
  - Under norris_active=true, summary suppressed (block absent)
  - :reset clears ctx.summary

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 11:18:37 +00:00
parent 3e57824684
commit 03497b5eea
+55 -7
View File
@@ -45,6 +45,17 @@ function M.new(opts)
-- Default true per PHASE2.md §12 "Q18 default"; flip from caller.
use_tool_role = (opts.use_tool_role == nil) and true
or opts.use_tool_role,
-- Phase 5: summarize-on-evict. When set, enforce_budget calls
-- summarize_fn(prior_summary, evicted_turns) -> string | nil
-- and updates ctx.summary instead of silently dropping turns.
-- Callback contract per PHASE5.md R-B1:
-- (nil, [turns]) → first-time summarize
-- (str, [turns]) → additive: extend prior summary with new turns
-- (str, nil) → compress: re-summarize the prior summary
-- Returns nil → fall back to silent eviction (Phase 0 behavior).
summarize_fn = opts.summarize_fn,
summary = nil, -- rolling summary string
max_summary_chars = opts.max_summary_chars or 2000,
}, Context)
end
@@ -152,6 +163,14 @@ local function compose_background(items)
return table.concat(lines, "\n")
end
-- Phase 5 R-C4: summary block composer. Mirrors the [background]
-- pattern; suppressed under Norris (callers already guard, but the
-- function returns "" for empty input regardless).
local function compose_summary(summary_text)
if not summary_text or summary_text == "" then return "" end
return "\n\n[earlier conversation summary]\n" .. summary_text
end
-- Phase 3: NORRIS MODE suffix appended to the system prompt when
-- self.norris_active. Carries self.norris_goal so eviction of the
-- user's "[norris] goal: ..." turn doesn't lose the anchor.
@@ -181,10 +200,13 @@ message if they declined.]]
function Context:to_messages()
local sys_content = self.system_prompt
-- Phase 4 [background] memory block. Suppressed during Norris (R-C1
-- — avoid ~16K of redundant tokens per planning iteration).
-- Phase 4 [background] memory block + Phase 5 [earlier summary]
-- block. Both suppressed during Norris (R-C1 / R-C4 — avoid
-- redundant tokens per planning iteration; planner stays focused
-- on its goal anchor).
if not self.norris_active then
sys_content = sys_content .. compose_background(self.memory_items)
sys_content = sys_content .. compose_summary(self.summary)
end
-- Phase 3 NORRIS MODE suffix. Last block so its instructions dominate.
if self.norris_active and self.norris_goal then
@@ -271,13 +293,38 @@ end
function Context:enforce_budget()
local evicted = 0
while #self.turns > self.max_turns do
-- Collect evicted slice (pair: user + assistant)
local pair = {}
pair[#pair + 1] = self.turns[1]
if #self.turns >= 2 then pair[#pair + 1] = self.turns[2] end
-- Phase 5: ask the summarize callback (if wired) to absorb this
-- slice into the rolling summary. Callback contract per R-B1:
-- summarize_fn(prior_summary, evicted_turns) -> string | nil
-- nil return → silent eviction (Phase 0 behavior).
if self.summarize_fn then
local ok, new_summary = pcall(self.summarize_fn, self.summary, pair)
if ok and type(new_summary) == "string" and new_summary ~= "" then
self.summary = new_summary
-- R-C1: if grown past cap, compress in a second pass.
if #self.summary > self.max_summary_chars then
local ok2, compressed = pcall(self.summarize_fn,
self.summary, nil)
if ok2 and type(compressed) == "string"
and compressed ~= "" then
self.summary = compressed
end
end
end
end
-- Remove the pair from turns (matches Phase 0 visible effect)
table.remove(self.turns, 1)
evicted = evicted + 1
if #self.turns > self.max_turns or evicted % 2 == 1 then
if #self.turns > 0 then
table.remove(self.turns, 1)
evicted = evicted + 1
end
if #self.turns > 0 and (#self.turns > self.max_turns
or evicted % 2 == 1) then
table.remove(self.turns, 1)
evicted = evicted + 1
end
end
return evicted
@@ -296,6 +343,7 @@ end
function Context:reset()
self.turns = {}
self.pending_exec_output = nil
self.summary = nil
end
return M