Skip to content

Commit dab2637

Browse files
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>
1 parent 39fb919 commit dab2637

8 files changed

Lines changed: 207 additions & 52 deletions

File tree

‎packages/core/src/plugin/agent.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,11 @@ Guidelines:
3030
3131
Complete the user's search request efficiently and report your findings clearly.`
3232

33-
constPROMPT_COMPACTION=`You are an anchored context summarization assistant for coding sessions.
34-
35-
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.
36-
37-
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.
33+
constPROMPT_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.
3834
3935
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.
4036
41-
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.`
37+
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.`
4238

4339
constPROMPT_TITLE=`You are a title generator. You output ONLY a thread title. Nothing else.
4440

‎packages/core/src/session/compaction.ts‎

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ Rules:
4444
- Use terse bullets, not prose paragraphs.
4545
- Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
4646
- Do not mention the summary process or that context was compacted.`
47+
constSUMMARY_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.
48+
49+
When combining:
50+
- 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.
51+
- The <conversation> is more recent than the <prior-summary>. Where they conflict, the conversation wins: state the corrected fact and drop the old claim.
52+
- Add new progress, decisions, constraints, and context from the conversation.
53+
- Move completed work from "Active" to "Completed".
54+
- If a blocker has been resolved, update the summary to reflect that while keeping any details still needed to continue the work.
55+
- Update "Objective" and "Next Move" to reflect the current work state.`
4756

