diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index f1103d0c3a..ce4e84e91f 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -80,24 +80,25 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/SessionCompaction") {} -const estimate = (value: unknown) => Token.estimate(JSON.stringify(value)) +const stringify = (value: unknown) => (typeof value === "string" ? value : (JSON.stringify(value) ?? String(value))) -const textContext = (request: LLMRequest) => ({ - system: request.system, - // TODO: Replace media exclusion with model-aware attachment token accounting. - messages: request.messages.map((message) => ({ - id: message.id, - role: message.role, - content: message.content.flatMap((part) => { - if (part.type === "media") return [] - if (part.type !== "tool-result" || part.result.type !== "content") return [part] - return [{ ...part, result: { ...part.result, value: part.result.value.filter((item) => item.type === "text") } }] - }), - metadata: message.metadata, - native: message.native, - })), - tools: request.tools, -}) +const serializeContent = (part: LLMRequest["messages"][number]["content"][number]) => { + if (part.type === "text" || part.type === "reasoning") return part.text + if (part.type === "media") return "" + if (part.type === "tool-call") return `${part.name}\n${stringify(part.input)}` + if (part.result.type === "content") + return [part.name, ...part.result.value.flatMap((item) => (item.type === "text" ? [item.text] : []))].join("\n") + return `${part.name}\n${stringify(part.result.value)}` +} + +const estimate = (request: LLMRequest) => + Token.estimate( + [ + ...request.system.map((part) => part.text), + JSON.stringify(request.tools), + ...request.messages.flatMap((message) => message.content.map(serializeContent).filter(Boolean)), + ].join("\n"), + ) const truncate = (value: string) => value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]` @@ -302,7 +303,7 @@ const make = (dependencies: Dependencies) => { const context = input.request.model.route.defaults.limits?.context if (context === undefined || context <= 0) return false const output = input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0 - if (estimate(textContext(input.request)) <= context - Math.max(output, config.buffer)) return false + if (estimate(input.request) <= context - Math.max(output, config.buffer)) return false return yield* compactAfterOverflow(input) }) return { diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index 0f9f8e12ec..ae6c1c70a8 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { LLM, LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/llm" +import { LLM, LLMClient, LLMEvent, Message, Model, ToolCallPart, type LLMRequest } from "@opencode-ai/llm" import { OpenAIChat } from "@opencode-ai/llm/protocols" import { Base64, FileAttachment } from "@opencode-ai/schema/prompt" import { Config } from "@opencode-ai/core/config" @@ -131,6 +131,68 @@ it.effect("does not count image attachments as text context", () => }), ) +it.effect("counts tool-call inputs as text context", () => + Effect.gen(function* () { + requests = [] + const db = (yield* Database.Service).db + const compaction = yield* SessionCompaction.Service + const text = "context ".repeat(4_500) + const sessionID = SessionV2.ID.make("ses_tool_input_compaction") + const inputModel = Model.make({ + id: "tool-input-model", + provider: "test", + route: OpenAIChat.route.with({ limits: { context: 30_000, output: 1_000 } }), + }) + const messages = [ + SessionMessage.User.make({ + id: SessionMessage.ID.create(), + type: "user", + text, + time: { created: DateTime.makeUnsafe(0) }, + }), + SessionMessage.User.make({ + id: SessionMessage.ID.create(), + type: "user", + text: "Continue", + time: { created: DateTime.makeUnsafe(1) }, + }), + ] + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "tool-input-compaction", + directory: "/project", + title: "Tool input compaction", + version: "test", + }) + .run() + .pipe(Effect.orDie) + + expect( + yield* compaction.compactIfNeeded({ + sessionID, + messages, + request: LLM.request({ + model: inputModel, + messages: [ + Message.user(text), + Message.assistant(ToolCallPart.make({ id: "call_read", name: "read", input: { path: "x".repeat(8_000) } })), + ], + }), + }), + ).toBe(true) + expect(requests).toHaveLength(1) + }), +) + test("compaction prompt requires the checkpoint headings in order", () => { const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] }) expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([