Skip to content

Commit 574b2c2

Browse files
nexxelnrekram1-node
authored andcommitted
fix(session): improve session compaction (anomalyco#23870)
1 parent fa8b7bc commit 574b2c2

6 files changed

Lines changed: 561 additions & 83 deletions

File tree

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
1-
You are a helpful AI assistant tasked with summarizing conversations.
1+
You are an anchored context summarization assistant for coding sessions.
22

3-
When asked to summarize, provide a detailed but concise summary of the older conversation history.
4-
The most recent turns may be preserved verbatim outside your summary, so focus on information that would still be needed to continue the work with that recent context available.
5-
Focus on information that would be helpful for continuing the conversation, including:
6-
- What was done
7-
- What is currently being worked on
8-
- Which files are being modified
9-
- What needs to be done next
10-
- Key user requests, constraints, or preferences that should persist
11-
- Important technical decisions and why they were made
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.
124

13-
Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.
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.
146

15-
Do not respond to any questions in the conversation, only output the summary.
16-
Respond in the same language the user used in the conversation.
7+
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.
8+
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.

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

Lines changed: 151 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,105 @@ export const Event = {
3232

3333
export const PRUNE_MINIMUM = 20_000
3434
export const PRUNE_PROTECT = 40_000
35+
const TOOL_OUTPUT_MAX_CHARS = 2_000
3536
const PRUNE_PROTECTED_TOOLS = ["skill"]
3637
const DEFAULT_TAIL_TURNS = 2
3738
const MIN_PRESERVE_RECENT_TOKENS = 2_000
3839
const MAX_PRESERVE_RECENT_TOKENS = 8_000
40+
const SUMMARY_TEMPLATE = `Output exactly this Markdown structure and keep the section order unchanged:
41+
---
42+
## Goal
43+
- [single-sentence task summary]
44+
45+
## Constraints & Preferences
46+
- [user constraints, preferences, specs, or "(none)"]
47+
48+
## Progress
49+
### Done
50+
- [completed work or "(none)"]
51+
52+
### In Progress
53+
- [current work or "(none)"]
54+
55+
### Blocked
56+
- [blockers or "(none)"]
57+
58+
## Key Decisions
59+
- [decision and why, or "(none)"]
60+
61+
## Next Steps
62+
- [ordered next actions or "(none)"]
63+
64+
## Critical Context
65+
- [important technical facts, errors, open questions, or "(none)"]
66+
67+
## Relevant Files
68+
- [file or directory path: why it matters, or "(none)"]
69+
---
70+
71+
Rules:
72+
- Keep every section, even when empty.
73+
- Use terse bullets, not prose paragraphs.
74+
- Preserve exact file paths, commands, error strings, and identifiers when known.
75+
- Do not mention the summary process or that context was compacted.`
3976
type Turn = {
4077
start: number
4178
end: number
4279
id: MessageID
4380
}
4481

82+
type Tail = {
83+
start: number
84+
id: MessageID
85+
}
86+
87+
type CompletedCompaction = {
88+
userIndex: number
89+
assistantIndex: number
90+
summary: string | undefined
91+
}
92+
93+
function summaryText(message: MessageV2.WithParts) {
94+
const text = message.parts
95+
.filter((part): part is MessageV2.TextPart => part.type === "text")
96+
.map((part) => part.text.trim())
97+
.filter(Boolean)
98+
.join("\n\n")
99+
.trim()
100+
return text || undefined
101+
}
102+
103+
function completedCompactions(messages: MessageV2.WithParts[]) {
104+
const users = new Map<MessageID, number>()
105+
for (let i = 0; i < messages.length; i++) {
106+
const msg = messages[i]
107+
if (msg.info.role !== "user") continue
108+
if (!msg.parts.some((part) => part.type === "compaction")) continue
109+
users.set(msg.info.id, i)
110+
}
111+
112+
return messages.flatMap((msg, assistantIndex): CompletedCompaction[] => {
113+
if (msg.info.role !== "assistant") return []
114+
if (!msg.info.summary || !msg.info.finish || msg.info.error) return []
115+
const userIndex = users.get(msg.info.parentID)
116+
if (userIndex === undefined) return []
117+
return [{ userIndex, assistantIndex, summary: summaryText(msg) }]
118+
})
119+
}
120+
121+
function buildPrompt(input: { previousSummary?: string; context: string[] }) {
122+
const anchor = input.previousSummary
123+
? [
124+
"Update the anchored summary below using the conversation history above.",
125+
"Preserve still-true details, remove stale details, and merge in the new facts.",
126+
"<previous-summary>",
127+
input.previousSummary,
128+
"</previous-summary>",
129+
].join("\n")
130+
: "Create a new anchored summary from the conversation history above."
131+
return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n")
132+
}
133+
45134
function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) {
46135
return (
47136
input.cfg.compaction?.preserve_recent_tokens ??
@@ -67,6 +156,31 @@ function turns(messages: MessageV2.WithParts[]) {
67156
return result
68157
}
69158

159+
function splitTurn(input: {
160+
messages: MessageV2.WithParts[]
161+
turn: Turn
162+
model: Provider.Model
163+
budget: number
164+
estimate: (input: { messages: MessageV2.WithParts[]; model: Provider.Model }) => Effect.Effect<number>
165+
}) {
166+
return Effect.gen(function* () {
167+
if (input.budget <= 0) return undefined
168+
if (input.turn.end - input.turn.start <= 1) return undefined
169+
for (let start = input.turn.start + 1; start < input.turn.end; start++) {
170+
const size = yield* input.estimate({
171+
messages: input.messages.slice(start, input.turn.end),
172+
model: input.model,
173+
})
174+
if (size > input.budget) continue
175+
return {
176+
start,
177+
id: input.messages[start]!.info.id,
178+
} satisfies Tail
179+
}
180+
return undefined
181+
})
182+
}
183+
70184
export interface Interface {
71185
readonly isOverflow: (input: {
72186
tokens: MessageV2.Assistant["tokens"]
@@ -147,18 +261,28 @@ export const layer: Layer.Layer<
147261
}),
148262
{ concurrency: 1 },
149263
)
150-
if (sizes.at(-1)! > budget) {
151-
log.info("tail fallback", { budget, size: sizes.at(-1) })
152-
return { head: input.messages, tail_start_id: undefined }
153-
}
154264

155265
let total = 0
156-
let keep: Turn | undefined
266+
let keep: Tail | undefined
157267
for (let i = recent.length - 1; i >= 0; i--) {
268+
const turn = recent[i]!
158269
const size = sizes[i]
159-
if (total + size > budget) break
160-
total += size
161-
keep = recent[i]
270+
if (total + size <= budget) {
271+
total += size
272+
keep = { start: turn.start, id: turn.id }
273+
continue
274+
}
275+
const remaining = budget - total
276+
const split = yield* splitTurn({
277+
messages: input.messages,
278+
turn,
279+
model: input.model,
280+
budget: remaining,
281+
estimate,
282+
})
283+
if (split) keep = split
284+
else if (!keep) log.info("tail fallback", { budget, size, total })
285+
break
162286
}
163287

164288
if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined }
@@ -192,17 +316,15 @@ export const layer: Layer.Layer<
192316
if (msg.info.role === "assistant" && msg.info.summary) break loop
193317
for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
194318
const part = msg.parts[partIndex]
195-
if (part.type === "tool")
196-
if (part.state.status === "completed") {
197-
if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
198-
if (part.state.time.compacted) break loop
199-
const estimate = Token.estimate(part.state.output)
200-
total += estimate
201-
if (total > PRUNE_PROTECT) {
202-
pruned += estimate
203-
toPrune.push(part)
204-
}
205-
}
319+
if (part.type !== "tool") continue
320+
if (part.state.status !== "completed") continue
321+
if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
322+
if (part.state.time.compacted) break loop
323+
const estimate = Token.estimate(part.state.output)
324+
total += estimate
325+
if (total <= PRUNE_PROTECT) continue
326+
pruned += estimate
327+
toPrune.push(part)
206328
}
207329
}
208330

