request-header.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. /**
  2. * Request-header reconstruction utilities: the pure fold/diff/apply trio over
  3. * the `request/header` / `request/header-delta` session events. Anyone
  4. * holding a session log reconstructs the {@link EpochHeader} any request was
  5. * built under by folding these events in log order; the loop uses the same
  6. * functions to decide whether a step's header changed and to encode the
  7. * change. Deltas are an encoding optimization with a safety valve — the
  8. * writer round-trip-verifies every delta before appending and falls back to
  9. * a full snapshot when the encoding cannot express the change — so folding
  10. * never needs error recovery on a well-formed log.
  11. *
  12. * @module dsh-session/request-header
  13. */
  14. import { callConfigEquals } from '@deepseek-ai/dsh-llm'
  15. import type { LlmCallConfig, ToolSchema } from '@deepseek-ai/dsh-llm'
  16. import type { EpochHeader, SessionEvent, SystemDelta, ToolsDelta } from './types.ts'
  17. /**
  18. * Normalize a header to canonical form: an empty system prompt and an empty
  19. * tool list become ABSENT fields, matching how requests are built (both
  20. * request-build spreads skip empty values). Diff, fold, and comparison all
  21. * operate on canonical headers, so "no system prompt" has exactly one
  22. * representation.
  23. * @param header - the header to normalize (not mutated).
  24. * @returns the canonical header.
  25. */
  26. export function canonicalHeader(header: EpochHeader): EpochHeader {
  27. return {
  28. config: header.config,
  29. ...header.system !== undefined && header.system.length > 0 ? { system: header.system } : {},
  30. ...header.tools !== undefined && header.tools.length > 0 ? { tools: header.tools } : {},
  31. }
  32. }
  33. /** Split a canonical (possibly absent) system prompt into lines; absence is zero lines. */
  34. function systemLines(system: string | undefined): string[] {
  35. return system === undefined ? [] : system.split('\n')
  36. }
  37. /** Join lines back into a canonical system value; zero lines is absence. */
  38. function joinSystem(lines: string[]): string | undefined {
  39. return lines.length === 0 ? undefined : lines.join('\n')
  40. }
  41. /**
  42. * Compute the line-level {@link SystemDelta} between two canonical system
  43. * prompts: trim the common prefix and (non-overlapping) common suffix, and
  44. * carry the replacement lines between them. Deterministic and library-free;
  45. * with nothing shared it degenerates to a full replacement.
  46. */
  47. function diffSystem(prev: string | undefined, next: string | undefined): SystemDelta {
  48. const a = systemLines(prev)
  49. const b = systemLines(next)
  50. let keepStart = 0
  51. while (keepStart < a.length && keepStart < b.length && a[keepStart] === b[keepStart]) keepStart += 1
  52. let keepEnd = 0
  53. while (
  54. keepEnd < a.length - keepStart &&
  55. keepEnd < b.length - keepStart &&
  56. a[a.length - 1 - keepEnd] === b[b.length - 1 - keepEnd]
  57. ) keepEnd += 1
  58. return { keepStart, keepEnd, insert: b.slice(keepStart, b.length - keepEnd) }
  59. }
  60. /** Apply a {@link SystemDelta} to a canonical system prompt. */
  61. function applySystem(prev: string | undefined, delta: SystemDelta): string | undefined {
  62. const a = systemLines(prev)
  63. return joinSystem([...a.slice(0, delta.keepStart), ...delta.insert, ...a.slice(a.length - delta.keepEnd)])
  64. }
  65. /** Canonical JSON equality for tool schemas — sound because schemas are
  66. * JSON-serializable by construction and both sides come from the same
  67. * assembly path, so key insertion order matches when the values do. */
  68. function sameSchema(a: ToolSchema, b: ToolSchema): boolean {
  69. return JSON.stringify(a) === JSON.stringify(b)
  70. }
  71. /**
  72. * Compute the name-keyed {@link ToolsDelta} between two canonical tool lists.
  73. * A pure reordering produces an empty delta — the writer's round-trip guard
  74. * catches that case and records a snapshot instead.
  75. */
  76. function diffTools(prev: readonly ToolSchema[], next: readonly ToolSchema[]): ToolsDelta {
  77. const prevByName = new Map(prev.map(tool => [tool.name, tool]))
  78. const nextNames = new Set(next.map(tool => tool.name))
  79. return {
  80. added: next.filter(tool => !prevByName.has(tool.name)),
  81. removed: prev.filter(tool => !nextNames.has(tool.name)).map(tool => tool.name),
  82. changed: next.filter((tool) => {
  83. const before = prevByName.get(tool.name)
  84. return before !== undefined && !sameSchema(before, tool)
  85. }),
  86. }
  87. }
  88. /** Apply a {@link ToolsDelta} to a canonical tool list: drop removed, replace changed in place, append added. */
  89. function applyTools(prev: readonly ToolSchema[], delta: ToolsDelta): ToolSchema[] {
  90. const removed = new Set(delta.removed)
  91. const changedByName = new Map(delta.changed.map(tool => [tool.name, tool]))
  92. const kept = prev
  93. .filter(tool => !removed.has(tool.name))
  94. .map(tool => changedByName.get(tool.name) ?? tool)
  95. return [...kept, ...delta.added]
  96. }
  97. /**
  98. * Field-wise equality over canonical headers — the cheap comparison the
  99. * writer's round-trip guard runs (`applyHeaderDelta(prev, delta)` must equal
  100. * the intended header) and the loop runs to skip logging an unchanged header.
  101. * Tools compare per-schema IN ORDER (canonical JSON), so a pure reordering is
  102. * correctly unequal.
  103. * @param a - one canonical header.
  104. * @param b - the other.
  105. * @returns whether config, system, and tools (in order) all match.
  106. */
  107. export function headerEquals(a: EpochHeader, b: EpochHeader): boolean {
  108. if (!callConfigEquals(a.config, b.config) || a.system !== b.system) return false
  109. const at = a.tools ?? []
  110. const bt = b.tools ?? []
  111. return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema))
  112. }
  113. /**
  114. * Compute the `request/header-delta` payload between two canonical headers,
  115. * or undefined when they are equal. The caller MUST round-trip the result
  116. * ({@link applyHeaderDelta} on `prev` deep-equals `next`) before logging it —
  117. * the encoding cannot express every change (a pure tool reordering) — and
  118. * fall back to a full `request/header` snapshot when the check fails.
  119. * @param prev - the folded header the log currently implies.
  120. * @param next - the header the next request will actually use.
  121. * @returns the delta payload, or undefined when nothing changed.
  122. */
  123. export function diffHeader(
  124. prev: EpochHeader, next: EpochHeader,
  125. ): { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } | undefined {
  126. const delta: { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } = {}
  127. if (prev.system !== next.system) delta.system = diffSystem(prev.system, next.system)
  128. const prevTools = prev.tools ?? []
  129. const nextTools = next.tools ?? []
  130. if (JSON.stringify(prevTools) !== JSON.stringify(nextTools)) delta.tools = diffTools(prevTools, nextTools)
  131. if (!callConfigEquals(prev.config, next.config)) delta.config = next.config
  132. return Object.keys(delta).length > 0 ? delta : undefined
  133. }
  134. /**
  135. * Apply a `request/header-delta` payload to a canonical header, producing the
  136. * canonical header it encodes. Total for well-formed logs (the writer only
  137. * appends round-trip-verified deltas).
  138. * @param prev - the folded header before the delta.
  139. * @param delta - the logged delta payload.
  140. * @returns the canonical header after the delta.
  141. */
  142. export function applyHeaderDelta(
  143. prev: EpochHeader, delta: { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig },
  144. ): EpochHeader {
  145. const system = delta.system !== undefined ? applySystem(prev.system, delta.system) : prev.system
  146. const tools = delta.tools !== undefined ? applyTools(prev.tools ?? [], delta.tools) : prev.tools
  147. return canonicalHeader({
  148. config: delta.config ?? prev.config,
  149. ...system !== undefined ? { system } : {},
  150. ...tools !== undefined ? { tools } : {},
  151. })
  152. }
  153. /**
  154. * Fold the header events of a log (or any prefix of one) into the
  155. * {@link EpochHeader} in force after the last of them: each
  156. * `request/header` snapshot replaces the state, each `request/header-delta`
  157. * amends it. The pure, offline form of reconstruction — external tooling and
  158. * the dev invariant both use it; the live session tracks the same fold
  159. * incrementally.
  160. * @param events - session events in log order (non-header events are skipped).
  161. * @param from - a previously folded state to continue from (the live session's
  162. * incremental cursor); omit to fold from nothing.
  163. * @returns the folded header, or undefined when no header event exists yet.
  164. */
  165. export function foldRequestHeader(events: readonly SessionEvent[], from?: EpochHeader): EpochHeader | undefined {
  166. let state: EpochHeader | undefined = from
  167. for (const event of events) {
  168. if (event.type === 'request/header') {
  169. state = canonicalHeader(event.data.header)
  170. } else if (event.type === 'request/header-delta') {
  171. if (state === undefined) {
  172. throw new Error(`request/header-delta at seq ${event.seq} before any request/header snapshot: corrupt log`)
  173. }
  174. state = applyHeaderDelta(state, event.data)
  175. }
  176. }
  177. return state
  178. }