Skip to content

Commit 5affb30

Browse files
Merge pull request #186 from browser-use/context-limits
feat(opencode): trim old tool output and media in one batch at a token mark
2 parents b200643 + e6347c6 commit 5affb30

5 files changed

Lines changed: 145 additions & 10 deletions

File tree

‎packages/core/src/v1/config/config.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@ export const Info = Schema.Struct({
154154
prune: Schema.optional(Schema.Boolean).annotate({
155155
description: "Enable pruning of old tool outputs (default: false)",
156156
}),
157+
prune_at: Schema.optional(PositiveInt).annotate({
158+
description:
159+
"With prune, clear old tool output and media in one batch inside the loop once the context reaches this many tokens (default: prune only when a turn ends)",
160+
}),
161+
keep_media: Schema.optional(PositiveInt).annotate({
162+
description:
163+
"With prune_at, the latest tool results with screenshots or other media that stay whole; everything older is cleared (default: 2)",
164+
}),
157165
tail_turns: Schema.optional(NonNegativeInt).annotate({
158166
description:
159167
"Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)",

‎packages/opencode/src/session/compaction.ts‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export const Event = SessionCompactionEvent
2727

2828
export const PRUNE_MINIMUM = 20_000
2929
export const PRUNE_PROTECT = 40_000
30+
const DEFAULT_KEEP_MEDIA = 2
31+
// Media is not part of the output text estimate, so a cleared screenshot counts as this many tokens.
32+
const MEDIA_TOKEN_ESTIMATE = 1_500
3033
const TOOL_OUTPUT_MAX_CHARS = 2_000
3134
const PRUNE_PROTECTED_TOOLS = ["skill"]
3235
const DEFAULT_TAIL_TURNS = 2
@@ -132,7 +135,7 @@ export interface Interface {
132135
tokens: SessionV1.Assistant["tokens"]
133136
model: Provider.Model
134137
}) => Effect.Effect<boolean>
135-
readonly prune: (input: { sessionID: SessionID }) => Effect.Effect<void>
138+
readonly prune: (input: { sessionID: SessionID; tokens?: number }) => Effect.Effect<boolean>
136139
readonly process: (input: {
137140
parentID: MessageID
138141
messages: SessionV1.WithParts[]
@@ -240,15 +243,21 @@ const layer = Layer.effect(
240243

241244
// goes backwards through parts until there are PRUNE_PROTECT tokens worth of tool
242245
// calls, then erases output of older tool calls to free context space
243-
const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID }) {
246+
// With `tokens` (the in-loop call) it only runs once prune_at is reached and clears everything older than the
247+
// latest keep_media results with media, in one batch; the turn-end call keeps the token-window rules.
248+
const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID; tokens?: number }) {
244249
const cfg = yield* config.get()
245-
if (!cfg.compaction?.prune) return
250+
if (!cfg.compaction?.prune) return false
251+
const trim = input.tokens !== undefined
252+
if (trim && (cfg.compaction.prune_at === undefined || input.tokens! < cfg.compaction.prune_at)) return false
253+
const keepMedia = cfg.compaction.keep_media ?? DEFAULT_KEEP_MEDIA
254+
let media = 0
246255
yield* Effect.logInfo("pruning")
247256

248257
const msgs = yield* session
249258
.messages({ sessionID: input.sessionID })
250259
.pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)))
251-
if (!msgs) return
260+
if (!msgs) return false
252261

253262
let total = 0
254263
let pruned = 0
@@ -258,18 +267,23 @@ const layer = Layer.effect(
258267
loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
259268
const msg = msgs[msgIndex]
260269
if (msg.info.role === "user") turns++
261-
if (turns < 2) continue
270+
if (!trim && turns < 2) continue
262271
if (msg.info.role === "assistant" && msg.info.summary) break loop
263272
for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
264273
const part = msg.parts[partIndex]
265274
if (part.type !== "tool") continue
266275
if (part.state.status !== "completed") continue
267276
if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
268277
if (part.state.time.compacted) break loop
278+
const attached = part.state.attachments?.filter((a) => MessageV2.isMedia(a.mime)).length ?? 0
279+
if (trim && media < keepMedia) {
280+
if (attached) media++
281+
continue
282+
}
269283
const estimate = Token.estimate(part.state.output)
270284
total += estimate
271-
if (total <= PRUNE_PROTECT) continue
272-
pruned += estimate
285+
if (!trim && total <= PRUNE_PROTECT) continue
286+
pruned += estimate + (trim ? attached * MEDIA_TOKEN_ESTIMATE : 0)
273287
toPrune.push(part)
274288
}
275289
}
@@ -283,7 +297,9 @@ const layer = Layer.effect(
283297
}
284298
}
285299
yield* Effect.logInfo("pruned", { count: toPrune.length })
300+
return true
286301
}
302+
return false
287303
})
288304