@@ -263,8 +385,11 @@ export const layer: Layer.Layer<
263385
: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
264386
const cfg = yield* config.get()
265387
const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages
388+
const prior = completedCompactions(history)
389+
const hidden = new Set(prior.flatMap((item) => [item.userIndex, item.assistantIndex]))
390+
const previousSummary = prior.at(-1)?.summary
266391
const selected = yield* select({
267-
messages: history,
392+
messages: history.filter((_, index) => !hidden.has(index)),
268393
cfg,
269394
model,
270395
})
@@ -274,34 +399,13 @@ export const layer: Layer.Layer<
274399
{ sessionID: input.sessionID },
275400
{ context: [], prompt: undefined },
276401
)
277-
const defaultPrompt = `When constructing the summary, try to stick to this template:
278-
---
279-
## Goal
280-
281-
[What goal(s) is the user trying to accomplish?]
282-
283-
## Instructions
284-
285-
- [What important instructions did the user give you that are relevant]
286-
- [If there is a plan or spec, include information about it so next agent can continue using it]
287-
288-
## Discoveries
289-
290-
[What notable things were learned during this conversation that would be useful for the next agent to know when continuing the work]
291-
292-
## Accomplished
293-
294-
[What work has been completed, what work is still in progress, and what work is left?]
295-
296-
## Relevant files / directories
297-
298-
[Construct a structured list of relevant files that have been read, edited, or created that pertain to the task at hand. If all the files in a directory are relevant, include the path to the directory.]
299-
---`
300-
301-
const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
402+
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
302403
const msgs = structuredClone(selected.head)
303404
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
304-
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true })
405+
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
406+
stripMedia: true,
407+
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
408+
})
305409
const ctx = yield* InstanceState.context
306410
const msg: MessageV2.Assistant = {
307411
id: MessageID.ascending(),
@@ -345,7 +449,7 @@ export const layer: Layer.Layer<
345449
...modelMessages,
346450
{
347451
role: "user",
348-
content: [{ type: "text", text: prompt }],
452+
content: [{ type: "text", text: nextPrompt }],
349453
},
350454
],
351455
model,

