Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 82 additions & 31 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,39 +119,58 @@ function completedCompactions(messages: MessageV2.WithParts[]) {
})
}

function buildPrompt(input: { previousSummary?: string; context: string[]; tail?: string }) {
const source = input.tail
? "the conversation history above and the serialized recent conversation tail below"
: "the conversation history above"
function buildPrompt(input: { previousSummary?: string; context: string[] }) {
const anchor = input.previousSummary
? [
`Update the anchored summary below using ${source}.`,
"Update the anchored summary below using the conversation history above.",
"Preserve still-true details, remove stale details, and merge in the new facts.",
"<previous-summary>",
input.previousSummary,
"</previous-summary>",
].join("\n")
: `Create a new anchored summary from ${source}.`
const tail = input.tail
? [
"Fold this serialized recent conversation tail into the summary; it is not provider message history.",
"<recent-conversation-tail>",
input.tail,
"</recent-conversation-tail>",
].join("\n")
: undefined
return [anchor, ...(tail ? [tail] : []), SUMMARY_TEMPLATE, ...input.context].join("\n\n")
: "Create a new anchored summary from the conversation history above."
return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n")
}

const serialize = Effect.fn("SessionCompaction.serialize")(function* (input: {
messages: MessageV2.WithParts[]
model: Provider.Model
}) {
const messages = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, {
stripMedia: true,
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
const parts = input.messages.flatMap((msg) => {
if (msg.info.role === "user") {
const content = msg.parts
.flatMap((part) => {
if (part.type === "text" && !part.ignored && part.text !== "") return [part.text]
if (part.type === "file" && MessageV2.isMedia(part.mime)) return [`[Attached ${part.mime}: ${part.filename ?? "file"}]`]
return []
})
.join("\n")
return content ? [`[User]: ${content}`] : []
}
if (msg.info.role === "assistant") {
return msg.parts.flatMap((part) => {
if (part.type === "reasoning") return part.text ? [`[Assistant thinking]: ${part.text}`] : []
if (part.type === "text") return part.text ? [`[Assistant]: ${part.text}`] : []
if (part.type !== "tool") return []
const input = Object.entries(part.state.input)
.map(([key, value]) => `${key}=${JSON.stringify(value)}`)
.join(", ")
if (part.state.status === "completed") {
const output = part.state.time.compacted
? "[Old tool result content cleared]"
: part.state.output.length <= TOOL_OUTPUT_MAX_CHARS
? part.state.output
: `${part.state.output.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[Tool output truncated for compaction: omitted ${part.state.output.length - TOOL_OUTPUT_MAX_CHARS} chars]`
return [`[Assistant tool call]: ${part.tool}(${input})`, `[Tool result]: ${output}`]
}
if (part.state.status === "error") {
return [`[Assistant tool call]: ${part.tool}(${input})`, `[Tool error]: ${part.state.error}`]
}
return [`[Assistant tool call]: ${part.tool}(${input})`]
})
}
return []
})
return messages.length ? JSON.stringify(messages, null, 2) : undefined
return parts.length ? parts.join("\n\n") : undefined
})

function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) {
Expand All @@ -167,6 +186,7 @@ function turns(messages: MessageV2.WithParts[]) {
const msg = messages[i]
if (msg.info.role !== "user") continue
if (msg.parts.some((part) => part.type === "compaction")) continue
if (msg.parts.some((part) => part.type === "text" && part.metadata?.compaction_tail === true)) continue
result.push({
start: i,
end: messages.length,
Expand Down Expand Up @@ -262,7 +282,7 @@ export const layer: Layer.Layer<
messages: MessageV2.WithParts[]
model: Provider.Model
}) {
return Token.estimate((yield* serialize(input)) ?? "")
return Token.estimate((yield* serialize({ messages: input.messages })) ?? "")
})

const select = Effect.fn("SessionCompaction.select")(function* (input: {
Expand Down Expand Up @@ -425,8 +445,8 @@ export const layer: Layer.Layer<
)
const tailMessages = structuredClone(selected.tail)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: tailMessages })
const tail = yield* serialize({ messages: tailMessages, model })
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context, tail })
const tail = yield* serialize({ messages: tailMessages })
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
const msgs = structuredClone(selected.head)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
Expand Down Expand Up @@ -493,6 +513,37 @@ export const layer: Layer.Layer<
return "stop"
Comment thread
rekram1-node marked this conversation as resolved.
}

