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

262 lines
8.1 KiB
TypeScript
Raw Normal View History

2025-11-19 02:09:50 +08:00
import { streamText, wrapLanguageModel, type ModelMessage } from "ai"
import { Session } from "."
import { Identifier } from "../id/id"
import { Instance } from "../project/instance"
import { Provider } from "../provider/provider"
import { MessageV2 } from "./message-v2"
import { SystemPrompt } from "./system"
import { Bus } from "../bus"
import z from "zod"
import type { ModelsDev } from "../provider/models"
import { SessionPrompt } from "./prompt"
import { Flag } from "../flag/flag"
import { Token } from "../util/token"
import { Log } from "../util/log"
import { ProviderTransform } from "@/provider/transform"
2025-11-17 23:57:18 +08:00
import { SessionProcessor } from "./processor"
import { fn } from "@/util/fn"
2025-11-23 00:32:49 +08:00
import { mergeDeep, pipe } from "remeda"
export namespace SessionCompaction {
const log = Log.create({ service: "session.compaction" })
export const Event = {
Compacted: Bus.event(
"session.compacted",
z.object({
sessionID: z.string(),
}),
),
}
2025-11-08 09:59:02 +08:00
export function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: ModelsDev.Model }) {
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) return false
2025-09-13 17:59:18 +08:00
const context = input.model.limit.context
if (context === 0) return false
const count = input.tokens.input + input.tokens.cache.read + input.tokens.output
2025-11-08 09:59:02 +08:00
const output = Math.min(input.model.limit.output, SessionPrompt.OUTPUT_TOKEN_MAX) || SessionPrompt.OUTPUT_TOKEN_MAX
2025-09-13 17:59:18 +08:00
const usable = context - output
2025-09-13 17:53:03 +08:00
return count > usable
}
export const PRUNE_MINIMUM = 20_000
export const PRUNE_PROTECT = 40_000
// goes backwards through parts until there are 40_000 tokens worth of tool
// calls. then erases output of previous tool calls. idea is to throw away old
// tool calls that are no longer relevant.
export async function prune(input: { sessionID: string }) {
if (Flag.OPENCODE_DISABLE_PRUNE) return
log.info("pruning")
2025-11-07 02:20:13 +08:00
const msgs = await Session.messages({ sessionID: input.sessionID })
let total = 0
let pruned = 0
const toPrune = []
let turns = 0
loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
const msg = msgs[msgIndex]
if (msg.info.role === "user") turns++
if (turns < 2) continue
if (msg.info.role === "assistant" && msg.info.summary) break loop
for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
const part = msg.parts[partIndex]
if (part.type === "tool")
if (part.state.status === "completed") {
if (part.state.time.compacted) break loop
const estimate = Token.estimate(part.state.output)
total += estimate
if (total > PRUNE_PROTECT) {
pruned += estimate
toPrune.push(part)
}
}
}
}
log.info("found", { pruned, total })
if (pruned > PRUNE_MINIMUM) {
for (const part of toPrune) {
if (part.state.status === "completed") {
part.state.time.compacted = Date.now()
await Session.updatePart(part)
}
}
log.info("pruned", { count: toPrune.length })
}
}
2025-11-17 23:57:18 +08:00
export async function process(input: {
parentID: string
messages: MessageV2.WithParts[]
sessionID: string
model: {
providerID: string
modelID: string
}
agent: string
2025-11-17 23:57:18 +08:00
abort: AbortSignal
auto: boolean
2025-11-17 23:57:18 +08:00
}) {
const model = await Provider.getModel(input.model.providerID, input.model.modelID)
2025-11-23 00:32:49 +08:00
const system = [...SystemPrompt.compaction(model.providerID)]
const msg = (await Session.updateMessage({
id: Identifier.ascending("message"),
role: "assistant",
2025-11-17 23:57:18 +08:00
parentID: input.parentID,
sessionID: input.sessionID,
mode: input.agent,
2025-11-17 23:57:18 +08:00
summary: true,
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
2025-11-17 23:57:18 +08:00
modelID: input.model.modelID,
providerID: model.providerID,
time: {
created: Date.now(),
},
})) as MessageV2.Assistant
2025-11-17 23:57:18 +08:00
const processor = SessionProcessor.create({
assistantMessage: msg,
sessionID: input.sessionID,
2025-11-17 23:57:18 +08:00
providerID: input.model.providerID,
model: model.info,
abort: input.abort,
})
const result = await processor.process(() =>
2025-10-23 07:31:36 +08:00
streamText({
2025-11-19 02:09:50 +08:00
onError(error) {
log.error("stream error", {
error,
})
},
2025-10-23 07:31:36 +08:00
// set to 0, we handle loop
maxRetries: 0,
2025-11-23 00:32:49 +08:00
providerOptions: ProviderTransform.providerOptions(
model.npm,
model.providerID,
pipe(
{},
mergeDeep(ProviderTransform.options(model.providerID, model.modelID, model.npm ?? "", input.sessionID)),
mergeDeep(model.info.options),
),
),
headers: model.info.headers,
2025-11-17 23:57:18 +08:00
abortSignal: input.abort,
tools: model.info.tool_call ? {} : undefined,
2025-10-23 07:31:36 +08:00
messages: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
2025-11-19 02:09:50 +08:00
...MessageV2.toModelMessage(
input.messages.filter((m) => {
if (m.info.role !== "assistant" || m.info.error === undefined) {
return true
}
if (
MessageV2.AbortedError.isInstance(m.info.error) &&
m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning")
) {
return true
}
return false
}),
),
2025-10-23 07:31:36 +08:00
{
role: "user",
content: [
{
type: "text",
text: "Summarize our conversation above. This summary will be the only context available when the conversation continues, so preserve critical information including: what was accomplished, current work in progress, files involved, next steps, and any key user requests or constraints. Be concise but detailed enough that work can continue seamlessly.",
2025-10-23 07:31:36 +08:00
},
],
},
],
2025-11-19 02:09:50 +08:00
model: wrapLanguageModel({
model: model.language,
middleware: [
{
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, model.providerID, model.modelID)
}
return args.params
},
},
],
}),
2025-11-17 23:57:18 +08:00
}),
)
if (result === "continue" && input.auto) {
2025-11-17 23:57:18 +08:00
const continueMsg = await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
sessionID: input.sessionID,
time: {
created: Date.now(),
},
agent: input.agent,
2025-11-17 23:57:18 +08:00
model: input.model,
2025-10-23 07:31:36 +08:00
})
2025-11-17 23:57:18 +08:00
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: continueMsg.id,
2025-10-19 00:49:29 +08:00
sessionID: input.sessionID,
2025-11-17 23:57:18 +08:00
type: "text",
synthetic: true,
text: "Continue if you have next steps",
time: {
start: Date.now(),
end: Date.now(),
},
2025-10-19 00:49:29 +08:00
})
}
2025-11-19 02:09:50 +08:00
if (processor.message.error) return "stop"
2025-11-26 03:47:10 +08:00
Bus.publish(Event.Compacted, { sessionID: input.sessionID })
2025-11-17 23:57:18 +08:00
return "continue"
}
2025-11-17 23:57:18 +08:00
export const create = fn(
z.object({
sessionID: Identifier.schema("session"),
agent: z.string(),
2025-11-17 23:57:18 +08:00
model: z.object({
providerID: z.string(),
modelID: z.string(),
}),
auto: z.boolean(),
2025-11-17 23:57:18 +08:00
}),
async (input) => {
const msg = await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
model: input.model,
sessionID: input.sessionID,
agent: input.agent,
2025-11-17 23:57:18 +08:00
time: {
created: Date.now(),
},
})
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: msg.id,
sessionID: msg.sessionID,
type: "compaction",
auto: input.auto,
2025-11-17 23:57:18 +08:00
})
},
)
}