diff --git a/packages/core/src/config/compaction.ts b/packages/core/src/config/compaction.ts index 3c5960c83556..3a9df791c615 100644 --- a/packages/core/src/config/compaction.ts +++ b/packages/core/src/config/compaction.ts @@ -12,4 +12,5 @@ export class Info extends Schema.Class("ConfigV2.Compaction")({ prune: Schema.Boolean.pipe(Schema.optional), keep: Keep.pipe(Schema.optional), buffer: NonNegativeInt.pipe(Schema.optional), + max_request_bytes: NonNegativeInt.pipe(Schema.optional), }) {} diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index ba52eb77dd00..5b0a245f0a1d 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -47,6 +47,7 @@ type Settings = { readonly auto: boolean readonly buffer: number readonly tokens: number + readonly maxRequestBytes?: number } type Dependencies = { @@ -64,7 +65,9 @@ type Input = { readonly request: LLMRequest } -const estimate = (value: unknown) => Token.estimate(JSON.stringify(value)) +const stringify = (value: unknown) => JSON.stringify(value) +const estimate = (value: unknown) => Token.estimate(stringify(value)) +const byteLength = (value: string) => new TextEncoder().encode(value).length const truncate = (value: string) => value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]` @@ -113,6 +116,7 @@ const settings = (documents: readonly Config.Entry[]) => { auto: current.auto ?? result.auto, buffer: current.buffer ?? result.buffer, tokens: current.keep?.tokens ?? result.tokens, + maxRequestBytes: current.max_request_bytes ?? result.maxRequestBytes, }), { auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }, ) @@ -220,9 +224,14 @@ export const make = (dependencies: Dependencies) => { const context = input.model.route.defaults.limits?.context if (context === undefined || context <= 0) return false const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0 + const payload = stringify({ + system: input.request.system, + messages: input.request.messages, + tools: input.request.tools, + }) if ( - estimate({ system: input.request.system, messages: input.request.messages, tools: input.request.tools }) <= - context - Math.max(output, config.buffer) + Token.estimate(payload) <= context - Math.max(output, config.buffer) && + (config.maxRequestBytes === undefined || byteLength(payload) <= config.maxRequestBytes) ) return false return yield* compactAfterOverflow(input) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index c3c42cab3022..419450f1dfba 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -340,6 +340,7 @@ describe("Config", () => { prune: false, keep: { tokens: 2000 }, buffer: 10000, + max_request_bytes: 1048576, }, skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"], instructions: ["CONTRIBUTING.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"], @@ -426,6 +427,7 @@ describe("Config", () => { prune: false, keep: { tokens: 2000 }, buffer: 10000, + max_request_bytes: 1048576, }) expect(documents[0]?.info.skills).toEqual([ "./skills", diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index e91c89c00954..8d087b781014 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -1,5 +1,13 @@ import { expect, test } from "bun:test" +import { Config } from "@opencode-ai/core/config" +import { ConfigCompaction } from "@opencode-ai/core/config/compaction" +import { EventV2 } from "@opencode-ai/core/event" import { SessionCompaction } from "@opencode-ai/core/session/compaction" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { SessionSchema } from "@opencode-ai/core/session/schema" +import { LLM, LLMEvent, Message, Model } from "@opencode-ai/llm" +import { route } from "@opencode-ai/llm/protocols/openai-chat" +import { DateTime, Effect, Stream } from "effect" test("compaction describes tool media without embedding base64", () => { const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" @@ -16,3 +24,78 @@ test("compaction describes tool media without embedding base64", () => { expect(serialized).toBe("Image read successfully\n[Attached image/png: pixel.png]") expect(serialized).not.toContain(base64) }) + +test("compaction can trigger from configured request byte envelope", async () => { + const events: Array<{ readonly type: string; readonly payload: unknown }> = [] + const compact = SessionCompaction.make({ + config: [ + new Config.Document({ + type: "document", + info: new Config.Info({ + compaction: new ConfigCompaction.Info({ + keep: new ConfigCompaction.Keep({ tokens: 16 }), + max_request_bytes: 512, + }), + }), + }), + ], + events: { + publish: (definition, payload) => + Effect.sync(() => { + const event = { id: EventV2.ID.create(), type: definition.type, data: payload } as EventV2.Payload< + typeof definition + > + events.push({ type: definition.type, payload }) + return event + }), + subscribe: () => Stream.empty, + all: () => Stream.empty, + durable: () => Stream.empty, + listen: () => Effect.succeed(Effect.void), + project: () => Effect.void, + replay: () => Effect.void, + replayAll: () => Effect.succeed(undefined), + remove: () => Effect.void, + claim: () => Effect.void, + }, + llm: { + stream: () => + Stream.make( + LLMEvent.textStart({ id: "summary" }), + LLMEvent.textDelta({ id: "summary", text: "## Objective\n- Keep request small" }), + LLMEvent.finish({ reason: "stop" }), + ), + }, + }) + const sessionID = SessionSchema.ID.make("ses_byte_guard") + const model = Model.make({ + id: "claude-fable-5", + provider: "opencode", + route: route.with({ limits: { context: 1_000_000, output: 4_096 } }), + }) + const message: SessionMessage.Message = { + id: SessionMessage.ID.create(), + type: "user", + text: "Important historical context ".repeat(80), + files: [], + agents: [], + time: { created: DateTime.makeUnsafe(Date.now()) }, + } + + const result = await Effect.runPromise( + compact.compactIfNeeded({ + sessionID, + entries: [{ seq: 1, message }], + model, + request: LLM.request({ + model, + messages: [Message.user("x".repeat(1_500))], + tools: [], + }), + }), + ) + + expect(result).toBe(true) + expect(events).toHaveLength(2) + expect(events.map((event) => (event.payload as { readonly reason?: string }).reason)).toEqual(["auto", "auto"]) +})