if (processor.message.error) return "stop"

if (tail) {
const tailMessage = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: { created: Date.now() },
agent: userMessage.agent,
model: userMessage.model,
format: userMessage.format,
tools: userMessage.tools,
system: userMessage.system,
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: tailMessage.id,
sessionID: input.sessionID,
type: "text",
metadata: { compaction_tail: true },
Comment thread
rekram1-node marked this conversation as resolved.
synthetic: true,
text: [
"The conversation history before this point was compacted into the summary above.",
"The following messages are the latest conversation turns after that summarized history.",
"<latest-messages>",
tail,
"</latest-messages>",
].join("\n\n"),
})
}

if (result === "continue" && input.auto) {
if (replay) {
const original = replay.info
Expand Down Expand Up @@ -575,19 +626,19 @@ export const layer: Layer.Layer<
}
}

if (processor.message.error) return "stop"
if (result === "continue") {
const summary = summaryText(
(yield* session.messages({ sessionID: input.sessionID })).find((item) => item.info.id === msg.id) ?? {
info: msg,
parts: [],
},
)
const summaryMessage = (yield* session.messages({ sessionID: input.sessionID })).find(
(item) => item.info.id === msg.id,
) ?? {
info: msg,
parts: [],
}
const summary = summaryText(summaryMessage) ?? ""
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
yield* sync.run(SessionEvent.Compaction.Ended.Sync, {
sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(Date.now()),
text: summary ?? "",
text: summary,
})
}
yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1642,7 +1642,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = []
for (let i = msgs.length - 1; i >= 0; i--) {
const msg = msgs[i]
if (!lastUser && msg.info.role === "user") lastUser = msg.info
if (
!lastUser &&
msg.info.role === "user" &&
!msg.parts.some((part) => part.type === "text" && part.metadata?.compaction_tail === true)
)
lastUser = msg.info
if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info
if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info
if (lastUser && lastFinished) break
Expand Down
80 changes: 69 additions & 11 deletions packages/opencode/test/session/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,9 @@ function createSummaryCompaction(sessionID: SessionID) {
function readCompactionPart(sessionID: SessionID) {
return SessionNs.Service.use((ssn) => ssn.messages({ sessionID })).pipe(
Effect.map((messages) =>
messages.at(-2)?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction"),
messages
.findLast((message) => message.parts.some((item) => item.type === "compaction"))
?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction"),
),
)
}
Expand Down Expand Up @@ -1005,7 +1007,7 @@ describe("session.compaction.process", () => {
)

itCompaction.instance(
"serializes retained tail media as text in the summary input",
"serializes retained tail media as text in the saved summary",
() => {
const stub = llm()
let captured = ""
Expand Down Expand Up @@ -1034,8 +1036,13 @@ describe("session.compaction.process", () => {
const part = yield* readCompactionPart(session.id)
expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBeUndefined()
expect(captured).toContain("recent image turn")
expect(captured).toContain("Attached image/png: big.png")
expect(captured).not.toContain("recent image turn")
expect(captured).not.toContain("Attached image/png: big.png")
const tail = (yield* ssn.messages({ sessionID: session.id })).find(
(item) => item.info.role === "user" && item.parts.some((part) => part.type === "text" && part.synthetic),
)
expect(JSON.stringify(tail?.parts)).toContain("recent image turn")
expect(JSON.stringify(tail?.parts)).toContain("Attached image/png: big.png")
}).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }))
},
{ git: true },
Expand Down Expand Up @@ -1080,12 +1087,17 @@ describe("session.compaction.process", () => {
expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBeUndefined()
expect(captured).toContain("zzzz")
expect(captured).toContain("keep tail")
expect(captured).not.toContain("keep tail")
const tail = (yield* ssn.messages({ sessionID: session.id })).find(
(item) => item.info.role === "user" && item.parts.some((part) => part.type === "text" && part.synthetic),
)
expect(JSON.stringify(tail?.parts)).toContain("keep tail")

const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(filtered.map((msg) => msg.info.id)).toEqual([parent!, expect.any(String)])
expect(filtered.map((msg) => msg.info.id)).toEqual([parent!, expect.any(String), expect.any(String)])
expect(filtered[1]?.info.role).toBe("assistant")
expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true)
expect(filtered[2]?.info.role).toBe("user")
expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id)
expect(filtered.map((msg) => msg.info.id)).not.toContain(keep.id)
}).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }))
Expand All @@ -1112,7 +1124,9 @@ describe("session.compaction.process", () => {
const last = all.at(-1)

expect(result).toBe("continue")
expect(last?.info.role).toBe("assistant")
expect(last?.info.role).toBe("user")
expect(last?.parts.some((part) => part.type === "text" && part.text.includes("latest-messages"))).toBe(true)
expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true)
expect(
all.some(
(msg) =>
Expand Down Expand Up @@ -1354,7 +1368,7 @@ describe("session.compaction.process", () => {
)

itCompaction.instance(
"summarizes the head while serializing recent tail into summary input",
"summarizes the head while appending serialized recent tail to saved summary",
() => {
const stub = llm()
let captured: LLM.StreamInput["messages"] = []
Expand Down Expand Up @@ -1386,10 +1400,16 @@ describe("session.compaction.process", () => {
expect(head).toContain("older context")
expect(head).not.toContain("keep this turn")
expect(head).not.toContain("and this one too")
expect(prompt).toContain("keep this turn")
expect(prompt).toContain("and this one too")
expect(prompt).toContain("recent-conversation-tail")
expect(prompt).not.toContain("keep this turn")
expect(prompt).not.toContain("and this one too")
expect(prompt).not.toContain("latest-messages")
expect(prompt).not.toContain("What did we do so far?")
const tail = (yield* ssn.messages({ sessionID: session.id })).find(
(item) => item.info.role === "user" && item.parts.some((part) => part.type === "text" && part.synthetic),
)
expect(JSON.stringify(tail?.parts)).toContain("keep this turn")
expect(JSON.stringify(tail?.parts)).toContain("and this one too")
expect(JSON.stringify(tail?.parts)).toContain("latest-messages")
}).pipe(withCompaction({ llm: stub.layer }))
},
{ git: true },
Expand Down Expand Up @@ -1477,6 +1497,44 @@ describe("session.compaction.process", () => {
}).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) }))
})

itCompaction.instance("summarizes previous synthetic tail on repeated compaction", () => {
const stub = llm()
let captured = ""
stub.push(reply("summary one"))
stub.push(reply("summary two", (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")
yield* createUserMessage(session.id, "previous tail")
yield* createCompactionMarker(session.id)

let msgs = yield* ssn.messages({ sessionID: session.id })
let parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })

yield* createUserMessage(session.id, "new tail")
yield* createCompactionMarker(session.id)

msgs = MessageV2.filterCompacted(MessageV2.stream(session.id))
parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })

const tails = (yield* ssn.messages({ sessionID: session.id })).filter(
(item) => item.info.role === "user" && item.parts.some((part) => part.type === "text" && part.metadata?.compaction_tail === true),
)
const latestTail = tails.at(-1)

expect(captured).toContain("previous tail")
expect(captured).toContain("latest-messages")
expect(JSON.stringify(latestTail?.parts)).toContain("new tail")
expect(JSON.stringify(latestTail?.parts)).not.toContain("previous tail")
}).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 10_000 }) }))
})

itCompaction.instance(
"ignores previous summaries when sizing the serialized tail",
Effect.gen(function* () {
Expand Down
Loading