‎packages/opencode/src/session/message-v2.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,12 @@ export const ToolStateCompleted = Schema.Struct({
319319
.pipe(withStatics((s) => ({ zod: zod(s) })))
320320
export type ToolStateCompleted = Types.DeepMutable<Schema.Schema.Type<typeof ToolStateCompleted>>
321321

322+
function truncateToolOutput(text: string, maxChars?: number) {
323+
if (!maxChars || text.length <= maxChars) return text
324+
const omitted = text.length - maxChars
325+
return `${text.slice(0, maxChars)}\n[Tool output truncated for compaction: omitted ${omitted} chars]`
326+
}
327+
322328
export const ToolStateError = Schema.Struct({
323329
status: Schema.Literal("error"),
324330
input: Schema.Record(Schema.String, Schema.Any),
@@ -700,7 +706,7 @@ function providerMeta(metadata: Record<string, any> | undefined) {
700706
export const toModelMessagesEffect = Effect.fnUntraced(function* (
701707
input: WithParts[],
702708
model: Provider.Model,
703-
options?: { stripMedia?: boolean },
709+
options?: { stripMedia?: boolean; toolOutputMaxChars?: number },
704710
) {
705711
const result: UIMessage[] = []
706712
const toolNames = new Set<string>()
@@ -839,7 +845,9 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
839845
if (part.type === "tool") {
840846
toolNames.add(part.tool)
841847
if (part.state.status === "completed") {
842-
const outputText = part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output
848+
const outputText = part.state.time.compacted
849+
? "[Old tool result content cleared]"
850+
: truncateToolOutput(part.state.output, options?.toolOutputMaxChars)
843851
const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? [])
844852

845853
// For providers that don't support media in tool results, extract media files
@@ -955,7 +963,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
955963
export function toModelMessages(
956964
input: WithParts[],
957965
model: Provider.Model,
958-
options?: { stripMedia?: boolean },
966+
options?: { stripMedia?: boolean; toolOutputMaxChars?: number },
959967
): Promise<ModelMessage[]> {
960968
return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer)))
961969
}

0 commit comments

Comments
 (0)