feat(core): compact v2 session context (#30986)
This commit is contained in:
@@ -23,13 +23,6 @@ function ownedAssistant(messages: SessionMessage[], messageID: string) {
|
||||
return message?.type === "assistant" ? message : undefined
|
||||
}
|
||||
|
||||
function activeCompaction(messages: SessionMessage[]) {
|
||||
const index = messages.findIndex((message) => message.type === "compaction")
|
||||
if (index < 0) return
|
||||
const compaction = messages[index]
|
||||
return compaction?.type === "compaction" ? compaction : undefined
|
||||
}
|
||||
|
||||
function activeShell(messages: SessionMessage[], callID: string) {
|
||||
const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID)
|
||||
if (index < 0) return
|
||||
@@ -410,32 +403,21 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
||||
})
|
||||
break
|
||||
case "session.next.retried":
|
||||
break
|
||||
case "session.next.compaction.started":
|
||||
case "session.next.compaction.delta":
|
||||
break
|
||||
case "session.next.compaction.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "compaction",
|
||||
reason: event.properties.reason,
|
||||
summary: "",
|
||||
summary: event.properties.text,
|
||||
recent: event.properties.recent,
|
||||
time: { created: event.properties.timestamp },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.compaction.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = activeCompaction(draft)
|
||||
if (match) match.summary += event.properties.text
|
||||
})
|
||||
break
|
||||
case "session.next.compaction.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = activeCompaction(draft)
|
||||
if (!match) return
|
||||
match.summary = event.properties.text
|
||||
match.include = event.properties.include
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ function ShellMessage(props: { message: SessionMessageShell }) {
|
||||
}
|
||||
|
||||
function CompactionMessage(props: { message: SessionMessageCompaction }) {
|
||||
const { theme, syntax } = useTheme()
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<box
|
||||
marginTop={1}
|
||||
@@ -240,23 +240,7 @@ function CompactionMessage(props: { message: SessionMessageCompaction }) {
|
||||
titleAlignment="center"
|
||||
borderColor={theme.borderActive}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Show when={props.message.summary}>
|
||||
{(summary) => (
|
||||
<box paddingLeft={3} paddingTop={1}>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={false}
|
||||
syntaxStyle={syntax()}
|
||||
content={summary().trim()}
|
||||
conceal={true}
|
||||
fg={theme.text}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { buildPrompt } from "@opencode-ai/core/session/compaction"
|
||||
|
||||
const log = Log.create({ service: "session.compaction" })
|
||||
|
||||
@@ -43,42 +44,6 @@ const PRUNE_PROTECTED_TOOLS = ["skill"]
|
||||
const DEFAULT_TAIL_TURNS = 2
|
||||
const MIN_PRESERVE_RECENT_TOKENS = 2_000
|
||||
const MAX_PRESERVE_RECENT_TOKENS = 8_000
|
||||
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
||||
<template>
|
||||
## Goal
|
||||
- [single-sentence task summary]
|
||||
|
||||
## Constraints & Preferences
|
||||
- [user constraints, preferences, specs, or "(none)"]
|
||||
|
||||
## Progress
|
||||
### Done
|
||||
- [completed work or "(none)"]
|
||||
|
||||
### In Progress
|
||||
- [current work or "(none)"]
|
||||
|
||||
### Blocked
|
||||
- [blockers or "(none)"]
|
||||
|
||||
## Key Decisions
|
||||
- [decision and why, or "(none)"]
|
||||
|
||||
## Next Steps
|
||||
- [ordered next actions or "(none)"]
|
||||
|
||||
## Critical Context
|
||||
- [important technical facts, errors, open questions, or "(none)"]
|
||||
|
||||
## Relevant Files
|
||||
- [file or directory path: why it matters, or "(none)"]
|
||||
</template>
|
||||
|
||||
Rules:
|
||||
- Keep every section, even when empty.
|
||||
- Use terse bullets, not prose paragraphs.
|
||||
- Preserve exact file paths, commands, error strings, and identifiers when known.
|
||||
- Do not mention the summary process or that context was compacted.`
|
||||
type Turn = {
|
||||
start: number
|
||||
end: number
|
||||
@@ -124,19 +89,6 @@ function completedCompactions(messages: SessionV1.WithParts[]) {
|
||||
})
|
||||
}
|
||||
|
||||
function buildPrompt(input: { previousSummary?: string; context: string[] }) {
|
||||
const anchor = input.previousSummary
|
||||
? [
|
||||
"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 the conversation history above."
|
||||
return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n")
|
||||
}
|
||||
|
||||
function preserveRecentBudget(input: { cfg: ConfigV1.Info; model: Provider.Model }) {
|
||||
return (
|
||||
input.cfg.compaction?.preserve_recent_tokens ??
|
||||
@@ -410,6 +362,18 @@ export const layer = Layer.effect(
|
||||
stripMedia: true,
|
||||
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
|
||||
})
|
||||
const tailIndex = selected.tail_start_id
|
||||
? history.findIndex((message) => message.info.id === selected.tail_start_id)
|
||||
: -1
|
||||
const recent =
|
||||
tailIndex < 0
|
||||
? ""
|
||||
: JSON.stringify(
|
||||
yield* MessageV2.toModelMessagesEffect(history.slice(tailIndex), model, {
|
||||
stripMedia: true,
|
||||
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
|
||||
}),
|
||||
)
|
||||
const ctx = yield* InstanceState.context
|
||||
const msg: SessionV1.Assistant = {
|
||||
id: MessageID.ascending(),
|
||||
@@ -572,12 +536,15 @@ export const layer = Layer.effect(
|
||||
},
|
||||
)
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
text: summary ?? "",
|
||||
include: selected.tail_start_id,
|
||||
})
|
||||
if (summary)
|
||||
yield* events.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.make(input.parentID),
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
reason: input.auto ? "auto" : "manual",
|
||||
text: summary ?? "",
|
||||
recent,
|
||||
})
|
||||
}
|
||||
yield* events.publish(Event.Compacted, { sessionID: input.sessionID })
|
||||
}
|
||||
@@ -610,7 +577,7 @@ export const layer = Layer.effect(
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
messageID: SessionMessage.ID.make(msg.id),
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
reason: input.auto ? "auto" : "manual",
|
||||
})
|
||||
|
||||
@@ -1,7 +1 @@
|
||||
const CHARS_PER_TOKEN = 4
|
||||
|
||||
export function estimate(input: string) {
|
||||
return Math.max(0, Math.round((input || "").length / CHARS_PER_TOKEN))
|
||||
}
|
||||
|
||||
export * as Token from "./token"
|
||||
export { Token, estimate } from "@opencode-ai/core/util/token"
|
||||
|
||||
@@ -34,6 +34,8 @@ test.skip("step snapshots carry over to assistant messages", () => {
|
||||
} satisfies SessionEvent.Event),
|
||||
)
|
||||
|
||||
expect(state.messages).toEqual([])
|
||||
|
||||
Effect.runSync(
|
||||
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
|
||||
id: EventV2.ID.create(),
|
||||
@@ -194,10 +196,11 @@ test.skip("tool completion stores completed timestamp", () => {
|
||||
expect(state.messages[0].content[0].provider).toEqual({ executed: true, metadata: { fake: { status: "done" } } })
|
||||
})
|
||||
|
||||
test.skip("compaction events reduce to compaction message", () => {
|
||||
test("compaction events reduce to compaction message only when completed", () => {
|
||||
const state: SessionMessageUpdater.MemoryState = { messages: [] }
|
||||
const sessionID = SessionID.make("session")
|
||||
const id = EventV2.ID.create()
|
||||
const compactionID = SessionMessage.ID.create()
|
||||
|
||||
Effect.runSync(
|
||||
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
|
||||
@@ -205,19 +208,22 @@ test.skip("compaction events reduce to compaction message", () => {
|
||||
type: "session.next.compaction.started",
|
||||
data: {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
messageID: compactionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
reason: "auto",
|
||||
},
|
||||
} satisfies SessionEvent.Event),
|
||||
)
|
||||
|
||||
expect(state.messages).toEqual([])
|
||||
|
||||
Effect.runSync(
|
||||
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
|
||||
id: EventV2.ID.create(),
|
||||
type: "session.next.compaction.delta",
|
||||
data: {
|
||||
sessionID,
|
||||
messageID: compactionID,
|
||||
timestamp: DateTime.makeUnsafe(2),
|
||||
text: "hello ",
|
||||
},
|
||||
@@ -230,6 +236,7 @@ test.skip("compaction events reduce to compaction message", () => {
|
||||
type: "session.next.compaction.delta",
|
||||
data: {
|
||||
sessionID,
|
||||
messageID: compactionID,
|
||||
timestamp: DateTime.makeUnsafe(3),
|
||||
text: "summary",
|
||||
},
|
||||
@@ -242,20 +249,22 @@ test.skip("compaction events reduce to compaction message", () => {
|
||||
type: "session.next.compaction.ended",
|
||||
data: {
|
||||
sessionID,
|
||||
messageID: compactionID,
|
||||
timestamp: DateTime.makeUnsafe(4),
|
||||
reason: "auto",
|
||||
text: "final summary",
|
||||
include: "recent context",
|
||||
recent: "recent context",
|
||||
},
|
||||
} satisfies SessionEvent.Event),
|
||||
)
|
||||
|
||||
expect(state.messages).toHaveLength(1)
|
||||
expect(state.messages[0]).toMatchObject({
|
||||
id,
|
||||
id: compactionID,
|
||||
type: "compaction",
|
||||
reason: "auto",
|
||||
summary: "final summary",
|
||||
include: "recent context",
|
||||
time: { created: DateTime.makeUnsafe(1) },
|
||||
recent: "recent context",
|
||||
time: { created: DateTime.makeUnsafe(4) },
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user