fix(core): filter attachments from context estimate
This commit is contained in:
parent
3963453f8d
commit
7b022ed71f
2 changed files with 56 additions and 30 deletions
|
|
@ -80,17 +80,28 @@ export interface Interface {
|
|||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionCompaction") {}
|
||||
|
||||
const estimate = (value: unknown) =>
|
||||
Token.estimate(
|
||||
JSON.stringify(value, (_key, item: unknown) => {
|
||||
if (typeof item !== "object" || item === null || !("type" in item)) return item
|
||||
// Providers account for native media separately; its base64 encoding is not prompt text.
|
||||
if (item.type === "media" && "data" in item) return { ...item, data: "[media bytes]" }
|
||||
if (item.type === "file" && "uri" in item && typeof item.uri === "string" && item.uri.startsWith("data:"))
|
||||
return { ...item, uri: item.uri.slice(0, item.uri.indexOf(",") + 1) + "[media bytes]" }
|
||||
return item
|
||||
}),
|
||||
)
|
||||
const estimate = (value: unknown) => Token.estimate(JSON.stringify(value))
|
||||
|
||||
const textContext = (request: LLMRequest) => ({
|
||||
system: request.system,
|
||||
// TODO: Replace blanket attachment exclusion with model-aware media and file token accounting.
|
||||
messages: request.messages
|
||||
.filter((message) => message.metadata?.attachment === undefined)
|
||||
.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 truncate = (value: string) =>
|
||||
value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
|
||||
|
|
@ -289,11 +300,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({ system: input.request.system, messages: input.request.messages, tools: input.request.tools }) <=
|
||||
context - Math.max(output, config.buffer)
|
||||
)
|
||||
return false
|
||||
if (estimate(textContext(input.request)) <= context - Math.max(output, config.buffer)) return false
|
||||
return yield* compactAfterOverflow(input)
|
||||
})
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { LLM, LLMClient, LLMEvent, Message, Model, type LLMRequest } from "@opencode-ai/llm"
|
||||
import { LLM, LLMClient, LLMEvent, Model, 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"
|
||||
|
|
@ -13,12 +13,15 @@ import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
|||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { DateTime, Effect, Layer, Stream } from "effect"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
|
|
@ -69,23 +72,33 @@ test("compaction describes tool media without embedding base64", () => {
|
|||
expect(serialized).not.toContain(base64)
|
||||
})
|
||||
|
||||
it.effect("does not count media base64 as text context", () =>
|
||||
it.effect("does not count file attachments as text context", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const text = "context ".repeat(4_000)
|
||||
const data = Base64.make(Buffer.alloc(64 * 1024).toString("base64"))
|
||||
const file = FileAttachment.make({
|
||||
const image = FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "screenshot.png",
|
||||
})
|
||||
const document = FileAttachment.make({
|
||||
data: Base64.make(Buffer.alloc(64 * 1024, "a").toString("base64")),
|
||||
mime: "text/plain",
|
||||
source: { type: "inline" },
|
||||
name: "notes.txt",
|
||||
})
|
||||
const inputModel = Model.make({
|
||||
id: "media-model",
|
||||
provider: "test",
|
||||
route: OpenAIChat.route.with({ limits: { context: 30_000, output: 1_000 } }),
|
||||
})
|
||||
const inputModelRef = ModelV2.Ref.make({
|
||||
id: ModelV2.ID.make(inputModel.id),
|
||||
providerID: ProviderV2.ID.make(inputModel.provider),
|
||||
})
|
||||
const messages = [
|
||||
SessionMessage.User.make({
|
||||
id: SessionMessage.ID.create(),
|
||||
|
|
@ -97,25 +110,31 @@ it.effect("does not count media base64 as text context", () =>
|
|||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Inspect this image",
|
||||
files: [file],
|
||||
files: [document, image],
|
||||
time: { created: DateTime.makeUnsafe(1) },
|
||||
}),
|
||||
]
|
||||
const request = LLM.request({
|
||||
model: inputModel,
|
||||
messages: toLLMMessages(messages, inputModelRef),
|
||||
})
|
||||
|
||||
expect(request.messages.flatMap((message) => message.content)).toContainEqual({
|
||||
type: "media",
|
||||
mediaType: "image/png",
|
||||
data,
|
||||
filename: "screenshot.png",
|
||||
})
|
||||
expect(request.messages[1]?.content[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("Attached file: notes.txt"),
|
||||
})
|
||||
|
||||
expect(
|
||||
yield* compaction.compactIfNeeded({
|
||||
sessionID: SessionV2.ID.make("ses_media_compaction"),
|
||||
messages,
|
||||
request: LLM.request({
|
||||
model: inputModel,
|
||||
messages: [
|
||||
Message.user(text),
|
||||
Message.user([
|
||||
{ type: "text", text: "Inspect this image" },
|
||||
{ type: "media", mediaType: "image/png", data, filename: "screenshot.png" },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
request,
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(requests).toHaveLength(0)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue