compaction.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. export * as SessionCompaction from "./compaction"
  2. import { LLM, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
  3. import { DateTime, Effect, Stream } from "effect"
  4. import type { Config } from "../config"
  5. import type { EventV2 } from "../event"
  6. import { SessionEvent } from "./event"
  7. import { SessionMessage } from "./message"
  8. import { SessionSchema } from "./schema"
  9. import { Token } from "../util/token"
  10. const DEFAULT_BUFFER = 20_000
  11. const DEFAULT_KEEP_TOKENS = 8_000
  12. const TOOL_OUTPUT_MAX_CHARS = 2_000
  13. const SUMMARY_OUTPUT_TOKENS = 4_096
  14. 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.
  15. <template>
  16. ## Goal
  17. - [single-sentence task summary]
  18. ## Constraints & Preferences
  19. - [user constraints, preferences, specs, or "(none)"]
  20. ## Progress
  21. ### Done
  22. - [completed work or "(none)"]
  23. ### In Progress
  24. - [current work or "(none)"]
  25. ### Blocked
  26. - [blockers or "(none)"]
  27. ## Key Decisions
  28. - [decision and why, or "(none)"]
  29. ## Next Steps
  30. - [ordered next actions or "(none)"]
  31. ## Critical Context
  32. - [important technical facts, errors, open questions, or "(none)"]
  33. ## Relevant Files
  34. - [file or directory path: why it matters, or "(none)"]
  35. </template>
  36. Rules:
  37. - Keep every section, even when empty.
  38. - Use terse bullets, not prose paragraphs.
  39. - Preserve exact file paths, commands, error strings, and identifiers when known.
  40. - Do not mention the summary process or that context was compacted.`
  41. type Entry = {
  42. readonly seq: number
  43. readonly message: SessionMessage.Message
  44. }
  45. type Settings = {
  46. readonly auto: boolean
  47. readonly buffer: number
  48. readonly tokens: number
  49. }
  50. type Dependencies = {
  51. readonly events: EventV2.Interface
  52. readonly llm: {
  53. readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
  54. }
  55. readonly config: readonly Config.Entry[]
  56. }
  57. type Input = {
  58. readonly sessionID: SessionSchema.ID
  59. readonly entries: readonly Entry[]
  60. readonly model: Model
  61. readonly request: LLMRequest
  62. }
  63. const estimate = (value: unknown) => Token.estimate(JSON.stringify(value))
  64. const truncate = (value: string) =>
  65. value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
  66. export const serializeToolContent = (content: SessionMessage.ToolStateCompleted["content"]) =>
  67. content
  68. .map((item) =>
  69. item.type === "text" ? item.text : `[Attached ${item.mime}${item.name === undefined ? "" : `: ${item.name}`}]`,
  70. )
  71. .join("\n")
  72. const serialize = (message: SessionMessage.Message) => {
  73. if (message.type === "user") {
  74. const files = message.files?.map((file) => `[Attached ${file.mime}: ${file.name ?? file.uri}]`) ?? []
  75. return [`[User]: ${message.text}`, ...files].join("\n")
  76. }
  77. if (message.type === "assistant") {
  78. return message.content
  79. .flatMap((part) => {
  80. if (part.type === "text") return [`[Assistant]: ${part.text}`]
  81. if (part.type === "reasoning") return part.text ? [`[Assistant reasoning]: ${part.text}`] : []
  82. const input = typeof part.state.input === "string" ? part.state.input : JSON.stringify(part.state.input)
  83. if (part.state.status === "completed")
  84. return [
  85. `[Assistant tool call]: ${part.name}(${input})`,
  86. `[Tool result]: ${truncate(serializeToolContent(part.state.content))}`,
  87. ]
  88. if (part.state.status === "error")
  89. return [`[Assistant tool call]: ${part.name}(${input})`, `[Tool error]: ${part.state.error.message}`]
  90. return [`[Assistant tool call]: ${part.name}(${input})`]
  91. })
  92. .join("\n")
  93. }
  94. if (message.type === "system") return `[System update]: ${message.text}`
  95. if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
  96. if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output)}`
  97. return ""
  98. }
  99. const settings = (documents: readonly Config.Entry[]) => {
  100. const configured = documents
  101. .filter((entry): entry is Config.Document => entry.type === "document")
  102. .flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
  103. return configured.reduce<Settings>(
  104. (result, current) => ({
  105. auto: current.auto ?? result.auto,
  106. buffer: current.buffer ?? result.buffer,
  107. tokens: current.keep?.tokens ?? result.tokens,
  108. }),
  109. { auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS },
  110. )
  111. }
  112. const select = (
  113. entries: readonly Entry[],
  114. tokens: number,
  115. ): { readonly head: string; readonly recent: string } | undefined => {
  116. const conversation = entries
  117. .filter((entry) => entry.message.type !== "compaction")
  118. .map((entry) => serialize(entry.message))
  119. .filter(Boolean)
  120. if (conversation.length === 0) return
  121. let total = 0
  122. let split = conversation.length
  123. let splitPrefix = ""
  124. let splitSuffix = ""
  125. for (let index = conversation.length - 1; index >= 0; index--) {
  126. const next = total + Token.estimate(conversation[index])
  127. if (next > tokens) {
  128. const remaining = Math.max(0, tokens - total) * 4
  129. if (remaining > 0) {
  130. splitPrefix = conversation[index].slice(0, -remaining)
  131. splitSuffix = conversation[index].slice(-remaining)
  132. split = index + 1
  133. }
  134. break
  135. }
  136. total = next
  137. split = index
  138. }
  139. return {
  140. head: [...conversation.slice(0, split), splitPrefix].filter(Boolean).join("\n\n"),
  141. recent: [splitSuffix, ...conversation.slice(split)].filter(Boolean).join("\n\n"),
  142. }
  143. }
  144. export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) =>
  145. [
  146. input.previousSummary
  147. ? `Update the anchored summary below using the conversation history above.\nPreserve still-true details, remove stale details, and merge in the new facts.\n<previous-summary>\n${input.previousSummary}\n</previous-summary>`
  148. : "Create a new anchored summary from the conversation history.",
  149. SUMMARY_TEMPLATE,
  150. ...input.context,
  151. ].join("\n\n")
  152. export const make = (dependencies: Dependencies) => {
  153. const config = settings(dependencies.config)
  154. const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: Input) {
  155. const context = input.model.route.defaults.limits?.context
  156. if (context === undefined || context <= 0) return false
  157. const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0
  158. const selected = select(input.entries, config.tokens)
  159. const previousSummary = input.entries.find((entry) => entry.message.type === "compaction")?.message
  160. if (!selected || (selected.head.length === 0 && previousSummary?.type !== "compaction")) return false
  161. const summaryPrompt = buildPrompt({
  162. previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
  163. context: [previousSummary?.type === "compaction" ? previousSummary.recent : "", selected.head].filter(Boolean),
  164. })
  165. const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS)
  166. if (Token.estimate(summaryPrompt) > context - summaryOutput) return false
  167. const messageID = SessionMessage.ID.create()
  168. yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
  169. sessionID: input.sessionID,
  170. messageID,
  171. timestamp: yield* DateTime.now,
  172. reason: "auto",
  173. })
  174. const chunks: string[] = []
  175. let failed = false
  176. const summarized = yield* dependencies.llm
  177. .stream(
  178. LLM.request({
  179. model: input.model,
  180. messages: [Message.user(summaryPrompt)],
  181. tools: [],
  182. generation: { maxTokens: summaryOutput },
  183. }),
  184. )
  185. .pipe(
  186. Stream.runForEach((event) => {
  187. if (LLMEvent.is.providerError(event)) failed = true
  188. if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
  189. return Effect.void
  190. }),
  191. Effect.as(true),
  192. Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
  193. )
  194. const summary = chunks.join("")
  195. if (!summarized || failed || !summary.trim()) return false
  196. yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
  197. sessionID: input.sessionID,
  198. messageID,
  199. timestamp: yield* DateTime.now,
  200. reason: "auto",
  201. text: summary,
  202. recent: selected.recent,
  203. })
  204. return true
  205. })
  206. const compactIfNeeded = Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: Input) {
  207. if (!config.auto) return false
  208. const context = input.model.route.defaults.limits?.context
  209. if (context === undefined || context <= 0) return false
  210. const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0
  211. if (
  212. estimate({ system: input.request.system, messages: input.request.messages, tools: input.request.tools }) <=
  213. context - Math.max(output, config.buffer)
  214. )
  215. return false
  216. return yield* compactAfterOverflow(input)
  217. })
  218. return {
  219. compactIfNeeded,
  220. compactAfterOverflow,
  221. }
  222. }