runtime-context.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. /**
  2. * Durable projection state for the two loop-owned surface messages the system
  3. * prompt plugin forms: the system prompt (surface node 0 and any in-history
  4. * replacement) and the dynamic runtime-context snapshot.
  5. * @module @deepseek-ai/dsh-agent-loop/runtime-context
  6. */
  7. import { createSystemMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
  8. import type { ContextSnapshotSection, Message } from '@deepseek-ai/dsh-llm'
  9. import type { Session, SessionEvent, SessionSeq, SurfaceIntent, SystemMessage, UserMessage } from '@deepseek-ai/dsh-session'
  10. import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session'
  11. import type { Context } from '@deepseek-ai/cordis'
  12. const SOURCE = '@deepseek-ai/dsh-system-prompt'
  13. const CLEARED = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'
  14. function isOwned(message: UserMessage): boolean {
  15. return message.source.kind === 'plugin' && message.source.plugin === SOURCE
  16. }
  17. function textOf(message: Message): string | undefined {
  18. const [block] = message.content
  19. return message.content.length === 1 && block?.type === 'text' ? block.text : undefined
  20. }
  21. /** One uncommitted system-prompt surface operation for request admission or reconciliation. */
  22. export interface SystemPromptCommit {
  23. /** Rendered prompt or empty content: an empty head records no prompt; empty tails are dormant. */
  24. message: SystemMessage
  25. /** `append` for a new system node, otherwise a replacement of one surviving system node. */
  26. intent: SurfaceIntent<'system/message'>
  27. }
  28. /** The request-series facts one prompt decision is made under. */
  29. export interface SystemPromptDecisionInput {
  30. /** Whether the prepared route for this attempt reads a later `system` message as the effective prompt. */
  31. inHistory: boolean
  32. /**
  33. * Whether this step's request starts a new model-message series: a pre-step
  34. * listener declared one, the surface was replaced since the last request, or
  35. * the assembled tool schemas differ from the logged header.
  36. */
  37. startsSeries: boolean
  38. }
  39. /** Committed events from the newest backward; the restore scans stop at the first match. */
  40. function eventsNewestFirst(session: Session): readonly SessionEvent[] {
  41. // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
  42. return session.snapshotEvents().toReversed()
  43. }
  44. /**
  45. * Decides how a rendered system prompt reaches the surface without owning the
  46. * commit. The first prompt, even empty, reserves surface node 0.
  47. * A capable continuing series appends changed nonempty text after the
  48. * cached history. An incapable route, broken series, or cleared prompt instead
  49. * normalizes the first system node and empties later active nodes. Dormant empty
  50. * tails do not supply effective text or require repeated replacements.
  51. */
  52. export class SystemPromptProjection {
  53. constructor(private readonly session: Session) {}
  54. /** The surviving `system/message` nodes in surface order. */
  55. private systemNodes(): { seq: SessionSeq; text: string | undefined }[] {
  56. const nodes: { seq: SessionSeq; text: string | undefined }[] = []
  57. for (const seq of this.session.surface.nodes) {
  58. // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
  59. const event = this.session.eventAt(seq)
  60. if (event?.type !== 'system/message') continue
  61. const content = event.data.message.content
  62. const text = content.length === 0 ? '' : textOf(event.data.message)
  63. nodes.push({ seq, text })
  64. }
  65. return nodes
  66. }
  67. /**
  68. * Reconcile effective text and retained nodes with the prepared route and series.
  69. * @param rendered - the fully rendered system prompt; `''` when none is active.
  70. * @param input - the route capability and series facts for this step.
  71. * @returns ordered per-node updates; an empty list means no update is needed.
  72. */
  73. project(rendered: string, input: SystemPromptDecisionInput): SystemPromptCommit[] {
  74. const nodes = this.systemNodes()
  75. const head = nodes[0]
  76. if (head === undefined) {
  77. return [{ message: createSystemMessage(rendered, SOURCE), intent: { surfaceOp: 'append' } }]
  78. }
  79. const latest = nodes.findLast(node => node.text !== '') ?? head
  80. if (!input.inHistory || input.startsSeries || rendered.length === 0) {
  81. const updates = nodes.slice(1).filter(node => node.text !== '')
  82. .map(node => this.replace(node.seq, ''))
  83. if (head.text !== rendered) updates.push(this.replace(head.seq, rendered))
  84. return updates
  85. }
  86. if (latest.text === rendered) return []
  87. return [{ message: createSystemMessage(rendered, SOURCE), intent: { surfaceOp: 'append' } }]
  88. }
  89. private replace(seq: SessionSeq, text: string): SystemPromptCommit {
  90. return {
  91. message: createSystemMessage(text, SOURCE),
  92. intent: { surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq] },
  93. }
  94. }
  95. }
  96. /** Tracks the last retained runtime-context snapshot without owning its commit. */
  97. export class RuntimeContextProjection {
  98. /** `undefined` means no snapshot ever existed; `null` means none is retained. */
  99. private retained: { seq: SessionSeq; text: string | undefined } | null | undefined
  100. /**
  101. * Restore projection state once, then follow authoritative session events.
  102. * @param ctx - agent-scoped event context.
  103. * @param session - session receiving projected messages.
  104. */
  105. constructor(ctx: Context, session: Session) {
  106. const surface = new Set(session.surface.nodes)
  107. for (const event of eventsNewestFirst(session)) {
  108. if (event.type !== 'user/message' || !isOwned(event.data)) continue
  109. this.retained ??= null
  110. if (surface.has(event.seq)) {
  111. this.retained = { seq: event.seq, text: textOf(event.data) }
  112. break
  113. }
  114. }
  115. ctx.on('session/event', (subject, event) => {
  116. if (subject !== session) return
  117. if (event.type === 'user/message' && isOwned(event.data)) {
  118. this.retained = { seq: event.seq, text: textOf(event.data) }
  119. } else if (this.retained
  120. && isReplacementSurfaceEvent(event)
  121. && event.sourceEventSeqs?.includes(this.retained.seq) === true) {
  122. this.retained = null
  123. }
  124. })
  125. }
  126. /**
  127. * Create an uncommitted snapshot only when the retained value differs.
  128. * @param current - fully rendered dynamic context.
  129. * @param sections - named contributions that formed the current snapshot.
  130. * @returns a candidate user message, or `undefined` when no update is needed.
  131. */
  132. project(current: string, sections: readonly ContextSnapshotSection[]): UserMessage | undefined {
  133. if (this.retained === undefined && current.length === 0) return
  134. const snapshot = current.length === 0 ? CLEARED : current
  135. if (this.retained?.text === snapshot) return
  136. return createUserMessage({
  137. content: [{ type: 'text', text: snapshot }],
  138. // The cleared marker has no contributions left to attribute.
  139. source: sections.length === 0
  140. ? { kind: 'plugin', plugin: SOURCE }
  141. : { kind: 'plugin', plugin: SOURCE, form: 'snapshot', sections },
  142. })
  143. }
  144. }