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
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md
2026-07-16-explicit-turn-cancellation.md: cce649976c9f4f596d5306b9fe8c3fd49a0e1adc
2026-07-16-explicit-turn-cancellation.zh.md: 6f8b83fdb42af03c97dc2e8a9345a01acc6019fc
2026-07-16-explicit-turn-cancellation.md: ca56c77a097e3008a50c2aec24040a4f4b6f0ba3
2026-07-16-explicit-turn-cancellation.zh.md: bf410e5c7284a9c9914edbd14445074e71dd6943
@@ -20,7 +20,7 @@ AgentLoop privately owns one `TurnCancellation` per prospective turn. It install
The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer belongs to the next turn. If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work.
The explicit event signatures keep their positional form and place `signal` inside `PreStepContext` or immediately before a waterfall's final `next`. Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn.
The explicit event signatures pass a single payload object: agent-scoped events carry `agent` and `signal` in the payload with `next` last, and the remaining seams keep `signal` immediately before a waterfall's final `next`. `PreStepContext` and `RequestFailureContext` are retired, with their fields folded into the `agent/pre-step` and `agent/request-error` payloads ([payload-object events](2026-08-06-agent-event-payload-objects.md)). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn.
`ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a canonical frozen cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam.
@@ -44,7 +44,7 @@ Initiator-scope tests assert that every hook still observes the exact Agent and
**Define speculative `superseded`, `timeout`, and `shutdown` variants now.** No current Agent cancellation producer implements those semantics. `shutdown` is already lifecycle disposal, and timeout or supersession should enter the union only with an owning policy and unique terminal meaning.
**Expose public turn or step context wrappers.** Existing positional seams already identify Agent, turn, and step. A wrapper would widen every API, duplicate ownership, and tempt callers to treat a captured object as durable authority.
**Expose public turn or step context wrappers.** Existing seams already identify Agent, turn, and step. A wrapper would widen every API, duplicate ownership, and tempt callers to treat a captured object as durable authority.
**Abandon uncooperative work after a grace period.** Returning idle while same-process work still runs breaks teardown and resource-ownership guarantees. Hard termination requires a worker or process isolation boundary and is outside this control seam.
@@ -20,7 +20,7 @@ AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它
对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作属于下一个轮次。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。
显式事件签名保留位置参数形式,并把 `signal` 放入 `PreStepContext`,或放在 waterfall(瀑布式事件)的最后一个参数 `next` 之前。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。
显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 seam 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.md))。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。
`ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。
@@ -44,7 +44,7 @@ Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时
**现在就定义推测性的 `superseded`、`timeout` 和 `shutdown` 变体。** 当前没有 Agent 取消生产方实现这些语义。`shutdown` 已经属于生命周期 dispose;超时或替代只有在拥有明确归属策略和唯一终态含义时才应进入联合类型。
**公开轮次或步骤上下文包装类型。** 现有位置参数 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。
**公开轮次或步骤上下文包装类型。** 现有 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。
**在宽限期后放弃不协作的工作。** 同进程工作仍在运行时就报告空闲状态,会破坏资源清理与资源归属保证。硬终止需要 worker 或进程隔离边界,不属于该控制 seam。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md
2026-08-06-agent-event-payload-objects.md: 470c8fb3f9282005829846307778d3d1088c3888
2026-08-06-agent-event-payload-objects.zh.md: ff201a7c3134c0ef809c9a798d65412541f9f1e7
@@ -0,0 +1,27 @@
# Agent Note: Agent-scoped events dispatch a single payload object
Status: implemented
English | [中文](2026-08-06-agent-event-payload-objects.zh.md)
## Problem
Agent-scoped events historically took positional arguments: a leading `agent` subject, event-specific fields, and a trailing `next` for waterfall/serial events. Adding a field or retiring a context type (as with `PreStepContext` and `RequestFailureContext`) rewrote every listener and emitter across packages, and the contract stayed spread across the parameter list instead of one named payload.
## Decision
Every agent-scoped event takes exactly one payload object as its first argument. The payload always carries the subject (`agent`), the event's fields, and the cancellation `signal` when the event has one; `next` remains the last argument of waterfall/serial events. The affected events are the twelve `agent/*` events, `agent-loop/config-start-failed` (the only one without a subject), and `goal/changed`.
`PreStepContext` and `RequestFailureContext` are retired; their fields live directly in the `agent/pre-step` and `agent/request-error` payloads.
Dispatch is fused: `agentEvents(ctx, agent)` (and the one-shot `emitAgentEvent`) injects the subject so the scope carrier key and the payload's `agent` cannot diverge, and the injected subject wins even over a structurally acceptable payload that happens to carry an `agent` field. `ReactLoopAgent` builds its dispatcher once in the constructor and routes every emit, serial, and waterfall through it, so hot-path dispatches allocate nothing.
## Alternatives considered
**Keep positional signatures.** Adding a field or retiring a context type would keep rewriting every listener and emitter, and the contract would stay spread across the parameter list instead of one named payload.
**Hand-build the subject at each dispatch site.** The loop's intermediate design called `ctx.waterfall(this.carrier, …)` with a manually constructed `{ agent: this, … }` payload; it avoided per-dispatch allocation but duplicated the subject injection and let the scope key and the payload subject diverge. The fused dispatcher is the single injection point for every dispatch mode.
## Consequences
Listener signatures name the full payload once, so extending a payload or retiring a context type is a one-shape change across all listeners and emitters. The subject/scope coupling is enforced by the dispatcher for every dispatch mode, and the loop's hot paths stay allocation-free.
@@ -0,0 +1,27 @@
# Agent Note: Agent 作用域事件 dispatch 单个 payload 对象
Status: implemented
[English](2026-08-06-agent-event-payload-objects.md) | 中文
## 问题
Agent 作用域事件历来采用位置参数:开头的 `agent` 主体、事件专属字段,以及末尾用于 waterfall(瀑布式事件)/serial 事件的 `next`。新增字段或退役上下文类型(如 `PreStepContext` 与 `RequestFailureContext`)都会迫使跨包重写每个监听器和 emitter,契约也一直分散在参数列表中,而不是集中在一个具名 payload 中。
## 决策
每个 agent 作用域事件都将恰好一个 payload 对象作为其第一个参数。payload 始终携带主体(`agent`)、事件的字段,以及事件有取消信号时的取消 `signal`;`next` 仍然是 waterfall/serial 事件的最后一个参数。受影响的事件是十二个 `agent/*` 事件、`agent-loop/config-start-failed`(唯一没有主体的事件)以及 `goal/changed`。
`PreStepContext` 与 `RequestFailureContext` 已退役;它们的字段直接存在于 `agent/pre-step` 与 `agent/request-error` 的 payload 中。
dispatch 是融合的:`agentEvents(ctx, agent)`(以及一次性 `emitAgentEvent`)注入主体,使作用域载体键与 payload 的 `agent` 不可能分叉;即使某个结构上可接受的 payload 恰好携带 `agent` 字段,注入的主体仍然优先。`ReactLoopAgent` 在构造函数中构建一次 dispatcher,并将每个 emit、serial 和 waterfall 都经由它路由,因此热路径上的 dispatch 不产生任何分配。
## 考虑过的替代方案
**保留位置签名。** 新增字段或退役上下文类型依旧会重写每个监听器和 emitter,契约也会继续分散在参数列表中,而不是集中在一个具名 payload 中。
**在每个 dispatch 位置手工构造主体。** loop 的中间设计调用 `ctx.waterfall(this.carrier, …)`,传入手工构造的 `{ agent: this, … }` payload;它避免了每次 dispatch 的分配,却重复了主体注入,并让作用域键与 payload 主体分叉。融合的 dispatcher 是每种 dispatch 模式的唯一注入点。
## 后果
监听器签名一次性命名完整 payload,因此扩展 payload 或退役上下文类型,对所有监听器和 emitter 都是一次形状变更。主体/作用域耦合由 dispatcher 在每种 dispatch 模式下强制执行,且 loop 的热路径保持零分配。