runtime-context.ts 3.1 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
  1. /**
  2. * Durable projection state for dynamic runtime context.
  3. * @module @deepseek-ai/dsh-agent-loop/runtime-context
  4. */
  5. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  6. import type { ContextSnapshotSection } from '@deepseek-ai/dsh-llm'
  7. import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
  8. import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session'
  9. import type { Context } from 'cordis'
  10. const SOURCE = '@deepseek-ai/dsh-system-prompt'
  11. const CLEARED = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'
  12. function isOwned(message: UserMessage): boolean {
  13. return message.source.kind === 'plugin' && message.source.plugin === SOURCE
  14. }
  15. function textOf(message: UserMessage): string | undefined {
  16. const [block] = message.content
  17. return message.content.length === 1 && block?.type === 'text' ? block.text : undefined
  18. }
  19. /** Tracks the last retained runtime-context snapshot without owning its commit. */
  20. export class RuntimeContextProjection {
  21. /** `undefined` means no snapshot ever existed; `null` means none is retained. */
  22. private retained: { seq: number; text: string | undefined } | null | undefined
  23. /**
  24. * Restore projection state once, then follow authoritative session events.
  25. * @param ctx - agent-scoped event context.
  26. * @param session - session receiving projected messages.
  27. */
  28. constructor(ctx: Context, session: Session) {
  29. const surface = new Set(session.surface.nodes)
  30. for (let index = session.events.length - 1; index >= 0; index -= 1) {
  31. const event = session.events[index]
  32. if (event?.type !== 'user/message' || !isOwned(event.data)) continue
  33. this.retained ??= null
  34. if (surface.has(event.seq)) {
  35. this.retained = { seq: event.seq, text: textOf(event.data) }
  36. break
  37. }
  38. }
  39. ctx.on('session/event', (subject, event) => {
  40. if (subject !== session) return
  41. if (event.type === 'user/message' && isOwned(event.data)) {
  42. this.retained = { seq: event.seq, text: textOf(event.data) }
  43. } else if (this.retained
  44. && isReplacementSurfaceEvent(event)
  45. && event.sourceEventSeqs?.includes(this.retained.seq) === true) {
  46. this.retained = null
  47. }
  48. })
  49. }
  50. /**
  51. * Create an uncommitted snapshot only when the retained value differs.
  52. * @param current - fully rendered dynamic context.
  53. * @param sections - named contributions that formed the current snapshot.
  54. * @returns a candidate user message, or `undefined` when no update is needed.
  55. */
  56. project(current: string, sections: readonly ContextSnapshotSection[]): UserMessage | undefined {
  57. if (this.retained === undefined && current.length === 0) return
  58. const snapshot = current.length === 0 ? CLEARED : current
  59. if (this.retained?.text === snapshot) return
  60. return createUserMessage({
  61. content: [{ type: 'text', text: snapshot }],
  62. // The cleared marker has no contributions left to attribute.
  63. source: sections.length === 0
  64. ? { kind: 'plugin', plugin: SOURCE }
  65. : { kind: 'plugin', plugin: SOURCE, form: 'snapshot', sections },
  66. })
  67. }
  68. }