dispatch.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. /**
  2. * Agent-scoped dispatch and prompt assembly helpers. The fused dispatcher
  3. * {@link agentEvents} couples the agent subject to its scope carrier, so the
  4. * scope key and the payload's `agent` cannot diverge; repeat dispatchers (the
  5. * loop driver) build it once in the agent's constructor and reuse it.
  6. * @module @deepseek-ai/dsh-agent/dispatch
  7. */
  8. import type { Context, Events } from 'cordis'
  9. import { scopeTarget } from '@deepseek-ai/dsh-scope'
  10. import type { Scoped } from '@deepseek-ai/dsh-scope'
  11. import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt'
  12. import type { Agent } from './types.ts'
  13. /** Extract the parameter tuple from an event handler type (its `this` is not part of the tuple). */
  14. type Params<F> = F extends (...args: infer P) => unknown ? P : never
  15. /** Extract the return type from an event handler type. */
  16. type Return<F> = F extends (...args: never[]) => infer R ? R : never
  17. /**
  18. * The event names whose subject is an agent: the handler's first parameter is
  19. * a payload object carrying the `agent` subject AND the handler declares a
  20. * `Scoped<Agent>` `this` (the scope-carrier contract). The `this` check keeps
  21. * accidental payload-happens-to-carry-an-Agent events (or zero-arg events,
  22. * whose parameter tuple would satisfy a bare rest-tuple check via callability)
  23. * out of the fused-dispatch surface.
  24. */
  25. export type AgentSubjectEvent = {
  26. [K in keyof Events]: Events[K] extends (this: Scoped<Agent>, ...args: infer P) => unknown
  27. ? P extends [infer Payload, ...unknown[]]
  28. ? Payload extends { agent: Agent } ? K : never
  29. : never
  30. : never
  31. }[keyof Events]
  32. /** The full payload object of one agent-subject event. */
  33. type PayloadOf<K extends AgentSubjectEvent> = Params<Events[K]> extends [infer Payload, ...unknown[]] ? Payload : never
  34. /** The event arguments AFTER the payload: the waterfall `next` when present. */
  35. type Tail<K extends AgentSubjectEvent> = Params<Events[K]> extends [unknown, ...infer R] ? R : never
  36. /**
  37. * The payload as emit-side callers pass it: the full payload minus the agent
  38. * field, which the fused dispatcher injects so subject and scope key cannot
  39. * diverge.
  40. */
  41. type PayloadRest<K extends AgentSubjectEvent> = Omit<PayloadOf<K> & object, 'agent'>
  42. /**
  43. * The fused dispatcher {@link agentEvents} returns: each method dispatches the
  44. * named agent-subject event with the agent's scope carrier as `thisArg` and
  45. * the agent itself injected into the payload.
  46. */
  47. export interface AgentEventDispatch {
  48. /**
  49. * Fire-and-forget notification in the agent's scope. Every listener is
  50. * invoked; synchronous throws and returned-promise rejections are logged and
  51. * contained per listener, so a notification cannot veto lifecycle progress
  52. * or starve a later observer.
  53. * @param name - the agent-subject event to emit.
  54. * @param payload - the event's payload fields; `agent` is injected.
  55. */
  56. emit<K extends AgentSubjectEvent>(name: K, payload: PayloadRest<K>): void
  57. /**
  58. * Awaited in-order dispatch (Cordis `serial`) in the agent's scope.
  59. * @param name - the agent-subject event to dispatch.
  60. * @param payload - the event's payload fields; `agent` is injected.
  61. * @returns the serial chain's result (the first bail value, if any).
  62. */
  63. serial<K extends AgentSubjectEvent>(name: K, payload: PayloadRest<K>): Promise<Awaited<Return<Events[K]>>>
  64. /**
  65. * Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The
  66. * declared event parameters already end with the `next` callback, so `rest`
  67. * is exactly the event's arguments after the payload — the final element
  68. * being the innermost `next` (the default the listener chain wraps).
  69. * @param name - the agent-subject event to dispatch.
  70. * @param payload - the event's payload fields; `agent` is injected.
  71. * @param rest - the event's arguments after the payload (the `next` callback).
  72. * @returns the waterfall's composed result.
  73. */
  74. waterfall<K extends AgentSubjectEvent>(name: K, payload: PayloadRest<K>, ...rest: Tail<K>): Return<Events[K]>
  75. }
  76. /**
  77. * Build the fused scope carrier for one agent subject.
  78. *
  79. * The carrier is a stateless routing object. {@link agentEvents} accepts an
  80. * existing carrier, so callers that dispatch repeatedly for the same agent
  81. * (the loop driver) build it once in the agent's constructor and reuse it,
  82. * keeping hot-path dispatches allocation-free.
  83. * @param agent - the subject agent and scope key.
  84. * @returns the carrier passed as the event dispatcher `this` value.
  85. */
  86. export function agentCarrier(agent: Agent): Scoped<Agent> {
  87. return scopeTarget(agent, agent)
  88. }
  89. /**
  90. * Build a dispatcher that couples the agent subject to its scope carrier.
  91. * @param ctx - the context to dispatch through (any context of the app).
  92. * @param agent - the subject agent; also the scope-carrier key.
  93. * @param carrier - the scope carrier to dispatch through; defaults to
  94. * {@link agentCarrier} for the agent. Pass a constructor-built carrier to
  95. * avoid rebuilding it for every dispatch.
  96. * @returns the fused dispatcher.
  97. */
  98. export function agentEvents(ctx: Context, agent: Agent, carrier: Scoped<Agent> = agentCarrier(agent)): AgentEventDispatch {
  99. // The ordinary dispatch methods forward through Cordis' variadic mixins. The
  100. // fused (carrier, name, payload, ...rest) tuple is provably a valid argument
  101. // list for the matching thisArg overload, but TypeScript cannot relate the
  102. // generic Tail<K> spread back to that overload's conditional parameter
  103. // tuple — hence one contained, shape-preserving cast per method.
  104. const fused = <K extends AgentSubjectEvent>(payload: PayloadRest<K>): PayloadOf<K> =>
  105. // The dispatcher owns the subject injection; callers pass PayloadRest, so
  106. // the fused record is exactly the declared payload. The spread comes
  107. // first, so a structurally acceptable payload that happens to carry an
  108. // `agent` field can never override the injected subject.
  109. ({ ...payload, agent } as PayloadOf<K>)
  110. return {
  111. emit(name, payload) {
  112. // Cordis emit invokes callbacks through Array.map: one synchronous throw
  113. // starves later listeners, and returned promises are discarded. Agent
  114. // notifications are non-vetoing, so resolve the same filtered callback
  115. // set ourselves and contain both failure modes independently.
  116. const args: unknown[] = [carrier, name, fused(payload)]
  117. const callbacks = ctx.events.dispatch('emit', args)
  118. for (const callback of callbacks) {
  119. try {
  120. const returned: unknown = callback(...args)
  121. void Promise.resolve(returned).catch((error: unknown) => {
  122. ctx.logger.warn(`agent event "${name}" listener rejected: ${String(error)}`)
  123. })
  124. } catch (error: unknown) {
  125. ctx.logger.warn(`agent event "${name}" listener threw: ${String(error)}`)
  126. }
  127. }
  128. },
  129. async serial(name, payload) {
  130. // oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function
  131. const serial = ctx.serial as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => Promise<never>
  132. return await serial(carrier, name, fused(payload))
  133. },
  134. waterfall(name, payload, ...rest) {
  135. // oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function
  136. const waterfall = ctx.waterfall as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => never
  137. return waterfall(carrier, name, fused(payload), ...rest)
  138. },
  139. }
  140. }
  141. /**
  142. * Emit one contained agent notification without allocating a retained dispatcher.
  143. * @param ctx - the context to dispatch through.
  144. * @param agent - the subject agent and scope key.
  145. * @param name - the agent-subject event to emit.
  146. * @param payload - the event's payload fields; `agent` is injected.
  147. */
  148. export function emitAgentEvent<K extends AgentSubjectEvent>(
  149. ctx: Context,
  150. agent: Agent,
  151. name: K,
  152. payload: PayloadRest<K>,
  153. ): void {
  154. agentEvents(ctx, agent).emit(name, payload)
  155. }
  156. /**
  157. * Build the prompt assembly context with agent and scope set together, so
  158. * agent-scoped prompt and tool contributions cannot be silently omitted.
  159. * @param agent - the agent the assembly is for.
  160. * @param signal - the current turn's explicit control signal, when assembly belongs to a turn.
  161. * @returns the context to pass to `assemble()`.
  162. */
  163. export function assembleContextFor(agent: Agent, signal?: AbortSignal): AssembleContext {
  164. return { agent, scope: agent, ...signal === undefined ? {} : { signal } }
  165. }