Compare commits

...

6 Commits

Author SHA1 Message Date
Aiden Cline 169560a557 fix(compaction): ignore synthetic tail turn boundaries 2026-05-12 10:14:54 -05:00
Aiden Cline 831a18faaf Merge branch 'dev' into fix-aws-issue 2026-05-12 09:53:10 -05:00
Aiden Cline 357cdcfcad test(compaction): read latest compaction marker 2026-05-12 09:48:27 -05:00
Aiden Cline e0501ac50b fix(compaction): skip tail persistence on error 2026-05-12 01:43:51 -05:00
Aiden Cline 44062b7976 fix(compaction): clarify serialized tail context 2026-05-12 01:30:20 -05:00
Aiden Cline a6c1941b1f fix(compaction): persist serialized tail separately 2026-05-12 01:02:41 -05:00
3 changed files with 157 additions and 43 deletions
+82 -31
View File
@@ -119,39 +119,58 @@ function completedCompactions(messages: MessageV2.WithParts[]) {
}) })
} }
function buildPrompt(input: { previousSummary?: string; context: string[]; tail?: string }) { function buildPrompt(input: { previousSummary?: string; context: string[] }) {
const source = input.tail
? "the conversation history above and the serialized recent conversation tail below"
: "the conversation history above"
const anchor = input.previousSummary 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.", "Preserve still-true details, remove stale details, and merge in the new facts.",
"<previous-summary>", "<previous-summary>",
input.previousSummary, input.previousSummary,
"</previous-summary>", "</previous-summary>",
].join("\n") ].join("\n")
: `Create a new anchored summary from ${source}.` : "Create a new anchored summary from the conversation history above."
const tail = input.tail return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n")
? [
"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")
} }
const serialize = Effect.fn("SessionCompaction.serialize")(function* (input: { const serialize = Effect.fn("SessionCompaction.serialize")(function* (input: {
messages: MessageV2.WithParts[] messages: MessageV2.WithParts[]
model: Provider.Model
}) { }) {
const messages = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, { const parts = input.messages.flatMap((msg) => {
stripMedia: true, if (msg.info.role === "user") {
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, 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 }) { function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) {
@@ -167,6 +186,7 @@ function turns(messages: MessageV2.WithParts[]) {
const msg = messages[i] const msg = messages[i]
if (msg.info.role !== "user") continue if (msg.info.role !== "user") continue
if (msg.parts.some((part) => part.type === "compaction")) 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({ result.push({
start: i, start: i,
end: messages.length, end: messages.length,
@@ -262,7 +282,7 @@ export const layer: Layer.Layer<
messages: MessageV2.WithParts[] messages: MessageV2.WithParts[]
model: Provider.Model 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: { const select = Effect.fn("SessionCompaction.select")(function* (input: {
@@ -425,8 +445,8 @@ export const layer: Layer.Layer<
) )
const tailMessages = structuredClone(selected.tail) const tailMessages = structuredClone(selected.tail)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: tailMessages }) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: tailMessages })
const tail = yield* serialize({ messages: tailMessages, model }) const tail = yield* serialize({ messages: tailMessages })
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context, tail }) 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 modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
@@ -493,6 +513,37 @@ export const layer: Layer.Layer<
return "stop" return "stop"
} }
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 },
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 (result === "continue" && input.auto) {
if (replay) { if (replay) {
const original = replay.info const original = replay.info
@@ -575,19 +626,19 @@ export const layer: Layer.Layer<
} }
} }
if (processor.message.error) return "stop"
if (result === "continue") { if (result === "continue") {
const summary = summaryText( const summaryMessage = (yield* session.messages({ sessionID: input.sessionID })).find(
(yield* session.messages({ sessionID: input.sessionID })).find((item) => item.info.id === msg.id) ?? { (item) => item.info.id === msg.id,
info: msg, ) ?? {
parts: [], info: msg,
}, parts: [],
) }
const summary = summaryText(summaryMessage) ?? ""
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
yield* sync.run(SessionEvent.Compaction.Ended.Sync, { yield* sync.run(SessionEvent.Compaction.Ended.Sync, {
sessionID: input.sessionID, sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(Date.now()), timestamp: DateTime.makeUnsafe(Date.now()),
text: summary ?? "", text: summary,
}) })
} }
yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
+6 -1
View File
@@ -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)[] = [] let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = []
for (let i = msgs.length - 1; i >= 0; i--) { for (let i = msgs.length - 1; i >= 0; i--) {
const msg = msgs[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 (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info
if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info
if (lastUser && lastFinished) break if (lastUser && lastFinished) break
@@ -282,7 +282,9 @@ function createSummaryCompaction(sessionID: SessionID) {
function readCompactionPart(sessionID: SessionID) { function readCompactionPart(sessionID: SessionID) {
return SessionNs.Service.use((ssn) => ssn.messages({ sessionID })).pipe( return SessionNs.Service.use((ssn) => ssn.messages({ sessionID })).pipe(
Effect.map((messages) => 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"),
), ),
) )
} }
@@ -1005,7 +1007,7 @@ describe("session.compaction.process", () => {
) )
itCompaction.instance( 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() const stub = llm()
let captured = "" let captured = ""
@@ -1034,8 +1036,13 @@ describe("session.compaction.process", () => {
const part = yield* readCompactionPart(session.id) const part = yield* readCompactionPart(session.id)
expect(part?.type).toBe("compaction") expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBeUndefined() expect(part?.tail_start_id).toBeUndefined()
expect(captured).toContain("recent image turn") expect(captured).not.toContain("recent image turn")
expect(captured).toContain("Attached image/png: big.png") 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 }) })) }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }))
}, },
{ git: true }, { git: true },
@@ -1080,12 +1087,17 @@ describe("session.compaction.process", () => {
expect(part?.type).toBe("compaction") expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBeUndefined() expect(part?.tail_start_id).toBeUndefined()
expect(captured).toContain("zzzz") 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)) 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).toBe("assistant")
expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) 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(large.id)
expect(filtered.map((msg) => msg.info.id)).not.toContain(keep.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 }) })) }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }))
@@ -1112,7 +1124,9 @@ describe("session.compaction.process", () => {
const last = all.at(-1) const last = all.at(-1)
expect(result).toBe("continue") 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( expect(
all.some( all.some(
(msg) => (msg) =>
@@ -1354,7 +1368,7 @@ describe("session.compaction.process", () => {
) )
itCompaction.instance( 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() const stub = llm()
let captured: LLM.StreamInput["messages"] = [] let captured: LLM.StreamInput["messages"] = []
@@ -1386,10 +1400,16 @@ describe("session.compaction.process", () => {
expect(head).toContain("older context") expect(head).toContain("older context")
expect(head).not.toContain("keep this turn") expect(head).not.toContain("keep this turn")
expect(head).not.toContain("and this one too") expect(head).not.toContain("and this one too")
expect(prompt).toContain("keep this turn") expect(prompt).not.toContain("keep this turn")
expect(prompt).toContain("and this one too") expect(prompt).not.toContain("and this one too")
expect(prompt).toContain("recent-conversation-tail") expect(prompt).not.toContain("latest-messages")
expect(prompt).not.toContain("What did we do so far?") 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 })) }).pipe(withCompaction({ llm: stub.layer }))
}, },
{ git: true }, { git: true },
@@ -1477,6 +1497,44 @@ describe("session.compaction.process", () => {
}).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })) }).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( itCompaction.instance(
"ignores previous summaries when sizing the serialized tail", "ignores previous summaries when sizing the serialized tail",
Effect.gen(function* () { Effect.gen(function* () {