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