fix(core): estimate semantic request context
This commit is contained in:
parent
ad550c544e
commit
a48d38b6cd
2 changed files with 82 additions and 19 deletions
|
|
@ -80,24 +80,25 @@ export interface Interface {
|
|||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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 {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue