Spaces:
Running on Zero
Running on Zero
File size: 9,254 Bytes
98acb70 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 | -- 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
|