fix(agent): route loop dispatches through prebuilt fused dispatcher

Address review feedback on PR #1738:
- ReactLoopAgent builds its AgentEventDispatch once in the constructor and
  routes every emit/serial/waterfall through it, so hot-path dispatches no
  longer allocate a carrier and dispatcher per call; the public carrier
  field is gone (fused dispatcher is private).
- agentEvents accepts an optional prebuilt carrier.
- The fused payload builder spreads the payload before the injected agent
  so a structurally acceptable payload carrying an agent field can never
  override the subject.
- Regenerate doc graphs; re-record core + architecture + affected Agent
  Note translation pairs; add payload-object event contract Agent Note.
This commit is contained in:
_Kerman
2026-08-06 13:44:23 +08:00
parent 23671848c3
commit 262d044642
23 changed files with 143 additions and 61 deletions

View File

@@ -1,7 +1,8 @@
/**
* Agent-scoped dispatch and prompt assembly helpers. Ordinary events use the
* fused dispatcher so subject and scope key cannot diverge; registry lifecycle
* code instead captures one stable carrier for both edges.
* Agent-scoped dispatch and prompt assembly helpers. The fused dispatcher
* {@link agentEvents} couples the agent subject to its scope carrier, so the
* scope key and the payload's `agent` cannot diverge; repeat dispatchers (the
* loop driver) build it once in the agent's constructor and reuse it.
* @module @deepseek-ai/dsh-agent/dispatch
*/
@@ -83,9 +84,10 @@ export interface AgentEventDispatch {
/**
* Build the fused scope carrier for one agent subject.
*
* The carrier is a stateless routing object; callers that dispatch repeatedly
* for the same agent (the loop driver) build it once in the agent's
* constructor and reuse it, so hot-path dispatches never allocate.
* The carrier is a stateless routing object. {@link agentEvents} accepts an
* existing carrier, so callers that dispatch repeatedly for the same agent
* (the loop driver) build it once in the agent's constructor and reuse it,
* keeping hot-path dispatches allocation-free.
* @param agent - the subject agent and scope key.
* @returns the carrier passed as the event dispatcher `this` value.
*/
@@ -97,10 +99,12 @@ export function agentCarrier(agent: Agent): Scoped<Agent> {
* Build a dispatcher that couples the agent subject to its scope carrier.
* @param ctx - the context to dispatch through (any context of the app).
* @param agent - the subject agent; also the scope-carrier key.
* @param carrier - the scope carrier to dispatch through; defaults to
* {@link agentCarrier} for the agent. Pass a constructor-built carrier to
* avoid rebuilding it for every dispatch.
* @returns the fused dispatcher.
*/
export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
const carrier = agentCarrier(agent)
export function agentEvents(ctx: Context, agent: Agent, carrier: Scoped<Agent> = agentCarrier(agent)): AgentEventDispatch {
// The ordinary dispatch methods forward through Cordis' variadic mixins. The
// fused (carrier, name, payload, ...rest) tuple is provably a valid argument
// list for the matching thisArg overload, but TypeScript cannot relate the
@@ -108,8 +112,10 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
// tuple — hence one contained, shape-preserving cast per method.
const fused = <K extends AgentSubjectEvent>(payload: PayloadRest<K>): PayloadOf<K> =>
// The dispatcher owns the subject injection; callers pass PayloadRest, so
// the fused record is exactly the declared payload.
({ agent, ...payload } as PayloadOf<K>)
// the fused record is exactly the declared payload. The spread comes
// first, so a structurally acceptable payload that happens to carry an
// `agent` field can never override the injected subject.
({ ...payload, agent } as PayloadOf<K>)
return {
emit(name, payload) {
// Cordis emit invokes callbacks through Array.map: one synchronous throw