index.ts 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779
  1. /**
  2. * `BasicCompactService`: the first implementation of the
  3. * `@deepseek-ai/dsh-compact` seam. It owns the entire compaction strategy:
  4. *
  5. * - **Token estimation** — chars/`charsPerToken` heuristic (config, default 4)
  6. * with per-block structural overhead.
  7. * - **Retention policy** — walk surface nodes tail→head, keep recent nodes up
  8. * to a token budget, compact everything older. The cutoff is snapped forward
  9. * to the next balanced tool-pairing boundary so a compacted region never
  10. * splits a step's tool-call/result pair (an open tail step is never crossed —
  11. * compaction declines and retries once it closes).
  12. * - **Summarization** — a direct one-shot `ctx.llm.stream()` call assembled
  13. * via `BlockAssembler` with a fixed condense-the-history system prompt;
  14. * NOT a loop step, so `agent/request` never fires — interception happens
  15. * at `llm/stream` like any other direct call.
  16. * - **Surface mutation** — a single `user/message` replace node carries the
  17. * summary; `compact/*` events are log-only lock + provenance records.
  18. * - **Auto-compaction** — an `agent/pre-step` listener delegates to
  19. * {@link BasicCompactService.compactIfNeeded} before EVERY step (so a
  20. * tool-heavy turn that grows the surface mid-turn still compacts); it owns the
  21. * sole token-pressure check.
  22. *
  23. * A different backend (real tokenizer, template summarizer, turn-count
  24. * retention) either subclasses this and overrides the {@link
  25. * BasicCompactService.estimateContentTokens} / {@link
  26. * BasicCompactService.summarize} hooks, or implements the abstract
  27. * {@link CompactService} from scratch.
  28. *
  29. * @module @deepseek-ai/dsh-compact-basic
  30. */
  31. import { Context } from 'cordis'
  32. import { CompactService } from '@deepseek-ai/dsh-compact'
  33. import type { CompactionResult } from '@deepseek-ai/dsh-compact'
  34. import { BlockAssembler } from '@deepseek-ai/dsh-llm'
  35. import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
  36. import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
  37. import { isToolPairingBalanced } from '@deepseek-ai/dsh-session'
  38. import type { Agent } from '@deepseek-ai/dsh-agent'
  39. import type { BasicCompactConfig, ResolvedConfig } from './types.ts'
  40. import { resolveConfig } from './types.ts'
  41. export type { BasicCompactConfig, ResolvedConfig } from './types.ts'
  42. export { resolveConfig } from './types.ts'
  43. /** Per-block structural overhead for JSON framing / type tag. */
  44. const BLOCK_OVERHEAD = 4
  45. /** Role-field framing overhead added per message in {@link BasicCompactService.estimateTokens}. */
  46. const ROLE_OVERHEAD = 4
  47. /** Tags wrapping the structured summary inside the landed checkpoint node. */
  48. const SUMMARY_OPEN_TAG = '<compacted-summary>'
  49. const SUMMARY_CLOSE_TAG = '</compacted-summary>'
  50. /**
  51. * The summarization system prompt: instructs the model to condense the
  52. * conversation into a fixed, fully-populated structure rather than freeform
  53. * bullets. The fixed structure guarantees coverage of the things a resuming
  54. * model needs (original intent, pending work, the next step, critical context)
  55. * and is stable across compaction cycles, so a prior checkpoint can be merged
  56. * in place. The final rule keys off {@link SUMMARY_OPEN_TAG}: when the
  57. * transcript already contains a prior checkpoint, the model consolidates rather
  58. * than re-summarizing it verbatim (a cheap incremental-merge that needs no
  59. * extra log/event machinery — the tag travels on the summary surface node).
  60. */
  61. const SUMMARIZE_SYSTEM_PROMPT = [
  62. 'You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.',
  63. '',
  64. 'Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.',
  65. '',
  66. '## Primary Request and Intent',
  67. "- [the user's original and evolving goals; quote verbatim where the exact wording matters]",
  68. '',
  69. '## Key Technical Concepts',
  70. '- [technologies, frameworks, patterns, and conventions in play]',
  71. '',
  72. '## Files and Code',
  73. '- [exact path: why it matters, key changes or snippets]',
  74. '',
  75. '## Errors and Fixes',
  76. '- [error: how it was resolved, plus any related user feedback]',
  77. '',
  78. '## Pending Tasks',
  79. '- [explicitly requested work not yet completed]',
  80. '',
  81. '## Current Work',
  82. '- [precisely what was in progress at this checkpoint]',
  83. '',
  84. '## Next Step',
  85. '- [the single next action, directly in line with the most recent request, or "(none)"]',
  86. '',
  87. '## Critical Context',
  88. '- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]',
  89. '',
  90. 'Rules:',
  91. '- Preserve exact file paths, commands, error strings, identifiers, and function signatures.',
  92. '- Capture user feedback and explicit instructions faithfully, especially corrections.',
  93. '- Do NOT mention this summarization process or that the context was compacted.',
  94. `- If the transcript already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`,
  95. ].join('\n')
  96. /**
  97. * Framing prepended to the landed summary so a resuming model reads it as a
  98. * checkpoint rather than a fresh user request, and continues the task from it.
  99. * It summarizes an earlier span of the conversation; the messages that follow
  100. * are the continuation. Because region compaction can be invoked manually, a
  101. * surface may hold several checkpoints, so the framing does NOT claim that
  102. * everything after it is recent or verbatim — only that the captured context
  103. * should be built on, not restated.
  104. */
  105. const CHECKPOINT_PREAMBLE =
  106. 'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.'
  107. /**
  108. * Map a terminal `FinishReason` to the error a SUMMARIZATION must throw, or
  109. * `undefined` for an acceptable finish. `FinishReason` is merge-extensible.
  110. *
  111. * Compaction fails CLOSED on a truncated summary: `error`, `aborted`, AND
  112. * `max-tokens` all raise. Unlike an ordinary agent turn — where `max-tokens` is
  113. * a normal "the model hit its budget" outcome the loop keeps — a summary cut off
  114. * at the token cap is an INCOMPLETE checkpoint, and committing it would shadow
  115. * (discard) the real history it summarizes. Raising here keeps the original
  116. * surface intact (the caller appends `compact/end` with the error and the auto
  117. * path proceeds with full history). `stop`/future kinds are accepted.
  118. */
  119. function finishError(finish: FinishReason): Error | undefined {
  120. switch (finish.kind) {
  121. case 'error': {
  122. const error = new Error(finish.message) as Error & { code?: string }
  123. if (finish.code !== undefined) error.code = finish.code
  124. return error
  125. }
  126. case 'aborted': {
  127. const error = new Error('summarization stream aborted') as Error & { code?: string }
  128. error.code = 'ABORTED'
  129. return error
  130. }
  131. case 'max-tokens': {
  132. const error = new Error('summarization truncated at the token cap (incomplete checkpoint)') as Error & { code?: string }
  133. error.code = 'MAX_TOKENS'
  134. return error
  135. }
  136. default:
  137. return undefined
  138. }
  139. }
  140. /**
  141. * Basic, dependency-light compaction backend: estimates the surface's token
  142. * footprint, summarizes the stale prefix through the model, and shadows it
  143. * behind a durable checkpoint. Every threshold/budget knob is required config
  144. * ({@link BasicCompactConfig}); the estimator's text density is the
  145. * `charsPerToken` knob.
  146. */
  147. export class BasicCompactService extends CompactService {
  148. static inject = ['llm']
  149. /** Resolved configuration (`auto` defaulted). */
  150. readonly config: ResolvedConfig
  151. constructor(ctx: Context, config: BasicCompactConfig) {
  152. super(ctx)
  153. this.config = resolveConfig(config)
  154. if (this.config.auto) {
  155. // Auto-compaction: delegate to compactIfNeeded before EVERY step. This is
  156. // LOAD-BEARING for runaway-turn survival: a tool-heavy ReAct turn appends
  157. // an assistant/message and a tool/result per step, so the surface (and the
  158. // derived token count) grows WITHIN a turn. The only moment to rescue a
  159. // turn that alone approaches the window is the next step's pre-step
  160. // checkpoint; gating to a turn's first step would let a runaway turn
  161. // overflow before the next turn's check. The listener owns NO threshold
  162. // logic — compactIfNeeded is the single place that decides whether to
  163. // compact, and its in-progress lock serializes concurrent attempts.
  164. //
  165. // It runs on `agent/pre-step` (a serial surface-mutation checkpoint fired
  166. // AFTER turn/start but BEFORE step/start), NOT `agent/request`: compaction
  167. // mutates the session surface, and the loop derives the request `messages`
  168. // AFTER this fires — so a single derive already reflects the compaction,
  169. // with no double-derive and no need to rewrite an already-assembled
  170. // `messages` array. Firing pre-step (outside any open step) keeps the
  171. // log-only `compact/*` records and the replacement node cleanly outside a
  172. // step, so a crash mid-compaction leaves an inert orphan the turn-repair
  173. // closes — never a half-open step.
  174. ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, fullSystemPrompt: string, signal: AbortSignal) => {
  175. try {
  176. const result = await this.compactIfNeeded(agent, fullSystemPrompt, signal)
  177. if (result) {
  178. const after = this.estimateTokens(agent.session.deriveMessages(), fullSystemPrompt)
  179. ctx.logger.info(
  180. `compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` +
  181. `(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` +
  182. `~${result.shadowedTokenCount} tokens) ` +
  183. `→ ${after} estimated tokens after compaction`,
  184. )
  185. }
  186. } catch (error: unknown) {
  187. // A failed compaction must not prevent the model call — the surface is
  188. // untouched on failure, so the loop derives the full history and the
  189. // call proceeds.
  190. const msg = error instanceof Error ? error.message : String(error)
  191. ctx.logger.warn(`compaction failed: ${msg}; proceeding with full history`)
  192. }
  193. })
  194. }
  195. }
  196. // ---- Token estimation (overridable hooks) ----
  197. // TODO: chars/charsPerToken is a coarse heuristic. Replace with an exact
  198. // count — a real tokenizer, or the provider's post-response `usage` (input
  199. // tokens) fed back as a correction — so threshold decisions match the
  200. // model's actual budget.
  201. /**
  202. * Estimate the token count of content blocks — chars divided by the
  203. * `charsPerToken` config, with per-block overhead. Override in a subclass to
  204. * plug in a real tokenizer.
  205. *
  206. * @param blocks - the blocks to estimate; `tool-result` blocks recurse into
  207. * their nested content, and unknown (merge-extended) types fall back to
  208. * their JSON-stringified length.
  209. * @returns the estimated token count.
  210. */
  211. estimateContentTokens(blocks: readonly ContentBlock[]): number {
  212. const { charsPerToken } = this.config
  213. let tokens = 0
  214. for (const block of blocks) {
  215. switch (block.type) {
  216. case 'text':
  217. case 'reasoning':
  218. tokens += Math.ceil(block.text.length / charsPerToken) + BLOCK_OVERHEAD
  219. break
  220. case 'tool-call':
  221. tokens += Math.ceil(block.name.length / charsPerToken)
  222. + Math.ceil(block.arguments.length / charsPerToken)
  223. + BLOCK_OVERHEAD
  224. break
  225. case 'tool-result':
  226. tokens += this.estimateContentTokens(block.content) + BLOCK_OVERHEAD
  227. break
  228. default:
  229. // Unknown block types (merge-extensible ContentBlockMap):
  230. // estimate conservatively via JSON stringify.
  231. tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / charsPerToken)
  232. }
  233. }
  234. return tokens
  235. }
  236. /**
  237. * Estimate token count for a single session event. Returns 0 for non-message
  238. * event types (boundaries, chunks, usage, errors, compact markers).
  239. *
  240. * @param event - any session event; only the message-bearing types carry
  241. * content to count.
  242. * @returns the estimated token count of the event's content, or 0 for a
  243. * non-message event.
  244. */
  245. estimateEventTokens(event: SessionEvent): number {
  246. switch (event.type) {
  247. case 'user/message':
  248. case 'assistant/message':
  249. case 'context/message':
  250. case 'steering/message':
  251. case 'tool/result':
  252. return this.estimateContentTokens(event.data.content)
  253. default:
  254. return 0
  255. }
  256. }
  257. /**
  258. * Estimate total tokens across a list of messages plus optional system prompt.
  259. *
  260. * @param messages - the derived conversation messages; each adds a fixed
  261. * role-framing overhead on top of its content estimate.
  262. * @param systemPrompt - counted at chars / `charsPerToken` when provided.
  263. * @returns the estimated token footprint of the whole request.
  264. */
  265. estimateTokens(messages: readonly Message[], systemPrompt?: string): number {
  266. let total = 0
  267. for (const msg of messages) {
  268. total += this.estimateContentTokens(msg.content)
  269. total += ROLE_OVERHEAD
  270. }
  271. if (systemPrompt) total += Math.ceil(systemPrompt.length / this.config.charsPerToken)
  272. return total
  273. }
  274. /**
  275. * Summarize conversation text into content blocks via `ctx.llm.stream()`
  276. * assembled through a `BlockAssembler`. A direct one-shot model call, NOT a
  277. * loop step: it does not run the `agent/request` waterfall (that seam shapes
  278. * the loop's conversation requests); per-call
  279. * interception happens at `llm/stream` like any other direct call. The model
  280. * comes from `BasicCompactConfig.summarizationModel`, falling back to the
  281. * agent's own model.
  282. * Override in a subclass for a template or remote summarizer.
  283. *
  284. * Honors the adapter failure contract: an adapter may report a model failure
  285. * by throwing from `stream()` (propagated here) OR by ending the stream with
  286. * a `finish {kind:'error'|'aborted'}` chunk — the latter is re-thrown so a
  287. * provider error never yields an empty summary.
  288. *
  289. * Forwards `signal` into `GenerateOptions.signal` so an abort/dispose tears
  290. * down the in-flight summarization rather than orphaning the model call.
  291. *
  292. * Returns the summary blocks TOGETHER with the call envelope it actually
  293. * used (`model`, `maxTokens`) — the caller logs the envelope on the
  294. * `compact/summary` provenance event, so an overriding subclass (template
  295. * or remote summarizer) reports its own envelope honestly.
  296. *
  297. * @param text - plain-text rendering of the conversation region to condense.
  298. * @param agent - supplies the fallback model and the session id stamped on
  299. * the call; throws when neither it nor the config names a model.
  300. * @param signal - optional abort signal, forwarded into the model call.
  301. * @returns the text-only summary blocks plus the call envelope used
  302. * (`model`, and `maxTokens` when the summarizer has a cap).
  303. */
  304. async summarize(
  305. text: string, agent: Agent, signal?: AbortSignal,
  306. ): Promise<{ summary: ContentBlock[]; model: string; maxTokens?: number }> {
  307. const assembler = new BlockAssembler()
  308. const options: GenerateOptions = {
  309. model: this.config.summarizationModel || agent.options.model || '',
  310. messages: [{
  311. role: 'user',
  312. content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }],
  313. }],
  314. system: SUMMARIZE_SYSTEM_PROMPT,
  315. maxTokens: this.config.maxTokens,
  316. sessionId: agent.session.id,
  317. }
  318. // exactOptionalPropertyTypes: only set `signal` when present — assigning
  319. // `undefined` to an optional `signal?: AbortSignal` is a type error.
  320. if (signal) options.signal = signal
  321. if (!options.model) {
  322. throw new Error('no model available for summarization: set BasicCompactConfig.summarizationModel or AgentOptions.model')
  323. }
  324. for await (const chunk of this.ctx.llm.stream(options)) {
  325. assembler.push(chunk)
  326. }
  327. const error = finishError(assembler.finish)
  328. if (error) throw error
  329. const summary = this._textOnly(assembler.message().content)
  330. if (!summary.some(block => block.type === 'text' && block.text.trim().length > 0)) {
  331. throw new Error('summarization produced no text summary content')
  332. }
  333. // config.maxTokens is required and validated positive, so this backend's
  334. // envelope always carries the cap; the return type's optionality exists
  335. // for overriding subclasses whose summarizer has none.
  336. return { summary, model: options.model, maxTokens: this.config.maxTokens }
  337. }
  338. // ---- Core API (implements the abstract contract) ----
  339. /**
  340. * The sole token-pressure gate: estimate the current surface-derived history,
  341. * and if it exceeds the threshold (`contextWindow * thresholdRatio`), compact
  342. * the oldest surface nodes outside the `retainTokens` budget. The auto-
  343. * compaction listener delegates here rather than pre-checking, so this is the
  344. * only place the decision lives.
  345. *
  346. * Retention is a UNIFORM tail→head walk over the whole surface — turn
  347. * boundaries play NO role. Walking node-by-node from the tail and summing
  348. * token estimates, once the retained total reaches `retainTokens` the cutoff
  349. * is rounded to a balanced tool-pairing boundary: if the cut before the
  350. * retained node is unbalanced (an unanswered tool-call sits before it — i.e.
  351. * it is mid-step), the walk continues head-ward until the cut is balanced so
  352. * the whole step is retained (never splitting a step's tool-calls from their
  353. * results); if it stopped on a free node (a node belonging to no step), that
  354. * cut is already balanced. This always rounds toward retaining MORE (retained
  355. * ≥ `retainTokens`) and is boundary-safe by construction — no separate snap
  356. * pass.
  357. *
  358. * The compacted range is always anchored at the surface HEAD (`nodes[0]`):
  359. * auto-compaction re-consolidates any prior head checkpoint into one fresh
  360. * checkpoint. Declines (`null`) when nothing is over threshold, when the whole
  361. * surface fits the retain budget, or when no balanced cutoff exists in the
  362. * compactable range (its only content is an open tail step — retry once it
  363. * closes).
  364. */
  365. override async compactIfNeeded(
  366. agent: Agent,
  367. fullSystemPrompt: string,
  368. signal: AbortSignal,
  369. ): Promise<CompactionResult | null> {
  370. const session = agent.session
  371. const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio)
  372. let result: CompactionResult | null = null
  373. for (let attempt = 0; attempt <= this.config.compactionRetries; attempt++) {
  374. const totalTokens = this.estimateTokens(session.deriveMessages(), fullSystemPrompt)
  375. if (totalTokens < threshold) return result
  376. const range = this._compactableRange(session)
  377. if (range === null) {
  378. /* v8 ignore else -- defensive for non-standard subclass mutations; the concrete replace keeps a compactable head checkpoint. */
  379. if (result === null) return null
  380. /* v8 ignore next -- paired with the ignored defensive branch above. */
  381. break
  382. }
  383. result = await this.compactRegion(session, range.start, range.end, agent, signal)
  384. }
  385. const totalTokens = this.estimateTokens(session.deriveMessages(), fullSystemPrompt)
  386. if (totalTokens < threshold) return result
  387. throw new Error(
  388. `compaction still above threshold after ${this.config.compactionRetries + 1} compaction attempts `
  389. + `(${totalTokens} estimated tokens >= threshold ${threshold})`,
  390. )
  391. }
  392. override async compactRegion(
  393. session: Session,
  394. start: number,
  395. end: number,
  396. agent: Agent,
  397. signal?: AbortSignal,
  398. ): Promise<CompactionResult> {
  399. // Resolve the range by surface POSITION, not numeric seq interval. A prior
  400. // replace lands a fresh high-seq summary node AT the shadowed range's
  401. // position, so the surface order (head→tail) no longer tracks seq order —
  402. // `[newSummarySeq, olderRetainedSeq, …]` is normal. Indexing into the
  403. // ordered node list and slicing it is the only correct way to read a range;
  404. // a `node.seq >= start && node.seq <= end` interval test would mis-collect
  405. // nodes (and `start > end` would falsely reject) once that happens.
  406. const nodes = session.surface.nodes
  407. const startIdx = nodes.findIndex(n => n.seq === start)
  408. const endIdx = nodes.findIndex(n => n.seq === end)
  409. if (startIdx === -1) throw new Error(`compactRegion: start seq ${start} not found in surface`)
  410. if (endIdx === -1) throw new Error(`compactRegion: end seq ${end} not found in surface`)
  411. if (startIdx > endIdx) {
  412. throw new Error(`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`)
  413. }
  414. // The region must never split a step's assistant-message tool-calls from
  415. // their tool/results (which would orphan one side and produce a transcript
  416. // every provider rejects). A region is safe iff BOTH its edges are balanced
  417. // cuts: the cut before `start`, and the cut after `end`. A node that belongs
  418. // to no step (pre-step user message, inter-step steering, injection context)
  419. // is a balanced (free) boundary; an `end` inside an open (unclosed) tail step
  420. // leaves the cut after it unbalanced (the open tool-call has no result yet),
  421. // so it is rejected. See dsh-session's tool-pairing balance check.
  422. const events = session.events
  423. if (!isToolPairingBalanced(nodes, events, start)) {
  424. throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
  425. }
  426. // The cut after `end` is named by `end`'s surface successor, or `null` when
  427. // `end` is the tail.
  428. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  429. const afterEnd: number | null = nodes[endIdx]!.next
  430. if (!isToolPairingBalanced(nodes, events, afterEnd)) {
  431. throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
  432. }
  433. if (this._isCompactionInProgress(session)) {
  434. throw new Error('compaction already in progress')
  435. }
  436. // Compaction's events (compact/* and the replacement user/message) must be
  437. // turn-enclosed: the session-log contract rejects any plugin event appended
  438. // outside an open turn. Auto-compaction satisfies this — it runs on the
  439. // `agent/pre-step` seam, after `turn/start` and before `step/start`, so
  440. // strictly inside the open turn (but outside any step). A manual call on a
  441. // fully-closed session has no turn to enclose the events, so reject rather
  442. // than emit an un-enclosed run.
  443. const openTurn = this._openTurn(session)
  444. if (openTurn === null) {
  445. throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn')
  446. }
  447. // Slice the ordered surface nodes [startIdx, endIdx] inclusive — the
  448. // shadowed range is positional, so this is the set the replace op covers.
  449. const shadowedSeqs = nodes.slice(startIdx, endIdx + 1).map(n => n.seq)
  450. // --- Acquire lock ---
  451. const startEvent = session.append('compact/start', { turn: openTurn })
  452. try {
  453. // --- Extract text and summarize ---
  454. const text = this._extractText(session, shadowedSeqs)
  455. const { summary, model, maxTokens } = await this.summarize(text, agent, signal)
  456. // Estimate token count of the shadowed content for provenance.
  457. let shadowedTokenCount = 0
  458. for (const seq of shadowedSeqs) {
  459. // seq comes from a surface node — always a valid log index by construction.
  460. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  461. shadowedTokenCount += this.estimateEventTokens(session.events[seq]!)
  462. }
  463. const framedSummary = this._frameSummary(summary)
  464. const framedSummaryTokenCount = this.estimateContentTokens(framedSummary)
  465. if (framedSummaryTokenCount >= shadowedTokenCount) {
  466. throw new Error(
  467. `summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${shadowedTokenCount})`,
  468. )
  469. }
  470. // --- Provenance record (log-only) ---
  471. const summaryEvent = session.append('compact/summary', {
  472. summary,
  473. shadowedRange: { start, end },
  474. shadowedSeqs,
  475. shadowedTokenCount,
  476. model,
  477. ...maxTokens !== undefined ? { maxTokens } : {},
  478. })
  479. // --- Surface replacement ---
  480. // The user/message directly shadows all compacted surface nodes with a
  481. // single replace op. It is the ONLY surface event in the compaction
  482. // sequence — compact/start, compact/summary, and compact/end are log-only
  483. // (surfaceOp is rejected by the compiler for non-SurfaceEventType).
  484. // The landed content is FRAMED (checkpoint preamble + tag-wrapped summary);
  485. // the compact/summary provenance event above holds the raw model output.
  486. session.append('user/message', {
  487. content: framedSummary,
  488. source: { kind: 'plugin', plugin: 'compact' },
  489. }, {
  490. surfaceOp: { op: 'replace', start, end },
  491. sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
  492. })
  493. // --- Release lock (log-only) ---
  494. // Appended LAST so the lock brackets the WHOLE operation: a crash between
  495. // compact/start and here leaves a detectable orphaned lock (a compact/start
  496. // with no matching compact/end) rather than a compact/end that falsely
  497. // claims compaction finished before the surface replacement landed.
  498. const endEvent = session.append('compact/end', { turn: openTurn })
  499. return {
  500. startSeq: startEvent.seq,
  501. summarySeq: summaryEvent.seq,
  502. endSeq: endEvent.seq,
  503. summary,
  504. shadowedRange: { start, end },
  505. shadowedSeqs,
  506. shadowedTokenCount,
  507. }
  508. } catch (error: unknown) {
  509. // Always release the lock — append compact/end with the error so a
  510. // wedged lock is impossible.
  511. const msg = error instanceof Error ? error.message : String(error)
  512. session.append('compact/end', { turn: openTurn, error: msg })
  513. throw error
  514. }
  515. }
  516. // ---- Internal helpers ----
  517. /**
  518. * Frame the raw summary blocks into the content that lands on the surface:
  519. * a checkpoint preamble (so a resuming model reads it as a checkpoint, not a
  520. * fresh user request) followed by the summary wrapped in
  521. * {@link SUMMARY_OPEN_TAG}/{@link SUMMARY_CLOSE_TAG}. The tags make a prior
  522. * checkpoint detectable in the transcript on the next compaction cycle, which
  523. * triggers the merge rule in the summarization prompt. The raw, unframed
  524. * `summary` is preserved separately on the `compact/summary` provenance event.
  525. */
  526. private _frameSummary(summary: readonly ContentBlock[]): ContentBlock[] {
  527. return [
  528. { type: 'text', text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` },
  529. ...summary,
  530. { type: 'text', text: SUMMARY_CLOSE_TAG },
  531. ]
  532. }
  533. /**
  534. * Whether a compaction is currently in progress for `session` — an unmatched
  535. * `compact/start` (no later `compact/end`) WITHIN the current turn.
  536. *
  537. * The scan is scoped to the current turn: walking back from the tail it stops
  538. * at the first `turn/end` (the boundary closing the prior turn). A
  539. * `compact/start` left orphaned by a crash mid-compaction lives in a turn that
  540. * persistence repair then closes with a synthetic `turn/end`; scoping here so
  541. * that a stale orphan from a PAST turn cannot wedge compaction forever (it sits
  542. * before the nearest `turn/end`, so the scan never reaches it). An in-progress
  543. * compaction's `compact/start` is always in the still-open current turn,
  544. * before any `turn/end`, so it is still detected.
  545. */
  546. private _isCompactionInProgress(session: Session): boolean {
  547. const events = session.events
  548. for (let i = events.length - 1; i >= 0; i--) {
  549. // Index bounded by i >= 0 and i < events.length — never undefined.
  550. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  551. const e = events[i]!
  552. if (e.type === 'compact/start') return true
  553. if (e.type === 'compact/end') break
  554. // A turn/end bounds the scan: anything before it belongs to a prior
  555. // (closed) turn and cannot be an in-progress compaction of THIS turn.
  556. if (e.type === 'turn/end') break
  557. }
  558. return false
  559. }
  560. /** Resolve the next head-anchored compactable surface range, or `null`. */
  561. private _compactableRange(session: Session): { start: number; end: number } | null {
  562. const nodes = session.surface.nodes
  563. if (nodes.length === 0) return null
  564. const events = session.events
  565. const retainBudget = this.config.retainTokens
  566. // Walk tail→head summing per-node token estimates. `keepFromIdx` is the
  567. // index of the OLDEST node we retain verbatim; everything strictly older
  568. // (`[0, keepFromIdx - 1]`) is the compactable range.
  569. let accumulated = 0
  570. let keepFromIdx = nodes.length // nothing retained yet
  571. for (let i = nodes.length - 1; i >= 0; i--) {
  572. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  573. const node = nodes[i]!
  574. const event = events[node.seq]
  575. /* v8 ignore next -- node.seq is a surface-node seq, always a valid log index by construction */
  576. if (event) accumulated += this.estimateEventTokens(event)
  577. keepFromIdx = i
  578. if (accumulated >= retainBudget) break
  579. }
  580. // The whole surface fits the retain budget — nothing to compact.
  581. if (keepFromIdx === 0) return null
  582. // Round the cutoff to a tool-pairing boundary: if the cut before
  583. // `nodes[keepFromIdx]` is unbalanced (an unanswered tool-call sits before
  584. // it — i.e. it is mid-step), extend the retained side head-ward until the
  585. // cut is balanced, so the compacted range ends without splitting an
  586. // assistant↔result pair. A node that belongs to no step is already a
  587. // balanced (free) boundary. Decline if no balanced cut exists at or below
  588. // `keepFromIdx` (the compactable range is only an un-splittable open tail
  589. // step — retry once it closes).
  590. while (keepFromIdx > 0) {
  591. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  592. if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!.seq)) break
  593. keepFromIdx -= 1
  594. }
  595. if (keepFromIdx === 0) return null
  596. // The compacted range is [head … keepFromIdx - 1], anchored at the head.
  597. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  598. const firstSeq = nodes[0]!.seq
  599. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  600. const cutoffSeq = nodes[keepFromIdx - 1]!.seq
  601. return { start: firstSeq, end: cutoffSeq }
  602. }
  603. /**
  604. * Keep ONLY text blocks from the model-produced summary before storing it.
  605. *
  606. * The summary lands on the surface as a synthesized `user/message` (see
  607. * {@link _frameSummary}), so the only block type that is both useful and safe
  608. * there is `text`. A model assistant message can otherwise carry `reasoning`
  609. * (private chain-of-thought, must not leak into the durable checkpoint) and
  610. * `tool-call` blocks — and a surviving `tool-call` in a user message would be
  611. * an orphaned call with no matching `tool-result`, exactly the tool-pairing
  612. * breakage compaction works to avoid. Filtering to text drops both.
  613. */
  614. private _textOnly(blocks: readonly ContentBlock[]): ContentBlock[] {
  615. return blocks.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
  616. }
  617. /**
  618. * The turn number of the currently OPEN turn — a `turn/start` not yet
  619. * followed by its `turn/end` — or `null` if the session has no open turn.
  620. *
  621. * Compaction's events must be enclosed in a turn, so scanning back from the
  622. * tail: a `turn/start` means that turn is open (return it); a `turn/end` means
  623. * the most recent turn already closed (return null). The whole compaction
  624. * sequence (compact/start … compact/end) is stamped with this turn.
  625. */
  626. private _openTurn(session: Session): number | null {
  627. for (let i = session.events.length - 1; i >= 0; i--) {
  628. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  629. const e = session.events[i]!
  630. if (e.type === 'turn/start') return e.data.turn
  631. if (e.type === 'turn/end') return null
  632. }
  633. return null
  634. }
  635. /**
  636. * Extract plain-text conversation from a set of surface node seqs, for
  637. * feeding into the summarization model. Walks the seqs in the order given
  638. * (surface order, as `compactRegion` slices the surface-node list) so the
  639. * summary follows the conversation as the model sees it — which, after a
  640. * `replace`, is NOT ascending log-seq order (a high-seq summary node heads the
  641. * surface before older retained lower-seq nodes).
  642. */
  643. private _extractText(session: Session, seqs: number[]): string {
  644. const lines: string[] = []
  645. // Walk seqs in the order given (surface order, as compactRegion slices the
  646. // surface-node list) — NOT ascending log-seq order. After a replace the
  647. // summary node carries a fresh high seq while sitting at the head of the
  648. // surface before older retained lower-seq nodes, so a log-order scan would
  649. // feed the transcript out of order and break the checkpoint-merge prompt.
  650. for (const seq of seqs) {
  651. const event = session.events[seq]
  652. /* v8 ignore next -- seq is a surface-node seq, always a valid log index by construction */
  653. if (!event) continue
  654. switch (event.type) {
  655. case 'user/message': {
  656. const text = this._blocksToText(event.data.content)
  657. if (text) lines.push(`User: ${text}`)
  658. break
  659. }
  660. case 'assistant/message': {
  661. const text = this._blocksToText(event.data.content)
  662. if (text) lines.push(`Assistant: ${text}`)
  663. break
  664. }
  665. case 'tool/result': {
  666. const text = this._blocksToText(event.data.content)
  667. const label = event.data.isError ? 'Tool error' : 'Tool result'
  668. if (text) lines.push(`${label} (call ${event.data.callId}): ${text}`)
  669. break
  670. }
  671. case 'context/message': {
  672. const text = this._blocksToText(event.data.content)
  673. if (text) lines.push(`[Context: ${text}]`)
  674. break
  675. }
  676. case 'steering/message': {
  677. const text = this._blocksToText(event.data.content)
  678. if (text) lines.push(`[Steering: ${text}]`)
  679. break
  680. }
  681. // SessionEventMap is merge-extensible — unknown types are
  682. // non-message events that carry no extractable text.
  683. /* v8 ignore next 2 -- seqs only name surface nodes, always one of the 5 handled SurfaceEventTypes; unreachable */
  684. default:
  685. break
  686. }
  687. }
  688. return lines.join('\n\n')
  689. }
  690. /**
  691. * Render content blocks to a single plain-text string for the summarization
  692. * prompt. Text and reasoning contribute their text; every other block type
  693. * contributes a type-tagged placeholder (`[tool-call: name(args)]`,
  694. * `[tool-result: …]`, …) so the summarizer is told what non-text content
  695. * existed in the region rather than silently losing it. Blocks join with
  696. * newlines; empty-text blocks contribute nothing.
  697. */
  698. private _blocksToText(blocks: readonly ContentBlock[]): string {
  699. const parts: string[] = []
  700. for (const block of blocks) {
  701. switch (block.type) {
  702. case 'text':
  703. if (block.text) parts.push(block.text)
  704. break
  705. case 'reasoning':
  706. if (block.text) parts.push(`[reasoning: ${block.text}]`)
  707. break
  708. case 'tool-call':
  709. parts.push(`[tool-call: ${block.name}(${block.arguments})]`)
  710. break
  711. case 'tool-result': {
  712. const inner = this._blocksToText(block.content)
  713. parts.push(inner ? `[tool-result: ${inner}]` : '[tool-result]')
  714. break
  715. }
  716. // ContentBlockMap is merge-extensible — render an unknown block as a
  717. // bare type-tagged placeholder so a plugin-added block type is still
  718. // signalled to the summarizer rather than dropped.
  719. default:
  720. parts.push(`[${(block as ContentBlock).type}]`)
  721. }
  722. }
  723. return parts.join('\n')
  724. }
  725. }
  726. export default BasicCompactService