Spaces:
Running on Zero
Running on Zero
Download context.lua from jeorgexyz/lua-agent-lab: direct link, hf CLI and curl.
- Browser
- Download file 9.25 kB
-
https://huggingface.co/spaces/jeorgexyz/lua-agent-lab/resolve/main/context.lua
- Command line
-
hf download hf://spaces/jeorgexyz/lua-agent-lab/context.lua
-
curl -L -o context.lua https://huggingface.co/spaces/jeorgexyz/lua-agent-lab/resolve/main/context.lua
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 | |