lua-agent-lab / context.lua
jeorgexyz's picture
Publish reproducible Lua agent experiments and traces
98acb70 verified
Raw History Blame Contribute Delete
9.25 kB
-- context.lua - Token budget and eviction.
--
-- Every minimal-agent tutorial estimates context as #chars/4 and calls it a
-- day. We have a real BPE tokenizer next door, so we count for real and print
-- the ledger. Knowing exactly what is in the window is most of context
-- management; the eviction policy is the small part.
--
-- The rule that matters more than the choice of policy: WHEN SOMETHING IS
-- DROPPED, THE MODEL IS TOLD. Silent truncation is the most common context
-- bug in real agents, and its signature is a model that confidently invents
-- the contents of an observation it can no longer see. A model that knows it
-- forgot something asks again.
local protocol = require('protocol')
local context = {}
local Context = {}
Context.__index = Context
-- tokenizer: anything with :encode(text) -> {ids}
-- budget: hard ceiling in tokens
-- policy: key of context.policies, default "elide_observations"
function context.new(tokenizer, budget, policy)
if not tokenizer or not tokenizer.encode then
error("context.new: needs a tokenizer with :encode")
end
local name = policy or "elide_observations"
if not context.policies[name] then
error("context.new: unknown policy " .. tostring(name))
end
return setmetatable({
tokenizer = tokenizer,
budget = budget or 4096,
policy = name,
cache = {},
cache_n = 0,
}, Context)
end
-- Price a string in real tokens. Memoized, because the system preamble and
-- tool catalogue would otherwise be re-tokenized on every single step -- and
-- BPE encoding is not cheap in pure Lua.
function Context:count(text)
if text == nil or text == "" then return 0 end
local hit = self.cache[text]
if hit then return hit end
local n = #self.tokenizer:encode(text)
-- Bounded cache: observations are unique and would otherwise grow this
-- without limit over a long run.
if self.cache_n > 512 then self.cache, self.cache_n = {}, 0 end
self.cache[text] = n
self.cache_n = self.cache_n + 1
return n
end
function Context:count_steps(steps)
return self:count(protocol.render_transcript(steps))
end
-- The ledger: what is actually in the window right now.
-- main.lua prints this each step under --verbose, and it is the single most
-- useful thing to look at when an agent misbehaves.
function Context:ledger(system, steps)
local sys = self:count(system)
local hist = self:count_steps(steps)
return {
system = sys,
transcript = hist,
total = sys + hist,
budget = self.budget,
headroom = self.budget - (sys + hist),
policy = self.policy,
}
end
function Context:format_ledger(l)
return string.format(
" context: system %d + transcript %d = %d / %d tokens (%d free, %s)",
l.system, l.transcript, l.total, l.budget, l.headroom, l.policy)
end
-- Fit `steps` into the budget left after fixed costs.
-- Returns kept_steps, note. The note is appended to the transcript so the
-- model learns it lost information rather than hallucinating around the gap.
function Context:fit(fixed_tokens, steps)
local available = self.budget - fixed_tokens
local used = self:count_steps(steps)
if used <= available then return steps, nil end
local kept, note = context.policies[self.policy](steps, used - available, self)
-- A policy can fail to free enough -- elision bottoms out once every
-- observation is already a stub. Fall back to dropping whole steps rather
-- than silently handing back an oversized window.
if self:count_steps(kept) > available and self.policy ~= "drop_oldest" then
local extra
kept, extra = context.policies.drop_oldest(
kept, self:count_steps(kept) - available, self)
note = (note and (note .. " ") or "") .. (extra or "")
end
-- Last resort: a SINGLE observation can be larger than the entire
-- budget, and no policy that evicts whole steps can help -- the newest
-- step is the one being reasoned about and dropping it makes the next
-- turn incoherent. So the observation itself is cut.
--
-- This is the floor of context management, and it is worth stating
-- plainly: eviction cannot save you from one oversized tool result.
-- Capping output at the tool (tools.MAX_OBSERVATION) is the real fix;
-- this is the backstop that keeps the request sendable when it is not
-- enough. Without it `fit` returns an over-budget window and the ledger
-- reports a number the model never actually received.
local over = self:count_steps(kept) - available
if over > 0 and #kept > 0 then
local last = kept[#kept]
if last.observation and #last.observation > 0 then
local copy = {}
for k, v in pairs(last) do copy[k] = v end
-- Cut by characters, then verify in tokens: the ratio varies.
local keep_chars = #last.observation
while keep_chars > 0 and self:count_steps(kept) - available > 0 do
keep_chars = math.floor(keep_chars * 0.6)
copy.observation = last.observation:sub(1, keep_chars)
.. string.format("\n... [cut: %d of %d bytes shown]",
keep_chars, #last.observation)
copy.truncated = true
kept[#kept] = copy
end
note = (note and (note .. " ") or "")
.. "[context: the most recent tool result was too large for the "
.. "window and was cut. Read a smaller portion if you need the rest.]"
end
end
return kept, note
end
--------------------------------------------------------------------------
-- Policies. Each takes (steps, tokens_to_free, ctx) and returns
-- kept_steps, note. Swapping these and re-running the eval is the ablation.
--------------------------------------------------------------------------
context.policies = {}
-- Truncate old observations to a stub, keeping every thought and call.
-- The usual right default: tool output is bulky, and the model mostly needs
-- to remember THAT it called something and with what, not the full result.
function context.policies.elide_observations(steps, to_free, ctx)
local kept, freed, n = {}, 0, 0
for i, s in ipairs(steps) do
kept[i] = s
end
-- Oldest first; the most recent observation is the one being reasoned
-- about right now, so it is elided last and only under real pressure.
for i = 1, #kept - 1 do
if freed >= to_free then break end
local s = kept[i]
if s.observation and not s.elided then
local before = ctx:count(s.observation)
local stub = string.format("[%d bytes elided]", #s.observation)
local after = ctx:count(stub)
if before > after then
local copy = {}
for k, v in pairs(s) do copy[k] = v end
copy.observation = stub
copy.elided = true
kept[i] = copy
freed = freed + (before - after)
n = n + 1
end
end
end
if n == 0 then return kept, nil end
return kept, string.format(
"[context: %d earlier tool result%s elided to stay within the window. "
.. "Call the tool again if you need the full output.]",
n, n == 1 and " was" or "s were")
end
-- Drop whole steps from the front, keeping the most recent work.
function context.policies.drop_oldest(steps, to_free, ctx)
local freed, cut = 0, 0
-- Never drop the final step: it holds the observation the next turn is a
-- response to.
while cut < #steps - 1 and freed < to_free do
cut = cut + 1
freed = freed + ctx:count(protocol.render_transcript({ steps[cut] }))
end
if cut == 0 then return steps, nil end
local kept = {}
for i = cut + 1, #steps do kept[#kept + 1] = steps[i] end
return kept, string.format(
"[context: %d earlier step%s dropped to stay within the window.]",
cut, cut == 1 and " was" or "s were")
end
-- Replace the oldest run of steps with a model-written summary. Costs an
-- extra backend call, which is why it is not the default -- and why it needs
-- a summarizer wired in explicitly.
function context.policies.summarize(steps, to_free, ctx)
if not ctx.summarizer then
-- Degrade loudly rather than silently doing something else.
local kept, note = context.policies.drop_oldest(steps, to_free, ctx)
return kept, (note or "") .. " [no summarizer configured; dropped instead]"
end
local half = math.max(1, math.floor(#steps / 2))
local old = {}
for i = 1, half do old[i] = steps[i] end
local ok, summary = pcall(ctx.summarizer, protocol.render_transcript(old))
if not ok or type(summary) ~= "string" then
return context.policies.drop_oldest(steps, to_free, ctx)
end
local kept = { { thought = nil, observation = nil, note =
"[context: earlier steps summarized] " .. summary } }
for i = half + 1, #steps do kept[#kept + 1] = steps[i] end
return kept, string.format("[context: %d earlier steps were summarized.]", half)
end
return context