289305
const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: {

‎packages/opencode/src/session/overflow.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ export function usable(input: { cfg: ConfigV1.Info; model: Provider.Model; outpu
1919
: Math.max(0, context - ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax))
2020
}
2121

22+
export function tokenCount(tokens: SessionV1.Assistant["tokens"]) {
23+
return tokens.total || tokens.input + tokens.output + tokens.cache.read + tokens.cache.write
24+
}
25+
2226
export function isOverflow(input: {
2327
cfg: ConfigV1.Info
2428
tokens: SessionV1.Assistant["tokens"]
@@ -28,7 +32,5 @@ export function isOverflow(input: {
2832
if (input.cfg.compaction?.auto === false) return false
2933
if (input.model.limit.context === 0) return false
3034

31-
const count =
32-
input.tokens.total || input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write
33-
return count >= usable(input)
35+
return tokenCount(input.tokens) >= usable(input)
3436
}

‎packages/opencode/src/session/prompt.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Provider } from "@/provider/provider"
1313
import { type Tool as AITool, tool, jsonSchema } from "ai"
1414
import type { JSONSchema7 } from "@ai-sdk/provider"
1515
import { SessionCompaction } from "./compaction"
16+
import { tokenCount } from "./overflow"
1617
import { SystemPrompt } from "./system"
1718
import { Instruction } from "./instruction"
1819
import { Plugin } from "../plugin"
@@ -1083,6 +1084,7 @@ const layer = Layer.effect(
10831084
const ctx = yield* InstanceState.context
10841085
let structured: unknown
10851086
let step = 0
1087+
let afterTrim = false
10861088
const session = yield* sessions.get(sessionID).pipe(Effect.orDie)
10871089

10881090
while (true) {
@@ -1142,6 +1144,17 @@ const layer = Layer.effect(
11421144
break
11431145
}
11441146

1147+
// A trim frees context the last finished message's usage does not show yet, so the next pass skips the
1148+
// overflow check instead of compacting on stale numbers.
1149+
const trimmed =
1150+
lastFinished && lastFinished.summary !== true
1151+
? yield* compaction.prune({ sessionID, tokens: tokenCount(lastFinished.tokens) })
1152+
: false
1153+
if (trimmed) {
1154+
afterTrim = true
1155+
continue
1156+
}
1157+
11451158
step++
11461159
if (step === 1)
11471160
yield* title({
@@ -1174,11 +1187,13 @@ const layer = Layer.effect(
11741187
if (
11751188
lastFinished &&
11761189
lastFinished.summary !== true &&
1190+
!afterTrim &&
11771191
(yield* compaction.isOverflow({ tokens: lastFinished.tokens, model }))
11781192
) {
11791193
yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })
11801194
continue
11811195
}
1196+
afterTrim = false
11821197

11831198
const agent = yield* agents.get(lastUser.agent)
11841199
if (!agent) {

‎packages/opencode/test/session/compaction.test.ts‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,100 @@ describe("session.compaction.prune", () => {
707707
),
708708
)
709709

710+
const screenshotResults = (count: number) =>
711+
Effect.gen(function* () {
712+
const ssn = yield* SessionNs.Service
713+
const info = yield* ssn.create({})
714+
const user = yield* ssn.updateMessage({
715+
id: MessageID.ascending(),
716+
role: "user",
717+
sessionID: info.id,
718+
agent: "build",
719+
model: ref,
720+
time: { created: Date.now() },
721+
})
722+
yield* ssn.updatePart({ id: PartID.ascending(), messageID: user.id, sessionID: info.id, type: "text", text: "task" })
723+
const assistant: SessionV1.Assistant = {
724+
id: MessageID.ascending(),
725+
role: "assistant",
726+
sessionID: info.id,
727+
mode: "build",
728+
agent: "build",
729+
path: { cwd: "/", root: "/" },
730+
cost: 0,
731+
tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } },
732+
modelID: ref.modelID,
733+
providerID: ref.providerID,
734+
parentID: user.id,
735+
time: { created: Date.now() },
736+
}
737+
yield* ssn.updateMessage(assistant)
738+
for (let i = 0; i < count; i++) {
739+
yield* ssn.updatePart({
740+
id: PartID.ascending(),
741+
messageID: assistant.id,
742+
sessionID: info.id,
743+
type: "tool",
744+
callID: crypto.randomUUID(),
745+
tool: "bash",
746+
state: {
747+
status: "completed",
748+
input: {},
749+
output: "x".repeat(40_000),
750+
title: "done",
751+
metadata: {},
752+
time: { start: Date.now(), end: Date.now() },
753+
attachments: [
754+
{
755+
id: PartID.ascending(),
756+
messageID: assistant.id,
757+
sessionID: info.id,
758+
type: "file",
759+
mime: "image/png",
760+
filename: "screenshot.png",
761+
url: "data:image/png;base64,Zm9v",
762+
},
763+
],
764+
},
765+
})
766+
}
767+
return info.id
768+
})
769+
const clearedParts = (id: SessionID) =>
770+
Effect.gen(function* () {
771+
const ssn = yield* SessionNs.Service
772+
const msgs = yield* ssn.messages({ sessionID: id })
773+
return msgs
774+
.flatMap((msg) => msg.parts)
775+
.filter((part) => part.type === "tool")
776+
.map((part) => part.type === "tool" && part.state.status === "completed" && !!part.state.time.compacted)
777+
})
778+
779+
itCompaction.instance(
780+
"with prune_at, waits for the mark, then clears everything older than the latest media results at once",
781+
Effect.gen(function* () {
782+
const id = yield* screenshotResults(6)
783+
784+
expect(yield* SessionCompaction.use.prune({ sessionID: id, tokens: 10_000 })).toBe(false)
785+
expect(yield* clearedParts(id)).toEqual([false, false, false, false, false, false])
786+
787+
expect(yield* SessionCompaction.use.prune({ sessionID: id, tokens: 60_000 })).toBe(true)
788+
expect(yield* clearedParts(id)).toEqual([true, true, true, true, false, false])
789+
790+
expect(yield* SessionCompaction.use.prune({ sessionID: id, tokens: 90_000 })).toBe(false)
791+
}).pipe(withCompaction({ config: cfg({ prune: true, prune_at: 50_000, keep_media: 2 }) })),
792+
)
793+
794+
itCompaction.instance(
795+
"without prune_at, the in-loop call does nothing",
796+
Effect.gen(function* () {
797+
const id = yield* screenshotResults(6)
798+
799+
expect(yield* SessionCompaction.use.prune({ sessionID: id, tokens: 200_000 })).toBe(false)
800+
expect(yield* clearedParts(id)).toEqual([false, false, false, false, false, false])
801+
}).pipe(withCompaction({ config: cfg({ prune: true }) })),
802+
)
803+
710804
it.live(
711805
"skips protected skill tool output",
712806
provideTmpdirInstance((dir) =>

0 commit comments

Comments
 (0)