compaction.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. import { BusEvent } from "@/bus/bus-event"
  2. import { Bus } from "@/bus"
  3. import { Session } from "."
  4. import { Identifier } from "../id/id"
  5. import { Instance } from "../project/instance"
  6. import { Provider } from "../provider/provider"
  7. import { MessageV2 } from "./message-v2"
  8. import z from "zod"
  9. import { SessionPrompt } from "./prompt"
  10. import { Flag } from "../flag/flag"
  11. import { Token } from "../util/token"
  12. import { Log } from "../util/log"
  13. import { SessionProcessor } from "./processor"
  14. import { fn } from "@/util/fn"
  15. import { Agent } from "@/agent/agent"
  16. import { Plugin } from "@/plugin"
  17. export namespace SessionCompaction {
  18. const log = Log.create({ service: "session.compaction" })
  19. export const Event = {
  20. Compacted: BusEvent.define(
  21. "session.compacted",
  22. z.object({
  23. sessionID: z.string(),
  24. }),
  25. ),
  26. }
  27. export function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
  28. if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) return false
  29. const context = input.model.limit.context
  30. if (context === 0) return false
  31. const count = input.tokens.input + input.tokens.cache.read + input.tokens.output
  32. const output = Math.min(input.model.limit.output, SessionPrompt.OUTPUT_TOKEN_MAX) || SessionPrompt.OUTPUT_TOKEN_MAX
  33. const usable = context - output
  34. return count > usable
  35. }
  36. export const PRUNE_MINIMUM = 20_000
  37. export const PRUNE_PROTECT = 40_000
  38. const PRUNE_PROTECTED_TOOLS = ["skill"]
  39. // goes backwards through parts until there are 40_000 tokens worth of tool
  40. // calls. then erases output of previous tool calls. idea is to throw away old
  41. // tool calls that are no longer relevant.
  42. export async function prune(input: { sessionID: string }) {
  43. if (Flag.OPENCODE_DISABLE_PRUNE) return
  44. log.info("pruning")
  45. const msgs = await Session.messages({ sessionID: input.sessionID })
  46. let total = 0
  47. let pruned = 0
  48. const toPrune = []
  49. let turns = 0
  50. loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
  51. const msg = msgs[msgIndex]
  52. if (msg.info.role === "user") turns++
  53. if (turns < 2) continue
  54. if (msg.info.role === "assistant" && msg.info.summary) break loop
  55. for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
  56. const part = msg.parts[partIndex]
  57. if (part.type === "tool")
  58. if (part.state.status === "completed") {
  59. if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
  60. if (part.state.time.compacted) break loop
  61. const estimate = Token.estimate(part.state.output)
  62. total += estimate
  63. if (total > PRUNE_PROTECT) {
  64. pruned += estimate
  65. toPrune.push(part)
  66. }
  67. }
  68. }
  69. }
  70. log.info("found", { pruned, total })
  71. if (pruned > PRUNE_MINIMUM) {
  72. for (const part of toPrune) {
  73. if (part.state.status === "completed") {
  74. part.state.time.compacted = Date.now()
  75. await Session.updatePart(part)
  76. }
  77. }
  78. log.info("pruned", { count: toPrune.length })
  79. }
  80. }
  81. export async function process(input: {
  82. parentID: string
  83. messages: MessageV2.WithParts[]
  84. sessionID: string
  85. abort: AbortSignal
  86. auto: boolean
  87. }) {
  88. const userMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User
  89. const agent = await Agent.get("compaction")
  90. const model = agent.model
  91. ? await Provider.getModel(agent.model.providerID, agent.model.modelID)
  92. : await Provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
  93. const msg = (await Session.updateMessage({
  94. id: Identifier.ascending("message"),
  95. role: "assistant",
  96. parentID: input.parentID,
  97. sessionID: input.sessionID,
  98. mode: "compaction",
  99. agent: "compaction",
  100. summary: true,
  101. path: {
  102. cwd: Instance.directory,
  103. root: Instance.worktree,
  104. },
  105. cost: 0,
  106. tokens: {
  107. output: 0,
  108. input: 0,
  109. reasoning: 0,
  110. cache: { read: 0, write: 0 },
  111. },
  112. modelID: model.id,
  113. providerID: model.providerID,
  114. time: {
  115. created: Date.now(),
  116. },
  117. })) as MessageV2.Assistant
  118. const processor = SessionProcessor.create({
  119. assistantMessage: msg,
  120. sessionID: input.sessionID,
  121. model,
  122. abort: input.abort,
  123. })
  124. // Allow plugins to inject context or replace compaction prompt
  125. const compacting = await Plugin.trigger(
  126. "experimental.session.compacting",
  127. { sessionID: input.sessionID },
  128. { context: [], prompt: undefined },
  129. )
  130. const defaultPrompt =
  131. "Provide a detailed prompt for continuing our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next considering new session will not have access to our conversation."
  132. const promptText = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
  133. const result = await processor.process({
  134. user: userMessage,
  135. agent,
  136. abort: input.abort,
  137. sessionID: input.sessionID,
  138. tools: {},
  139. system: [],
  140. messages: [
  141. ...MessageV2.toModelMessage(input.messages),
  142. {
  143. role: "user",
  144. content: [
  145. {
  146. type: "text",
  147. text: promptText,
  148. },
  149. ],
  150. },
  151. ],
  152. model,
  153. })
  154. if (result === "continue" && input.auto) {
  155. const continueMsg = await Session.updateMessage({
  156. id: Identifier.ascending("message"),
  157. role: "user",
  158. sessionID: input.sessionID,
  159. time: {
  160. created: Date.now(),
  161. },
  162. agent: userMessage.agent,
  163. model: userMessage.model,
  164. })
  165. await Session.updatePart({
  166. id: Identifier.ascending("part"),
  167. messageID: continueMsg.id,
  168. sessionID: input.sessionID,
  169. type: "text",
  170. synthetic: true,
  171. text: "Continue if you have next steps",
  172. time: {
  173. start: Date.now(),
  174. end: Date.now(),
  175. },
  176. })
  177. }
  178. if (processor.message.error) return "stop"
  179. Bus.publish(Event.Compacted, { sessionID: input.sessionID })
  180. return "continue"
  181. }
  182. export const create = fn(
  183. z.object({
  184. sessionID: Identifier.schema("session"),
  185. agent: z.string(),
  186. model: z.object({
  187. providerID: z.string(),
  188. modelID: z.string(),
  189. }),
  190. auto: z.boolean(),
  191. }),
  192. async (input) => {
  193. const msg = await Session.updateMessage({
  194. id: Identifier.ascending("message"),
  195. role: "user",
  196. model: input.model,
  197. sessionID: input.sessionID,
  198. agent: input.agent,
  199. time: {
  200. created: Date.now(),
  201. },
  202. })
  203. await Session.updatePart({
  204. id: Identifier.ascending("part"),
  205. messageID: msg.id,
  206. sessionID: msg.sessionID,
  207. type: "compaction",
  208. auto: input.auto,
  209. })
  210. },
  211. )
  212. }