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

@@ -7,6 +7,7 @@
import type {
Agent,
AgentCancelCause,
AgentEventDispatch,
AgentOptions,
AgentStatus,
CancelOptions,
@@ -14,7 +15,7 @@ import type {
PreStepDecision,
RequestErrorAction,
} from '@deepseek-ai/dsh-agent'
import { Inbox, agentCarrier, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent'
import { Inbox, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm'
import {
BlockAssembler,
@@ -24,7 +25,7 @@ import {
errorChain,
markAgentLoopRequest,
} from '@deepseek-ai/dsh-llm'
import type { Scope, Scoped } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session'
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
@@ -69,8 +70,8 @@ export class ReactLoopAgent implements Agent {
readonly scope: Scope
readonly ctx: Context
/** Fused scope carrier, built once in the constructor for every dispatch. */
readonly carrier: Scoped<Agent>
/** Fused dispatcher, built once in the constructor so hot-path dispatches never allocate. */
private readonly dispatch: AgentEventDispatch
/** Whether this loop instance has appended its initial/resume request anchor. */
private requestHeaderLogged = false
@@ -82,11 +83,11 @@ export class ReactLoopAgent implements Agent {
public readonly options: AgentOptions,
public readonly session: Session,
) {
this.carrier = agentCarrier(this)
this.dispatch = agentEvents(loopCtx, this)
this.inbox = new Inbox(session, {
inserted: (message) => { emitAgentEvent(loopCtx, this, 'agent/inbox/inserted', { message }) },
discarded: (message) => { emitAgentEvent(loopCtx, this, 'agent/inbox/discarded', { message }) },
claimed: (message, turn) => { emitAgentEvent(loopCtx, this, 'agent/inbox/claimed', { message, turn }) },
inserted: (message) => { this.dispatch.emit('agent/inbox/inserted', { message }) },
discarded: (message) => { this.dispatch.emit('agent/inbox/discarded', { message }) },
claimed: (message, turn) => { this.dispatch.emit('agent/inbox/claimed', { message, turn }) },
})
const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0
this.phase = { kind: 'idle', lastTurn }
@@ -105,7 +106,7 @@ export class ReactLoopAgent implements Agent {
this.phase = next
const status = this.status
if (status !== previousStatus) {
emitAgentEvent(this.loopCtx, this, 'agent/status', { status })
this.dispatch.emit('agent/status', { status })
}
}
@@ -183,7 +184,7 @@ export class ReactLoopAgent implements Agent {
private throwError(error: unknown): never {
const turn = this.phase.kind === 'running' ? this.phase.turn : this.phase.lastTurn
const step = this.phase.kind === 'running' ? this.phase.step : 0
emitAgentEvent(this.loopCtx, this, 'agent/error', { turn, step, error })
this.dispatch.emit('agent/error', { turn, step, error })
throw error
}
@@ -209,8 +210,8 @@ export class ReactLoopAgent implements Agent {
signal.throwIfAborted()
const sections = renderContextSections(assembly)
const context = this.runtimeContext.project(joinContextSections(sections), sections)
const decision = await this.loopCtx.waterfall(
this.carrier, 'agent/pre-step', { agent: this, messages: claimed, ...position, signal },
const decision = await this.dispatch.waterfall(
'agent/pre-step', { messages: claimed, ...position, signal },
(): Promise<PreStepDecision> => Promise.resolve<PreStepDecision>({
kind: 'enter',
messages: context === undefined ? claimed : [...claimed, context],
@@ -271,7 +272,7 @@ export class ReactLoopAgent implements Agent {
}
signal.throwIfAborted()
if (turnEnds && this.inbox.nextStep.length === 0) {
await this.loopCtx.serial(this.carrier, 'agent/turn-stopping', { agent: this, turn, signal })
await this.dispatch.serial('agent/turn-stopping', { turn, signal })
signal.throwIfAborted()
}
if (turnEnds && this.inbox.nextStep.length === 0) break
@@ -328,9 +329,8 @@ export class ReactLoopAgent implements Agent {
signal.throwIfAborted()
const finish = assembler.finish
if (finish.kind === 'error' || finish.kind === 'aborted') {
const action = await this.loopCtx.waterfall(
this.carrier, 'agent/request-error', {
agent: this,
const action = await this.dispatch.waterfall(
'agent/request-error', {
turn,
step,
provider: request.provider,
@@ -412,8 +412,8 @@ export class ReactLoopAgent implements Agent {
...maxTokens === undefined ? {} : { maxTokens },
},
))
const proposedConfig = await this.loopCtx.waterfall(
this.carrier, 'agent/request', { agent: this, turn, step, signal },
const proposedConfig = await this.dispatch.waterfall(
'agent/request', { turn, step, signal },
() => Promise.resolve(seedConfig),
)
signal.throwIfAborted()