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