4857
typeEntry={
4958
readonlyseq: number
@@ -136,36 +145,33 @@ const select = (
136145
if(conversation.length===0)return
137146
lettotal=0
138147
letsplit=conversation.length
139-
letsplitPrefix=""
140-
letsplitSuffix=""
141148
for(letindex=conversation.length-1;index>=0;index--){
142149
constnext=total+Token.estimate(conversation[index])
143-
if(next>tokens){
144-
constremaining=Math.max(0,tokens-total)*4
145-
if(remaining>0){
146-
splitPrefix=conversation[index].slice(0,-remaining)
147-
splitSuffix=conversation[index].slice(-remaining)
148-
split=index+1
149-
}
150-
break
151-
}
150+
if(next>tokens)break
152151
total=next
153152
split=index
154153
}
155154
return{
156-
head: [...conversation.slice(0,split),splitPrefix].filter(Boolean).join("\n\n"),
157-
recent: [splitSuffix, ...conversation.slice(split)].filter(Boolean).join("\n\n"),
155+
head: conversation.slice(0,split).join("\n\n"),
156+
recent: conversation.slice(split).join("\n\n"),
158157
}
159158
}
160159

161-
exportconstbuildPrompt=(input: {readonlypreviousSummary?: string;readonlycontext: readonlystring[]})=>
162-
[
163-
input.previousSummary
164-
? `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>`
165-
: "Create a new anchored summary from the conversation history.",
160+
exportconstbuildPrompt=(input: {readonlypreviousSummary?: string;readonlycontext: readonlystring[]})=>{
161+
constconversation=`Here is the conversation so far:\n\n<conversation>\n${input.context.join("\n\n")}\n</conversation>`
162+
if(!input.previousSummary)
163+
return[
164+
conversation,
165+
"Create a new anchored summary from the conversation history in the <conversation> tags above so another coding agent can continue the work.",
166+
SUMMARY_TEMPLATE,
167+
].join("\n\n")
168+
return[
169+
conversation,
170+
`Here is the summary of the conversation before the <conversation> above:\n\n<prior-summary>\n${input.previousSummary}\n</prior-summary>`,
171+
SUMMARY_UPDATE_INSTRUCTIONS,
166172
SUMMARY_TEMPLATE,
167-
...input.context,
168173
].join("\n\n")
174+
}
169175

170176
exportconstmake=(dependencies: Dependencies)=>{
171177
constconfig=settings(dependencies.config)

‎packages/core/src/v1/config/config.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ export const Info = Schema.Struct({
156156
}),
157157
tail_turns: Schema.optional(NonNegativeInt).annotate({
158158
description:
159-
"Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)",
159+
"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.",
160160
}),
161161
preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({
162162
description: "Maximum number of tokens from recent turns to preserve verbatim after compaction",

‎packages/core/test/session-compaction.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,32 @@ import { SessionCompaction } from "@opencode-ai/core/session/compaction"
44
test("compaction prompt preserves detailed work state and relevant files",()=>{
55
constprompt=SessionCompaction.buildPrompt({context: ["conversation history"]})
66

7+
expect(prompt).toStartWith(
8+
"Here is the conversation so far:\n\n<conversation>\nconversation history\n</conversation>",
9+
)
10+
expect(prompt.indexOf("</conversation>")).toBeLessThan(prompt.indexOf("Create a new anchored summary"))
11+
expect(prompt).toContain("conversation history in the <conversation> tags above")
712
expect(prompt).toContain("## Work State\n### Completed")
813
expect(prompt).toContain("### Active")
914
expect(prompt).toContain("### Blocked")
1015
expect(prompt).toContain("## Relevant Files")
1116
})
1217

18+
test("compaction prompt gives update instructions for a prior summary",()=>{
19+
constprompt=SessionCompaction.buildPrompt({
20+
context: ["new conversation"],
21+
previousSummary: "existing summary",
22+
})
23+
24+
expect(prompt.indexOf("<conversation>")).toBeLessThan(prompt.indexOf("<prior-summary>"))
25+
expect(prompt.indexOf("</prior-summary>")).toBeLessThan(prompt.indexOf("The <prior-summary> summarizes"))
26+
expect(prompt).toContain(
27+
"Carry forward objectives, constraints, user directives, decisions, and parallel workstreams from the <prior-summary>",
28+
)
29+
expect(prompt).toContain('Move completed work from "Active" to "Completed".')
30+
expect(prompt).toContain('Update "Objective" and "Next Move" to reflect the current work state.')
31+
})
32+
1333
test("compaction describes tool media without embedding base64",()=>{
1434
constbase64="iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
1535
constserialized=SessionCompaction.serializeToolContent([

‎packages/core/test/session-runner.test.ts‎

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1135,7 +1135,7 @@ describe("SessionRunnerLLM", () => {
11351135

11361136
expect(requests).toHaveLength(2)
11371137
expect(userTexts(requests[0])[0]).toContain(
1138-
"<previous-summary>\n## Objective\n- Preserve the task\n</previous-summary>",
1138+
"<prior-summary>\n## Objective\n- Preserve the task\n</prior-summary>",
11391139
)
11401140
expect(userTexts(requests[0])[0]).toContain("Recent exact request")
11411141
expect((yield*(yield*SessionStore.Service).context(sessionID))[0]).toMatchObject({
@@ -1145,6 +1145,67 @@ describe("SessionRunnerLLM", () => {
11451145
}),
11461146
)
11471147

1148+
it.effect("retains only complete serialized messages during compaction",()=>
1149+
Effect.gen(function*(){
1150+
yield*setup
1151+
constsession=yield*SessionV2.Service
1152+
constearlier=`EARLIER_BOUNDARY ${"a".repeat(3_000)} EARLIER_END`
1153+
constrecent=`RECENT_BOUNDARY ${"b".repeat(3_000)} RECENT_END`
1154+
response=fragmentFixture("text","text-earlier",["Earlier answer"]).completeEvents
1155+
yield*session.prompt({ sessionID,prompt: Prompt.make({text: earlier}),resume: false})
1156+
yield*session.resume(sessionID)
1157+
1158+
currentModel=compactModel
1159+
requests.length=0
1160+
responses=[
1161+
fragmentFixture("text","text-summary",["## Objective\n- Preserve the task"]).completeEvents,
1162+
fragmentFixture("text","text-final",["Continued"]).completeEvents,
1163+
]
1164+
yield*session.prompt({ sessionID,prompt: Prompt.make({text: recent}),resume: false})
1165+
yield*session.resume(sessionID)
1166+
1167+
expect(requests).toHaveLength(2)
1168+
constsummary=userTexts(requests[0])[0]
1169+
constcontinuation=userTexts(requests[1])[0]
1170+
expect(summary.match(/EARLIER_BOUNDARY/g)).toHaveLength(1)
1171+
expect(summary).toContain(`EARLIER_BOUNDARY ${"a".repeat(3_000)} EARLIER_END`)
1172+
expect(summary).not.toContain("RECENT_BOUNDARY")
1173+
expect(continuation).not.toContain("EARLIER_BOUNDARY")
1174+
expect(continuation).not.toContain("EARLIER_END")
1175+
expect(continuation).toContain("<recent-context>\n[Assistant]: Earlier answer")
1176+
expect(continuation).toContain(`RECENT_BOUNDARY ${"b".repeat(3_000)} RECENT_END`)
1177+
}),
1178+
)
1179+
1180+
it.effect("summarizes an oversized newest message without retaining a fragment",()=>
1181+
Effect.gen(function*(){
1182+
yield*setup
1183+
constsession=yield*SessionV2.Service
1184+
response=fragmentFixture("text","text-earlier",["Earlier answer"]).completeEvents
1185+
yield*session.prompt({ sessionID,prompt: Prompt.make({text: "Earlier question"}),resume: false})
1186+
yield*session.resume(sessionID)
1187+
1188+
constoversized=`OVERSIZED_BOUNDARY ${"x".repeat(4_500)} OVERSIZED_END`
1189+
currentModel=compactModel
1190+
requests.length=0
1191+
responses=[
1192+
fragmentFixture("text","text-summary",["## Objective\n- Preserve the task"]).completeEvents,
1193+
fragmentFixture("text","text-final",["Continued"]).completeEvents,
1194+
]
1195+
yield*session.prompt({ sessionID,prompt: Prompt.make({text: oversized}),resume: false})
1196+
yield*session.resume(sessionID)
1197+
1198+
expect(requests).toHaveLength(2)
1199+
constsummary=userTexts(requests[0])[0]
1200+
constcontinuation=userTexts(requests[1])[0]
1201+
expect(summary.match(/OVERSIZED_BOUNDARY/g)).toHaveLength(1)
1202+
expect(summary).toContain(oversized)
1203+
expect(continuation).not.toContain("OVERSIZED_BOUNDARY")
1204+
expect(continuation).not.toContain("OVERSIZED_END")
1205+
expect(continuation).toContain("<recent-context>\n\n</recent-context>")
1206+
}),
1207+
)
1208+
11481209
it.effect("forces one compaction and retries after provider context overflow",()=>
11491210
Effect.gen(function*(){
11501211
constsession=yield*setupOverflowRecovery
Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
1-
You are an anchored context summarization assistant for coding sessions.
2-
3-
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.
4-
5-
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.
1+
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.
62

73
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.
84

9-
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.
5+
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.

‎packages/opencode/src/session/compaction.ts‎

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,8 @@ export const PRUNE_MINIMUM = 20_000
2929
exportconstPRUNE_PROTECT=40_000
3030
constTOOL_OUTPUT_MAX_CHARS=2_000
3131
constPRUNE_PROTECTED_TOOLS=["skill"]
32-
constDEFAULT_TAIL_TURNS=2
3332
constMIN_PRESERVE_RECENT_TOKENS=2_000
34-
constMAX_PRESERVE_RECENT_TOKENS=8_000
33+
constMAX_PRESERVE_RECENT_TOKENS=15_000
3534
typeTurn={
3635
start: number
3736
end: number
@@ -226,27 +225,22 @@ const layer = Layer.effect(
226225
cfg: ConfigV1.Info
227226
model: Provider.Model
228227
}){
229-
constlimit=input.cfg.compaction?.tail_turns??DEFAULT_TAIL_TURNS
230-
if(limit<=0)return{head: input.messages,tail_start_id: undefined}
228+
constlimit=input.cfg.compaction?.tail_turns
229+
if(limit!==undefined&&limit<=0)return{head: input.messages,tail_start_id: undefined}
231230
constbudget=preserveRecentBudget({cfg: input.cfg,model: input.model})
232231
constall=turns(input.messages)
233232
if(!all.length)return{head: input.messages,tail_start_id: undefined}
234-
constrecent=all.slice(-limit)
235-
constsizes=yield*Effect.forEach(
236-
recent,
237-
(turn)=>
238-
estimate({
239-
messages: input.messages.slice(turn.start,turn.end),
240-
model: input.model,
241-
}),
242-
{concurrency: 1},
243-
)
233+
constrecent=limit===undefined ? all : all.slice(-limit)
244234

245235
lettotal=0
246236
letkeep: Tail|undefined
247237
for(leti=recent.length-1;i>=0;i--){
248238
constturn=recent[i]!
249-
constsize=sizes[i]
239+
// estimate lazily so cost stays proportional to the retained tail, not the whole session
240+
constsize=yield*estimate({
241+
messages: input.messages.slice(turn.start,turn.end),
242+
model: input.model,
243+
})
250244
if(total+size<=budget){
251245
total+=size
252246
keep={start: turn.start,id: turn.id}
@@ -381,10 +375,20 @@ const layer = Layer.effect(
381375
{sessionID: input.sessionID},
382376
{context: [],prompt: undefined},
383377
)
384-
constnextPrompt=compacting.prompt??buildPrompt({ previousSummary,context: compacting.context})
385378
constmsgs=structuredClone(selected.head)
386379
yield*plugin.trigger("experimental.chat.messages.transform",{},{messages: msgs})
387380
constconversation=msgs.map(serialize).filter(Boolean).join("\n\n")
381+
constnextPrompt=
382+
compacting.prompt??
383+
[
384+
buildPrompt({
385+
previousSummary,
386+
context: [conversation],
387+
}),
388+
...compacting.context,
389+
]
390+
.filter(Boolean)
391+
.join("\n\n")
388392
constctx=yield*InstanceState.context
389393
constmsg: SessionV1.Assistant={
390394
id: MessageID.ascending(),
@@ -430,7 +434,10 @@ const layer = Layer.effect(
430434
content: [
431435
{
432436
type: "text",
433-
text: [nextPrompt,"The following is the conversation history:",conversation]
437+
text: [
438+
nextPrompt,
439+
...(compacting.prompt ? ["The following is the conversation history:",conversation] : []),
440+
]
434441
.filter(Boolean)
435442
.join("\n\n"),
436443
},

0 commit comments

Comments
 (0)