/** * Fused scope-carrier dispatch for agent-subject events, plus the assembly * context builder. The ONE sanctioned spelling for dispatching `agent/*` * events: `agentEvents(ctx, agent).waterfall('agent/request', …)` builds the * scope carrier ({@link scopeTarget} keyed by the agent) AND injects the * subject as the first event argument in one move, so the correct dispatch is * also the shortest — a dispatch site cannot pass a carrier keyed to one * agent while naming another as the subject, which is the invariant the * dev-mode scoped-dispatch check asserts at runtime. * * @module @deepseek-ai/dsh-agent/dispatch */ import type { Context, Events } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt' import type { Agent } from './types.ts' /** Extract the parameter tuple from an event handler type (its `this` is not part of the tuple). */ type Params = F extends (...args: infer P) => unknown ? P : never /** Extract the return type from an event handler type. */ type Return = F extends (...args: never[]) => infer R ? R : never /** * The event names whose subject is an agent: handler parameters start with an * `Agent` AND the handler declares a `Scoped` `this` (the scope-carrier * contract). The `this` check keeps accidental first-parameter-happens-to-be- * an-Agent events (or zero-arg events, whose parameter tuple would satisfy a * bare rest-tuple check via callability) out of the fused-dispatch surface. */ export type AgentSubjectEvent = { [K in keyof Events]: Events[K] extends (this: Scoped, ...args: infer P) => unknown ? P extends [Agent, ...unknown[]] ? K : never : never }[keyof Events] /** The event arguments AFTER the injected agent subject. */ type Tail = Params extends [Agent, ...infer R] ? R : never /** * The fused dispatcher {@link agentEvents} returns: each method dispatches the * named agent-subject event with the agent's scope carrier as `thisArg` and * the agent itself injected as the first event argument. */ export interface AgentEventDispatch { /** * Fire-and-forget notification in the agent's scope. Every listener is * invoked; synchronous throws and returned-promise rejections are logged and * contained per listener, so a notification cannot veto lifecycle progress * or starve a later observer. * @param name - the agent-subject event to emit. * @param rest - the event's arguments after the injected agent. */ emit(name: K, ...rest: Tail): void /** * Awaited in-order dispatch (Cordis `serial`) in the agent's scope. * @param name - the agent-subject event to dispatch. * @param rest - the event's arguments after the injected agent. * @returns the serial chain's result (the first bail value, if any). */ serial(name: K, ...rest: Tail): Promise>> /** * Await listeners in order and return the first value other than `undefined`. * Unlike Cordis `serial`, this does not silently treat `null` or `false` as * abstentions. Use it for a runtime-validated public boundary whose declared * abstention is exactly `undefined` (currently `agent/turn-stop`). * @param name - the agent-subject event to dispatch. * @param rest - the event's arguments after the injected agent. * @returns the first non-undefined listener result, or undefined. */ strictSerial(name: K, ...rest: Tail): Promise>> /** * Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The * declared event parameters already end with the `next` callback, so `rest` * is exactly the event's arguments after the injected agent — the final * element being the innermost `next` (the default the listener chain wraps). * @param name - the agent-subject event to dispatch. * @param rest - the event's arguments after the injected agent. * @returns the waterfall's composed result. */ waterfall(name: K, ...rest: Tail): Return } /** * Build the fused dispatcher for `agent`'s events (see the module doc). Cheap * (one carrier + one small object) — dispatch sites create it per run/turn * rather than caching it on the agent. * @param ctx - the context to dispatch through (any context of the app). * @param agent - the subject agent; also the scope-carrier key. * @returns the fused dispatcher. */ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { const carrier: Scoped = scopeTarget(agent, agent) // The ordinary dispatch methods forward through Cordis' variadic mixins. The // fused (carrier, name, agent, ...rest) tuple is provably a valid argument // list for the matching thisArg overload, but TypeScript cannot relate the // generic Tail spread back to that overload's conditional parameter // tuple — hence one contained, shape-preserving cast per method. return { emit(name, ...rest) { // Cordis emit invokes callbacks through Array.map: one synchronous throw // starves later listeners, and returned promises are discarded. Agent // notifications are non-vetoing, so resolve the same filtered callback // set ourselves and contain both failure modes independently. const args: unknown[] = [carrier, name, agent, ...rest] const callbacks = ctx.events.dispatch('emit', args) for (const callback of callbacks) { try { const returned: unknown = callback(...args) void Promise.resolve(returned).catch((error: unknown) => { ctx.logger.warn(`agent event "${name}" listener rejected: ${renderThrown(error)}`) }) } catch (error: unknown) { ctx.logger.warn(`agent event "${name}" listener threw: ${renderThrown(error)}`) } } }, async serial(name, ...rest) { // eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function const serial = ctx.serial as (thisArg: Scoped, name: string, ...args: unknown[]) => Promise return await serial(carrier, name, agent, ...rest) }, strictSerial(name, ...rest) { return (async (): Promise => { // EventsService.dispatch applies the carrier filter and emits the same // internal/dispatch instrumentation as ctx.serial, then mutates `args` // down to the actual listener parameters. Invoke those callbacks in order // ourselves so every non-undefined value reaches the caller's validator; // Cordis serial would discard null/false before validation could see them. const args: unknown[] = [carrier, name, agent, ...rest] const callbacks = ctx.events.dispatch('serial', args) for (const callback of callbacks) { const result: unknown = await callback(...args) if (result !== undefined) return result } return undefined })() as Promise>> }, waterfall(name, ...rest) { // eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function const waterfall = ctx.waterfall as (thisArg: Scoped, name: string, ...args: unknown[]) => never return waterfall(carrier, name, agent, ...rest) }, } } /** Render an arbitrary thrown value without allowing coercion to throw again. */ function renderThrown(value: unknown): string { try { return value instanceof Error ? `${value.name}: ${value.message}` : String(value) } catch { return '' } } /** * The assembly context for one agent's prompt: the typed `agent` DX field and * the `scope` layer selector, set together (setting `agent` without `scope` * silently drops the agent's scoped sections/tools from the assembly — the * dev invariants flag it). THE way the loop (and any custom driver) builds * its per-step `ctx.systemPrompt.assemble(…)` input. * @param agent - the agent the assembly is for. * @returns the context to pass to `assemble()`. */ export function assembleContextFor(agent: Agent): AssembleContext { return { agent, scope: agent } }