compaction.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  1. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  2. import { SessionV1 } from "@opencode-ai/core/v1/session"
  3. import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
  4. import { Session } from "./session"
  5. import { SessionID, MessageID, PartID } from "./schema"
  6. import { Provider } from "@/provider/provider"
  7. import { MessageV2 } from "./message-v2"
  8. import { Token } from "@/util/token"
  9. import { SessionProcessor } from "./processor"
  10. import { Agent } from "@/agent/agent"
  11. import { Plugin } from "@/plugin"
  12. import { Config } from "@/config/config"
  13. import { NotFoundError } from "@/storage/storage"
  14. import { Effect, Layer, Context } from "effect"
  15. import * as DateTime from "effect/DateTime"
  16. import { InstanceState } from "@/effect/instance-state"
  17. import { isOverflow as overflow, usable } from "./overflow"
  18. import { serviceUse } from "@opencode-ai/core/effect/service-use"
  19. import { RuntimeFlags } from "@/effect/runtime-flags"
  20. import { EventV2Bridge } from "@/event-v2-bridge"
  21. import { SessionEvent } from "@opencode-ai/core/session/event"
  22. import { SessionMessage } from "@opencode-ai/core/session/message"
  23. import { ProviderV2 } from "@opencode-ai/core/provider"
  24. import { ModelV2 } from "@opencode-ai/core/model"
  25. import { buildPrompt } from "@opencode-ai/core/session/compaction"
  26. import { SessionCompactionEvent } from "@opencode-ai/schema/session-compaction-event"
  27. export const Event = SessionCompactionEvent
  28. export const PRUNE_MINIMUM = 20_000
  29. export const PRUNE_PROTECT = 40_000
  30. const TOOL_OUTPUT_MAX_CHARS = 2_000
  31. const PRUNE_PROTECTED_TOOLS = ["skill"]
  32. const DEFAULT_TAIL_TURNS = 2
  33. const MIN_PRESERVE_RECENT_TOKENS = 2_000
  34. const MAX_PRESERVE_RECENT_TOKENS = 8_000
  35. type Turn = {
  36. start: number
  37. end: number
  38. id: MessageID
  39. }
  40. type Tail = {
  41. start: number
  42. id: MessageID
  43. }
  44. type CompletedCompaction = {
  45. userIndex: number
  46. assistantIndex: number
  47. summary: string | undefined
  48. }
  49. function summaryText(message: SessionV1.WithParts) {
  50. const text = message.parts
  51. .filter((part): part is SessionV1.TextPart => part.type === "text")
  52. .map((part) => part.text.trim())
  53. .filter(Boolean)
  54. .join("\n\n")
  55. .trim()
  56. return text || undefined
  57. }
  58. function completedCompactions(messages: SessionV1.WithParts[]) {
  59. const users = new Map<MessageID, number>()
  60. for (let i = 0; i < messages.length; i++) {
  61. const msg = messages[i]
  62. if (msg.info.role !== "user") continue
  63. if (!msg.parts.some((part) => part.type === "compaction")) continue
  64. users.set(msg.info.id, i)
  65. }
  66. return messages.flatMap((msg, assistantIndex): CompletedCompaction[] => {
  67. if (msg.info.role !== "assistant") return []
  68. if (!msg.info.summary || !msg.info.finish || msg.info.error) return []
  69. const userIndex = users.get(msg.info.parentID)
  70. if (userIndex === undefined) return []
  71. return [{ userIndex, assistantIndex, summary: summaryText(msg) }]
  72. })
  73. }
  74. function preserveRecentBudget(input: { cfg: ConfigV1.Info; model: Provider.Model }) {
  75. return (
  76. input.cfg.compaction?.preserve_recent_tokens ??
  77. Math.min(MAX_PRESERVE_RECENT_TOKENS, Math.max(MIN_PRESERVE_RECENT_TOKENS, Math.floor(usable(input) * 0.25)))
  78. )
  79. }
  80. function turns(messages: SessionV1.WithParts[]) {
  81. const result: Turn[] = []
  82. for (let i = 0; i < messages.length; i++) {
  83. const msg = messages[i]
  84. if (msg.info.role !== "user") continue
  85. if (msg.parts.some((part) => part.type === "compaction")) continue
  86. result.push({
  87. start: i,
  88. end: messages.length,
  89. id: msg.info.id,
  90. })
  91. }
  92. for (let i = 0; i < result.length - 1; i++) {
  93. result[i].end = result[i + 1].start
  94. }
  95. return result
  96. }
  97. function splitTurn(input: {
  98. messages: SessionV1.WithParts[]
  99. turn: Turn
  100. model: Provider.Model
  101. budget: number
  102. estimate: (input: { messages: SessionV1.WithParts[]; model: Provider.Model }) => Effect.Effect<number>
  103. }) {
  104. return Effect.gen(function* () {
  105. if (input.budget <= 0) return undefined
  106. if (input.turn.end - input.turn.start <= 1) return undefined
  107. for (let start = input.turn.start + 1; start < input.turn.end; start++) {
  108. const size = yield* input.estimate({
  109. messages: input.messages.slice(start, input.turn.end),
  110. model: input.model,
  111. })
  112. if (size > input.budget) continue
  113. return {
  114. start,
  115. id: input.messages[start]!.info.id,
  116. } satisfies Tail
  117. }
  118. return undefined
  119. })
  120. }
  121. export interface Interface {
  122. readonly isOverflow: (input: {
  123. tokens: SessionV1.Assistant["tokens"]
  124. model: Provider.Model
  125. }) => Effect.Effect<boolean>
  126. readonly prune: (input: { sessionID: SessionID }) => Effect.Effect<void>
  127. readonly process: (input: {
  128. parentID: MessageID
  129. messages: SessionV1.WithParts[]
  130. sessionID: SessionID
  131. auto: boolean
  132. overflow?: boolean
  133. }) => Effect.Effect<"continue" | "stop">
  134. readonly create: (input: {
  135. sessionID: SessionID
  136. agent: string
  137. model: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
  138. auto: boolean
  139. overflow?: boolean
  140. }) => Effect.Effect<void>
  141. }
  142. export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
  143. export const use = serviceUse(Service)
  144. export const layer = Layer.effect(
  145. Service,
  146. Effect.gen(function* () {
  147. const config = yield* Config.Service
  148. const session = yield* Session.Service
  149. const agents = yield* Agent.Service
  150. const plugin = yield* Plugin.Service
  151. const processors = yield* SessionProcessor.Service
  152. const provider = yield* Provider.Service
  153. const events = yield* EventV2Bridge.Service
  154. const flags = yield* RuntimeFlags.Service
  155. const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: {
  156. tokens: SessionV1.Assistant["tokens"]
  157. model: Provider.Model
  158. }) {
  159. return overflow({
  160. cfg: yield* config.get(),
  161. tokens: input.tokens,
  162. model: input.model,
  163. outputTokenMax: flags.outputTokenMax,
  164. })
  165. })
  166. const estimate = Effect.fn("SessionCompaction.estimate")(function* (input: {
  167. messages: SessionV1.WithParts[]
  168. model: Provider.Model
  169. }) {
  170. const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model)
  171. return Token.estimate(JSON.stringify(msgs))
  172. })
  173. const select = Effect.fn("SessionCompaction.select")(function* (input: {
  174. messages: SessionV1.WithParts[]
  175. cfg: ConfigV1.Info
  176. model: Provider.Model
  177. }) {
  178. const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS
  179. if (limit <= 0) return { head: input.messages, tail_start_id: undefined }
  180. const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model })
  181. const all = turns(input.messages)
  182. if (!all.length) return { head: input.messages, tail_start_id: undefined }
  183. const recent = all.slice(-limit)
  184. const sizes = yield* Effect.forEach(
  185. recent,
  186. (turn) =>
  187. estimate({
  188. messages: input.messages.slice(turn.start, turn.end),
  189. model: input.model,
  190. }),
  191. { concurrency: 1 },
  192. )
  193. let total = 0
  194. let keep: Tail | undefined
  195. for (let i = recent.length - 1; i >= 0; i--) {
  196. const turn = recent[i]!
  197. const size = sizes[i]
  198. if (total + size <= budget) {
  199. total += size
  200. keep = { start: turn.start, id: turn.id }
  201. continue
  202. }
  203. const remaining = budget - total
  204. const split = yield* splitTurn({
  205. messages: input.messages,
  206. turn,
  207. model: input.model,
  208. budget: remaining,
  209. estimate,
  210. })
  211. if (split) keep = split
  212. else if (!keep) {
  213. yield* Effect.logInfo("tail fallback", { budget, size, total })
  214. }
  215. break
  216. }
  217. if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined }
  218. return {
  219. head: input.messages.slice(0, keep.start),
  220. tail_start_id: keep.id,
  221. }
  222. })
  223. // goes backwards through parts until there are PRUNE_PROTECT tokens worth of tool
  224. // calls, then erases output of older tool calls to free context space
  225. const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID }) {
  226. const cfg = yield* config.get()
  227. if (!cfg.compaction?.prune) return
  228. yield* Effect.logInfo("pruning")
  229. const msgs = yield* session
  230. .messages({ sessionID: input.sessionID })
  231. .pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)))
  232. if (!msgs) return
  233. let total = 0
  234. let pruned = 0
  235. const toPrune: SessionV1.ToolPart[] = []
  236. let turns = 0
  237. loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
  238. const msg = msgs[msgIndex]
  239. if (msg.info.role === "user") turns++
  240. if (turns < 2) continue
  241. if (msg.info.role === "assistant" && msg.info.summary) break loop
  242. for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
  243. const part = msg.parts[partIndex]
  244. if (part.type !== "tool") continue
  245. if (part.state.status !== "completed") continue
  246. if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
  247. if (part.state.time.compacted) break loop
  248. const estimate = Token.estimate(part.state.output)
  249. total += estimate
  250. if (total <= PRUNE_PROTECT) continue
  251. pruned += estimate
  252. toPrune.push(part)
  253. }
  254. }
  255. yield* Effect.logInfo("found", { pruned, total })
  256. if (pruned > PRUNE_MINIMUM) {
  257. for (const part of toPrune) {
  258. if (part.state.status === "completed") {
  259. part.state.time.compacted = Date.now()
  260. yield* session.updatePart(part)
  261. }
  262. }
  263. yield* Effect.logInfo("pruned", { count: toPrune.length })
  264. }
  265. })
  266. const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: {
  267. parentID: MessageID
  268. messages: SessionV1.WithParts[]
  269. sessionID: SessionID
  270. auto: boolean
  271. overflow?: boolean
  272. }) {
  273. const parent = input.messages.findLast((m) => m.info.id === input.parentID)
  274. if (!parent || parent.info.role !== "user") {
  275. throw new Error(`Compaction parent must be a user message: ${input.parentID}`)
  276. }
  277. const userMessage = parent.info
  278. const compactionPart = parent.parts.find((part): part is SessionV1.CompactionPart => part.type === "compaction")
  279. let messages = input.messages
  280. let replay:
  281. | {
  282. info: SessionV1.User
  283. parts: SessionV1.Part[]
  284. }
  285. | undefined
  286. if (input.overflow) {
  287. const idx = input.messages.findIndex((m) => m.info.id === input.parentID)
  288. for (let i = idx - 1; i >= 0; i--) {
  289. const msg = input.messages[i]
  290. if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) {
  291. replay = { info: msg.info, parts: msg.parts }
  292. messages = input.messages.slice(0, i)
  293. break
  294. }
  295. }
  296. const hasContent =
  297. replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"))
  298. if (!hasContent) {
  299. replay = undefined
  300. messages = input.messages
  301. }
  302. }
  303. const agent = yield* agents.get("compaction")
  304. const model = agent.model
  305. ? yield* provider.getModel(agent.model.providerID, agent.model.modelID).pipe(Effect.orDie)
  306. : yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID).pipe(Effect.orDie)
  307. const cfg = yield* config.get()
  308. const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages
  309. const prior = completedCompactions(history)
  310. const hidden = new Set(prior.flatMap((item) => [item.userIndex, item.assistantIndex]))
  311. const previousSummary = prior.at(-1)?.summary
  312. const selected = yield* select({
  313. messages: history.filter((_, index) => !hidden.has(index)),
  314. cfg,
  315. model,
  316. })
  317. // Allow plugins to inject context or replace compaction prompt.
  318. const compacting = yield* plugin.trigger(
  319. "experimental.session.compacting",
  320. { sessionID: input.sessionID },
  321. { context: [], prompt: undefined },
  322. )
  323. const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
  324. const msgs = structuredClone(selected.head)
  325. yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
  326. const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
  327. stripMedia: true,
  328. toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
  329. })
  330. const tailIndex = selected.tail_start_id
  331. ? history.findIndex((message) => message.info.id === selected.tail_start_id)
  332. : -1
  333. const recent =
  334. tailIndex < 0
  335. ? ""
  336. : JSON.stringify(
  337. yield* MessageV2.toModelMessagesEffect(history.slice(tailIndex), model, {
  338. stripMedia: true,
  339. toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
  340. }),
  341. )
  342. const ctx = yield* InstanceState.context
  343. const msg: SessionV1.Assistant = {
  344. id: MessageID.ascending(),
  345. role: "assistant",
  346. parentID: input.parentID,
  347. sessionID: input.sessionID,
  348. mode: "compaction",
  349. agent: "compaction",
  350. variant: userMessage.model.variant,
  351. summary: true,
  352. path: {
  353. cwd: ctx.directory,
  354. root: ctx.worktree,
  355. },
  356. cost: 0,
  357. tokens: {
  358. output: 0,
  359. input: 0,
  360. reasoning: 0,
  361. cache: { read: 0, write: 0 },
  362. },
  363. modelID: model.id,
  364. providerID: model.providerID,
  365. time: {
  366. created: Date.now(),
  367. },
  368. }
  369. yield* session.updateMessage(msg)
  370. const processor = yield* processors.create({
  371. assistantMessage: msg,
  372. sessionID: input.sessionID,
  373. model,
  374. })
  375. const result = yield* processor.process({
  376. user: userMessage,
  377. agent,
  378. sessionID: input.sessionID,
  379. tools: {},
  380. system: [],
  381. messages: [
  382. ...modelMessages,
  383. {
  384. role: "user",
  385. content: [{ type: "text", text: nextPrompt }],
  386. },
  387. ],
  388. model,
  389. })
  390. if (result === "compact") {
  391. processor.message.error = new SessionV1.ContextOverflowError({
  392. message: replay
  393. ? "Conversation history too large to compact - exceeds model context limit"
  394. : "Session too large to compact - context exceeds model limit even after stripping media",
  395. }).toObject()
  396. processor.message.finish = "error"
  397. yield* session.updateMessage(processor.message)
  398. return "stop"
  399. }
  400. if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) {
  401. yield* session.updatePart({
  402. ...compactionPart,
  403. tail_start_id: selected.tail_start_id,
  404. })
  405. }
  406. if (result === "continue" && input.auto) {
  407. if (replay) {
  408. const original = replay.info
  409. const replayMsg = yield* session.updateMessage({
  410. id: MessageID.ascending(),
  411. role: "user",
  412. sessionID: input.sessionID,
  413. time: { created: Date.now() },
  414. agent: original.agent,
  415. model: original.model,
  416. format: original.format,
  417. tools: original.tools,
  418. system: original.system,
  419. })
  420. for (const part of replay.parts) {
  421. if (part.type === "compaction") continue
  422. const replayPart =
  423. part.type === "file" && MessageV2.isMedia(part.mime)
  424. ? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` }
  425. : part
  426. yield* session.updatePart({
  427. ...replayPart,
  428. id: PartID.ascending(),
  429. messageID: replayMsg.id,
  430. sessionID: input.sessionID,
  431. })
  432. }
  433. }
  434. if (!replay) {
  435. const info = yield* provider.getProvider(userMessage.model.providerID)
  436. if (
  437. (yield* plugin.trigger(
  438. "experimental.compaction.autocontinue",
  439. {
  440. sessionID: input.sessionID,
  441. agent: userMessage.agent,
  442. model: yield* provider
  443. .getModel(userMessage.model.providerID, userMessage.model.modelID)
  444. .pipe(Effect.orDie),
  445. provider: {
  446. source: info.source,
  447. info,
  448. options: info.options,
  449. },
  450. message: userMessage,
  451. overflow: input.overflow === true,
  452. },
  453. { enabled: true },
  454. )).enabled
  455. ) {
  456. const continueMsg = yield* session.updateMessage({
  457. id: MessageID.ascending(),
  458. role: "user",
  459. sessionID: input.sessionID,
  460. time: { created: Date.now() },
  461. agent: userMessage.agent,
  462. model: userMessage.model,
  463. })
  464. const text =
  465. (input.overflow
  466. ? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
  467. : "") +
  468. "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
  469. yield* session.updatePart({
  470. id: PartID.ascending(),
  471. messageID: continueMsg.id,
  472. sessionID: input.sessionID,
  473. type: "text",
  474. // Internal marker for auto-compaction followups so provider plugins
  475. // can distinguish them from manual post-compaction user prompts.
  476. // This is not a stable plugin contract and may change or disappear.
  477. metadata: { compaction_continue: true },
  478. synthetic: true,
  479. text,
  480. time: {
  481. start: Date.now(),
  482. end: Date.now(),
  483. },
  484. })
  485. }
  486. }
  487. }
  488. if (processor.message.error) return "stop"
  489. if (result === "continue") {
  490. const summary = summaryText(
  491. (yield* session.messages({ sessionID: input.sessionID }).pipe(Effect.orDie)).find(
  492. (item) => item.info.id === msg.id,
  493. ) ?? {
  494. info: msg,
  495. parts: [],
  496. },
  497. )
  498. if (flags.experimentalEventSystem) {
  499. if (summary)
  500. yield* events.publish(SessionEvent.Compaction.Ended, {
  501. sessionID: input.sessionID,
  502. messageID: SessionMessage.ID.make(input.parentID),
  503. timestamp: DateTime.makeUnsafe(Date.now()),
  504. reason: input.auto ? "auto" : "manual",
  505. text: summary ?? "",
  506. recent,
  507. })
  508. }
  509. yield* events.publish(Event.Compacted, { sessionID: input.sessionID })
  510. }
  511. return result
  512. })
  513. const create = Effect.fn("SessionCompaction.create")(function* (input: {
  514. sessionID: SessionID
  515. agent: string
  516. model: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
  517. auto: boolean
  518. overflow?: boolean
  519. }) {
  520. const msg = yield* session.updateMessage({
  521. id: MessageID.ascending(),
  522. role: "user",
  523. model: input.model,
  524. sessionID: input.sessionID,
  525. agent: input.agent,
  526. time: { created: Date.now() },
  527. })
  528. yield* session.updatePart({
  529. id: PartID.ascending(),
  530. messageID: msg.id,
  531. sessionID: msg.sessionID,
  532. type: "compaction",
  533. auto: input.auto,
  534. overflow: input.overflow,
  535. })
  536. if (flags.experimentalEventSystem) {
  537. yield* events.publish(SessionEvent.Compaction.Started, {
  538. sessionID: input.sessionID,
  539. messageID: SessionMessage.ID.make(msg.id),
  540. timestamp: DateTime.makeUnsafe(Date.now()),
  541. reason: input.auto ? "auto" : "manual",
  542. })
  543. }
  544. })
  545. return Service.of({
  546. isOverflow,
  547. prune,
  548. process: processCompaction,
  549. create,
  550. })
  551. }),
  552. )
  553. export const defaultLayer = Layer.suspend(() =>
  554. layer.pipe(
  555. Layer.provide(Provider.defaultLayer),
  556. Layer.provide(Session.defaultLayer),
  557. Layer.provide(SessionProcessor.defaultLayer),
  558. Layer.provide(Agent.defaultLayer),
  559. Layer.provide(Plugin.defaultLayer),
  560. Layer.provide(Config.defaultLayer),
  561. Layer.provide(RuntimeFlags.defaultLayer),
  562. Layer.provide(EventV2Bridge.defaultLayer),
  563. ),
  564. )
  565. export const node = LayerNode.make({
  566. service: Service,
  567. layer: layer,
  568. deps: [
  569. Config.node,
  570. Session.node,
  571. Agent.node,
  572. Plugin.node,
  573. SessionProcessor.node,
  574. Provider.node,
  575. EventV2Bridge.node,
  576. RuntimeFlags.node,
  577. ],
  578. })
  579. export * as SessionCompaction from "./compaction"