fix(compaction): adjust instructions and structure to be more clear to smaller models like dsv4 flash (#42045)

Co-authored-by: akenra <37288280+akenra@users.noreply.github.com>
This commit is contained in:
Aiden Cline
2026-08-12 14:36:51 -05:00
committed by GitHub
parent 45344d9347
commit 85171e868e
8 changed files with 207 additions and 52 deletions
+2 -6
View File
@@ -30,15 +30,11 @@ Guidelines:
Complete the user's search request efficiently and report your findings clearly.` Complete the user's search request efficiently and report your findings clearly.`
const PROMPT_COMPACTION = `You are an anchored context summarization assistant for coding sessions. const PROMPT_COMPACTION = `You are a context summarization agent. You are given a conversation between a user and an agent. Your goal is to produce a structured summary matching the format specified so another coding agent can continue the work.
Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work.
If the prompt includes a <previous-summary> block, treat it as the current anchored summary. Update it with the new history by preserving still-true details, removing stale details, and merging in new facts.
Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs. Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs.
Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation.` Do not continue the conversation. Do not respond to any questions in the conversation. Only output the structured summary in the exact format requested by the user prompt. Respond in the same language as the conversation.`
const PROMPT_TITLE = `You are a title generator. You output ONLY a thread title. Nothing else. const PROMPT_TITLE = `You are a title generator. You output ONLY a thread title. Nothing else.
+25 -19
View File
@@ -44,6 +44,15 @@ Rules:
- Use terse bullets, not prose paragraphs. - Use terse bullets, not prose paragraphs.
- Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known. - Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
- Do not mention the summary process or that context was compacted.` - Do not mention the summary process or that context was compacted.`
const SUMMARY_UPDATE_INSTRUCTIONS = `The <prior-summary> summarizes everything that happened before the <conversation>. Construct a new summary that combines both. The <prior-summary> is discarded after this: anything you do not carry into the new summary is lost.
When combining:
- Carry forward objectives, constraints, user directives, decisions, and parallel workstreams from the <prior-summary> even when the <conversation> does not mention them. Drop only what is finished and no longer needed.
- The <conversation> is more recent than the <prior-summary>. Where they conflict, the conversation wins: state the corrected fact and drop the old claim.
- Add new progress, decisions, constraints, and context from the conversation.
- Move completed work from "Active" to "Completed".
- If a blocker has been resolved, update the summary to reflect that while keeping any details still needed to continue the work.
- Update "Objective" and "Next Move" to reflect the current work state.`
type Entry = { type Entry = {
readonly seq: number readonly seq: number
@@ -136,36 +145,33 @@ const select = (
if (conversation.length === 0) return if (conversation.length === 0) return
let total = 0 let total = 0
let split = conversation.length let split = conversation.length
let splitPrefix = ""
let splitSuffix = ""
for (let index = conversation.length - 1; index >= 0; index--) { for (let index = conversation.length - 1; index >= 0; index--) {
const next = total + Token.estimate(conversation[index]) const next = total + Token.estimate(conversation[index])
if (next > tokens) { if (next > tokens) break
const remaining = Math.max(0, tokens - total) * 4
if (remaining > 0) {
splitPrefix = conversation[index].slice(0, -remaining)
splitSuffix = conversation[index].slice(-remaining)
split = index + 1
}
break
}
total = next total = next
split = index split = index
} }
return { return {
head: [...conversation.slice(0, split), splitPrefix].filter(Boolean).join("\n\n"), head: conversation.slice(0, split).join("\n\n"),
recent: [splitSuffix, ...conversation.slice(split)].filter(Boolean).join("\n\n"), recent: conversation.slice(split).join("\n\n"),
} }
} }
export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) => export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) => {
[ const conversation = `Here is the conversation so far:\n\n<conversation>\n${input.context.join("\n\n")}\n</conversation>`
input.previousSummary if (!input.previousSummary)
? `Update the anchored summary below using the conversation history above.\nPreserve still-true details, remove stale details, and merge in the new facts.\n<previous-summary>\n${input.previousSummary}\n</previous-summary>` return [
: "Create a new anchored summary from the conversation history.", conversation,
"Create a new anchored summary from the conversation history in the <conversation> tags above so another coding agent can continue the work.",
SUMMARY_TEMPLATE, SUMMARY_TEMPLATE,
...input.context,
].join("\n\n") ].join("\n\n")
return [
conversation,
`Here is the summary of the conversation before the <conversation> above:\n\n<prior-summary>\n${input.previousSummary}\n</prior-summary>`,
SUMMARY_UPDATE_INSTRUCTIONS,
SUMMARY_TEMPLATE,
].join("\n\n")
}
export const make = (dependencies: Dependencies) => { export const make = (dependencies: Dependencies) => {
const config = settings(dependencies.config) const config = settings(dependencies.config)
+1 -1
View File
@@ -156,7 +156,7 @@ export const Info = Schema.Struct({
}), }),
tail_turns: Schema.optional(NonNegativeInt).annotate({ tail_turns: Schema.optional(NonNegativeInt).annotate({
description: description:
"Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)", "Maximum number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction. By default retention is limited only by the preserved token budget.",
}), }),
preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({ preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({
description: "Maximum number of tokens from recent turns to preserve verbatim after compaction", description: "Maximum number of tokens from recent turns to preserve verbatim after compaction",
@@ -4,12 +4,32 @@ import { SessionCompaction } from "@opencode-ai/core/session/compaction"
test("compaction prompt preserves detailed work state and relevant files", () => { test("compaction prompt preserves detailed work state and relevant files", () => {
const prompt = SessionCompaction.buildPrompt({ context: ["conversation history"] }) const prompt = SessionCompaction.buildPrompt({ context: ["conversation history"] })
expect(prompt).toStartWith(
"Here is the conversation so far:\n\n<conversation>\nconversation history\n</conversation>",
)
expect(prompt.indexOf("</conversation>")).toBeLessThan(prompt.indexOf("Create a new anchored summary"))
expect(prompt).toContain("conversation history in the <conversation> tags above")
expect(prompt).toContain("## Work State\n### Completed") expect(prompt).toContain("## Work State\n### Completed")
expect(prompt).toContain("### Active") expect(prompt).toContain("### Active")
expect(prompt).toContain("### Blocked") expect(prompt).toContain("### Blocked")
expect(prompt).toContain("## Relevant Files") expect(prompt).toContain("## Relevant Files")
}) })
test("compaction prompt gives update instructions for a prior summary", () => {
const prompt = SessionCompaction.buildPrompt({
context: ["new conversation"],
previousSummary: "existing summary",
})
expect(prompt.indexOf("<conversation>")).toBeLessThan(prompt.indexOf("<prior-summary>"))
expect(prompt.indexOf("</prior-summary>")).toBeLessThan(prompt.indexOf("The <prior-summary> summarizes"))
expect(prompt).toContain(
"Carry forward objectives, constraints, user directives, decisions, and parallel workstreams from the <prior-summary>",
)
expect(prompt).toContain('Move completed work from "Active" to "Completed".')
expect(prompt).toContain('Update "Objective" and "Next Move" to reflect the current work state.')
})
test("compaction describes tool media without embedding base64", () => { test("compaction describes tool media without embedding base64", () => {
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
const serialized = SessionCompaction.serializeToolContent([ const serialized = SessionCompaction.serializeToolContent([
+62 -1
View File
@@ -1135,7 +1135,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2) expect(requests).toHaveLength(2)
expect(userTexts(requests[0])[0]).toContain( expect(userTexts(requests[0])[0]).toContain(
"<previous-summary>\n## Objective\n- Preserve the task\n</previous-summary>", "<prior-summary>\n## Objective\n- Preserve the task\n</prior-summary>",
) )
expect(userTexts(requests[0])[0]).toContain("Recent exact request") expect(userTexts(requests[0])[0]).toContain("Recent exact request")
expect((yield* (yield* SessionStore.Service).context(sessionID))[0]).toMatchObject({ expect((yield* (yield* SessionStore.Service).context(sessionID))[0]).toMatchObject({
@@ -1145,6 +1145,67 @@ describe("SessionRunnerLLM", () => {
}), }),
) )
it.effect("retains only complete serialized messages during compaction", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const earlier = `EARLIER_BOUNDARY ${"a".repeat(3_000)} EARLIER_END`
const recent = `RECENT_BOUNDARY ${"b".repeat(3_000)} RECENT_END`
response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: earlier }), resume: false })
yield* session.resume(sessionID)
currentModel = compactModel
requests.length = 0
responses = [
fragmentFixture("text", "text-summary", ["## Objective\n- Preserve the task"]).completeEvents,
fragmentFixture("text", "text-final", ["Continued"]).completeEvents,
]
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: recent }), resume: false })
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
const summary = userTexts(requests[0])[0]
const continuation = userTexts(requests[1])[0]
expect(summary.match(/EARLIER_BOUNDARY/g)).toHaveLength(1)
expect(summary).toContain(`EARLIER_BOUNDARY ${"a".repeat(3_000)} EARLIER_END`)
expect(summary).not.toContain("RECENT_BOUNDARY")
expect(continuation).not.toContain("EARLIER_BOUNDARY")
expect(continuation).not.toContain("EARLIER_END")
expect(continuation).toContain("<recent-context>\n[Assistant]: Earlier answer")
expect(continuation).toContain(`RECENT_BOUNDARY ${"b".repeat(3_000)} RECENT_END`)
}),
)
it.effect("summarizes an oversized newest message without retaining a fragment", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Earlier question" }), resume: false })
yield* session.resume(sessionID)
const oversized = `OVERSIZED_BOUNDARY ${"x".repeat(4_500)} OVERSIZED_END`
currentModel = compactModel
requests.length = 0
responses = [
fragmentFixture("text", "text-summary", ["## Objective\n- Preserve the task"]).completeEvents,
fragmentFixture("text", "text-final", ["Continued"]).completeEvents,
]
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: oversized }), resume: false })
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
const summary = userTexts(requests[0])[0]
const continuation = userTexts(requests[1])[0]
expect(summary.match(/OVERSIZED_BOUNDARY/g)).toHaveLength(1)
expect(summary).toContain(oversized)
expect(continuation).not.toContain("OVERSIZED_BOUNDARY")
expect(continuation).not.toContain("OVERSIZED_END")
expect(continuation).toContain("<recent-context>\n\n</recent-context>")
}),
)
it.effect("forces one compaction and retries after provider context overflow", () => it.effect("forces one compaction and retries after provider context overflow", () =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* setupOverflowRecovery const session = yield* setupOverflowRecovery
@@ -1,9 +1,5 @@
You are an anchored context summarization assistant for coding sessions. You are a context summarization agent. You are given a conversation between a user and an agent. Your goal is to produce a structured summary matching the format specified so another coding agent can continue the work.
Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work.
If the prompt includes a <previous-summary> block, treat it as the current anchored summary. Update it with the new history by preserving still-true details, removing stale details, and merging in new facts.
Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs. Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs.
Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation. Do not continue the conversation. Do not respond to any questions in the conversation. Only output the structured summary in the exact format requested by the user prompt. Respond in the same language as the conversation.
+24 -17
View File
@@ -29,9 +29,8 @@ export const PRUNE_MINIMUM = 20_000
export const PRUNE_PROTECT = 40_000 export const PRUNE_PROTECT = 40_000
const TOOL_OUTPUT_MAX_CHARS = 2_000 const TOOL_OUTPUT_MAX_CHARS = 2_000
const PRUNE_PROTECTED_TOOLS = ["skill"] const PRUNE_PROTECTED_TOOLS = ["skill"]
const DEFAULT_TAIL_TURNS = 2
const MIN_PRESERVE_RECENT_TOKENS = 2_000 const MIN_PRESERVE_RECENT_TOKENS = 2_000
const MAX_PRESERVE_RECENT_TOKENS = 8_000 const MAX_PRESERVE_RECENT_TOKENS = 15_000
type Turn = { type Turn = {
start: number start: number
end: number end: number
@@ -226,27 +225,22 @@ const layer = Layer.effect(
cfg: ConfigV1.Info cfg: ConfigV1.Info
model: Provider.Model model: Provider.Model
}) { }) {
const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS const limit = input.cfg.compaction?.tail_turns
if (limit <= 0) return { head: input.messages, tail_start_id: undefined } if (limit !== undefined && limit <= 0) return { head: input.messages, tail_start_id: undefined }
const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model }) const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model })
const all = turns(input.messages) const all = turns(input.messages)
if (!all.length) return { head: input.messages, tail_start_id: undefined } if (!all.length) return { head: input.messages, tail_start_id: undefined }
const recent = all.slice(-limit) const recent = limit === undefined ? all : all.slice(-limit)
const sizes = yield* Effect.forEach(
recent,
(turn) =>
estimate({
messages: input.messages.slice(turn.start, turn.end),
model: input.model,
}),
{ concurrency: 1 },
)
let total = 0 let total = 0
let keep: Tail | undefined let keep: Tail | undefined
for (let i = recent.length - 1; i >= 0; i--) { for (let i = recent.length - 1; i >= 0; i--) {
const turn = recent[i]! const turn = recent[i]!
const size = sizes[i] // estimate lazily so cost stays proportional to the retained tail, not the whole session
const size = yield* estimate({
messages: input.messages.slice(turn.start, turn.end),
model: input.model,
})
if (total + size <= budget) { if (total + size <= budget) {
total += size total += size
keep = { start: turn.start, id: turn.id } keep = { start: turn.start, id: turn.id }
@@ -381,10 +375,20 @@ const layer = Layer.effect(
{ sessionID: input.sessionID }, { sessionID: input.sessionID },
{ context: [], prompt: undefined }, { context: [], prompt: undefined },
) )
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
const msgs = structuredClone(selected.head) const msgs = structuredClone(selected.head)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const conversation = msgs.map(serialize).filter(Boolean).join("\n\n") const conversation = msgs.map(serialize).filter(Boolean).join("\n\n")
const nextPrompt =
compacting.prompt ??
[
buildPrompt({
previousSummary,
context: [conversation],
}),
...compacting.context,
]
.filter(Boolean)
.join("\n\n")
const ctx = yield* InstanceState.context const ctx = yield* InstanceState.context
const msg: SessionV1.Assistant = { const msg: SessionV1.Assistant = {
id: MessageID.ascending(), id: MessageID.ascending(),
@@ -430,7 +434,10 @@ const layer = Layer.effect(
content: [ content: [
{ {
type: "text", type: "text",
text: [nextPrompt, "The following is the conversation history:", conversation] text: [
nextPrompt,
...(compacting.prompt ? ["The following is the conversation history:", conversation] : []),
]
.filter(Boolean) .filter(Boolean)
.join("\n\n"), .join("\n\n"),
}, },
@@ -365,6 +365,20 @@ function autocontinue(enabled: boolean) {
}) })
} }
function compactionContext(context: string) {
return Layer.mock(Plugin.Service)({
trigger: <Name extends string, Input, Output>(name: Name, _input: Input, output: Output) => {
if (name !== "experimental.session.compacting") return Effect.succeed(output)
return Effect.sync(() => {
;(output as { context: string[] }).context.push(context)
return output
})
},
list: () => Effect.succeed([]),
init: () => Effect.void,
})
}
describe("session.compaction.isOverflow", () => { describe("session.compaction.isOverflow", () => {
it.live( it.live(
"returns true when token count exceeds usable context", "returns true when token count exceeds usable context",
@@ -1389,11 +1403,21 @@ describe("session.compaction.process", () => {
const captured = JSON.stringify(messages) const captured = JSON.stringify(messages)
expect(messages).toHaveLength(1) expect(messages).toHaveLength(1)
expect(messages[0]?.role).toBe("user") expect(messages[0]?.role).toBe("user")
expect(captured).toContain("Here is the conversation so far:")
expect(captured).toContain("<conversation>")
expect(captured.indexOf("[User]: older context")).toBeLessThan(
captured.indexOf("Create a new anchored summary"),
)
expect(captured).toContain("[User]: older context") expect(captured).toContain("[User]: older context")
expect(captured).not.toContain("keep this turn") expect(captured).not.toContain("keep this turn")
expect(captured).not.toContain("and this one too") expect(captured).not.toContain("and this one too")
expect(captured).not.toContain("What did we do so far?") expect(captured).not.toContain("What did we do so far?")
}).pipe(withCompaction({ llm: stub.llmLayer })) }).pipe(
withCompaction({
llm: stub.llmLayer,
config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }),
}),
)
}, },
{ git: true }, { git: true },
) )
@@ -1430,9 +1454,11 @@ describe("session.compaction.process", () => {
expect(parent).toBeTruthy() expect(parent).toBeTruthy()
yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
expect(captured).toContain("<previous-summary>") expect(captured).toContain("<prior-summary>")
expect(captured).toContain("summary one") expect(captured).toContain("summary one")
expect(captured.match(/summary one/g)?.length).toBe(1) expect(captured.match(/summary one/g)?.length).toBe(1)
expect(captured.indexOf("latest turn")).toBeLessThan(captured.indexOf("<prior-summary>"))
expect(captured).toContain("summary of the conversation before the <conversation> above")
expect(captured).toContain("## Important Details") expect(captured).toContain("## Important Details")
expect(captured).toContain("## Work State") expect(captured).toContain("## Work State")
}).pipe(withCompaction({ llm: stub.llmLayer })) }).pipe(withCompaction({ llm: stub.llmLayer }))
@@ -1440,6 +1466,49 @@ describe("session.compaction.process", () => {
{ git: true }, { git: true },
) )
itCompaction.instance(
"keeps plugin context outside the serialized conversation",
() => {
const stub = llm()
let captured = ""
stub.push(
reply("summary", (input) => {
captured = JSON.stringify(input.messages)
}),
)
return Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
yield* createUserMessage(session.id, "older context")
yield* createUserMessage(session.id, "keep this turn")
yield* createUserMessage(session.id, "and this one too")
yield* createCompactionMarker(session.id)
const msgs = yield* ssn.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
yield* SessionCompaction.use.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
})
expect(captured).toContain("Prioritize unresolved migration details")
expect(captured.indexOf("</conversation>")).toBeLessThan(
captured.indexOf("Prioritize unresolved migration details"),
)
}).pipe(
withCompaction({
llm: stub.llmLayer,
plugin: compactionContext("Prioritize unresolved migration details"),
}),
)
},
{ git: true },
)
itCompaction.instance( itCompaction.instance(
"serializes repeated compaction history as one user message", "serializes repeated compaction history as one user message",
() => { () => {