From 03497b5eea4fe7fd8ab1297f759a83e30d40c843 Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Wed, 13 May 2026 11:18:37 +0000 Subject: [PATCH] context: summarize-on-evict callback + summary block (Phase 5 commit #2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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] 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) --- context.lua | 62 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/context.lua b/context.lua index 01ea1ee..2c14e91 100644 --- a/context.lua +++ b/context.lua @@ -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