inbox.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. /**
  2. * Driver-owned durable agent inbox projection and command facade.
  3. *
  4. * @module @deepseek-ai/dsh-agent-loop/inbox
  5. */
  6. import type { MessageId } from '@deepseek-ai/dsh-llm'
  7. import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
  8. import type SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  9. import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session'
  10. import type {
  11. AgentEventDispatch,
  12. Inbox as InboxContract,
  13. InboxState,
  14. InboxTarget,
  15. InboxWireState,
  16. } from '@deepseek-ai/dsh-agent'
  17. import { z } from 'zod'
  18. /** Wire validation for pending agent input reconstructed from durable inbox splices. */
  19. export const inboxProjectionSchema = z.object({
  20. 'next-turn': z.array(z.custom<UserMessage>()).readonly(),
  21. 'next-step': z.array(z.custom<UserMessage>()).readonly(),
  22. }).readonly()
  23. /** Standard fold that reconstructs pending input and rejects invalid durable splice history. */
  24. export const inboxProjectionDefinition = {
  25. key: 'inbox',
  26. stateSchema: inboxProjectionSchema,
  27. init: (): InboxState => ({ 'next-turn': [], 'next-step': [] }),
  28. apply(state: InboxState, event) {
  29. if (event.type !== 'agent/inbox/spliced') return state
  30. const splice = event.data
  31. try {
  32. const inbox = state[splice.target]
  33. const removedCount = splice.removedCount ?? 0
  34. if (!Number.isSafeInteger(splice.start) || splice.start < 0 || splice.start > inbox.length
  35. || !Number.isSafeInteger(removedCount) || removedCount < 0
  36. || splice.start + removedCount > inbox.length) {
  37. throw new Error('invalid inbox splice')
  38. }
  39. const next = inbox.toSpliced(splice.start, removedCount, ...splice.inserted)
  40. const ids = new Set<string>()
  41. for (const message of splice.target === 'next-turn'
  42. ? [...next, ...state['next-step']]
  43. : [...state['next-turn'], ...next]) {
  44. if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`)
  45. ids.add(message.id)
  46. }
  47. return splice.target === 'next-turn'
  48. ? { 'next-turn': next, 'next-step': state['next-step'] }
  49. : { 'next-turn': state['next-turn'], 'next-step': next }
  50. } catch (error: unknown) {
  51. throw new Error(`invalid persisted inbox splice at session seq ${event.seq}`, { cause: error })
  52. }
  53. },
  54. wire: {
  55. // The wire value is the fold state itself: every pending message already
  56. // round-trips the session log as lossless JSON. Only the static type
  57. // narrows to the JSON-safe projection table entry.
  58. viewSchema: inboxProjectionSchema as unknown as z.ZodType<InboxWireState>,
  59. view: (state: InboxState) => state as unknown as InboxWireState,
  60. },
  61. stateVersion: 1,
  62. } satisfies ProjectionDefinition<'inbox', InboxState>
  63. /**
  64. * Driver-owned durable Inbox implementation used by ReactLoopAgent and focused
  65. * provider tests.
  66. * @param projections - registry that owns the standard Inbox projection.
  67. * @param session - session whose durable events store pending input.
  68. * @param dispatch - agent-scoped notifications for Inbox lifecycle events.
  69. */
  70. export class ReactLoopInbox implements InboxContract {
  71. constructor(
  72. private readonly projections: SessionProjectionRegistry,
  73. private readonly session: Session,
  74. private readonly dispatch: AgentEventDispatch,
  75. ) {
  76. this.projections.register(inboxProjectionDefinition)
  77. }
  78. /** Prompts awaiting individual turns. */
  79. get nextTurn(): readonly UserMessage[] {
  80. return this.current()['next-turn']
  81. }
  82. /** Input awaiting the next step boundary. */
  83. get nextStep(): readonly UserMessage[] {
  84. return this.current()['next-step']
  85. }
  86. /** Whether either pending-message list contains work. */
  87. get hasPending(): boolean {
  88. const state = this.current()
  89. return state['next-turn'].length > 0 || state['next-step'].length > 0
  90. }
  91. /** Durably cancel all pending input, clearing next-step before next-turn. */
  92. clear(): void {
  93. this.splice('next-step', 0, this.nextStep.length, [])
  94. this.splice('next-turn', 0, this.nextTurn.length, [])
  95. }
  96. /**
  97. * Remove and return the complete batch proposed for one step.
  98. * @param target - whether this boundary also consumes one queued turn.
  99. * @param turn - turn that will own the claimed batch.
  100. * @returns next-step input followed by the queued turn, when requested.
  101. */
  102. claim(target: InboxTarget, turn: number): UserMessage[] {
  103. const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false)
  104. if (target === 'next-turn') claimed.push(...this.mutate('next-turn', 0, 1, [], false))
  105. for (const message of claimed) this.dispatch.emit('agent/inbox/claimed', { message, turn })
  106. return claimed
  107. }
  108. /**
  109. * Append one message to a pending list.
  110. * @param target - pending list to extend.
  111. * @param message - message to append.
  112. */
  113. append(target: InboxTarget, message: UserMessage): void {
  114. this.splice(target, this.current()[target].length, 0, [message])
  115. }
  116. /**
  117. * Prepend one message to a pending list.
  118. * @param target - pending list to extend.
  119. * @param message - message to prepend.
  120. */
  121. prepend(target: InboxTarget, message: UserMessage): void {
  122. this.splice(target, 0, 0, [message])
  123. }
  124. /**
  125. * Replace one pending message in place.
  126. * @param messageId - identity of the pending message to replace.
  127. * @param newMessage - replacement message.
  128. * @returns whether the message was still pending.
  129. */
  130. replace(messageId: MessageId, newMessage: UserMessage): boolean {
  131. const location = this.locate(messageId)
  132. if (location === undefined) return false
  133. this.splice(location.target, location.index, 1, [newMessage])
  134. return true
  135. }
  136. /**
  137. * Remove one pending message.
  138. * @param messageId - identity of the pending message to remove.
  139. * @returns whether the message was still pending.
  140. */
  141. remove(messageId: MessageId): boolean {
  142. const location = this.locate(messageId)
  143. if (location === undefined) return false
  144. this.splice(location.target, location.index, 1, [])
  145. return true
  146. }
  147. /**
  148. * Apply standard splice semantics and durably record the normalized result.
  149. * @param target - pending list to mutate.
  150. * @param start - splice position.
  151. * @param deleteCount - maximum number of messages to remove.
  152. * @param inserted - messages to insert at the resolved position.
  153. * @returns messages removed by the splice.
  154. */
  155. splice(
  156. target: InboxTarget,
  157. start: number,
  158. deleteCount: number,
  159. inserted: UserMessage[],
  160. ): UserMessage[] {
  161. return this.mutate(target, start, deleteCount, inserted, true)
  162. }
  163. /** Locate one pending identity across both owned lists. */
  164. private locate(messageId: MessageId): { target: InboxTarget; index: number } | undefined {
  165. const state = this.current()
  166. for (const target of ['next-turn', 'next-step'] as const) {
  167. const index = state[target].findIndex(message => message.id === messageId)
  168. if (index >= 0) return { target, index }
  169. }
  170. return undefined
  171. }
  172. /** Read the current durable projection state. */
  173. private current(): InboxState {
  174. const state = this.projections.stateOf(this.session, 'inbox')
  175. /* v8 ignore next -- the constructor registers this key before any read */
  176. if (state === undefined) {
  177. throw new Error(
  178. `agent "${this.session.id}" cannot read inbox state: its projection registration is not active`,
  179. )
  180. }
  181. return state
  182. }
  183. /** Commit one normalized mutation and publish its live events. */
  184. private mutate(
  185. target: InboxTarget,
  186. start: number,
  187. deleteCount: number,
  188. inserted: UserMessage[],
  189. discardRemoved: boolean,
  190. ): UserMessage[] {
  191. const state = this.current()
  192. const inbox = state[target]
  193. const truncatedStart = Math.trunc(start)
  194. const offset = Number.isNaN(truncatedStart) ? 0 : truncatedStart
  195. const actualStart = offset < 0
  196. ? Math.max(inbox.length + offset, 0)
  197. : Math.min(offset, inbox.length)
  198. const truncatedDeleteCount = Math.trunc(deleteCount)
  199. const actualDeleteCount = Math.min(
  200. Math.max(Number.isNaN(truncatedDeleteCount) ? 0 : truncatedDeleteCount, 0),
  201. inbox.length - actualStart,
  202. )
  203. if (actualDeleteCount === 0 && inserted.length === 0) return []
  204. const candidate = inbox.toSpliced(actualStart, actualDeleteCount, ...inserted)
  205. const ids = new Set<string>()
  206. for (const message of target === 'next-turn'
  207. ? [...candidate, ...state['next-step']]
  208. : [...state['next-turn'], ...candidate]) {
  209. if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`)
  210. ids.add(message.id)
  211. }
  212. const outcome = discardRemoved && actualDeleteCount > 0 ? 'canceled' as const : undefined
  213. const splice: SessionEventMap['agent/inbox/spliced'] = {
  214. target,
  215. start: actualStart,
  216. ...(actualDeleteCount === 0 ? {} : { removedCount: actualDeleteCount }),
  217. inserted,
  218. ...(outcome === undefined ? {} : { outcome }),
  219. }
  220. const removed = inbox.slice(actualStart, actualStart + actualDeleteCount)
  221. const event = this.session.append('agent/inbox/spliced', splice)
  222. if (discardRemoved) {
  223. for (const message of removed) this.dispatch.emit('agent/inbox/discarded', { message })
  224. }
  225. for (const message of event.data.inserted) {
  226. this.dispatch.emit('agent/inbox/inserted', { message })
  227. }
  228. return removed
  229. }
  230. }