index.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. /**
  2. * Basic replay-aware compaction backend.
  3. *
  4. * @module @deepseek-ai/dsh-compact-basic
  5. */
  6. import { Context } from 'cordis'
  7. import z from 'schemastery'
  8. import { CompactService } from '@deepseek-ai/dsh-compact'
  9. import type { CompactionResult } from '@deepseek-ai/dsh-compact'
  10. import { canonicalHeader } from '@deepseek-ai/dsh-session'
  11. import type { EpochHeader, Session } from '@deepseek-ai/dsh-session'
  12. import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
  13. import type { Agent } from '@deepseek-ai/dsh-agent'
  14. import { registerAutomaticCompaction } from './automatic.ts'
  15. import { resolveConfig } from './config.ts'
  16. import { compactSurfaceRegion, selectCompactableRange } from './region.ts'
  17. import { summarizeWithLlm } from './summarizer.ts'
  18. import type {
  19. BasicCompactConfig,
  20. ResolvedConfig,
  21. } from './types.ts'
  22. export type {
  23. BasicCompactConfig,
  24. ResolvedConfig,
  25. } from './types.ts'
  26. /** Resolve the latest actual routed provider/model, then the complete agent fallback pair. */
  27. function effectiveTarget(agent: Agent): { provider: string; model: string } | undefined {
  28. const latest = agent.session.requestHeader()?.config
  29. if (latest !== undefined) return { provider: latest.provider, model: latest.model }
  30. const { provider, model } = agent.options
  31. if (provider === undefined || provider.length === 0 || model === undefined || model.length === 0) {
  32. return undefined
  33. }
  34. return { provider, model }
  35. }
  36. /**
  37. * Build the provisional pre-step request envelope. Prompt and prefix are exact;
  38. * tools and non-model call config come from the latest logged request because
  39. * later request middleware has not run yet.
  40. */
  41. function provisionalHeader(
  42. target: { provider: string; model: string },
  43. session: Session,
  44. fullSystemPrompt: string,
  45. sessionPrefix: readonly Message[],
  46. ): EpochHeader {
  47. const latest = session.requestHeader()
  48. return canonicalHeader({
  49. config: latest === undefined ? target : { ...latest.config, ...target },
  50. ...fullSystemPrompt.length === 0 ? {} : { system: fullSystemPrompt },
  51. ...latest?.tools === undefined ? {} : { tools: latest.tools },
  52. ...sessionPrefix.length === 0 ? {} : { messagePrefix: [...sessionPrefix] },
  53. })
  54. }
  55. /**
  56. * Dependency-light compaction backend using `ctx.tokenMeter` for pressure,
  57. * retention, provenance, and summary-convergence pricing.
  58. *
  59. * `summarize()` is the sole subclass customization hook; the replay and durable
  60. * mutation strategy stays fixed so every pricing decision uses the singleton
  61. * token meter.
  62. */
  63. export class BasicCompactService extends CompactService {
  64. static inject = ['llm', 'tokenMeter']
  65. static Config: z<BasicCompactConfig> = z.object({
  66. thresholdRatio: z.number().default(0.8),
  67. retainTokens: z.number().step(1),
  68. summarizationProvider: z.string().default(''),
  69. summarizationModel: z.string().default(''),
  70. maxTokens: z.number().step(1).min(1).default(8192),
  71. compactionRetries: z.number().step(1).min(0).default(1),
  72. auto: z.boolean().default(true),
  73. })
  74. /** Resolved and validated compaction configuration. */
  75. readonly config: ResolvedConfig
  76. constructor(ctx: Context, config: BasicCompactConfig = {}) {
  77. super(ctx)
  78. this.config = resolveConfig(config, ctx.tokenMeter)
  79. if (this.config.auto) registerAutomaticCompaction(ctx, this)
  80. }
  81. /**
  82. * Summarize a rendered region through a direct one-shot `ctx.llm.stream()`
  83. * call. Override this sole hook for a template or remote summarizer.
  84. * @param text - plain-text conversation region to condense.
  85. * @param agent - supplies routed-model history, fallback model, and session id.
  86. * @param signal - optional cancellation forwarded to the adapter.
  87. * @returns safe text summary blocks and exact auxiliary-call provenance.
  88. */
  89. protected async summarize(
  90. text: string,
  91. agent: Agent,
  92. signal?: AbortSignal,
  93. ): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
  94. return summarizeWithLlm(this.ctx, this.config, text, agent, signal)
  95. }
  96. /**
  97. * Check replayed pressure for the provisional pre-step envelope and compact
  98. * a tool-balanced head until it falls below the service-wide threshold.
  99. * A genuinely model-less router-first step skips this provisional check.
  100. * @param agent - agent whose session and provisional provider/model are measured.
  101. * @param fullSystemPrompt - current assembled system prompt override.
  102. * @param sessionPrefix - current request-only prefix override.
  103. * @param signal - live step cancellation signal forwarded to summarization.
  104. * @returns the latest compaction result, or `null` when no check/work applies.
  105. */
  106. override async compactIfNeeded(
  107. agent: Agent,
  108. fullSystemPrompt: string,
  109. sessionPrefix: readonly Message[],
  110. signal: AbortSignal,
  111. ): Promise<CompactionResult | null> {
  112. const target = effectiveTarget(agent)
  113. if (target === undefined) return null
  114. const meter = this.ctx.tokenMeter
  115. const requestHeader = provisionalHeader(target, agent.session, fullSystemPrompt, sessionPrefix)
  116. const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio)
  117. let measurement = meter.measure(agent.session, requestHeader)
  118. if (measurement.totalTokens < threshold) return null
  119. let result: CompactionResult | null = null
  120. for (let attempt = 0; attempt <= this.config.compactionRetries; attempt += 1) {
  121. const range = selectCompactableRange(agent.session, measurement, this.config.retainTokens)
  122. if (range === null) {
  123. /* v8 ignore else -- concrete replacement preserves a compactable checkpoint; subclass hooks cannot mutate it. */
  124. if (result === null) return null
  125. /* v8 ignore next -- paired with the defensive post-success branch above. */
  126. break
  127. }
  128. result = await this.compactRegion(range.start, range.end, agent, signal)
  129. measurement = meter.measure(agent.session, requestHeader)
  130. if (measurement.totalTokens < threshold) return result
  131. }
  132. throw new Error(
  133. `compaction still above threshold after ${this.config.compactionRetries + 1} compaction attempts `
  134. + `(${measurement.totalTokens} estimated tokens >= threshold ${threshold})`,
  135. )
  136. }
  137. /**
  138. * Compact one inclusive positional range from the agent-owned surface using
  139. * the effective token meter for all retention and shrink pricing.
  140. * @param start - inclusive first surface-node seq.
  141. * @param end - inclusive last surface-node seq.
  142. * @param agent - owner of the target session, used by the summarizer.
  143. * @param signal - optional summarization cancellation signal.
  144. * @returns the successful durable compaction result.
  145. */
  146. override async compactRegion(
  147. start: number,
  148. end: number,
  149. agent: Agent,
  150. signal?: AbortSignal,
  151. ): Promise<CompactionResult> {
  152. const session = agent.session
  153. return compactSurfaceRegion({
  154. meter: this.ctx.tokenMeter,
  155. summarize: (text, owner, abort) => this.summarize(text, owner, abort),
  156. }, session, start, end, agent, signal)
  157. }
  158. }
  159. export default BasicCompactService