mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 19:09:49 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 169560a557 | |||
| 831a18faaf | |||
| 357cdcfcad | |||
| e0501ac50b | |||
| 44062b7976 | |||
| a6c1941b1f |
@@ -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 })
|
||||||
|
|||||||
@@ -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* () {
|
||||||
|
|||||||
Reference in New Issue
Block a user