diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index 9c5d00eae0..820299cf2e 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md index cce649976c..ca56c77a09 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index 6f8b83fdb4..bf410e5c72 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -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。 diff --git a/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.i18n.yaml new file mode 100644 index 0000000000..b6e58aabc7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md new file mode 100644 index 0000000000..470c8fb3f9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.zh.md b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.zh.md new file mode 100644 index 0000000000..ff201a7c31 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.zh.md @@ -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 的热路径保持零分配。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.i18n.yaml new file mode 100644 index 0000000000..bb344c5afe --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.i18n.yaml @@ -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/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.md +2026-08-06-token-surface-unpriced-replace-compatibility.md: 77b778fba695f560eb68af6da2f416e61ccff181 +2026-08-06-token-surface-unpriced-replace-compatibility.zh.md: dd4c5883c4c6d37809c2f8100f266655d8ba600c diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.md b/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.md new file mode 100644 index 0000000000..77b778fba6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.md @@ -0,0 +1,35 @@ +# Agent Note: unpriced surface replacements fold neutrally + +Status: implemented + +English | [中文](2026-08-06-token-surface-unpriced-replace-compatibility.zh.md) + +## Problem + +The `contextPressure` and `contextBreakdown` projections keep a running surface-token total plus at most one pending shadow-price claim, so their persisted checkpoints stay O(1) over a session's life. Current replace producers append a `compact/summary` or `compact/prune` metering event immediately before the replacement; its `shadowedTokenCount` prices the exact replaced range, and `foldSurfaceProjection` turns that into the signed delta. + +Sessions recorded before the shadow-price protocol log replacements with no adjacent metering event. The O(1) state cannot reconstruct the replaced range's price, and the fold treated every unpriced replacement as a contract violation and threw — so replaying such a session died at its first replacement (`token surface: replace at seq … has no adjacent shadow price`), leaving the session permanently unopenable. + +## Decision + +A replace that arrives with no armed claim folds price-neutrally: `foldSurfaceProjection` returns `deltaTokens: 0`, pricing the replaced range as if it had cost exactly what its replacement costs, and replay continues. A claim expired by an intervening event reaches the same neutral path, since the fold cannot distinguish it from a log that never metered. + +An armed claim naming a **different** range still throws. There the metering event was adjacent, so the producer wrote contradictory adjacent events — a live shadow-price contract violation, not historical data, and it must fail loud rather than let the total drift silently. + +Both projections share the one fold, so neither gains state fields nor bumps its `stateVersion`. `surface-fold.ts` and `ctx.tokenMeter.measure()` are unaffected: they hold the per-node priced surface and never needed the claim protocol. + +## Alternatives considered + +**Keep throwing.** Preserves the strict producer contract, but every pre-protocol session stays permanently unreplayable, and the projections exist to serve replay. + +**Persist the full priced surface in the projection state.** Could price any replaced range exactly, but grows the checkpoint by one node per model-visible message without bound — defeating the O(1) constraint the shadow-price protocol exists to preserve (see [the context-meter note](2026-08-05-context-meter-blind-to-compaction.md)). + +## Consequences + +An unpriced replacement holds the total still instead of shrinking it, so the compacted-away span stays counted: `contextBreakdown.messageTokens` retains the overcount, and `contextPressure.projectedTokens` overestimates occupancy only until the next usage sample re-anchors it, because that figure tracks movement since the sample rather than the absolute level. The error direction is safe — overestimating occupancy at worst invites an earlier compaction. + +The loud failure survives where it still means something: a range-mismatched adjacent claim is a current producer bug and still throws. + +## Testing + +`packages/llm/token-meter/tests/context-breakdown-projection.spec.ts` pins the neutral fold for the no-claim and expired-claim replacements, the throw for a mismatched claim, and the exact pricing for a matched one. `packages/llm/token-meter/tests/token-usage-projection.spec.ts` pins `contextPressure` holding still across an unpriced replacement. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.zh.md new file mode 100644 index 0000000000..dd4c5883c4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 未计价的表层替换以中性方式折叠 + +Status: implemented + +[English](2026-08-06-token-surface-unpriced-replace-compatibility.md) | 中文 + +## 问题 + +`contextPressure` 与 `contextBreakdown` 两个投影只维护一份滚动累计的表层 token 总量,外加至多一条待结算的影子价格(shadow price)声明,因此其持久化检查点在会话整个生命周期内保持 O(1)。当前的替换生产方会紧贴在替换之前追加一条 `compact/summary` 或 `compact/prune` 计量事件;其 `shadowedTokenCount` 对被替换区间精确计价,`foldSurfaceProjection` 再把它换算成有符号增量。 + +影子价格协议引入之前录制的会话,其日志中的替换没有相邻的计量事件。O(1) 状态无法重建被替换区间的价格,而折叠此前把每一次未计价替换都当作契约违规并抛出异常,于是回放这类会话会在第一处替换就中断(`token surface: replace at seq … has no adjacent shadow price`),会话从此永远无法打开。 + +## 决策 + +到达时没有已就位声明的替换以价格中性的方式折叠:`foldSurfaceProjection` 返回 `deltaTokens: 0`,相当于把被替换区间计价为恰好等于其替换内容的成本,回放随即继续。因中间插入的事件而过期的声明也走同一条中性路径,因为折叠无法把它与从未计量过的日志区分开。 + +已就位但指向**另一个**区间的声明仍会抛出异常。此时计量事件确实相邻,说明生产方写入了互相矛盾的相邻事件:这是现行影子价格契约的违规,不是历史数据,必须响亮失败,而不能任由总量悄然漂移。 + +两个投影共用同一个折叠,因此二者都不新增状态字段,也不提升 `stateVersion`。`surface-fold.ts` 与 `ctx.tokenMeter.measure()` 不受影响:它们持有逐节点的已计价表层,本来就不需要声明协议。 + +## 备选方案 + +**维持抛出异常。**保住了严格的生产方契约,但协议之前的每个会话都将永远无法回放,而投影本就是为服务回放而存在的。 + +**在投影状态中持久化完整的已计价表层。**可以对任意被替换区间精确计价,但检查点会随每条模型可见消息各增加一个节点、无上限地增长,恰恰破坏了影子价格协议所要守住的 O(1) 约束(见[上下文仪表的 Agent Note](2026-08-05-context-meter-blind-to-compaction.md))。 + +## 影响 + +未计价的替换让总量保持不动而不是缩小,因此被压缩(compaction)掉的区段仍被计入:`contextBreakdown.messageTokens` 保留这部分多计的量;`contextPressure.projectedTokens` 会高估占用率,但只持续到下一个用量样本重新锚定为止,因为该数字追踪的是自样本以来的增减,而非绝对水平。误差方向是安全的:高估占用率最坏不过是招致一次更早的压缩。 + +响亮失败保留在它仍有意义的地方:区间不匹配的相邻声明是现行生产方的缺陷,仍会抛出异常。 + +## 测试 + +`packages/llm/token-meter/tests/context-breakdown-projection.spec.ts` 钉住了无声明与声明过期两种替换的中性折叠、声明区间不匹配时的抛出异常,以及声明匹配时的精确计价。`packages/llm/token-meter/tests/token-usage-projection.spec.ts` 钉住了 `contextPressure` 在一次未计价替换前后保持不动。 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml index d337e7e0e3..c981d84400 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml @@ -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/feature/2026-06-18-compaction-capability-seam.md -2026-06-18-compaction-capability-seam.md: 27dbde9f2349681cf47c4d25b16399b26ed9e1ca -2026-06-18-compaction-capability-seam.zh.md: 1fe9ece2861bd6d75633a866a4a11eaadbf7ef26 +2026-06-18-compaction-capability-seam.md: 26e6e2468c7bea661d85c8fb994adf8b109105ee +2026-06-18-compaction-capability-seam.zh.md: 8f9cd1f6bc31e648a5b923e816cad75ebc1a0bd8 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index 27dbde9f23..26e6e2468c 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -119,7 +119,7 @@ The lifecycle boundary makes crash state unambiguous: ## Consequences - **Packages**: `packages/compact/compact` supplies the interface, `compact-basic` supplies the backend, `compact-tool-result-prune` supplies optional deterministic rewriting, and `command-compact` supplies human `/compact`. `packages/llm/token-meter` owns replay-aware measurement independently. -- **Automatic seams**: `agent/pre-step` (`@mode waterfall`) handles pressure before request derivation and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. Pre-step receives the claimed batch and `PreStepContext`, with no compaction-only prompt/prefix payload. +- **Automatic seams**: `agent/pre-step` (`@mode waterfall`) handles pressure before request derivation and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. The pre-step payload carries the claimed batch, turn, step, and signal (see the [payload-object events decision](../architecture/2026-08-06-agent-event-payload-objects.md)), with no compaction-only prompt/prefix payload. - **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. - **`dsh-compact`** owns `COMPACT_CHECKPOINT_SOURCE`, `isCompactCheckpointSource(source)`, `toolPairingBalancedBefore(session, seq)`, and `toolPairingBalancedAfter(session, seq)`. The marker identifies replacement summaries across backend implementations. The cached surface-edge checks prevent `compactRegion` and `compactIfNeeded` from splitting a tool-call/result pair, validate current membership by seq, answer both edges from one per-cut balance sequence, and reject stale or missing seqs and orphan results. - **`dsh-session`** validates positional replacement, complete provenance, and content-only single-node `tool/result` rewrites through its one surface manager. Its invariant companion treats fresh appended tool results as executions that require an open step and pending call, while the compaction companion owns numeric-turn versus standalone-null bracket relations. diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md index 1fe9ece286..8f9cd1f6bc 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -119,7 +119,7 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab ## 后果 - **包**:`packages/compact/compact` 提供接口,`compact-basic` 提供后端,`compact-tool-result-prune` 提供可选的确定性重写,`command-compact` 提供面向用户的 `/compact`。`packages/llm/token-meter` 独立拥有回放感知的测量。 -- **自动 seam**:`agent/pre-step`(`@mode waterfall`)在请求派生前处理压力,`agent/request-error`(`@mode waterfall`)处理失败步骤关闭后的最终请求失败。pre-step 接收已领取批次与 `PreStepContext`,不携带压缩专属的提示词/前缀 payload。 +- **自动 seam**:`agent/pre-step`(`@mode waterfall`)在请求派生前处理压力,`agent/request-error`(`@mode waterfall`)处理失败步骤关闭后的最终请求失败。pre-step 的 payload 携带已领取批次、轮次、步骤与 signal(参见 [payload-object 事件决策](../architecture/2026-08-06-agent-event-payload-objects.md)),不携带压缩专属的提示词/前缀 payload。 - **`SessionEventMap`** 通过可合并扩展的声明合并获得 `compact/start` / `compact/summary` / `compact/end`;`SurfaceEventType` **未被**触及。这些是会话事件,不是 cordis `Events`,因此事件分类门禁无需新增条目。 - **`dsh-compact`** 拥有 `COMPACT_CHECKPOINT_SOURCE`、`isCompactCheckpointSource(source)`、`toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`。该标记用于跨后端实现识别替换摘要。带缓存的 surface 边缘检查会防止 `compactRegion` 和 `compactIfNeeded` 拆分工具调用/结果对,按 seq 校验当前成员关系,从每个切割点的一条平衡序列回答两侧边缘,并拒绝陈旧或缺失的 seq 与孤立结果。 - **`dsh-session`** 通过唯一的 surface 管理器校验位置替换、完整溯源信息和仅内容的单节点 `tool/result` 重写。其不变式配套插件将新追加的工具结果视为执行,要求存在已打开的步骤与待处理调用,而压缩配套组件拥有数字轮次归属与独立 `null` 归属标记对之间的关系。 diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml index 604255dee5..3be447fe2f 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml @@ -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/feature/2026-06-30-interception-seams.md -2026-06-30-interception-seams.md: 629a1aed509bd9bce9a2da89ce84b17a1db8e6b6 -2026-06-30-interception-seams.zh.md: d6958c9d1e7a8af8fa06d859d1905719a19cd43d +2026-06-30-interception-seams.md: c318e41cfb1d64230b6151f1febad85d75b1451d +2026-06-30-interception-seams.zh.md: 1b274fae4bc7fde326dbb0eeec54d57f73987803 diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md index 629a1aed50..c318e41cfb 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md @@ -15,8 +15,8 @@ The surface needs distinct contracts for per-prompt policy (CC's `UserPromptSubm The canonical surface separates transformable policy, around-dispatch control, and observe-only notification. Policy waterfalls return small seam-specific **typed Decision unions**; wrappers return normalized results; notifications receive immutable snapshots and cannot affect the outcome. The set covers the hook points in scope (`session-start`, `prompt-submit`, `pre-tool`, `post-tool`, `stop`-via-continuation) while leaving non-hook execution policy independently composable. **Agent events** (`dsh-agent`): -- `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. -- `agent/pre-step(agent, messages, context, next) → PreStepDecision` — waterfall, fired before every proposed step after the loop has atomically removed its exclusive inbox batch. `PreStepContext` carries that request's `turn`, `step`, and cancellation `signal`; `messages` is empty for a tool continuation with no intervening input. `enter` returns the complete message batch, including any current-request context a listener contributes; `reject` opens no step and leaves the claimed messages removed. +- `agent/session-start({ agent, source })` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. +- `agent/pre-step({ agent, messages, turn, step, signal }, next) → PreStepDecision` — waterfall, fired before every proposed step after the loop has atomically removed its exclusive inbox batch. The payload carries the request's `turn`, `step`, and cancellation `signal` (the retired `PreStepContext` fields live in the payload; see the [payload-object events decision](../architecture/2026-08-06-agent-event-payload-objects.md)); `messages` is empty for a tool continuation with no intervening input. `enter` returns the complete message batch, including any current-request context a listener contributes; `reject` opens no step and leaves the claimed messages removed. **`agent/turn-stopping`** is an awaited notification at the natural stop boundary. A listener that needs another step calls `agent.steer()` with explicitly sourced model-facing content; the loop then re-reads the outbox and either continues or closes the turn. diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md index d6958c9d1e..1b274fae4b 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md @@ -15,8 +15,8 @@ harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那 规范表面将可变换策略、环绕调度控制与仅观测通知分离。策略 waterfall(瀑布式事件)返回小型的、seam 专属的**类型化 Decision 联合类型**;包装层返回规范化结果;通知接收不可变快照,无法影响结果。覆盖的钩子点包括 `session-start`、`prompt-submit`、`pre-tool`、`post-tool`、通过 continuation 实现的 `stop`,同时将非钩子的执行策略留作独立可组合。 **Agent 事件**(`dsh-agent`): -- `agent/session-start(agent, source)` ——emit,在第 1 轮次之前触发一次,携带 `SessionStartSource`(`startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知,不能阻塞启动(这是有意的空白:桥接可以记录/注入,但不管控启动)。监听器通过 `agent.inject()` 注入上下文。 -- `agent/pre-step(agent, messages, context, next) → PreStepDecision` ——waterfall,在每个拟议步骤之前、循环原子移除其独占 inbox 批次后触发。`PreStepContext` 携带该请求的 `turn`、`step` 与取消 `signal`;没有中途输入的工具续步会收到空批次。`enter` 返回完整消息批次,其中包括监听器为当前请求贡献的上下文;`reject` 不打开步骤,并让已领取消息保持已删除。 +- `agent/session-start({ agent, source })` ——emit,在第 1 轮次之前触发一次,携带 `SessionStartSource`(`startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知,不能阻塞启动(这是有意的空白:桥接可以记录/注入,但不管控启动)。监听器通过 `agent.inject()` 注入上下文。 +- `agent/pre-step({ agent, messages, turn, step, signal }, next) → PreStepDecision` ——waterfall,在每个拟议步骤之前、循环原子移除其独占 inbox 批次后触发。payload 携带该请求的 `turn`、`step` 与取消 `signal`(已退役的 `PreStepContext` 字段位于 payload 中;参见 [payload-object 事件决策](../architecture/2026-08-06-agent-event-payload-objects.md));没有中途输入的工具续步会收到空批次。`enter` 返回完整消息批次,其中包括监听器为当前请求贡献的上下文;`reject` 不打开步骤,并让已领取消息保持已删除。 **`agent/turn-stopping`** 是自然停止边界上的一次 awaited 通知。需要再执行一步的监听器调用 `agent.steer()`,传入来源显式的 steering(中途引导)内容供模型使用;循环随后重新读取 outbox,继续执行或关闭轮次。 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml new file mode 100644 index 0000000000..900bc3562d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml @@ -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/feature/2026-08-04-web-slash-command-fuzzy-discovery.md +2026-08-04-web-slash-command-fuzzy-discovery.md: 8d7fe88f8d19a6edc7b51e63578c468df085c238 +2026-08-04-web-slash-command-fuzzy-discovery.zh.md: d3efdc23351a1b50853ee76fb731aee046004750 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md new file mode 100644 index 0000000000..8d7fe88f8d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md @@ -0,0 +1,27 @@ +# Agent Note: Web slash-command fuzzy discovery + +Status: implemented + +English | [中文](2026-08-04-web-slash-command-fuzzy-discovery.zh.md) + +## Problem + +The web command menu required a command-name prefix, so discovery failed when a user remembered the significant letters but not their exact positions. Broadening menu matching could make discovery easier, but command execution must remain exact and deterministic: an approximate line must never execute a nearby command. + +## Decision + +The `/` command source fuzzy-matches the typed query against command names as a case-insensitive ordered subsequence. Exact prefixes form the highest ranking class. Within each class, the strongest alignment score rewards separator boundaries and adjacent characters while penalizing leading characters and gaps; equal scores retain the host-directory and client-contribution order. Position filtering still removes argument-taking commands from inline menus before ranking. + +The scorer uses dynamic programming in `O(query length × name length)` time and `O(name length)` memory per candidate. Candidate scoring stays client-side and examines names only; descriptions do not affect matching. Menu selection still dispatches the selected exact name, while space and Enter adjudication continue to require an exact command token. + +## Alternatives considered + +**Keep prefix-only matching.** Rejected because it preserves the recall failure that motivates the feature; `/cpt` cannot discover `/compact`. + +**Match unordered characters or descriptions.** Rejected because unordered matches are difficult to predict, while description matches can surface commands whose visible names do not explain why they ranked. + +**Use a general fuzzy-search dependency.** Rejected because this surface needs one constrained subsequence rule over a small command catalog; a configurable search index would add bundle weight and ranking behavior not used by the product. + +## Consequences + +Users can discover a command from remembered in-order letters, and ranking remains stable across identical catalogs. The score is deliberately heuristic: a separator-aligned match can outrank a match with a shorter raw span. Package tests pin each ranking factor and stable ties, while the assembled Web replay snapshot pins `/cpt` resolving to `/compact`. Exact execution semantics are unchanged. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md new file mode 100644 index 0000000000..d3efdc2335 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Web 斜杠命令模糊发现 + +Status: implemented + +[English](2026-08-04-web-slash-command-fuzzy-discovery.md) | 中文 + +## Problem + +Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字母却不记得其准确位置时,就无法发现命令。扩大菜单的匹配范围可使命令更易发现,但命令执行仍必须保持精确匹配和确定性:近似输入行绝不能执行相近命令。 + +## Decision + +`/` 命令 source 将键入的查询作为不区分大小写的有序子序列,与命令名进行模糊匹配。精确前缀构成排名最高的一类匹配。在每类匹配中,对齐分数越高越优先:分隔符边界和相邻字符会提高分数,前导字符和间隔会降低分数;分数相同则保持 host 目录和 client contribution 的顺序。位置过滤仍会在排名前从行内菜单中移除接收参数的命令。 + +评分器对每个候选项使用动态规划,时间复杂度为 `O(query length × name length)`,空间复杂度为 `O(name length)`。候选项评分只在客户端进行且只检查命令名;命令描述不影响匹配。菜单选择仍派发所选的精确名称,而 space 与 Enter 裁决继续要求命令 token 精确匹配。 + +## Alternatives considered + +**保留仅前缀匹配。** 否决,因为本功能要解决的用户无法准确回忆前缀的问题依然存在:`/cpt` 无法发现 `/compact`。 + +**匹配无序字符或描述。** 否决,因为无序匹配难以预测,而描述匹配可能展示命令,但命令的可见名称无法解释其排名。 + +**使用通用模糊搜索依赖。** 否决,因为该界面只需对小型命令目录使用一种受限的子序列规则;可配置搜索索引会增加 bundle 体积,并引入产品未使用的排名行为。 + +## Consequences + +用户可以凭按顺序记得的字母发现命令;只要目录相同,排名就保持稳定。评分刻意采用启发式规则:与分隔符对齐的匹配可能排在原始跨度更短的匹配之前。包(package)测试固定各项排名因素以及同分时的稳定顺序,组装后的 Web 回放快照固定 `/cpt` 解析为 `/compact` 的行为。精确执行语义保持不变。 diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 8c40dde156..5ceccb330e 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -111,7 +111,7 @@ export async function runHeadless(task: string): Promise { const abort = new AbortController() const frames = api.events.mux({}, abort.signal) const idle = new Promise((resolve) => { - ctx.on('agent/status', (agent, status) => { + ctx.on('agent/status', ({ agent, status }) => { if (agent.id === created.sessionId && status === 'idle') resolve() }) }) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 8c81f55810..06c0cc591d 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -26,6 +26,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', impor const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md') +const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md') const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md') // Post-reload golden: the same settled conversation rebuilt purely from // persistence + history — byte-equal rendering is exactly the recovery claim. @@ -83,6 +84,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () expect(Math.abs( launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height, )).toBeLessThan(1) + await input.fill('/cpt') + await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual([ + 'compactCompact older conversation history', + ]) + const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(FUZZY_COMMAND_MENU_EXPECTED, fuzzySnapshot, MODE) await input.fill('') await expect.poll(() => menu.count()).toBe(0) }) @@ -258,7 +265,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md', + 'session.jsonl', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md', ]) }) }) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/command-menu-fuzzy.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/command-menu-fuzzy.expected.md new file mode 100644 index 0000000000..13d915959d --- /dev/null +++ b/apps/web/tests/snapshots/lifecycle-chrome/command-menu-fuzzy.expected.md @@ -0,0 +1,3 @@ +- listbox "Trigger suggestions": + - text: Commands + - option "compact Compact older conversation history" [selected] diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index b1d4bae895..0459323eea 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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 docs/architecture.md -architecture.md: 9b84c1482cb379fd796e21db45f128ba49750c54 -architecture.zh.md: 84708fcae24623e50b0157782cf459c35a55844b +architecture.md: 40c20a1c9eeabe5ecbbc6edacde81c20071b8a04 +architecture.zh.md: 6fddaa883775cf8345aba01af52575c0f0e1aaa0 diff --git a/docs/architecture.md b/docs/architecture.md index 9b84c1482c..40c20a1c9e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,7 +83,7 @@ forever: -> 'turn/start' claim next-step input plus one next-turn message -> emit agent/inbox/claimed({ message, turn }) for each claimed message - -> agent/pre-step(messages, { turn, step, signal }) + -> agent/pre-step({ agent, messages, turn, step, signal }) reject, empty input, cancellation, or listener failure -> the claimed batch stays removed; close the no-step turn; stop the driver enter -> step loop: @@ -112,7 +112,7 @@ idle inject: Each step assembles ordered prompt sections, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona; the loop supplies `provider`, `model`, and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -`inject()` queues non-waking `next-step` context; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. Post-tool `additionalContexts` use the same inbox. `agent/pre-step` receives the exclusive claimed batch and upcoming turn, step, and signal. Reject opens no step; enter supplies the complete batch appended after `step/start`. Empty tool continuations still traverse the waterfall, whose final value settles all rewrites. +`inject()` queues non-waking `next-step` context; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. Post-tool `additionalContexts` use the same inbox. The `agent/pre-step` payload carries the exclusive claimed batch and the upcoming turn, step, and signal. Reject opens no step; enter supplies the complete batch appended after `step/start`. Empty tool continuations still traverse the waterfall, whose final value settles all rewrites. Pruning precedes summaries; overflow retries require durable progress. `agent/request-error` may authorize a same-step retry of the frozen prompt; cancellation wins. Adapter `retryPolicy` bounds normal mode, while always mode retries after specialized recovery ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry foundation](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md), [provider policy](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md)). The generated [agent lifecycle](agent-lifecycle.md) owns exact event order, and the [agent-loop README](../packages/core/agent-loop/README.md) owns queue, steering, retry, and cancellation mechanics. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 84708fcae2..6fddaa8837 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -83,7 +83,7 @@ forever: -> 'turn/start' claim next-step input plus one next-turn message -> emit agent/inbox/claimed({ message, turn }) for each claimed message - -> agent/pre-step(messages, { turn, step, signal }) + -> agent/pre-step({ agent, messages, turn, step, signal }) reject, empty input, cancellation, or listener failure -> the claimed batch stays removed; close the no-step turn; stop the driver enter -> step loop: @@ -112,7 +112,7 @@ idle inject: 每个步骤都会组装有序的提示词片段、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `provider`、`model` 和 `cwd`([提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 -`inject()` 将不会唤醒驱动器的上下文排入 `next-step`;空闲驱动器会让它保持待处理,直至 `followup()` 或 `steer()` 唤醒。工具执行后的 `additionalContexts` 使用同一个 inbox。`agent/pre-step` 接收独占的已领取批次,以及即将使用的轮次、步骤和信号。拒绝则不进入步骤;进入则提供在 `step/start` 后追加的完整批次。空的工具续跑仍会经过 waterfall,其最终值一次性结算所有改写。 +`inject()` 将不会唤醒驱动器的上下文排入 `next-step`;空闲驱动器会让它保持待处理,直至 `followup()` 或 `steer()` 唤醒。工具执行后的 `additionalContexts` 使用同一个 inbox。`agent/pre-step` 的 payload 携带独占的已领取批次,以及即将使用的轮次、步骤和信号。拒绝则不进入步骤;进入则提供在 `step/start` 后追加的完整批次。空的工具续跑仍会经过 waterfall,其最终值一次性结算所有改写。 裁剪先于摘要;溢出重试必须取得持久进展。`agent/request-error` 可以授权使用冻结提示词进行同步骤重试;取消优先。适配器的 `retryPolicy` 使 normal mode 保持有界,always mode 则在专门恢复后重试([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试基础](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)、[提供方策略](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md))。精确事件顺序由生成的 [agent 生命周期](agent-lifecycle.md)定义;队列、steering、重试与取消机制由 [agent-loop README](../packages/core/agent-loop/README.md)定义。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index fa34738768..44e0727101 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -24,16 +24,16 @@ A fully configured agent and live session were published. Setup is composition-o * Synchronous listener failure vetoes publication, while returned-promise * rejection is reported. Detach requested during dispatch waits until every * creation listener has observed the stable entry. - * @param agent - the newly registered agent with its live session and completed setup. + * @param payload.agent - the newly registered agent with its live session and completed setup. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/created'(this: Scoped, agent: Agent): void +'agent/created'(this: Scoped, payload: { agent: Agent }): void ``` Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -44,16 +44,16 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco * An agent left the registry; AgentLoop emits this after driver quiescence * and scoped-registration unwind, but before session detachment. Custom * registry users own their driver-ordering contract. - * @param agent - the exact agent removed from the registry. + * @param payload.agent - the exact agent removed from the registry. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/disposed'(this: Scoped, agent: Agent): void +'agent/disposed'(this: Scoped, payload: { agent: Agent }): void ``` Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:187`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -63,19 +63,19 @@ A step or turn errored. The machine reports a failure here even when the error h /** * A step or turn errored. The machine reports a failure here even when * the error has no in-turn position for a durable record. - * @param agent - the agent whose turn errored. - * @param turn - the turn in which the failure surfaced. - * @param step - the step at which the failure surfaced. - * @param error - the failure, verbatim. + * @param payload.agent - the agent whose turn errored. + * @param payload.turn - the turn in which the failure surfaced. + * @param payload.step - the step at which the failure surfaced. + * @param payload.error - the failure, verbatim. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: unknown): void +'agent/error'(this: Scoped, payload: { agent: Agent; turn: number; step: number; error: unknown }): void ``` Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts) ### `agent/inbox/claimed` — emit @@ -86,17 +86,18 @@ One message left the inbox inside its open turn. If the proposed step is rejecte * One message left the inbox inside its open turn. If the proposed step * is rejected, the claimed message ends here: it is neither discarded nor * re-emitted as a user/message, and the turn closes without a step. - * @param agent - the agent whose inbox changed. - * @param event - the claimed message and owning turn. + * @param payload.agent - the agent whose inbox changed. + * @param payload.message - the claimed message. + * @param payload.turn - the owning turn. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/claimed'(this: Scoped, agent: Agent, event: { message: UserMessage; turn: number }): void +'agent/inbox/claimed'(this: Scoped, payload: { agent: Agent; message: UserMessage; turn: number }): void ``` Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:215`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:192`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discarded` — emit @@ -105,17 +106,17 @@ One message was discarded from the live inbox. ```ts cordis-catalog /** * One message was discarded from the live inbox. - * @param agent - the agent whose inbox changed. - * @param event - the discarded message. + * @param payload.agent - the agent whose inbox changed. + * @param payload.message - the discarded message. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/discarded'(this: Scoped, agent: Agent, event: { message: UserMessage }): void +'agent/inbox/discarded'(this: Scoped, payload: { agent: Agent; message: UserMessage }): void ``` Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:223`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:200`](../../packages/core/agent/src/types.ts) ### `agent/inbox/inserted` — emit @@ -124,17 +125,17 @@ One message entered the live inbox. ```ts cordis-catalog /** * One message entered the live inbox. - * @param agent - the agent whose inbox changed. - * @param event - the inserted message. + * @param payload.agent - the agent whose inbox changed. + * @param payload.message - the inserted message. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/inserted'(this: Scoped, agent: Agent, event: { message: UserMessage }): void +'agent/inbox/inserted'(this: Scoped, payload: { agent: Agent; message: UserMessage }): void ``` Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:205`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:181`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — waterfall @@ -144,18 +145,20 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p /** * Reject a proposed step or replace the messages that enter it. Calling * `next()` preserves the current messages. - * @param agent - the agent proposing the step. - * @param messages - messages removed from the inbox for this step. - * @param context - proposed turn and step coordinates plus cancellation. + * @param payload.agent - the agent proposing the step. + * @param payload.messages - messages removed from the inbox for this step. + * @param payload.turn - the turn that will own the step. + * @param payload.step - the step proposed by the loop. + * @param payload.signal - the current turn's cancellation signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ -'agent/pre-step'(this: Scoped, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise): Promise +'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise ``` -Types: [Agent](../core-data-structures/core.md) · [PreStepContext](../core-data-structures/core.md) · [PreStepDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) +Types: [Agent](../core-data-structures/core.md) · [PreStepDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:247`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -167,19 +170,19 @@ Replace the frozen call configuration. `await next()` yields the config the mach * the machine would use (agent options on the first request, the logged * header afterwards); return a replacement to switch. Model-visible * content must use logged channels; this seam cannot mutate messages. - * @param agent - the agent making the model call. - * @param turn - the open turn number. - * @param step - the step whose request this is. - * @param signal - the current turn's explicit abort signal. + * @param payload.agent - the agent making the model call. + * @param payload.turn - the open turn number. + * @param payload.step - the step whose request this is. + * @param payload.signal - the current turn's explicit abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ -'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise): Promise +'agent/request'(this: Scoped, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise ``` Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:260`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -191,18 +194,22 @@ Handle one failed model-request attempt before the loop retries or closes its st * its step. A listener returns `{ kind: 'retry' }` without calling `next()` * when it owns recovery, or calls `next()` to delegate. The default * `undefined` leaves the failure terminal. - * @param agent - the agent whose request failed. - * @param context - request coordinates, provider, normalized failure, and serving policy. - * @param signal - the turn abort signal. + * @param payload.agent - the agent whose request failed. + * @param payload.turn - the turn containing the failed request. + * @param payload.step - the step containing the failed request attempt. + * @param payload.provider - the provider selected for the failed request. + * @param payload.failure - serializable facts normalized at the final adapter boundary. + * @param payload.retryPolicy - the policy of the adapter registration that served the failed request. + * @param payload.signal - the turn abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ -'agent/request-error'(this: Scoped, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise): Promise +'agent/request-error'(this: Scoped, payload: { agent: Agent; turn: number; step: number; provider: string; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined; signal: AbortSignal }, next: () => Promise): Promise ``` -Types: [Agent](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [RequestFailureContext](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:272`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -214,17 +221,17 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to * `agent.inject()` to seed model-facing context. This is a notification, not * a veto; disposal requested by a lifecycle owner is rechecked before the * driver starts. - * @param agent - the agent whose session lifecycle began. - * @param source - why the session started (fresh startup, resume, …). + * @param payload.agent - the agent whose session lifecycle began. + * @param payload.source - why the session started (fresh startup, resume, …). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/session-start'(this: Scoped, agent: Agent, source: SessionStartSource): void +'agent/session-start'(this: Scoped, payload: { agent: Agent; source: SessionStartSource }): void ``` Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:235`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -235,17 +242,17 @@ Agent status changed (`idle` ⇄ `running`). A waking delivery enters `running` * Agent status changed (`idle` ⇄ `running`). A waking delivery enters * `running` synchronously after reserving cancellation; `idle` means no * driver remains scheduled or active. - * @param agent - the agent whose status flipped. - * @param status - the status just entered (the transition's destination). + * @param payload.agent - the agent whose status flipped. + * @param payload.status - the status just entered (the transition's destination). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void +'agent/status'(this: Scoped, payload: { agent: Agent; status: AgentStatus }): void ``` Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:197`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:173`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -263,18 +270,18 @@ The turn is about to close: the model owes no response (no live tool calls, no f * never short-circuits already-submitted next-step work: same-step * `additionalContexts` or racing steering still runs, and the turn * closes only when that inbox drains. - * @param agent - the agent whose turn is at its stop boundary. - * @param turn - the turn about to close. - * @param signal - the current turn's explicit abort signal. + * @param payload.agent - the agent whose turn is at its stop boundary. + * @param payload.turn - the turn about to close. + * @param payload.signal - the current turn's explicit abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode serial */ -'agent/turn-stopping'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | void +'agent/turn-stopping'(this: Scoped, payload: { agent: Agent; turn: number; signal: AbortSignal }): Promise | void ``` Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:290`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` @@ -288,11 +295,11 @@ A declarative agent entry failed before it could publish a live agent. Consumers * Consumers that buffer work for the configured identity use this * transient signal to reject that work instead of waiting forever. Normal * factory teardown suppresses failures from the cancelled startup attempt. - * @param sessionId - exact shared agent/session identity that failed startup. - * @param error - persistence, setup, or publication failure. + * @param payload.sessionId - exact shared agent/session identity that failed startup. + * @param payload.error - persistence, setup, or publication failure. * @mode emit */ -'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void +'agent-loop/config-start-failed'(payload: { sessionId: SessionId; error: unknown }): void ``` Types: [SessionId](../core-data-structures/core.md) @@ -456,11 +463,11 @@ Goal mutation accepted by one live agent. The matching `goal/change` session eve * Goal mutation accepted by one live agent. The matching `goal/change` * session event has already committed. Listener failures are contained. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @param agent - agent whose session owns the goal. - * @param change - fresh current projection or clear tombstone. + * @param payload.agent - agent whose session owns the goal. + * @param payload.change - fresh current projection or clear tombstone. * @mode emit */ -'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped, agent: Agent, change: GoalChanged): void +'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped, payload: { agent: Agent; change: GoalChanged }): void ``` Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md) diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 7a9255ed79..5600da9e54 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -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 docs/core-data-structures/core.md -core.md: 6c9b778f2ec6a0b4e3ffdf7d69e5e4d34df0b4a9 -core.zh.md: 4912060cad1f3b9fce910ec8c24b8b50dafd6ed1 +core.md: 52e77be89d939eefa2b42ef5586c5798e194a303 +core.zh.md: 5f0134a4c8b3dead49830b41f62e8b7238327cfa diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 6c9b778f2e..52e77be89d 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -728,19 +728,7 @@ Pre-step decisions use the same identified `UserMessage` shape as durable user-r Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -`agent/pre-step` receives the exclusive claimed batch and the proposed step's coordinates and cancellation signal. The initial proposal runs inside an open turn before any step; a tool continuation may submit an empty claimed batch between steps: - -```ts type-equiv -/** Coordinates and cancellation for a proposed step. */ -interface PreStepContext { - /** Turn that will own the step. */ - readonly turn: number - /** Step proposed by the loop. */ - readonly step: number - /** Current turn cancellation signal. */ - readonly signal: AbortSignal -} -``` +`agent/pre-step` receives one payload carrying the exclusive claimed batch (`messages`), the proposed step's coordinates (`turn`, `step`), and the current turn's cancellation `signal`. The initial proposal runs inside an open turn before any step; a tool continuation may submit an empty claimed batch between steps: It returns a `PreStepDecision`. Reject opens no step. Enter supplies the complete message batch appended after `step/start`; claimed messages omitted by the final decision remain removed, while input inserted after the claim stays pending: diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 4912060cad..5f0134a4c8 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -736,19 +736,7 @@ pre-step 决策使用与持久 user-role 输入相同、带标识的 `UserMessag 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -`agent/pre-step` 接收独占的已领取批次,以及拟进入步骤的坐标与取消 signal。首次提案在已打开的轮次内、任何步骤开始前运行;工具 continuation 可以在步骤之间提交空的已领取批次: - -```ts type-equiv -/** Coordinates and cancellation for a proposed step. */ -interface PreStepContext { - /** Turn that will own the step. */ - readonly turn: number - /** Step proposed by the loop. */ - readonly step: number - /** Current turn cancellation signal. */ - readonly signal: AbortSignal -} -``` +`agent/pre-step` 接收一个 payload,携带独占的已领取批次(`messages`)、拟进入步骤的坐标(`turn`、`step`)与当前轮次的取消 `signal`。首次提案在已打开的轮次内、任何步骤开始前运行;工具 continuation 可以在步骤之间提交空的已领取批次: 它返回 `PreStepDecision`。reject 不会打开步骤。enter 提供在 `step/start` 后追加的完整消息批次;最终决策省略的已领取消息保持已删除,而领取后插入的输入仍留待后续处理: diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index be22f29ad2..3de02d1b4c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,18 +8,18 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:178`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:187`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:215`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:223`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:205`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:247`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:272`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:235`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:197`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`jsonrpc`](../packages/ui/jsonrpc) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:290`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:154`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:192`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:200`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:173`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`jsonrpc`](../packages/ui/jsonrpc) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy` | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index f033fde18f..93b01139a8 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -100,7 +100,7 @@ Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src } ``` -Source: [`packages/core/agent/src/types.ts:313`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) ### `approval/*` diff --git a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts index 5dd19bb348..d3ffa4e8a7 100644 --- a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts +++ b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts @@ -84,13 +84,13 @@ export function apply(ctx: Context): void { // runs, so the queued FIFO order is what the transcript records. The first // child enqueue is the initial delegation, which also pins the real child id. let accepted = 0 - ctx.on('agent/inbox/inserted', (agent) => { + ctx.on('agent/inbox/inserted', ({ agent }) => { if (agent.session.header.parentSession === undefined) return if (realChildId === undefined) realChildId = agent.session.header.id accepted += 1 if (accepted >= 3) followupsAccepted.resolve(undefined) }) - ctx.on('agent/pre-step', async (agent, _messages, _context, next) => { + ctx.on('agent/pre-step', async ({ agent }, next) => { if (agent.session.header.parentSession !== undefined) await followupsAccepted.promise return next() }) diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index 648e77156c..ad049ab308 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -302,7 +302,7 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { function waitForIdle(harness: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = harness.on('agent/status', (subject, status) => { + const dispose = harness.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts index 72aa7b199d..80a4f7e240 100644 --- a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts +++ b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts @@ -59,7 +59,7 @@ export const inject = ['llm'] /** Register the keyless `cli-mock` adapter. */ export function apply(ctx: Context): void { ctx.llm.registerAdapter(['cli-mock'], new CliMockAdapter()) - ctx.on('agent/request', async (_agent, _turn, step, _signal, next) => { + ctx.on('agent/request', async ({ step }, next) => { const config = await next() return step === 2 ? { ...config, reasoningEffort: OFF } : config }) diff --git a/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts b/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts index de64e4599b..d8dc2c2465 100644 --- a/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts +++ b/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts @@ -7,7 +7,7 @@ export const name = 'seed-goal' export const inject = ['goals'] export function apply(ctx: Context): void { - ctx.on('agent/pre-step', (agent, _messages, _context, next) => { + ctx.on('agent/pre-step', ({ agent }, next) => { if (ctx.goals.get(agent) === undefined) { ctx.goals.create(agent, { objective: 'Prove the composed goal survives in the session log', diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index 756cc58e39..b57a4c2d2d 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -84,7 +84,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio export function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index a794c52901..50549d7ab0 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -184,13 +184,13 @@ export function apply(ctx: Context, config: AcpConfig): void { } }) - ctx.on('agent/inbox/claimed', (agent, { message, turn }) => { + ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => { const record = ownedRecord(agent) const inflight = record?.inflight if (inflight !== undefined && inflight.messageId === message.id) inflight.turn = turn }) - ctx.on('agent/error', (agent, turn, _step, error) => { + ctx.on('agent/error', ({ agent, turn, error }) => { const record = ownedRecord(agent) const inflight = record?.inflight if (record === undefined || inflight === undefined || inflight.turn === turn) return diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index 329aff6d96..f2cffb4010 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -87,7 +87,7 @@ describe('ACP prompt lifecycle', () => { const sessionId = await newSession(harness) const agent = harness.ctx.agents.get(SessionId(sessionId))! let injected = false - harness.ctx.on('agent/inbox/inserted', (subject, { message }) => { + harness.ctx.on('agent/inbox/inserted', ({ agent: subject, message }) => { if (subject === agent && message.source.kind === 'user' && !injected) { injected = true agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } })) @@ -235,7 +235,7 @@ describe('ACP prompt lifecycle', () => { harness = await makeBridgeHarness({ script: [errorResponse('transient boom'), textResponse('recovered')] }) // A recovery policy: schedule one retry for the failed request. let retried = false - harness.ctx.on('agent/request-error', async (_subject) => { + harness.ctx.on('agent/request-error', async () => { if (!retried) { retried = true return { kind: 'retry' } @@ -272,7 +272,7 @@ describe('ACP prompt lifecycle', () => { it('cancels a prompt removed before its turn claims it', async () => { harness = await makeBridgeHarness({ script: [] }) const sessionId = await newSession(harness) - const dispose = harness.ctx.on('agent/inbox/inserted', (agent, { message }) => { + const dispose = harness.ctx.on('agent/inbox/inserted', ({ agent, message }) => { if (message.source.kind === 'user') agent.inbox.remove(message.id) }) diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 788c086ba6..69c641a06d 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -48,7 +48,7 @@ afterEach(() => { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/client/ui-command/README.i18n.yaml b/packages/client/ui-command/README.i18n.yaml index e8bce8520f..4d8ace8955 100644 --- a/packages/client/ui-command/README.i18n.yaml +++ b/packages/client/ui-command/README.i18n.yaml @@ -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 packages/client/ui-command/README.md -README.md: a19bfe7135acc5408448dc73d04813ed4104dd48 -README.zh.md: 79d903b916728c1200ab1311055f2be190008787 +README.md: bc7386c8fca3b5c623473328bee6322fa7295277 +README.zh.md: 478ee4ccee4558c70075baa45ab34ac6e3d71617 diff --git a/packages/client/ui-command/README.md b/packages/client/ui-command/README.md index a19bfe7135..bc7386c8fc 100644 --- a/packages/client/ui-command/README.md +++ b/packages/client/ui-command/README.md @@ -8,6 +8,8 @@ Client command surface (`ctx.command`): the session-keyed command-directory cach `CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. +Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md). + `PopupSelectController` (`src/client/popup.ts`) is the headless shell state: `PopupSelectView` self-registers into `conversation.input.overlay` (the SlotMap key is ui-conversation's; this package pulls the declaration in with a type-only import — no runtime edge). The shell is a transient layer holding focus while open; token-segment consumption after onSelect runs both branches through `consumeTokenSegment` (menu-path span CAS, enter-path bare-token equality) against the draft face the wiring layer binds via `bindDraft`. The `/client` export surface is the plugin body (`apply`/`inject`), `CommandService`, the directory and popup classes with their state types, and the frozen contract types; the shell component itself is internal to the overlay registration. diff --git a/packages/client/ui-command/README.zh.md b/packages/client/ui-command/README.zh.md index 79d903b916..478ee4ccee 100644 --- a/packages/client/ui-command/README.zh.md +++ b/packages/client/ui-command/README.zh.md @@ -8,6 +8,8 @@ `CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 +菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和 contribution 顺序打破平局。此行为只影响命令发现:space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。 + `PopupSelectController`(`src/client/popup.ts`)是无头的壳状态:`PopupSelectView` 自行注册进 `conversation.input.overlay`(SlotMap key 归 ui-conversation 所有;本包只以 type-only 导入引入该声明——没有运行时依赖边)。壳是打开期间持有焦点的瞬态层;onSelect 之后的 token 片段消费在两条分支上都经 `consumeTokenSegment` 执行(菜单路径做 span CAS,回车路径做裸 token 相等比较),作用于接线层经 `bindDraft` 绑定的草稿表层。 `/client` 导出表层是插件主体(`apply`/`inject`)、`CommandService`、目录类和 popup 类及其状态类型,以及冻结的契约类型;壳组件本身是 overlay 注册的内部实现。 diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index 9784c56ced..33b42f6b51 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -2,9 +2,10 @@ * CommandService (`ctx.command`): the '/' command source over the * session-keyed directory, the client-contribution registry, and the * per-session popupSelect controllers. Candidate synthesis merges the host - * catalog with contributions by availability, then query/position filtering; - * a host/contribution name collision fails loud. Every execute addresses the - * session's agent by sessionId — sessions are always agent-backed. + * catalog with contributions by availability, then fuzzy query/position + * filtering; a host/contribution name collision fails loud. Every execute + * addresses the session's agent by sessionId — sessions are always + * agent-backed. */ import { Service } from 'cordis' import type { Context } from 'cordis' @@ -27,6 +28,69 @@ interface LiveState { readonly popups: Map> } +/** One fuzzy match with its stable source position. */ +interface RankedCandidate { + readonly candidate: SlashCandidate + readonly index: number + readonly prefix: boolean + readonly score: number +} + +/** Extra weight for command-name starts and separator boundaries. */ +function boundaryBonus(name: string, index: number): number { + return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0 +} + +/** + * Score the strongest ordered-subsequence alignment in O(name × query). + * Boundary and adjacent matches earn weight; skipped and leading characters + * cost weight. + */ +function fuzzyScore(name: string, query: string): number | undefined { + if (query === '') return 0 + if (query.length > name.length) return undefined + const noMatch = Number.NEGATIVE_INFINITY + let previous = Array(name.length).fill(noMatch) + for (let index = 0; index < name.length; index++) { + if (name.charAt(index) === query.charAt(0)) previous[index] = 1 + boundaryBonus(name, index) - index + } + for (let queryIndex = 1; queryIndex < query.length; queryIndex++) { + const current = Array(name.length).fill(noMatch) + let bestGapped = noMatch + for (let index = 0; index < name.length; index++) { + const gappedIndex = index - 2 + if (gappedIndex >= 0) { + const prior = previous[gappedIndex] ?? noMatch + if (prior !== noMatch) bestGapped = Math.max(bestGapped, prior + gappedIndex) + } + if (name.charAt(index) !== query.charAt(queryIndex)) continue + const bonus = 1 + boundaryBonus(name, index) + const adjacent = index > 0 ? previous[index - 1] ?? noMatch : noMatch + if (adjacent !== noMatch) current[index] = adjacent + bonus + 4 + if (bestGapped !== noMatch) current[index] = Math.max(current[index] ?? noMatch, bestGapped + bonus + 1 - index) + } + previous = current + } + let best = noMatch + for (const score of previous) best = Math.max(best, score) + return best === noMatch ? undefined : best +} + +/** Case-insensitive fuzzy filtering with stable ordering for equal matches. */ +function fuzzyCandidates(candidates: readonly SlashCandidate[], rawQuery: string): readonly SlashCandidate[] { + const query = rawQuery.toLowerCase() + if (query === '') return candidates + const ranked: RankedCandidate[] = [] + candidates.forEach((candidate, index) => { + const name = candidate.name.toLowerCase() + const score = fuzzyScore(name, query) + if (score !== undefined) ranked.push({ candidate, index, prefix: name.startsWith(query), score }) + }) + ranked.sort((left, right) => + Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index) + return ranked.map(match => match.candidate) +} + /** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */ export class CommandService extends Service implements CommandServiceContract { static inject = ['slash', 'sessions', 'connection'] @@ -147,7 +211,7 @@ export class CommandService extends Service implements CommandServiceContract { } } - /** Menu candidates: host catalog + contribution availability, then query/position filtering. */ + /** Menu candidates: host catalog + contribution availability, then position filtering and fuzzy name ranking. */ private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise { const list = await this.directory.ensureReady(session.sessionId, req.signal) const rows: SlashCandidate[] = [] @@ -163,9 +227,10 @@ export class CommandService extends Service implements CommandServiceContract { } rows.push({ name: contribution.name, description: contribution.description }) } - return rows - .filter(c => c.name.startsWith(req.query)) - .filter(c => req.position === 'leading' || c.hint === undefined) + return fuzzyCandidates( + rows.filter(c => req.position === 'leading' || c.hint === undefined), + req.query, + ) } /** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */ diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index 08fda13a6f..bd6d72c916 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -164,13 +164,33 @@ describe('candidates', () => { expect(b.listCalls).toEqual([]) }) - it('pulls the session catalog; prefix filter and hint mapping apply', async () => { + it('pulls the session catalog; fuzzy filter and hint mapping apply', async () => { const { source, listCalls } = await bench() const list = await source.candidates(proj('s1'), req('g')) expect(listCalls).toEqual([{ sessionId: sid('s1') }]) expect(list).toEqual([{ name: 'goal', description: 'leadingInput kind', hint: 'goal text' }]) }) + it('matches case-insensitive subsequences and ranks prefixes, boundaries, adjacency, gaps, then source order', async () => { + const commands: CommandDescriptor[] = [ + { name: 'q-xylophone', description: '' }, + { name: 'qx-long', description: '' }, + { name: 'fabulous', description: '' }, + { name: 'foo-bar', description: '' }, + { name: 'zuv', description: '' }, + { name: 'zu1v', description: '' }, + { name: 'yu1v', description: '' }, + { name: 'zu12v', description: '' }, + ] + const { source } = await bench({ commands: () => Promise.resolve({ commands }) }) + const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name) + await expect(names('QX')).resolves.toEqual(['qx-long', 'q-xylophone']) + await expect(names('fb')).resolves.toEqual(['foo-bar', 'fabulous']) + await expect(names('uv')).resolves.toEqual(['zuv', 'zu1v', 'yu1v', 'zu12v']) + await expect(names('zzz')).resolves.toEqual([]) + await expect(names('query-longer-than-every-name')).resolves.toEqual([]) + }) + it('catalogs are per session: another session pulls its own key', async () => { const { source, listCalls } = await bench() const names = (await source.candidates(proj('s2'), req(''))).map(c => c.name) @@ -195,10 +215,10 @@ describe('candidates', () => { expect(s2Names).not.toContain('theme') }) - it('contribution rows ride the same query prefix filter', async () => { + it('contribution rows ride the same fuzzy query filter', async () => { const { command, source } = await bench() command.register(themeContribution()) - const names = (await source.candidates(proj('s1'), req('th'))).map(c => c.name) + const names = (await source.candidates(proj('s1'), req('tm'))).map(c => c.name) expect(names).toEqual(['theme']) }) diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 0bf76975ba..211ac8a920 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -144,9 +144,7 @@ export class BasicCompactService extends CompactService { } ctx.on('agent/pre-step', async ( - agent: Agent, - _messages, - { signal }, + { agent, signal }, next, ): Promise => { if (!signal.aborted) { @@ -165,7 +163,7 @@ export class BasicCompactService extends CompactService { return next() }) - ctx.on('agent/status', (agent, status) => { + ctx.on('agent/status', ({ agent, status }) => { if (status === 'idle') this.overflowRetries.delete(agent) }) @@ -178,12 +176,9 @@ export class BasicCompactService extends CompactService { }) ctx.on('agent/request-error', async ( - agent, - context, - signal, + { agent, failure, signal }, next, ) => { - const { failure } = context if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next() this.overflowAgents.set(agent.session, agent) const target = routedTarget(agent.session) diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index fddbaceb80..a8efad741b 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1372,7 +1372,7 @@ describe('default one-shot summarizer', () => { describe('automatic listener and loader composition', () => { function preStep(ctx: Context, owner: Agent, signal = SIGNAL) { return agentEvents(ctx, owner).waterfall( - 'agent/pre-step', [], { turn: 1, step: 1, signal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) } @@ -1388,8 +1388,7 @@ describe('automatic listener and loader composition', () => { const turn = owner.session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 1 return agentEvents(ctx, owner).waterfall( 'agent/request-error', - { turn, step: 1, provider: 'test', failure, retryPolicy: undefined }, - signal, + { turn, step: 1, provider: 'test', failure, retryPolicy: undefined, signal }, next, ).then(action => action?.kind === 'retry') } diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index 132135bd48..471207aa97 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -175,7 +175,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -217,7 +217,7 @@ function overflowHistorySeed(): SessionEvent[] { describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => { it('uses the model actually routed by agent/request for post-step pressure', async () => { const { ctx } = await harness(8) - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ + ctx.on('agent/request', async (_payload, next) => ({ ...await next(), provider: 'mock', model: 'mock', })) try { @@ -315,7 +315,7 @@ describe('context-overflow recovery across the real loop and compact-basic', () await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeterService) ctx.llm.registerAdapter(['mock'], adapter) - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ + ctx.on('agent/request', async (_payload, next) => ({ ...await next(), provider: 'mock', model: 'mock', })) await ctx.plugin(BasicCompactService, { diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 4cc9e5de9c..5e95beb2b2 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -157,9 +157,7 @@ export function apply(ctx: Context, config: Config): void { const resolvedTimeZone = formatter.resolvedOptions().timeZone ctx.on('agent/pre-step', async ( - agent: Agent, - _messages, - { turn, step, signal }, + { agent, turn, step, signal }, next, ): Promise => { const decision = await next() diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 94228bbc9f..a0ffb9e619 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -82,8 +82,7 @@ async function fire( ): Promise { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - [], - { turn, step, signal }, + { messages: [], turn, step, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) if (decision.kind === 'enter') { @@ -378,7 +377,7 @@ describe('real agent-loop request history', () => { ] as const)('does not commit a preparation reading when a downstream pre-step listener %s', async (mode) => { const adapter = new ScriptedAdapter([textResponse('unused')]) const ctx = await loopHarness(adapter) - ctx.on('agent/pre-step', (subject, _messages, _context, next) => { + ctx.on('agent/pre-step', ({ agent: subject }, next) => { if (mode === 'throws') throw new Error('later pre-step failure') subject.cancel({ kind: 'user' }) return next() diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts index ff9243416e..e79ad89b6b 100644 --- a/packages/context/tmux-context/src/index.ts +++ b/packages/context/tmux-context/src/index.ts @@ -216,9 +216,7 @@ export function apply(ctx: Context, config: Config): void { validateRefreshInterval(refreshIntervalMs) ctx.on('agent/pre-step', async ( - agent: Agent, - _messages, - { turn, step, signal }, + { agent, turn, step, signal }, next, ): Promise => { const decision = await next() diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index d7e4a0a7fb..12bc37b9ce 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -138,8 +138,7 @@ async function fire( ): Promise { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - [], - { turn, step, signal }, + { messages: [], turn, step, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) if (decision.kind === 'enter') { diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index efa9bdf771..f07ccf46f8 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -213,9 +213,7 @@ export function apply(ctx: Context, config: Config): void { } ctx.on('agent/pre-step', async ( - agent: Agent, - messages, - { step, signal }, + { agent, messages, step, signal }, next, ): Promise => { const decision = await next() diff --git a/packages/context/workspace-context/tests/workspace-context.e2e.ts b/packages/context/workspace-context/tests/workspace-context.e2e.ts index 6a8095da0e..c1151428e2 100644 --- a/packages/context/workspace-context/tests/workspace-context.e2e.ts +++ b/packages/context/workspace-context/tests/workspace-context.e2e.ts @@ -57,7 +57,7 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index ae5889b241..6157a9fdbf 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -209,8 +209,7 @@ async function workspaceContextOf(agent: Agent): Promise { async function syncWorkspaceContext(ctx: Context, agent: Agent): Promise { await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', [], - { turn: 1, step: 1, signal: testToolSignal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, signal: testToolSignal }, async () => ({ kind: 'enter' as const, messages: [] }), ) } @@ -245,15 +244,13 @@ async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const claimed = agent.inbox.claim('next-step', 1) const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - claimed, - { turn: 1, step: 2, signal }, + { messages: claimed, turn: 1, step: 2, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: claimed }), ) const entered = decision.kind === 'enter' ? decision.messages : [] @@ -969,8 +966,7 @@ describe('workspace context request injection', () => { const original = stubAgent(root) await agentEvents(ctx, original).waterfall( 'agent/pre-step', - [], - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const inserted = original.inbox.nextStep[0] @@ -979,12 +975,11 @@ describe('workspace context request injection', () => { await fiber.dispose() await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const resumed = stubAgent(root, [...original.session.events]) - agentEvents(ctx, resumed).emit('agent/session-start', 'resume') + agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) const decision = await agentEvents(ctx, resumed).waterfall( 'agent/pre-step', - claimed, - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve({ kind: 'enter' as const, messages: claimed }), ) if (decision.kind !== 'enter') throw new Error('recovered baseline was rejected') @@ -1016,8 +1011,7 @@ describe('workspace context request injection', () => { const original = stubAgent(root) await agentEvents(ctx, original).waterfall( 'agent/pre-step', - [], - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const stale = original.inbox.nextStep[0] @@ -1027,12 +1021,11 @@ describe('workspace context request injection', () => { await fiber.dispose() await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const resumed = stubAgent(root, [...original.session.events]) - agentEvents(ctx, resumed).emit('agent/session-start', 'resume') + agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const staleClaim = resumed.inbox.claim('next-step', 1) const staleDecision = await agentEvents(ctx, resumed).waterfall( 'agent/pre-step', - staleClaim, - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: staleClaim, turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve({ kind: 'enter' as const, messages: staleClaim }), ) @@ -1071,8 +1064,7 @@ describe('workspace context request injection', () => { const original = stubAgent(root) await agentEvents(originalCtx, original).waterfall( 'agent/pre-step', - [], - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const stale = original.inbox.nextStep[0] @@ -1082,12 +1074,11 @@ describe('workspace context request injection', () => { if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' }) await resumedCtx.plugin(workspaceContext, { dshHome: home, maxBytes }) const resumed = stubAgent(root, [...original.session.events]) - agentEvents(resumedCtx, resumed).emit('agent/session-start', 'resume') + agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) const decision = await agentEvents(resumedCtx, resumed).waterfall( 'agent/pre-step', - claimed, - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve({ kind: 'enter' as const, messages: claimed }), ) @@ -1191,8 +1182,7 @@ describe('workspace context request injection', () => { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - [prompt], - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve(downstream), ) @@ -1249,8 +1239,7 @@ describe('workspace context request injection', () => { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - [], - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve(downstream), ) @@ -1356,7 +1345,7 @@ describe('workspace context request injection', () => { const resumed = stubAgent(root, [...original.session.events]) // Resume announces its lifecycle start before the first step. - agentEvents(ctx, resumed).emit('agent/session-start', 'resume') + agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) await composeBaselinePrefix(ctx, resumed) const baselines = baselineEvents(resumed) @@ -1404,7 +1393,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => { + ctx.on('agent/pre-step', async (_payload, next) => { const decision = await next() if (decision.kind === 'reject') return decision return { @@ -1678,8 +1667,7 @@ describe('workspace context request injection', () => { const reason = new Error('cancel prefix') const pending = agentEvents(ctx, stubAgent(root)).waterfall( 'agent/pre-step', - [], - { turn: 1, step: 1, signal: controller.signal }, + { messages: [], turn: 1, step: 1, signal: controller.signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) @@ -3868,8 +3856,7 @@ describe('workspace context inbox synchronization', () => { controller.abort(new Error('abort pre-step reconciliation')) await expect(agentEvents(ctx, agent).waterfall( - 'agent/pre-step', [], - { turn: 1, step: 1, signal: controller.signal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, signal: controller.signal }, async () => ({ kind: 'enter' as const, messages: [] }), )).rejects.toThrow('abort pre-step reconciliation') @@ -3979,8 +3966,7 @@ describe('workspace context inbox synchronization', () => { const downstream = { kind: 'enter' as const, messages: claimed } const decision = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', claimed, - { turn: 1, step: 1, signal: testToolSignal }, + 'agent/pre-step', { messages: claimed, turn: 1, step: 1, signal: testToolSignal }, async () => downstream, ) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 5f08dc0a54..32ac294f8b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1225,92 +1225,92 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent-loop/config-start-failed', mode: 'emit', - signature: '\'agent-loop/config-start-failed\'(sessionId: SessionId, error: unknown): void', - jsDoc: '/**\n * A declarative agent entry failed before it could publish a live agent.\n * Consumers that buffer work for the configured identity use this\n * transient signal to reject that work instead of waiting forever. Normal\n * factory teardown suppresses failures from the cancelled startup attempt.\n * @param sessionId - exact shared agent/session identity that failed startup.\n * @param error - persistence, setup, or publication failure.\n * @mode emit\n */', + signature: '\'agent-loop/config-start-failed\'(payload: { sessionId: SessionId; error: unknown }): void', + jsDoc: '/**\n * A declarative agent entry failed before it could publish a live agent.\n * Consumers that buffer work for the configured identity use this\n * transient signal to reject that work instead of waiting forever. Normal\n * factory teardown suppresses failures from the cancelled startup attempt.\n * @param payload.sessionId - exact shared agent/session identity that failed startup.\n * @param payload.error - persistence, setup, or publication failure.\n * @mode emit\n */', summary: 'A declarative agent entry failed before it could publish a live agent.', }, { name: 'agent/created', mode: 'emit', - signature: '\'agent/created\'(this: Scoped, agent: Agent): void', - jsDoc: '/**\n * A fully configured agent and live session were published. Setup is\n * composition-only; `agent/session-start` is the first startup-driving seam.\n * Synchronous listener failure vetoes publication, while returned-promise\n * rejection is reported. Detach requested during dispatch waits until every\n * creation listener has observed the stable entry.\n * @param agent - the newly registered agent with its live session and completed setup.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/created\'(this: Scoped, payload: { agent: Agent }): void', + jsDoc: '/**\n * A fully configured agent and live session were published. Setup is\n * composition-only; `agent/session-start` is the first startup-driving seam.\n * Synchronous listener failure vetoes publication, while returned-promise\n * rejection is reported. Detach requested during dispatch waits until every\n * creation listener has observed the stable entry.\n * @param payload.agent - the newly registered agent with its live session and completed setup.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A fully configured agent and live session were published.', }, { name: 'agent/disposed', mode: 'emit', - signature: '\'agent/disposed\'(this: Scoped, agent: Agent): void', - jsDoc: '/**\n * An agent left the registry; AgentLoop emits this after driver quiescence\n * and scoped-registration unwind, but before session detachment. Custom\n * registry users own their driver-ordering contract.\n * @param agent - the exact agent removed from the registry.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/disposed\'(this: Scoped, payload: { agent: Agent }): void', + jsDoc: '/**\n * An agent left the registry; AgentLoop emits this after driver quiescence\n * and scoped-registration unwind, but before session detachment. Custom\n * registry users own their driver-ordering contract.\n * @param payload.agent - the exact agent removed from the registry.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'An agent left the registry; AgentLoop emits this after driver quiescence and scoped-registration unwind, but before session detachment.', }, { name: 'agent/error', mode: 'emit', - signature: '\'agent/error\'(this: Scoped, agent: Agent, turn: number, step: number, error: unknown): void', - jsDoc: '/**\n * A step or turn errored. The machine reports a failure here even when\n * the error has no in-turn position for a durable record.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/error\'(this: Scoped, payload: { agent: Agent; turn: number; step: number; error: unknown }): void', + jsDoc: '/**\n * A step or turn errored. The machine reports a failure here even when\n * the error has no in-turn position for a durable record.\n * @param payload.agent - the agent whose turn errored.\n * @param payload.turn - the turn in which the failure surfaced.\n * @param payload.step - the step at which the failure surfaced.\n * @param payload.error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A step or turn errored.', }, { name: 'agent/inbox/claimed', mode: 'emit', - signature: '\'agent/inbox/claimed\'(this: Scoped, agent: Agent, event: { message: UserMessage; turn: number }): void', - jsDoc: '/**\n * One message left the inbox inside its open turn. If the proposed step\n * is rejected, the claimed message ends here: it is neither discarded nor\n * re-emitted as a user/message, and the turn closes without a step.\n * @param agent - the agent whose inbox changed.\n * @param event - the claimed message and owning turn.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/claimed\'(this: Scoped, payload: { agent: Agent; message: UserMessage; turn: number }): void', + jsDoc: '/**\n * One message left the inbox inside its open turn. If the proposed step\n * is rejected, the claimed message ends here: it is neither discarded nor\n * re-emitted as a user/message, and the turn closes without a step.\n * @param payload.agent - the agent whose inbox changed.\n * @param payload.message - the claimed message.\n * @param payload.turn - the owning turn.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'One message left the inbox inside its open turn.', }, { name: 'agent/inbox/discarded', mode: 'emit', - signature: '\'agent/inbox/discarded\'(this: Scoped, agent: Agent, event: { message: UserMessage }): void', - jsDoc: '/**\n * One message was discarded from the live inbox.\n * @param agent - the agent whose inbox changed.\n * @param event - the discarded message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/discarded\'(this: Scoped, payload: { agent: Agent; message: UserMessage }): void', + jsDoc: '/**\n * One message was discarded from the live inbox.\n * @param payload.agent - the agent whose inbox changed.\n * @param payload.message - the discarded message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'One message was discarded from the live inbox.', }, { name: 'agent/inbox/inserted', mode: 'emit', - signature: '\'agent/inbox/inserted\'(this: Scoped, agent: Agent, event: { message: UserMessage }): void', - jsDoc: '/**\n * One message entered the live inbox.\n * @param agent - the agent whose inbox changed.\n * @param event - the inserted message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/inserted\'(this: Scoped, payload: { agent: Agent; message: UserMessage }): void', + jsDoc: '/**\n * One message entered the live inbox.\n * @param payload.agent - the agent whose inbox changed.\n * @param payload.message - the inserted message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'One message entered the live inbox.', }, { name: 'agent/pre-step', mode: 'waterfall', - signature: '\'agent/pre-step\'(this: Scoped, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise): Promise', - jsDoc: '/**\n * Reject a proposed step or replace the messages that enter it. Calling\n * `next()` preserves the current messages.\n * @param agent - the agent proposing the step.\n * @param messages - messages removed from the inbox for this step.\n * @param context - proposed turn and step coordinates plus cancellation.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + signature: '\'agent/pre-step\'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise', + jsDoc: '/**\n * Reject a proposed step or replace the messages that enter it. Calling\n * `next()` preserves the current messages.\n * @param payload.agent - the agent proposing the step.\n * @param payload.messages - messages removed from the inbox for this step.\n * @param payload.turn - the turn that will own the step.\n * @param payload.step - the step proposed by the loop.\n * @param payload.signal - the current turn\'s cancellation signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Reject a proposed step or replace the messages that enter it.', }, { name: 'agent/request', mode: 'waterfall', - signature: '\'agent/request\'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Replace the frozen call configuration. `await next()` yields the config\n * the machine would use (agent options on the first request, the logged\n * header afterwards); return a replacement to switch. Model-visible\n * content must use logged channels; this seam cannot mutate messages.\n * @param agent - the agent making the model call.\n * @param turn - the open turn number.\n * @param step - the step whose request this is.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n*/', + signature: '\'agent/request\'(this: Scoped, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise', + jsDoc: '/**\n * Replace the frozen call configuration. `await next()` yields the config\n * the machine would use (agent options on the first request, the logged\n * header afterwards); return a replacement to switch. Model-visible\n * content must use logged channels; this seam cannot mutate messages.\n * @param payload.agent - the agent making the model call.\n * @param payload.turn - the open turn number.\n * @param payload.step - the step whose request this is.\n * @param payload.signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n*/', summary: 'Replace the frozen call configuration.', }, { name: 'agent/request-error', mode: 'waterfall', - signature: '\'agent/request-error\'(this: Scoped, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Handle one failed model-request attempt before the loop retries or closes\n * its step. A listener returns `{ kind: \'retry\' }` without calling `next()`\n * when it owns recovery, or calls `next()` to delegate. The default\n * `undefined` leaves the failure terminal.\n * @param agent - the agent whose request failed.\n * @param context - request coordinates, provider, normalized failure, and serving policy.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + signature: '\'agent/request-error\'(this: Scoped, payload: { agent: Agent; turn: number; step: number; provider: string; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined; signal: AbortSignal }, next: () => Promise): Promise', + jsDoc: '/**\n * Handle one failed model-request attempt before the loop retries or closes\n * its step. A listener returns `{ kind: \'retry\' }` without calling `next()`\n * when it owns recovery, or calls `next()` to delegate. The default\n * `undefined` leaves the failure terminal.\n * @param payload.agent - the agent whose request failed.\n * @param payload.turn - the turn containing the failed request.\n * @param payload.step - the step containing the failed request attempt.\n * @param payload.provider - the provider selected for the failed request.\n * @param payload.failure - serializable facts normalized at the final adapter boundary.\n * @param payload.retryPolicy - the policy of the adapter registration that served the failed request.\n * @param payload.signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Handle one failed model-request attempt before the loop retries or closes its step.', }, { name: 'agent/session-start', mode: 'emit', - signature: '\'agent/session-start\'(this: Scoped, agent: Agent, source: SessionStartSource): void', - jsDoc: '/**\n * The session lifecycle began, once before the first turn. Use\n * `agent.inject()` to seed model-facing context. This is a notification, not\n * a veto; disposal requested by a lifecycle owner is rechecked before the\n * driver starts.\n * @param agent - the agent whose session lifecycle began.\n * @param source - why the session started (fresh startup, resume, …).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/session-start\'(this: Scoped, payload: { agent: Agent; source: SessionStartSource }): void', + jsDoc: '/**\n * The session lifecycle began, once before the first turn. Use\n * `agent.inject()` to seed model-facing context. This is a notification, not\n * a veto; disposal requested by a lifecycle owner is rechecked before the\n * driver starts.\n * @param payload.agent - the agent whose session lifecycle began.\n * @param payload.source - why the session started (fresh startup, resume, …).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'The session lifecycle began, once before the first turn.', }, { name: 'agent/status', mode: 'emit', - signature: '\'agent/status\'(this: Scoped, agent: Agent, status: AgentStatus): void', - jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`). A waking delivery enters\n * `running` synchronously after reserving cancellation; `idle` means no\n * driver remains scheduled or active.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/status\'(this: Scoped, payload: { agent: Agent; status: AgentStatus }): void', + jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`). A waking delivery enters\n * `running` synchronously after reserving cancellation; `idle` means no\n * driver remains scheduled or active.\n * @param payload.agent - the agent whose status flipped.\n * @param payload.status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'Agent status changed (`idle` ⇄ `running`).', }, { name: 'agent/turn-stopping', mode: 'serial', - signature: '\'agent/turn-stopping\'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | void', - jsDoc: '/**\n * The turn is about to close: the model owes no response (no live tool\n * calls, no fresh steering). Awaited before the boundary commits — a\n * listener that objects steers (`agent.steer(...)`) and the machine\n * re-reads its inbox: fresh steering runs another step, none closes the\n * turn. Data decides, so listener order cannot change the outcome. The\n * inverse control (stop a tool loop early) is data too: a tool result\n * carrying `concludesTurn` ends the turn at its step. The conclusion\n * never short-circuits already-submitted next-step work: same-step\n * `additionalContexts` or racing steering still runs, and the turn\n * closes only when that inbox drains.\n * @param agent - the agent whose turn is at its stop boundary.\n * @param turn - the turn about to close.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', + signature: '\'agent/turn-stopping\'(this: Scoped, payload: { agent: Agent; turn: number; signal: AbortSignal }): Promise | void', + jsDoc: '/**\n * The turn is about to close: the model owes no response (no live tool\n * calls, no fresh steering). Awaited before the boundary commits — a\n * listener that objects steers (`agent.steer(...)`) and the machine\n * re-reads its inbox: fresh steering runs another step, none closes the\n * turn. Data decides, so listener order cannot change the outcome. The\n * inverse control (stop a tool loop early) is data too: a tool result\n * carrying `concludesTurn` ends the turn at its step. The conclusion\n * never short-circuits already-submitted next-step work: same-step\n * `additionalContexts` or racing steering still runs, and the turn\n * closes only when that inbox drains.\n * @param payload.agent - the agent whose turn is at its stop boundary.\n * @param payload.turn - the turn about to close.\n * @param payload.signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', summary: 'The turn is about to close: the model owes no response (no live tool calls, no fresh steering).', }, { @@ -1365,8 +1365,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'goal/changed', mode: 'emit', - signature: '\'goal/changed\'(this: import(\'@deepseek-ai/dsh-scope\').Scoped, agent: Agent, change: GoalChanged): void', - jsDoc: '/**\n * Goal mutation accepted by one live agent. The matching `goal/change`\n * session event has already committed. Listener failures are contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - agent whose session owns the goal.\n * @param change - fresh current projection or clear tombstone.\n * @mode emit\n */', + signature: '\'goal/changed\'(this: import(\'@deepseek-ai/dsh-scope\').Scoped, payload: { agent: Agent; change: GoalChanged }): void', + jsDoc: '/**\n * Goal mutation accepted by one live agent. The matching `goal/change`\n * session event has already committed. Listener failures are contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param payload.agent - agent whose session owns the goal.\n * @param payload.change - fresh current projection or clear tombstone.\n * @mode emit\n */', summary: 'Goal mutation accepted by one live agent.', }, { diff --git a/packages/cordis/tool-cordis/tests/integration.spec.ts b/packages/cordis/tool-cordis/tests/integration.spec.ts index 01a748a284..488ab996b3 100644 --- a/packages/cordis/tool-cordis/tests/integration.spec.ts +++ b/packages/cordis/tool-cordis/tests/integration.spec.ts @@ -28,7 +28,7 @@ async function harness(adapter: MockAdapter): Promise { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 2b97dad3a4..cfac8262c2 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -7,13 +7,15 @@ import type { Agent, AgentCancelCause, + AgentEventDispatch, AgentOptions, AgentStatus, CancelOptions, InboxTarget, + PreStepDecision, RequestErrorAction, } from '@deepseek-ai/dsh-agent' -import { Inbox, agentCarrier, agentEvents, 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, @@ -68,6 +70,9 @@ export class ReactLoopAgent implements Agent { readonly scope: Scope readonly ctx: Context + /** 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 private readonly runtimeContext: RuntimeContextProjection @@ -78,10 +83,11 @@ export class ReactLoopAgent implements Agent { public readonly options: AgentOptions, public readonly session: Session, ) { + 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 } @@ -100,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 }) } } @@ -178,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 } @@ -204,9 +210,9 @@ export class ReactLoopAgent implements Agent { signal.throwIfAborted() const sections = renderContextSections(assembly) const context = this.runtimeContext.project(joinContextSections(sections), sections) - const decision = await agentEvents(this.loopCtx, this).waterfall( - 'agent/pre-step', claimed, { ...position, signal }, - () => Promise.resolve({ + const decision = await this.dispatch.waterfall( + 'agent/pre-step', { messages: claimed, ...position, signal }, + (): Promise => Promise.resolve({ kind: 'enter', messages: context === undefined ? claimed : [...claimed, context], }), @@ -266,7 +272,7 @@ export class ReactLoopAgent implements Agent { } signal.throwIfAborted() if (turnEnds && this.inbox.nextStep.length === 0) { - await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal) + await this.dispatch.serial('agent/turn-stopping', { turn, signal }) signal.throwIfAborted() } if (turnEnds && this.inbox.nextStep.length === 0) break @@ -323,14 +329,15 @@ export class ReactLoopAgent implements Agent { signal.throwIfAborted() const finish = assembler.finish if (finish.kind === 'error' || finish.kind === 'aborted') { - const action = await this.loopCtx.waterfall( - agentCarrier(this), 'agent/request-error', this, { + const action = await this.dispatch.waterfall( + 'agent/request-error', { turn, step, provider: request.provider, failure: finish.failure, retryPolicy: preparedCall?.retryPolicy, - }, signal, + signal, + }, () => Promise.resolve(undefined), ) signal.throwIfAborted() @@ -405,8 +412,8 @@ export class ReactLoopAgent implements Agent { ...maxTokens === undefined ? {} : { maxTokens }, }, )) - const proposedConfig = await this.loopCtx.waterfall( - agentCarrier(this), 'agent/request', this, turn, step, signal, + const proposedConfig = await this.dispatch.waterfall( + 'agent/request', { turn, step, signal }, () => Promise.resolve(seedConfig), ) signal.throwIfAborted() diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 3f77973d92..a589f3c131 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -175,11 +175,11 @@ declare module 'cordis' { * Consumers that buffer work for the configured identity use this * transient signal to reject that work instead of waiting forever. Normal * factory teardown suppresses failures from the cancelled startup attempt. - * @param sessionId - exact shared agent/session identity that failed startup. - * @param error - persistence, setup, or publication failure. + * @param payload.sessionId - exact shared agent/session identity that failed startup. + * @param payload.error - persistence, setup, or publication failure. * @mode emit */ - 'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void + 'agent-loop/config-start-failed'(payload: { sessionId: SessionId; error: unknown }): void } } @@ -351,7 +351,7 @@ export class AgentLoop extends Service implements AgentFactory { ): void { if (!this.ownership.isActive()) return this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${errorChain(error)}`) - const args: unknown[] = ['agent-loop/config-start-failed', sessionId, error] + const args: unknown[] = ['agent-loop/config-start-failed', { sessionId, error }] for (const callback of this.ctx.events.dispatch('emit', args)) { try { const returned: unknown = callback(...args) @@ -400,7 +400,7 @@ export class AgentLoop extends Service implements AgentFactory { released.resolve() } } - const disposeAgentListener = ownerCtx.on('agent/disposed', checkReleased) + const disposeAgentListener = ownerCtx.on('agent/disposed', () => { checkReleased() }) const disposeSessionListener = ownerCtx.on('session/disposed', checkReleased) try { checkReleased() @@ -525,7 +525,7 @@ export class AgentLoop extends Service implements AgentFactory { // A synchronous announce/session-start listener may have started // teardown; the machine is already live (delivery works from the // session-start seam), so only the liveness recheck is owed. - emitAgentEvent(loopCtx, agent, 'agent/session-start', source) + emitAgentEvent(loopCtx, agent, 'agent/session-start', { source }) assertLive() return { agent, dispose } }, diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index bbc3e548e2..5af6e70fe0 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -31,7 +31,7 @@ async function harness(adapter: LlmAdapter): Promise { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -164,18 +164,18 @@ describe('AgentLoop initiator scope', () => { if (context.agent === agent) capture(context.signal) return next() }) - ctx.on('agent/pre-step', async (subject, _message, { signal }, next) => { + ctx.on('agent/pre-step', async ({ agent: subject, signal }, next) => { if (subject === agent) { expect(ctx.agents.requireInitiator()).toBe(agent) preStepSignals.push(signal) } return next() }) - ctx.on('agent/request', async (subject, _turn, _step, signal, next) => { + ctx.on('agent/request', async ({ agent: subject, signal }, next) => { if (subject === agent) capture(signal) return next() }) - ctx.on('agent/turn-stopping', (subject, _turn, signal) => { + ctx.on('agent/turn-stopping', ({ agent: subject, signal }) => { if (subject === agent) capture(signal) }) ctx.tools.register(defineContentToolFixture({ diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 1692f19291..7ac8a0dd64 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -60,17 +60,17 @@ describe('Agent', () => { ctx.on('session/event', (session, event) => { if (session === agent.session && event.type === 'turn/start') lifecycle.push('turn/start') }) - ctx.on('agent/inbox/inserted', (subject, event) => { - if (subject === agent) inserted.push(event) + ctx.on('agent/inbox/inserted', ({ agent: subject, message }) => { + if (subject === agent) inserted.push({ message }) }) - ctx.on('agent/inbox/claimed', (subject, event) => { + ctx.on('agent/inbox/claimed', ({ agent: subject, message, turn }) => { if (subject === agent) { lifecycle.push('agent/inbox/claimed') - claimed.push(event) + claimed.push({ message, turn }) } }) - ctx.on('agent/inbox/discarded', (subject, event) => { - if (subject === agent) discarded.push(event) + ctx.on('agent/inbox/discarded', ({ agent: subject, message }) => { + if (subject === agent) discarded.push({ message }) }) const context = createUserMessage({ content: [{ type: 'text', text: 'discard me' }], @@ -114,7 +114,7 @@ describe('Agent', () => { const ctx = await harness(new MockAdapter([textResponse('ok')])) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const statuses: string[] = [] - ctx.on('agent/status', (subject, status) => { + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent) statuses.push(status) }) @@ -152,7 +152,7 @@ describe('Agent', () => { const ctx = await harness(new MockAdapter([textResponse('ok')])) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/status', (_subject, status) => { + ctx.on('agent/status', ({ status }) => { throw new Error(`bad ${status} listener`) }) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 87f2d0991d..5c0deed621 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -40,7 +40,7 @@ function send(agent: Agent, text: string) { /** Resolve on the agent's next idle transition (event-based, not status poll). */ function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose(); resolve() } }) }) @@ -156,7 +156,7 @@ describe('Agent.cancel()', () => { const running = Promise.withResolvers() let disposalDone: Promise | undefined - ctx.on('agent/status', (subject, status) => { + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject !== agent || status !== 'running') return disposalDone = handle.dispose() running.resolve(undefined) @@ -200,7 +200,7 @@ describe('Agent.cancel()', () => { const replacementRegistered = Promise.withResolvers() let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined - ctx.on('agent/status', (subject, status) => { + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject !== agent || status !== 'idle' || replacementObservation !== undefined) return send(agent, 'cancelled replacement') replacementObservation = agent.whenIdle().then(() => ({ @@ -239,7 +239,7 @@ describe('Agent.cancel()', () => { const replacementRegistered = Promise.withResolvers() let replacementIdle: Promise | undefined - ctx.on('agent/status', (subject, status) => { + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject !== agent || status !== 'idle' || replacementIdle !== undefined) return send(agent, 'cancelled replacement') agent.cancel({ kind: 'user' }) @@ -440,7 +440,7 @@ describe('Agent.cancel()', () => { }) let cancelled = false - ctx.on('agent/turn-stopping', (subject) => { + ctx.on('agent/turn-stopping', ({ agent: subject }) => { if (subject === agent && !cancelled) { cancelled = true agent.cancel({ kind: 'user' }) @@ -465,7 +465,7 @@ describe('Agent.cancel()', () => { // durable turn-start commit and must drop the reserved work. let streamed = false ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true }) - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'running') agent.cancel({ kind: 'user' }) }) @@ -485,7 +485,7 @@ describe('Agent.cancel()', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let replaced = false - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject !== agent || status !== 'running' || replaced) return replaced = true agent.cancel({ kind: 'user' }) @@ -664,7 +664,7 @@ describe('Agent.cancel()', () => { switch (stage) { case 'pre-step': - ctx.on('agent/pre-step', async (subject, _message, { signal }, next) => { + ctx.on('agent/pre-step', async ({ agent: subject, signal }, next) => { if (subject === agent) await blockUntilAbort(signal) return next() }) @@ -679,13 +679,13 @@ describe('Agent.cancel()', () => { }) break case 'request': - ctx.on('agent/request', async (subject, _turn, _step, signal, next) => { + ctx.on('agent/request', async ({ agent: subject, signal }, next) => { if (subject === agent) await blockUntilAbort(signal) return next() }) break case 'stopping': - ctx.on('agent/turn-stopping', async (subject, _turn, signal) => { + ctx.on('agent/turn-stopping', async ({ agent: subject, signal }) => { if (subject === agent) await blockUntilAbort(signal) }) break diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index c0be39e41b..74608e5f1a 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -19,7 +19,7 @@ afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose(); resolve() } }) }) @@ -170,7 +170,7 @@ describe('config-driven session id', () => { await cleanupStarted.promise expect(first.status).toBe('idle') const failures: unknown[] = [] - ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) + ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) const secondLoop = await ctx.plugin(AgentLoop, config) await new Promise(resolve => setTimeout(resolve, 0)) expect(ctx.agents.get(sessionId)).toBe(first) @@ -234,7 +234,7 @@ describe('config-driven session id', () => { const failures: { sessionId: SessionId; error: unknown }[] = [] ctx.on('agent-loop/config-start-failed', () => { throw listenerFailure }) ctx.on('agent-loop/config-start-failed', () => Promise.reject(asyncListenerFailure) as never) - ctx.on('agent-loop/config-start-failed', (sessionId, error) => { + ctx.on('agent-loop/config-start-failed', ({ sessionId, error }) => { failures.push({ sessionId, error }) }) vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(failure) @@ -274,7 +274,7 @@ describe('config-driven session id', () => { // Deliberately violate the normal Error-only rejection rule to exercise the unknown boundary. // oxlint-disable-next-line typescript/prefer-promise-reject-errors ctx.on('agent-loop/config-start-failed', () => Promise.reject(unrenderable) as never) - ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) + ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(unrenderable) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) @@ -307,7 +307,7 @@ describe('config-driven session id', () => { const released = vi.fn() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const failures: unknown[] = [] - ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) + ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) const loop = await ctx.plugin(AgentLoop, { agents: [{ id: 'main', sessionId: SessionId('config-exact-dispose'), model: 'mock' }], @@ -479,7 +479,7 @@ describe('startup reporting after factory teardown', () => { gate.promise.catch(() => undefined) vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(gate.promise) const failures: unknown[] = [] - ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) + ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const loop = await ctx.plugin(AgentLoop, { diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index ad3a3ef507..b6540d9912 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -40,7 +40,7 @@ async function harness(adapter: MockAdapter) { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -191,7 +191,7 @@ describe('abort during tool execution ends the turn', () => { const adapter = new MockAdapter([textResponse('must not run')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-empty-batch'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/pre-step', (subject, _messages, _context, next) => { + ctx.on('agent/pre-step', ({ agent: subject }, next) => { if (subject !== agent) return next() return Promise.resolve({ kind: 'enter', messages: [] }) }) @@ -288,7 +288,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'leave an unmatched historical call') await waitForIdle(ctx, agent) - const disposeInjection = ctx.on('agent/pre-step', async (subject, _messages, { turn }, next) => { + const disposeInjection = ctx.on('agent/pre-step', async ({ agent: subject, turn }, next) => { const decision = await next() if (subject === agent && turn === 2 && decision.kind === 'enter') { disposeInjection() @@ -382,7 +382,7 @@ describe('disposal leaves the two-state status contract balanced', () => { const statuses: string[] = [] const reasons: TurnEndReason[] = [] - ctx.on('agent/status', (_agent, status) => void statuses.push(status)) + ctx.on('agent/status', ({ status }) => void statuses.push(status)) ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') @@ -411,7 +411,7 @@ describe('disposal leaves the two-state status contract balanced', () => { agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) - ctx.on('agent/status', (_agent, status) => { + ctx.on('agent/status', ({ status }) => { if (status === 'idle') throw new Error('broken status listener') }) @@ -457,7 +457,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + ctx.on('agent/request', async (_payload, next) => { return { ...await next(), provider: 'mock', model: 'mock' } }) @@ -540,7 +540,7 @@ describe('turn numbering continues across seeded sessions', () => { ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) forked.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })) await new Promise((resolve) => { - ctx2.on('agent/status', (subject, status) => { + ctx2.on('agent/status', ({ agent: subject, status }) => { if (subject === forked && status === 'idle') resolve() }) }) @@ -586,7 +586,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () const reasons: TurnEndReason[] = [] const errors: unknown[] = [] - ctx.on('agent/error', (_agent, turn, step, error) => { + ctx.on('agent/error', ({ turn, step, error }) => { expect({ turn, step }).toEqual({ turn: 1, step: 1 }) errors.push(error) }) @@ -710,7 +710,7 @@ describe('turn and step boundary recovery', () => { if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') } }) const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) @@ -743,7 +743,7 @@ describe('turn and step boundary recovery', () => { } }) const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) @@ -800,7 +800,7 @@ describe('turn and step boundary recovery', () => { } }) const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) @@ -893,14 +893,14 @@ describe('turn and step boundary recovery', () => { }, { inject: ['agentLoop'] })) let threw = false - ctx.on('agent/pre-step', (_subject, _messages, _context, next) => { + ctx.on('agent/pre-step', (_payload, next) => { if (threw) return next() threw = true void fiber.dispose() throw new Error('boom pre-step during disposal') }) const errorEmits: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errorEmits.push(error) }) @@ -926,7 +926,7 @@ describe('turn and step boundary recovery', () => { if (!threw && event.type === 'turn/start') { threw = true; throw new Error('boom turn/start append') } }) const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) @@ -959,7 +959,7 @@ describe('turn and step boundary recovery', () => { if (event.type === 'step/end' && !threw) { threw = true; throw new Error('boom step-end') } }) const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) @@ -1000,7 +1000,7 @@ describe('turn and step boundary recovery', () => { if (!threw && event.type === 'step/end') { threw = true; throw new Error('boom step/end listener') } }) const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) @@ -1215,7 +1215,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) - ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => { + ctx.on('agent/pre-step', async (_payload, next) => { await blocker return next() }) @@ -1261,7 +1261,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) - ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => { + ctx.on('agent/pre-step', async (_payload, next) => { await blocker return next() }) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 273ef022fd..617c305071 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -28,7 +28,7 @@ async function harness(adapter: MockAdapter) { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -120,7 +120,7 @@ describe('thrown-value propagation', () => { }) const errors: unknown[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) + ctx.on('agent/error', ({ error }) => void errors.push(error)) send(agent, 'fails before turn start') send(agent, 'survives as the next item') @@ -143,7 +143,7 @@ describe('thrown-value propagation', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threwOnce = false - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + ctx.on('agent/request', async (_payload, next) => { if (!threwOnce) { threwOnce = true throw { code: 500 } @@ -167,7 +167,7 @@ describe('durable error rendering', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threwOnce = false - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + ctx.on('agent/request', async (_payload, next) => { if (!threwOnce) { threwOnce = true throw new LlmError('server overloaded', 'RATE_LIMIT') @@ -250,7 +250,7 @@ describe('request-error action edges', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('retry-after-cancel'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request-error', async (subject) => { + ctx.on('agent/request-error', async ({ agent: subject }) => { subject.cancel({ kind: 'user' }) return { kind: 'retry' } }) @@ -271,7 +271,7 @@ describe('request-error action edges', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('retry-raced'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request-error', async (subject, _context, signal, next) => { + ctx.on('agent/request-error', async ({ agent: subject, signal }, next) => { await next() subject.cancel({ kind: 'user' }) expect(signal.aborted).toBe(true) @@ -350,7 +350,7 @@ describe('persistent step-close rejection', () => { if (event.type === 'step/end') throw new Error('step close permanently rejected') }) const statuses: string[] = [] - ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) }) + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent) statuses.push(status) }) send(agent, 'go') await agent.whenIdle() @@ -406,7 +406,7 @@ describe('turn close failure containment', () => { } }) const errors: unknown[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) }) + ctx.on('agent/error', ({ error }) => { errors.push(error) }) send(agent, 'go') await agent.whenIdle() @@ -484,11 +484,11 @@ describe('driver bookkeeping edges', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('reject-next-step'), { provider: 'mock', model: 'mock' }) let proposals = 0 - ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => { + ctx.on('agent/pre-step', async (_payload, next) => { proposals += 1 return proposals === 2 ? { kind: 'reject' } : next() }) - ctx.on('agent/turn-stopping', (subject) => { + ctx.on('agent/turn-stopping', ({ agent: subject }) => { subject.inject(createUserMessage({ content: [{ type: 'text', text: 'do not enter the next step' }], source: { kind: 'plugin', plugin: 'test' }, diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 86a3d664c5..a26f463b0d 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -41,7 +41,7 @@ async function harness(adapter: MockAdapter) { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -65,7 +65,7 @@ describe('agent/pre-step', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const seen: string[] = [] - ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { + ctx.on('agent/pre-step', async ({ messages }, next) => { seen.push(messages[0]!.content.map(b => (b.type === 'text' ? b.text : '')).join('')) return next() }) @@ -92,8 +92,8 @@ describe('agent/pre-step', () => { })) const agent = ctx.agentLoop.create(SessionId('prompt-coordinates'), { provider: 'mock', model: 'mock' }) const seen: Array<{ turn: number; step: number; messages: number }> = [] - ctx.on('agent/pre-step', async (_agent, messages, context, next) => { - seen.push({ turn: context.turn, step: context.step, messages: messages.length }) + ctx.on('agent/pre-step', async ({ messages, turn, step }, next) => { + seen.push({ turn, step, messages: messages.length }) return next() }) @@ -113,7 +113,7 @@ describe('agent/pre-step', () => { const entered = Promise.withResolvers() const decision = Promise.withResolvers() const observed: UserMessage[] = [] - ctx.on('agent/pre-step', async (subject, messages) => { + ctx.on('agent/pre-step', async ({ agent: subject, messages }) => { if (subject !== agent) return { kind: 'enter', messages } const message = messages[0]! expect(Object.isFrozen(message)).toBe(true) @@ -161,7 +161,7 @@ describe('agent/pre-step', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/pre-step', async (_agent, messages): Promise => + ctx.on('agent/pre-step', async ({ messages }): Promise => ({ kind: 'enter', messages: [{ ...messages[0]!, content: [{ type: 'text', text: 'REWRITTEN' }] }], @@ -182,7 +182,7 @@ describe('agent/pre-step', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/pre-step', async (_agent, messages): Promise => + ctx.on('agent/pre-step', async ({ messages }): Promise => ({ kind: 'enter', messages: [...messages, createUserMessage({ @@ -211,15 +211,15 @@ describe('agent/pre-step', () => { provider: 'mock', model: 'mock', }) - ctx.on('agent/turn-stopping', (subject) => { + ctx.on('agent/turn-stopping', ({ agent: subject }) => { subject.inject(createUserMessage({ content: [{ type: 'text', text: 'pending context' }], source: { kind: 'plugin', plugin: 'test' }, })) }) - ctx.on('agent/pre-step', async (_subject, _messages, context, next) => { + ctx.on('agent/pre-step', async ({ step }, next) => { const decision = await next() - return context.step === 1 || decision.kind === 'reject' + return step === 1 || decision.kind === 'reject' ? decision : { kind: 'enter', messages: [] } }) @@ -262,7 +262,7 @@ describe('agent/pre-step', () => { const decision = Promise.withResolvers() let claimed: UserMessage[] = [] let firstProposal = true - ctx.on('agent/pre-step', async (_agent, messages) => { + ctx.on('agent/pre-step', async ({ messages }) => { if (!firstProposal) return { kind: 'enter', messages } firstProposal = false claimed = messages @@ -372,14 +372,14 @@ describe('agent/pre-step', () => { provider: 'mock', model: 'mock', }) - ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { + ctx.on('agent/pre-step', async ({ messages }, next) => { const decision = await next() return messages.some(message => message.content.some(block => block.type === 'text' && block.text === 'blocked prompt')) ? { kind: 'reject' as const } : decision }) - ctx.on('agent/pre-step', async (subject, messages, _signal, next) => { + ctx.on('agent/pre-step', async ({ agent: subject, messages }, next) => { if (messages.some(message => message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))) { subject.inject(createUserMessage({ @@ -482,7 +482,7 @@ describe('agent/pre-step', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise => { + ctx.on('agent/pre-step', async ({ messages }, next): Promise => { const text = messages.flatMap(message => message.content) .map(b => (b.type === 'text' ? b.text : '')).join('') return text === 'secret' @@ -519,17 +519,17 @@ describe('agent/pre-step', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threw = false - ctx.on('agent/pre-step', async (_agent, messages) => { + ctx.on('agent/pre-step', async ({ messages }) => { if (!threw) { threw = true; throw new Error('prompt hook broke') } return { kind: 'enter' as const, messages } }) const errors: Error[] = [] const reasons: TurnEndReason[] = [] const statuses: string[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) - ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) }) + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent) statuses.push(status) }) ctx.on('session/event', (session, event) => { if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -559,7 +559,7 @@ describe('agent/session-start', () => { const ctx = await harness(adapter) const sources: SessionStartSource[] = [] - ctx.on('agent/session-start', (_agent, source) => void sources.push(source)) + ctx.on('agent/session-start', ({ source }) => void sources.push(source)) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // fires synchronously at create, before any turn @@ -576,7 +576,7 @@ describe('agent/session-start', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - ctx.on('agent/session-start', (agent) => { + ctx.on('agent/session-start', ({ agent }) => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'session preamble' }], source: { kind: 'plugin', plugin: 'test' } })) }) @@ -724,11 +724,11 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se name: 'native-guard', apply(ctx: Context) { // 1. SessionStart: seed a standing instruction. - ctx.on('agent/session-start', (agent, source) => { + ctx.on('agent/session-start', ({ agent, source }) => { agent.inject(createUserMessage({ content: [{ type: 'text', text: `policy active (started: ${source})` }], source: { kind: 'plugin', plugin: 'native-guard' } })) }) // 2. PreStep: reject a forbidden prompt, annotate the rest. - ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise => { + ctx.on('agent/pre-step', async ({ messages }, next): Promise => { const text = messages.flatMap(message => message.content) .map(b => (b.type === 'text' ? b.text : '')).join('') if (text.includes('rm -rf')) { diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 94e39a08a2..c8d048ca47 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -28,7 +28,7 @@ async function harness(adapter: MockAdapter, persona = '') { /** Wait for the agent's next transition to idle after a waking send. */ function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -216,7 +216,7 @@ describe('agent loop', () => { const adapter = new MockAdapter([textResponse('ok after rescue')]) const ctx = await harness(adapter, 'In {{cwd}}.') const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -263,7 +263,7 @@ describe('agent loop', () => { assembly.variables['model'] = 'mock' return next() }) - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + ctx.on('agent/request', async (_payload, next) => { const config = await next() return { ...config, provider: 'mock', model: 'mock' } }) @@ -553,7 +553,7 @@ describe('agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' }) let fail = true - ctx.on('agent/pre-step', (subject, _messages, _context, next) => { + ctx.on('agent/pre-step', ({ agent: subject }, next) => { if (subject !== agent || !fail) return next() fail = false subject.steer(createUserMessage({ content: [{ type: 'text', text: 'pending steering' }], source: { kind: 'user' } })) @@ -713,7 +713,7 @@ describe('agent loop', () => { let steps = 0 ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) - ctx.on('agent/turn-stopping', (subject) => { + ctx.on('agent/turn-stopping', ({ agent: subject }) => { if (steps < 3) { subject.steer(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'plugin', plugin: 'loop-test' } })) } @@ -785,7 +785,7 @@ describe('agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + ctx.on('agent/request', async (_payload, next) => { const config = await next() // The seed is frozen — config is not a mutable per-call knob; a switch // is proposed by returning a replacement, and the loop logs it. @@ -816,7 +816,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const fires: { turn: number; step: number; signal: AbortSignal }[] = [] - ctx.on('agent/pre-step', (subject, _messages, { turn, step, signal }, next) => { + ctx.on('agent/pre-step', ({ agent: subject, turn, step, signal }, next) => { if (subject === agent) fires.push({ turn, step, signal }) return next() }) @@ -837,7 +837,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let boundaryOpen = true - ctx.on('agent/pre-step', (subject, _messages, _context, next) => { + ctx.on('agent/pre-step', ({ agent: subject }, next) => { if (subject === agent) boundaryOpen = subject.session.events.at(-1)?.type === 'step/start' return next() }) @@ -855,13 +855,13 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let throwOnce = true - ctx.on('agent/pre-step', (_agent, _messages, _context, next) => { + ctx.on('agent/pre-step', (_payload, next) => { if (throwOnce) { throwOnce = false; throw new Error('boom in pre-step') } return next() }) const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) @@ -933,7 +933,7 @@ describe('agent loop', () => { ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) // Force exactly one continuation (step 1 → step 2), then defer to default // (step 2 is a plain stop with no tool calls → stops). - ctx.on('agent/turn-stopping', (subject) => { + ctx.on('agent/turn-stopping', ({ agent: subject }) => { if (steps < 2) { subject.steer(createUserMessage({ content: [{ type: 'text', text: 'continue after truncation' }], source: { kind: 'plugin', plugin: 'max-tokens-test' } })) } @@ -1296,7 +1296,7 @@ describe('agent loop', () => { const errors: unknown[] = [] const reasons: TurnEndReason[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { + ctx.on('agent/error', ({ error }) => { errors.push(error) }) ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index c5dec8c142..0add31bd1f 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -50,7 +50,7 @@ async function harness() { /** Resolve on the agent's next transition to idle (event-based, not polled). */ function nextIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -63,7 +63,7 @@ function nextIdle(ctx: Context, agent: Agent): Promise { * the seen list plus a disposer for the listener (per the registry convention). */ function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: () => void } { const seen: string[] = [] - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent) seen.push(status) }) return { seen, dispose } diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index 287badfc15..0c7c65e483 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -59,7 +59,7 @@ async function loopHarness(): Promise { function waitForIdle(context: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = context.on('agent/status', (subject, status) => { + const dispose = context.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index 96b6bfc045..d143bd79ae 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -62,12 +62,12 @@ describe('agent/request-error', () => { retryPolicy: ResolvedRetryPolicy | undefined }[] = [] const statuses: string[] = [] - ctx.on('agent/status', (subject, status) => { + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent) statuses.push(status) }) - ctx.on('agent/request-error', async (subject, context) => { + ctx.on('agent/request-error', async ({ agent: subject, turn, step, failure, retryPolicy }) => { expect(subject).toBe(agent) - seen.push(context) + seen.push({ turn, step, failure, retryPolicy }) return { kind: 'retry' } }) @@ -102,7 +102,7 @@ describe('agent/request-error', () => { const adapter = new MockAdapter([fail('busy', 'RATE_LIMIT'), textResponse('unused')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('request-error-cancel'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request-error', async (subject) => { + ctx.on('agent/request-error', async ({ agent: subject }) => { subject.cancel({ kind: 'user' }) return { kind: 'retry' } }) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 565bb73f63..62a7ae5e71 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -38,7 +38,7 @@ async function harnessRoutes( function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -122,7 +122,7 @@ describe('request stability across the loop', () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')], reasoning) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('effort'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => { + ctx.on('agent/request', async ({ turn }, next) => { const config = await next() return turn === 2 ? { ...config, reasoningEffort: ReasoningEffortId('max') } : config }) @@ -198,7 +198,7 @@ describe('request stability across the loop', () => { provider: 'deepseek', model: 'deepseek-model', }) - ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => { + ctx.on('agent/request', async ({ turn }, next) => { const config = await next() return turn === 2 ? { ...config, provider: 'other', model: 'other-model' } @@ -232,7 +232,7 @@ describe('request stability across the loop', () => { model: 'deepseek-model', maxTokens: 4_096, }) - ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => { + ctx.on('agent/request', async ({ turn }, next) => { const config = await next() return turn === 2 ? { ...config, provider: 'other', model: 'other-model' } @@ -460,7 +460,7 @@ describe('request stability across the loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let injected = false - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + ctx.on('agent/request', async (_payload, next) => { if (!injected) { injected = true agent.inject(createUserMessage({ content: [{ type: 'text', text: '[late context]' }], source: { kind: 'plugin', plugin: 'test' } })) @@ -539,7 +539,7 @@ describe('request stability across the loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + ctx.on('agent/request', async (_payload, next) => { const config = await next() // next() resolves the SAME frozen seed — in-place shaping after // delegation is unrepresentable, so a "mutate what next() returned" @@ -576,7 +576,7 @@ describe('request stability across the loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'now with guidance' }) - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ + ctx.on('agent/request', async (_payload, next) => ({ ...await next(), temperature: 0.5, maxTokens: 99, stop: [''], })) send(agent, 'again') @@ -658,7 +658,7 @@ describe('request/context capacity records', () => { send(agent, 'first') await waitForIdle(ctx, agent) - ctx.on('agent/request', (subject, _turn, _step, _signal, next) => subject === agent + ctx.on('agent/request', ({ agent: subject }, next) => subject === agent ? Promise.resolve({ provider: 'mock', model: 'large' }) : next()) send(agent, 'second') @@ -686,7 +686,7 @@ describe('request/context capacity records', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('capacity-clear'), { provider: 'mock', model: 'known' }) let model = 'known' - ctx.on('agent/request', (subject, _turn, _step, _signal, next) => subject === agent + ctx.on('agent/request', ({ agent: subject }, next) => subject === agent ? Promise.resolve({ provider: 'mock', model }) : next()) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 62f9e9059e..964ef82aa6 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -66,7 +66,7 @@ function preparationFromSnapshot( function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose(); resolve() } }) }) @@ -260,7 +260,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const adapter1 = new MockAdapter([textResponse('a')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const sources1: string[] = [] - ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source)) + ctx1.on('agent/session-start', ({ source }) => void sources1.push(source)) const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent expect(sources1).toEqual(['startup']) a1.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })) @@ -279,7 +279,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) const sources2: string[] = [] - ctx2.on('agent/session-start', (_agent, source) => void sources2.push(source)) + ctx2.on('agent/session-start', ({ source }) => void sources2.push(source)) await ctx2.agents.resume({ resumeSessionId: SessionId('start-sess') }) expect(sources2).toEqual(['resume']) await ctx2.fiber.dispose() @@ -298,11 +298,11 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', expect(ctx.agents.get(sessionId)?.session).toBe(session) order.push('session/created') }) - ctx.on('agent/created', (agent) => { + ctx.on('agent/created', ({ agent }) => { expect(agent.status).toBe('idle') order.push('agent/created') }) - ctx.on('agent/session-start', (agent) => { + ctx.on('agent/session-start', ({ agent }) => { expect(() => { agent.cancel({ kind: 'user' }) }).not.toThrow() order.push('agent/session-start') }) @@ -882,7 +882,7 @@ describe('configured-start failure edges', () => { configured.llm.registerAdapter(['mock'], new MockAdapter([])) configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal) const configFailures: unknown[] = [] - configured.on('agent-loop/config-start-failed', (_id, error) => { configFailures.push(error) }) + configured.on('agent-loop/config-start-failed', ({ error }) => { configFailures.push(error) }) const configWarnings: string[] = [] const configWarn = configured.logger.warn.bind(configured.logger) configured.logger.warn = ((...args: unknown[]) => { @@ -915,7 +915,7 @@ describe('configured-start failure edges', () => { return gate.promise } const failures: unknown[] = [] - ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) + ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) const configured = new Context() await configured.plugin(LlmService) @@ -926,7 +926,7 @@ describe('configured-start failure edges', () => { await configured.plugin(SessionPersistenceJsonl, { root }) configured.llm.registerAdapter(['mock'], new MockAdapter([])) configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal) - configured.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) + configured.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) const loop = await configured.plugin(AgentLoop, { agents: [{ id: 'main', resumeSessionId: sessionId, provider: 'mock', model: 'mock' }], }) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index a618a130cf..3f3e0a43d8 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -31,7 +31,7 @@ async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok' function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -199,7 +199,7 @@ describe('agent scope lifecycle', () => { const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' }) const heard: string[] = [] - a.ctx.on('agent/status', (subject, status) => void heard.push(`a-sees:${subject.id}:${status}`)) + a.ctx.on('agent/status', ({ agent: subject, status }) => void heard.push(`a-sees:${subject.id}:${status}`)) a.ctx.on('session/event', (_s, event) => { if (event.type === 'user/message') heard.push('a-sees:user-message') }) @@ -217,7 +217,7 @@ describe('agent scope lifecycle', () => { it('runs setup in the guaranteed slot: scoped world complete before session-start and the first assembly', async () => { const ctx = await harness() const order: string[] = [] - ctx.on('agent/session-start', (agent) => { + ctx.on('agent/session-start', ({ agent }) => { order.push('session-start') // The scoped section is already registered by the time session-start fires. void ctx.systemPrompt.assemble(assembleContextFor(agent)).then((assembly) => { @@ -673,19 +673,19 @@ describe('agent scope lifecycle', () => { ctx.on('session/created', (session) => { if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created') }) - ctx.on('agent/created', (agent) => { + ctx.on('agent/created', ({ agent }) => { if (agent.id !== SessionId('agent-created-barrier-s')) return lifecycle.push('agent-created:dispose') disposeCurrentLifecycle(ownerCtx) }) - ctx.on('agent/created', (agent) => { + ctx.on('agent/created', ({ agent }) => { if (agent.id !== SessionId('agent-created-barrier-s')) return expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') }) lifecycle.push('agent-created:observer') }) - ctx.on('agent/disposed', (agent) => { + ctx.on('agent/disposed', ({ agent }) => { if (agent.id === SessionId('agent-created-barrier-s')) lifecycle.push('agent-disposed') }) ctx.on('session/disposed', (session) => { @@ -720,8 +720,8 @@ describe('agent scope lifecycle', () => { const starts: string[] = [] let ownerCtx!: Context let creating!: ReturnType - ctx.on('agent/session-start', agent => void starts.push(agent.id)) - ctx.on('agent/created', (agent) => { + ctx.on('agent/session-start', ({ agent }) => void starts.push(agent.id)) + ctx.on('agent/created', ({ agent }) => { if (agent.id === SessionId('listener-dispose-s')) disposeCurrentLifecycle(ownerCtx) }) @@ -749,15 +749,15 @@ describe('agent scope lifecycle', () => { const statuses: string[] = [] let scopeDisposed = false let observerSawLive = false - ctx.on('agent/status', (agent, status) => { + ctx.on('agent/status', ({ agent, status }) => { if (agent.id === SessionId('session-start-dispose-s')) statuses.push(status) }) - ctx.on('agent/session-start', (agent) => { + ctx.on('agent/session-start', ({ agent }) => { if (agent.id !== SessionId('session-start-dispose-s')) return announced = agent disposeCurrentLifecycle(ownerCtx) }) - ctx.on('agent/session-start', (agent) => { + ctx.on('agent/session-start', ({ agent }) => { if (agent.id !== SessionId('session-start-dispose-s')) return expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) @@ -840,7 +840,7 @@ describe('agent scope lifecycle', () => { const ctx = await harness() let boom = true const disposed: string[] = [] - ctx.on('agent/disposed', agent => void disposed.push(agent.id)) + ctx.on('agent/disposed', ({ agent }) => void disposed.push(agent.id)) ctx.on('session/created', () => { if (boom) { boom = false; throw new Error('boom created') } }) @@ -861,11 +861,11 @@ describe('agent scope lifecycle', () => { const lifecycle: string[] = [] ctx.on('session/created', (session) => { lifecycle.push(`session-created:${session.id}`) }) ctx.on('session/disposed', (session) => { lifecycle.push(`session-disposed:${session.id}`) }) - ctx.on('agent/created', (agent) => { + ctx.on('agent/created', ({ agent }) => { lifecycle.push(`agent-created:${agent.id}`) throw new Error('agent observer failed') }) - ctx.on('agent/disposed', (agent) => { lifecycle.push(`agent-disposed:${agent.id}`) }) + ctx.on('agent/disposed', ({ agent }) => { lifecycle.push(`agent-disposed:${agent.id}`) }) await expect(ctx.agents.create({ sessionId: SessionId('partial-session'), @@ -911,10 +911,10 @@ describe('agent scope lifecycle', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' }) const heard: string[] = [] - agent.ctx.on('agent/error', (subject: Agent, turn: number) => void heard.push(`${subject.id}:${turn}`)) + agent.ctx.on('agent/error', ({ agent: subject, turn }) => void heard.push(`${subject.id}:${turn}`)) - agentEvents(ctx, other).emit('agent/error', 1, 0, new Error('not for a1')) - agentEvents(ctx, agent).emit('agent/error', 2, 0, new Error('for a1')) + agentEvents(ctx, other).emit('agent/error', { turn: 1, step: 0, error: new Error('not for a1') }) + agentEvents(ctx, agent).emit('agent/error', { turn: 2, step: 0, error: new Error('for a1') }) expect(heard).toEqual(['a1:2']) }) @@ -1064,7 +1064,7 @@ describe('agent scope lifecycle', () => { }) const agent = handle.agent let reentered = false - ctx.on('agent/status', (subject, status) => { + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject !== agent || status !== 'idle' || reentered) return reentered = true agent.followup(createUserMessage({ content: [{ type: 'text', text: 'reentrant' }], source: { kind: 'user' } })) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index f3548cca52..89b2e300bd 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -31,7 +31,7 @@ async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose(); resolve() } }) }) diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 8aec38004f..9fa321697a 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -33,7 +33,7 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 4cfc5328e8..5c03669baf 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -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 packages/core/agent/README.md -README.md: c3d6e6c24480894b6059417c1ab89db7aa0d7fa2 -README.zh.md: 16ee8f5e6c483555839b0c3ab174e2e2356b1359 +README.md: 2a69ab380eaad3929e27039582807037969eba64 +README.zh.md: 176f3f75cf0f6e3309b2f5d34afb4d562105608e diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index c3d6e6c244..2a69ab380e 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -50,7 +50,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. -Most interception points are cooperative waterfalls. `agent/pre-step` receives the exclusive claimed `UserMessage[]` plus a `PreStepContext` containing the proposed `turn`, `step`, and cancellation `signal`; its batch may be empty when tools already require another request. Other turn-scoped asynchronous seams receive their explicit `AbortSignal` positionally. Listeners may cooperate with a signal but must not retain it as authority over another turn. `agent/request-error` is the failed-model-request recovery waterfall: it receives request coordinates, normalized failure facts, the serving registration's retry policy when available, and the signal. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery. `agent/turn-stopping` runs before an otherwise completed turn closes. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. +Most interception points are cooperative waterfalls. `agent/pre-step` receives a payload carrying the subject `agent`, the exclusive claimed `UserMessage[]`, and the proposed `turn`, `step`, and cancellation `signal`; its batch may be empty when tools already require another request. Agent-scoped turn seams carry their explicit `AbortSignal` in the payload; the remaining turn-scoped seams receive it through their request value. Listeners may cooperate with a signal but must not retain it as authority over another turn. `agent/request-error` is the failed-model-request recovery waterfall: it receives request coordinates, normalized failure facts, the serving registration's retry policy when available, and the signal. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery. `agent/turn-stopping` runs before an otherwise completed turn closes. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. `PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages }`. The enter branch is the complete identified, frozen batch for the proposed step. A listener that wraps downstream entry preserves that batch unless it intentionally replaces it; additions follow the waterfall's natural return order. Claiming already removed the offered messages from the inbox, so rejection does not retain them. Messages inserted after the claim remain pending for a later boundary. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 16ee8f5e6c..176f3f75cf 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -50,7 +50,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, 生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器完全停稳后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。 -大多数拦截点都是协作式 waterfall(瀑布式事件)。`agent/pre-step` 接收独占的已领取 `UserMessage[]`,以及包含拟进入 `turn`、`step` 与取消 `signal` 的 `PreStepContext`;当工具已经要求继续请求时,该批次可以为空。其他轮次作用域异步 seam 仍按位置接收显式 `AbortSignal`。监听器可以配合信号,但不得将它保留为控制另一轮次的权限。`agent/request-error` 是失败模型请求的恢复 waterfall:它接收请求坐标、规范化失败事实、可用时提供服务的注册项重试策略以及信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`。`agent/turn-stopping` 在本可完成的轮次关闭前运行。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。 +大多数拦截点都是协作式 waterfall(瀑布式事件)。`agent/pre-step` 接收一个 payload,携带主体 `agent`、独占的已领取 `UserMessage[]` 以及拟进入的 `turn`、`step` 与取消 `signal`;当工具已经要求继续请求时,该批次可以为空。agent 作用域轮次 seam 在 payload 中携带显式 `AbortSignal`;其余轮次作用域 seam 通过其请求值接收它。监听器可以配合信号,但不得将它保留为控制另一轮次的权限。`agent/request-error` 是失败模型请求的恢复 waterfall:它接收请求坐标、规范化失败事实、可用时提供服务的注册项重试策略以及信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`。`agent/turn-stopping` 在本可完成的轮次关闭前运行。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。 `PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages }`。enter 分支是拟进入步骤的完整、带标识且冻结的批次。包装下游 enter 的监听器会保留该批次,除非有意替换它;新增消息遵循 waterfall 的自然返回顺序。领取操作已经把候选消息从 inbox 删除,因此 reject 不会保留它们;领取后插入的消息仍等待后续边界。 diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index b28586b6b8..cf07f24ecf 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -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 */ @@ -17,25 +18,38 @@ type Params = F extends (...args: infer P) => unknown ? P : never 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. + * The event names whose subject is an agent: the handler's first parameter is + * a payload object carrying the `agent` subject AND the handler declares a + * `Scoped` `this` (the scope-carrier contract). The `this` check keeps + * accidental payload-happens-to-carry-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 + ? P extends [infer Payload, ...unknown[]] + ? Payload extends { agent: Agent } ? K : never + : never : never }[keyof Events] -/** The event arguments AFTER the injected agent subject. */ -type Tail = Params extends [Agent, ...infer R] ? R : never +/** The full payload object of one agent-subject event. */ +type PayloadOf = Params extends [infer Payload, ...unknown[]] ? Payload : never + +/** The event arguments AFTER the payload: the waterfall `next` when present. */ +type Tail = Params extends [unknown, ...infer R] ? R : never + +/** + * The payload as emit-side callers pass it: the full payload minus the agent + * field, which the fused dispatcher injects so subject and scope key cannot + * diverge. + */ +type PayloadRest = Omit & object, 'agent'> /** * 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. + * the agent itself injected into the payload. */ export interface AgentEventDispatch { /** @@ -44,30 +58,36 @@ export interface AgentEventDispatch { * 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. + * @param payload - the event's payload fields; `agent` is injected. */ - emit(name: K, ...rest: Tail): void + emit(name: K, payload: PayloadRest): 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. + * @param payload - the event's payload fields; `agent` is injected. * @returns the serial chain's result (the first bail value, if any). */ - serial(name: K, ...rest: Tail): Promise>> + serial(name: K, payload: PayloadRest): 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). + * is exactly the event's arguments after the payload — 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. + * @param payload - the event's payload fields; `agent` is injected. + * @param rest - the event's arguments after the payload (the `next` callback). * @returns the waterfall's composed result. */ - waterfall(name: K, ...rest: Tail): Return + waterfall(name: K, payload: PayloadRest, ...rest: Tail): Return } /** - * Return the fused scope carrier for one agent subject. + * Build the fused scope carrier for one agent subject. + * + * 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. */ @@ -79,22 +99,30 @@ export function agentCarrier(agent: Agent): Scoped { * 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 = agentCarrier(agent)): AgentEventDispatch { // The ordinary dispatch methods forward through Cordis' variadic mixins. The - // fused (carrier, name, agent, ...rest) tuple is provably a valid argument + // fused (carrier, name, payload, ...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. + const fused = (payload: PayloadRest): PayloadOf => + // The dispatcher owns the subject injection; callers pass PayloadRest, so + // 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) return { - emit(name, ...rest) { + emit(name, payload) { // 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 args: unknown[] = [carrier, name, fused(payload)] const callbacks = ctx.events.dispatch('emit', args) for (const callback of callbacks) { try { @@ -107,15 +135,15 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { } } }, - async serial(name, ...rest) { + async serial(name, payload) { // oxlint-disable-next-line typescript/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) + return await serial(carrier, name, fused(payload)) }, - waterfall(name, ...rest) { + waterfall(name, payload, ...rest) { // oxlint-disable-next-line typescript/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) + return waterfall(carrier, name, fused(payload), ...rest) }, } } @@ -125,15 +153,15 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { * @param ctx - the context to dispatch through. * @param agent - the subject agent and scope key. * @param name - the agent-subject event to emit. - * @param rest - the event arguments after the injected agent. + * @param payload - the event's payload fields; `agent` is injected. */ export function emitAgentEvent( ctx: Context, agent: Agent, name: K, - ...rest: Tail + payload: PayloadRest, ): void { - agentEvents(ctx, agent).emit(name, ...rest) + agentEvents(ctx, agent).emit(name, payload) } /** diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 0a16a2bf53..55cb94d8f9 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -498,7 +498,7 @@ export class AgentRegistry extends Service { /** Emit the paired disposal edge through the entry's stable carrier. */ private emitDisposed(entry: AgentEntry): void { - const args: unknown[] = [entry.carrier, 'agent/disposed', entry.agent] + const args: unknown[] = [entry.carrier, 'agent/disposed', { agent: entry.agent }] for (const callback of this.ctx.events.dispatch('emit', args)) { try { const returned: unknown = callback(...args) @@ -530,7 +530,7 @@ export class AgentRegistry extends Service { // lifecycle edge; detach still pairs a partially delivered first edge. entry.announcing = true entry.announced = true - const args: unknown[] = [entry.carrier, 'agent/created', entry.agent] + const args: unknown[] = [entry.carrier, 'agent/created', { agent: entry.agent }] try { for (const callback of this.ctx.events.dispatch('emit', args)) { // A synchronous creation failure vetoes publication and rolls back. diff --git a/packages/core/agent/src/invariant.ts b/packages/core/agent/src/invariant.ts index f2d9a69539..a561e862cb 100644 --- a/packages/core/agent/src/invariant.ts +++ b/packages/core/agent/src/invariant.ts @@ -14,7 +14,7 @@ export const inject = ['invariants'] /** Install the agent contribution into its child registration fiber. */ const install: InvariantInstaller = (ctx, fail) => { const lastStatus = new WeakMap() - ctx.on('agent/status', (agent, status) => { + ctx.on('agent/status', ({ agent, status }) => { const previous = lastStatus.get(agent) if (previous === status) { fail(`agent/status repeated ${status} (no-op transition)`) diff --git a/packages/core/agent/src/llm-target.ts b/packages/core/agent/src/llm-target.ts index 7b5d1a4df6..e23ea9d750 100644 --- a/packages/core/agent/src/llm-target.ts +++ b/packages/core/agent/src/llm-target.ts @@ -53,7 +53,7 @@ export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetR }) const disposeRequest = agentCtx.on( 'agent/request', - async (_agent, _turn, _step, _signal, next): Promise => { + async (_payload, next): Promise => { const resolved = await next() const selected = target.assembled if (selected === undefined) return resolved diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index e634762494..fae9267347 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -48,35 +48,11 @@ export interface CancelOptions { */ export type AgentStatus = 'idle' | 'running' -/** Coordinates and cancellation for a proposed step. */ -export interface PreStepContext { - /** Turn that will own the step. */ - readonly turn: number - /** Step proposed by the loop. */ - readonly step: number - /** Current turn cancellation signal. */ - readonly signal: AbortSignal -} - /** Whether and with which messages the loop enters a proposed step. */ export type PreStepDecision = | { kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] } -/** One failed model-request attempt presented to recovery listeners. */ -export interface RequestFailureContext { - /** Turn containing the failed request. */ - readonly turn: number - /** Step containing the failed request attempt. */ - readonly step: number - /** Provider selected for the failed request. */ - readonly provider: string - /** Serializable facts normalized at the final adapter boundary. */ - readonly failure: LlmFailure - /** Policy of the adapter registration that served the failed request. */ - readonly retryPolicy: ResolvedRetryPolicy | undefined -} - /** Action returned by a listener that owns model-request recovery. */ export type RequestErrorAction = { kind: 'retry' } | undefined @@ -171,105 +147,112 @@ declare module 'cordis' { * Synchronous listener failure vetoes publication, while returned-promise * rejection is reported. Detach requested during dispatch waits until every * creation listener has observed the stable entry. - * @param agent - the newly registered agent with its live session and completed setup. + * @param payload.agent - the newly registered agent with its live session and completed setup. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/created'(this: Scoped, agent: Agent): void + 'agent/created'(this: Scoped, payload: { agent: Agent }): void /** * An agent left the registry; AgentLoop emits this after driver quiescence * and scoped-registration unwind, but before session detachment. Custom * registry users own their driver-ordering contract. - * @param agent - the exact agent removed from the registry. + * @param payload.agent - the exact agent removed from the registry. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/disposed'(this: Scoped, agent: Agent): void + 'agent/disposed'(this: Scoped, payload: { agent: Agent }): void /** * Agent status changed (`idle` ⇄ `running`). A waking delivery enters * `running` synchronously after reserving cancellation; `idle` means no * driver remains scheduled or active. - * @param agent - the agent whose status flipped. - * @param status - the status just entered (the transition's destination). + * @param payload.agent - the agent whose status flipped. + * @param payload.status - the status just entered (the transition's destination). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void + 'agent/status'(this: Scoped, payload: { agent: Agent; status: AgentStatus }): void /** * One message entered the live inbox. - * @param agent - the agent whose inbox changed. - * @param event - the inserted message. + * @param payload.agent - the agent whose inbox changed. + * @param payload.message - the inserted message. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/inserted'(this: Scoped, agent: Agent, event: { message: UserMessage }): void + 'agent/inbox/inserted'(this: Scoped, payload: { agent: Agent; message: UserMessage }): void /** * One message left the inbox inside its open turn. If the proposed step * is rejected, the claimed message ends here: it is neither discarded nor * re-emitted as a user/message, and the turn closes without a step. - * @param agent - the agent whose inbox changed. - * @param event - the claimed message and owning turn. + * @param payload.agent - the agent whose inbox changed. + * @param payload.message - the claimed message. + * @param payload.turn - the owning turn. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/claimed'(this: Scoped, agent: Agent, event: { message: UserMessage; turn: number }): void + 'agent/inbox/claimed'(this: Scoped, payload: { agent: Agent; message: UserMessage; turn: number }): void /** * One message was discarded from the live inbox. - * @param agent - the agent whose inbox changed. - * @param event - the discarded message. + * @param payload.agent - the agent whose inbox changed. + * @param payload.message - the discarded message. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/discarded'(this: Scoped, agent: Agent, event: { message: UserMessage }): void + 'agent/inbox/discarded'(this: Scoped, payload: { agent: Agent; message: UserMessage }): void // ---- session lifecycle (emit) ---- /** * The session lifecycle began, once before the first turn. Use * `agent.inject()` to seed model-facing context. This is a notification, not * a veto; disposal requested by a lifecycle owner is rechecked before the * driver starts. - * @param agent - the agent whose session lifecycle began. - * @param source - why the session started (fresh startup, resume, …). + * @param payload.agent - the agent whose session lifecycle began. + * @param payload.source - why the session started (fresh startup, resume, …). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/session-start'(this: Scoped, agent: Agent, source: SessionStartSource): void + 'agent/session-start'(this: Scoped, payload: { agent: Agent; source: SessionStartSource }): void // ---- the machine's extension seams ---- /** * Reject a proposed step or replace the messages that enter it. Calling * `next()` preserves the current messages. - * @param agent - the agent proposing the step. - * @param messages - messages removed from the inbox for this step. - * @param context - proposed turn and step coordinates plus cancellation. + * @param payload.agent - the agent proposing the step. + * @param payload.messages - messages removed from the inbox for this step. + * @param payload.turn - the turn that will own the step. + * @param payload.step - the step proposed by the loop. + * @param payload.signal - the current turn's cancellation signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ - 'agent/pre-step'(this: Scoped, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise): Promise + 'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise /** * Replace the frozen call configuration. `await next()` yields the config * the machine would use (agent options on the first request, the logged * header afterwards); return a replacement to switch. Model-visible * content must use logged channels; this seam cannot mutate messages. - * @param agent - the agent making the model call. - * @param turn - the open turn number. - * @param step - the step whose request this is. - * @param signal - the current turn's explicit abort signal. + * @param payload.agent - the agent making the model call. + * @param payload.turn - the open turn number. + * @param payload.step - the step whose request this is. + * @param payload.signal - the current turn's explicit abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ - 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise): Promise + 'agent/request'(this: Scoped, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise /** * Handle one failed model-request attempt before the loop retries or closes * its step. A listener returns `{ kind: 'retry' }` without calling `next()` * when it owns recovery, or calls `next()` to delegate. The default * `undefined` leaves the failure terminal. - * @param agent - the agent whose request failed. - * @param context - request coordinates, provider, normalized failure, and serving policy. - * @param signal - the turn abort signal. + * @param payload.agent - the agent whose request failed. + * @param payload.turn - the turn containing the failed request. + * @param payload.step - the step containing the failed request attempt. + * @param payload.provider - the provider selected for the failed request. + * @param payload.failure - serializable facts normalized at the final adapter boundary. + * @param payload.retryPolicy - the policy of the adapter registration that served the failed request. + * @param payload.signal - the turn abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ - 'agent/request-error'(this: Scoped, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise): Promise + 'agent/request-error'(this: Scoped, payload: { agent: Agent; turn: number; step: number; provider: string; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined; signal: AbortSignal }, next: () => Promise): Promise /** * The turn is about to close: the model owes no response (no live tool * calls, no fresh steering). Awaited before the boundary commits — a @@ -281,25 +264,25 @@ declare module 'cordis' { * never short-circuits already-submitted next-step work: same-step * `additionalContexts` or racing steering still runs, and the turn * closes only when that inbox drains. - * @param agent - the agent whose turn is at its stop boundary. - * @param turn - the turn about to close. - * @param signal - the current turn's explicit abort signal. + * @param payload.agent - the agent whose turn is at its stop boundary. + * @param payload.turn - the turn about to close. + * @param payload.signal - the current turn's explicit abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode serial */ - 'agent/turn-stopping'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | void + 'agent/turn-stopping'(this: Scoped, payload: { agent: Agent; turn: number; signal: AbortSignal }): Promise | void // ---- error notifications (emit) ---- /** * A step or turn errored. The machine reports a failure here even when * the error has no in-turn position for a durable record. - * @param agent - the agent whose turn errored. - * @param turn - the turn in which the failure surfaced. - * @param step - the step at which the failure surfaced. - * @param error - the failure, verbatim. + * @param payload.agent - the agent whose turn errored. + * @param payload.turn - the turn in which the failure surfaced. + * @param payload.step - the step at which the failure surfaced. + * @param payload.error - the failure, verbatim. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: unknown): void + 'agent/error'(this: Scoped, payload: { agent: Agent; turn: number; step: number; error: unknown }): void } } diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 313850faa4..e80d575aeb 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -11,6 +11,7 @@ import type { Agent, AgentCancelCause, AgentFactory, + AgentStatus, CreateAgentOptions, ResumeAgentOptions, } from '@deepseek-ai/dsh-agent' @@ -145,8 +146,8 @@ describe('AgentRegistry', () => { const ctx = new Context() await ctx.plugin(AgentRegistry) const lifecycle: string[] = [] - ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`)) - ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) + ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`)) + ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`)) const agent = stubAgent('a1') const dispose = ctx.agents.register(agent) @@ -195,9 +196,9 @@ describe('AgentRegistry', () => { const ctx = new Context() await ctx.plugin(AgentRegistry) const lifecycle: string[] = [] - ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`)) + ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`)) ctx.on('agent/created', () => { throw new Error('creation veto') }) - ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) + ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`)) expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto') expect(ctx.agents.get(SessionId('vetoed'))).toBeUndefined() @@ -213,7 +214,7 @@ describe('AgentRegistry', () => { ctx.on('agent/created', () => Promise.reject(new Error('created async')) as never) ctx.on('agent/disposed', () => { throw new Error('disposed sync') }) ctx.on('agent/disposed', () => Promise.reject(new Error('disposed async')) as never) - ctx.on('agent/disposed', agent => void heard.push(agent.id)) + ctx.on('agent/disposed', ({ agent }) => void heard.push(agent.id)) const dispose = ctx.agents.register(stubAgent('contained')) await Promise.resolve() @@ -232,8 +233,8 @@ describe('AgentRegistry', () => { const ctx = new Context() await ctx.plugin(AgentRegistry) const lifecycle: string[] = [] - ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`)) - ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) + ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`)) + ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`)) const first = stubAgent('split') const detachFirst = ctx.agents.enter(first, undefined) @@ -280,9 +281,9 @@ describe('agentEvents()', () => { const agent = stubAgent('event') ctx.on('agent/status', () => { throw new Error('sync listener') }) ctx.on('agent/status', () => Promise.reject(new Error('async listener')) as never) - ctx.on('agent/status', (_agent, status) => void heard.push(status)) + ctx.on('agent/status', ({ status }) => void heard.push(status)) - agentEvents(ctx, agent).emit('agent/status', 'running') + agentEvents(ctx, agent).emit('agent/status', { status: 'running' }) await Promise.resolve() expect(heard).toEqual(['running']) expect(warnings).toEqual([ @@ -296,15 +297,30 @@ describe('agentEvents()', () => { const agent = stubAgent('serial-event') const signal = new AbortController().signal const heard: Array<{ agent: Agent; turn: number; signal: AbortSignal }> = [] - ctx.on('agent/turn-stopping', async (subject, turn, receivedSignal) => { + ctx.on('agent/turn-stopping', async ({ agent: subject, turn, signal: receivedSignal }) => { await Promise.resolve() heard.push({ agent: subject, turn, signal: receivedSignal }) }) - await agentEvents(ctx, agent).serial('agent/turn-stopping', 3, signal) + await agentEvents(ctx, agent).serial('agent/turn-stopping', { turn: 3, signal }) expect(heard).toEqual([{ agent, turn: 3, signal }]) }) + + it('injects the fused subject even when the payload carries a conflicting agent field', async () => { + const ctx = new Context() + const agent = stubAgent('fused-subject') + const other = stubAgent('payload-agent') + const heard: Agent[] = [] + ctx.on('agent/status', ({ agent: subject }) => void heard.push(subject)) + // A structurally acceptable payload may carry an extra `agent` field; the + // dispatcher's injected subject must win over it. + const payload: { status: AgentStatus; agent: Agent } = { status: 'running', agent: other } + + agentEvents(ctx, agent).emit('agent/status', payload) + + expect(heard).toEqual([agent]) + }) }) describe('explicit cancellation contract', () => { diff --git a/packages/core/agent/tests/invariant.spec.ts b/packages/core/agent/tests/invariant.spec.ts index 158376a3d7..458a10714d 100644 --- a/packages/core/agent/tests/invariant.spec.ts +++ b/packages/core/agent/tests/invariant.spec.ts @@ -21,17 +21,17 @@ describe('agent status invariants', () => { const ctx = await setup() const agent = mockAgent('a1') expect(() => { - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') + ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'idle' }) + ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'running' }) + ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'idle' }) }).not.toThrow() }) it('rejects a no-op transition', async () => { const ctx = await setup() const agent = mockAgent('a3') - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') - expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') }) + ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'running' }) + expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'running' }) }) .toThrow(/no-op transition/) }) @@ -39,7 +39,7 @@ describe('agent status invariants', () => { const ctx = await setup() const a = mockAgent('a5') const b = mockAgent('b5') - ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running') - expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow() + ctx.emit(scopeTarget(a, a), 'agent/status', { agent: a, status: 'running' }) + expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', { agent: b, status: 'running' }) }).not.toThrow() }) }) diff --git a/packages/core/agent/tests/llm-target.spec.ts b/packages/core/agent/tests/llm-target.spec.ts index d3ec2f96bd..991a69ea32 100644 --- a/packages/core/agent/tests/llm-target.spec.ts +++ b/packages/core/agent/tests/llm-target.spec.ts @@ -21,7 +21,7 @@ describe('installAgentLlmTarget()', () => { expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 1, 0, signal, () => Promise.resolve(seed), + 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed), )).resolves.toBe(seed) target.current = { @@ -32,7 +32,7 @@ describe('installAgentLlmTarget()', () => { expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' }) target.current = { provider: 'beta', model: 'b1' } await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 1, 0, signal, () => Promise.resolve(seed), + 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed), )).resolves.toEqual({ provider: 'alpha', model: 'a1', @@ -48,13 +48,13 @@ describe('installAgentLlmTarget()', () => { temperature: 0.2, } await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 1, 1, signal, () => Promise.resolve(inherited), + 'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(inherited), )).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 }) dispose() expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 2, 0, signal, () => Promise.resolve(seed), + 'agent/request', { turn: 2, step: 0, signal }, () => Promise.resolve(seed), )).resolves.toBe(seed) await ctx.fiber.dispose() }) diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts index e544c47987..672914c5c3 100644 --- a/packages/core/scope/src/scoped-events.generated.ts +++ b/packages/core/scope/src/scoped-events.generated.ts @@ -8,20 +8,20 @@ type ScopedSubjectResolver = (args: readonly unknown[]) => unknown const scopedSubjectResolvers: Readonly> = Object.freeze({ - 'agent/created': args => args[0], - 'agent/disposed': args => args[0], - 'agent/error': args => args[0], - 'agent/inbox/claimed': args => args[0], - 'agent/inbox/discarded': args => args[0], - 'agent/inbox/inserted': args => args[0], - 'agent/pre-step': args => args[0], - 'agent/request': args => args[0], - 'agent/request-error': args => args[0], - 'agent/session-start': args => args[0], - 'agent/status': args => args[0], - 'agent/turn-stopping': args => args[0], + 'agent/created': args => (args[0] as Record)['agent'], + 'agent/disposed': args => (args[0] as Record)['agent'], + 'agent/error': args => (args[0] as Record)['agent'], + 'agent/inbox/claimed': args => (args[0] as Record)['agent'], + 'agent/inbox/discarded': args => (args[0] as Record)['agent'], + 'agent/inbox/inserted': args => (args[0] as Record)['agent'], + 'agent/pre-step': args => (args[0] as Record)['agent'], + 'agent/request': args => (args[0] as Record)['agent'], + 'agent/request-error': args => (args[0] as Record)['agent'], + 'agent/session-start': args => (args[0] as Record)['agent'], + 'agent/status': args => (args[0] as Record)['agent'], + 'agent/turn-stopping': args => (args[0] as Record)['agent'], 'approval/request': args => (args[0] as Record)['agent'], - 'goal/changed': args => args[0], + 'goal/changed': args => (args[0] as Record)['agent'], 'session/created': null, 'session/disposed': null, 'session/event': null, diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index d647344537..8744bb9aa7 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -28,7 +28,7 @@ describe('scoped-dispatch invariants', () => { const ctx = await setup() expect(() => { emit(ctx, undefined, 'ordinary/event', []) }).not.toThrow() const agent = { id: 'a1' } - expect(() => { emit(ctx, undefined, 'agent/error', [agent, 1, 0, new Error('x')]) }) + expect(() => { emit(ctx, undefined, 'agent/error', [{ agent, turn: 1, step: 0, error: new Error('x') }]) }) .toThrow(/dispatched without a scope carrier/) }) @@ -45,34 +45,34 @@ describe('scoped-dispatch invariants', () => { source: { kind: 'user' }, }) const agentRows = { - 'agent/created': [agent], - 'agent/disposed': [agent], - 'agent/status': [agent, 'idle'], - 'agent/inbox/inserted': [agent, { message }], - 'agent/inbox/claimed': [agent, { message, turn: 1 }], - 'agent/inbox/discarded': [agent, { message }], - 'agent/session-start': [agent, 'startup'], - 'agent/pre-step': [agent, [message], { turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [message] })], - 'agent/request': [agent, 1, 1, signal, () => Promise.resolve(config)], + 'agent/created': [{ agent }], + 'agent/disposed': [{ agent }], + 'agent/status': [{ agent, status: 'idle' }], + 'agent/inbox/inserted': [{ agent, message }], + 'agent/inbox/claimed': [{ agent, message, turn: 1 }], + 'agent/inbox/discarded': [{ agent, message }], + 'agent/session-start': [{ agent, source: 'startup' }], + 'agent/pre-step': [{ agent, messages: [message], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [message] })], + 'agent/request': [{ agent, turn: 1, step: 1, signal }, () => Promise.resolve(config)], 'agent/request-error': [ - agent, { + agent, turn: 1, step: 1, provider: 'p', failure: { message: 'request', code: 'UNKNOWN' }, retryPolicy: undefined, + signal, }, - signal, () => Promise.resolve(undefined), ], - 'agent/turn-stopping': [agent, 1, signal], - 'agent/error': [agent, 1, 0, new Error('x')], + 'agent/turn-stopping': [{ agent, turn: 1, signal }], + 'agent/error': [{ agent, turn: 1, step: 0, error: new Error('x') }], } satisfies { [K in AgentEventName]: EventArgs } const rows: Array<[string, unknown[]]> = [ ...Object.entries(agentRows), ['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]], - ['goal/changed', [agent, { operation: 'create', ref: { id: 'goal-a', revision: 1 } }]], + ['goal/changed', [{ agent, change: { operation: 'create', ref: { id: 'goal-a', revision: 1 } } }]], ['system-prompt/assemble', [[], { scope: agent }]], ['tools/code-dispatch-log', [{ exec: { callId: 'c', name: 't', arguments: {} }, agent, subCallId: 'c:code:1', name: 't', isError: false, content: [] }, () => Promise.resolve([])]], ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]], diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 1a8938534f..9c60bee081 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -48,7 +48,7 @@ async function composePrefix(ctx: Context): Promise { const agent = ctx.agentLoop.create(SessionId(`acp-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' }) const signal = new AbortController().signal const decision = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', [], { turn: 1, step: 1, signal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [] }), ) if (decision.kind === 'enter') { diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index a524fe0630..87fd17c36b 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -41,7 +41,7 @@ async function composePrefix(ctx: Context, cwd: string): Promise { const agent = ctx.agentLoop.create(SessionId('agent-spine-prefix'), {}, { cwd }) const signal = new AbortController().signal const decision = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', [], { turn: 1, step: 1, signal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [] }), ) if (decision.kind === 'enter') { diff --git a/packages/examples/cli-demo/tests/cli-demo.spec.ts b/packages/examples/cli-demo/tests/cli-demo.spec.ts index 933466b2e3..574e18c583 100644 --- a/packages/examples/cli-demo/tests/cli-demo.spec.ts +++ b/packages/examples/cli-demo/tests/cli-demo.spec.ts @@ -45,7 +45,7 @@ async function composePrefix(ctx: Context): Promise { const agent = ctx.agentLoop.create(SessionId(`cli-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' }) const signal = new AbortController().signal const decision = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', [], { turn: 1, step: 1, signal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [] }), ) if (decision.kind === 'enter') { diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index 39afe0a24e..9fd87aaf52 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -401,7 +401,7 @@ describe('runOneShot and executeCli', () => { if (session === agent.session && event.type === 'assistant/message' && event.data.turn === 1) startupStarted() }) - ctx.on('agent/turn-stopping', async (subject, turn) => { + ctx.on('agent/turn-stopping', async ({ agent: subject, turn }) => { if (subject === agent && turn === 1) await releaseStartup.promise }) agent.followup(createUserMessage({ @@ -432,7 +432,7 @@ describe('runOneShot and executeCli', () => { } let replacementQueued = false - ctx.on('agent/status', (subject, status) => { + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject !== agent || status !== 'idle' || replacementQueued) return replacementQueued = true agent.followup(createUserMessage({ diff --git a/packages/fs/tool-fs/tests/harness.ts b/packages/fs/tool-fs/tests/harness.ts index eff2588ec2..0d700e61b6 100644 --- a/packages/fs/tool-fs/tests/harness.ts +++ b/packages/fs/tool-fs/tests/harness.ts @@ -25,7 +25,7 @@ export async function fsHarness(fsCwd: string, persona = ''): Promise { export function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index b81d1cb599..5c92f048d8 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -243,20 +243,20 @@ export function apply(ctx: Context): void { // One composite effect keeps the step fence installed until this // plugin's own scheduling tasks settle. ctx.effect(function* () { - ctx.on('agent/error', (agent) => { + ctx.on('agent/error', ({ agent }) => { const state = stateFor(agent) disarm(state) }) - ctx.on('agent/created', (agent) => { stateFor(agent) }) - ctx.on('agent/disposed', (agent) => { states.delete(agent) }) - ctx.on('agent/session-start', (agent) => { + ctx.on('agent/created', ({ agent }) => { stateFor(agent) }) + ctx.on('agent/disposed', ({ agent }) => { states.delete(agent) }) + ctx.on('agent/session-start', ({ agent }) => { const state = stateFor(agent) state.attempt = undefined state.competingQueued = false state.needsCheckpoint = false }) - ctx.on('agent/status', (agent, status) => { + ctx.on('agent/status', ({ agent, status }) => { const state = stateFor(agent) if (status === 'idle') { state.competingQueued = false @@ -275,13 +275,13 @@ export function apply(ctx: Context): void { requestDrive(state) } }) - ctx.on('goal/changed', (agent) => { + ctx.on('goal/changed', ({ agent }) => { const state = stateFor(agent) state.needsCheckpoint = true requestDrive(state) }) - ctx.on('agent/inbox/inserted', (agent, { message }) => { + ctx.on('agent/inbox/inserted', ({ agent, message }) => { if (!agent.inbox.nextTurn.some(candidate => candidate.id === message.id)) return const state = stateFor(agent) const attempt = state.attempt @@ -289,14 +289,14 @@ export function apply(ctx: Context): void { state.competingQueued = true if (attempt?.phase === 'queued') attempt.stale = true }) - ctx.on('agent/inbox/claimed', (agent, { message }) => { + ctx.on('agent/inbox/claimed', ({ agent, message }) => { const state = stateFor(agent) const attempt = state.attempt if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) { attempt.phase = 'claimed' } }) - ctx.on('agent/inbox/discarded', (agent, { message }) => { + ctx.on('agent/inbox/discarded', ({ agent, message }) => { const state = stateFor(agent) const attempt = state.attempt if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) { @@ -346,7 +346,7 @@ export function apply(ctx: Context): void { && source.round === goal.roundsStarted + 1 } - ctx.on('agent/pre-step', async (agent, messages, { signal }, next): Promise => { + ctx.on('agent/pre-step', async ({ agent, messages, signal }, next): Promise => { const submitted = messages.find((message): message is UserMessage & { source: GoalMessageSource } => isGoalRoundSource(message.source)) if (submitted === undefined) return next() diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index d9fd63c940..2d14abf158 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -107,7 +107,7 @@ function onInboxMessage( agent: Agent, listener: (message: UserMessage) => void, ): () => void { - return ctx.on('agent/inbox/inserted', (subject, { message }) => { + return ctx.on('agent/inbox/inserted', ({ agent: subject, message }) => { if (subject === agent) listener(message) }) } @@ -118,7 +118,7 @@ function onClaimedMessage( agent: Agent, listener: (message: UserMessage) => void, ): () => void { - return ctx.on('agent/inbox/claimed', (subject, { message }) => { + return ctx.on('agent/inbox/claimed', ({ agent: subject, message }) => { if (subject === agent) listener(message) }) } @@ -247,7 +247,7 @@ describe('same-session goal driving', () => { it('maps a downstream step rejection to blocked without entering the round', async () => { const test = await harness([]) - test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal' + test.ctx.on('agent/pre-step', ({ messages }, next) => messages[0]?.source.kind === 'goal' ? Promise.resolve({ kind: 'reject' as const }) : next()) test.ctx.goals.create(test.agent, { objective: 'respect policy' }) @@ -265,10 +265,10 @@ describe('same-session goal driving', () => { it('does not reserve again when a stopped-goal observer queues cancel-scoped work', async () => { const test = await harness([textResponse('human follow-up')]) - test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal' + test.ctx.on('agent/pre-step', ({ messages }, next) => messages[0]?.source.kind === 'goal' ? Promise.resolve({ kind: 'reject' as const }) : next()) - test.ctx.on('goal/changed', (agent, change) => { + test.ctx.on('goal/changed', ({ agent, change }) => { if (change.operation === 'block') agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the blocker' }], source: { kind: 'user' } })) }) test.ctx.goals.create(test.agent, { objective: 'stop and inspect' }) @@ -370,7 +370,7 @@ describe('same-session goal driving', () => { it('rechecks revision after downstream prompt hooks before admitting', async () => { const test = await harness([textResponse('new revision')]) let edited = false - test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', ({ agent, messages }, next) => { if (messages[0]?.source.kind === 'goal' && !edited) { edited = true const current = test.ctx.goals.get(agent) @@ -389,7 +389,7 @@ describe('same-session goal driving', () => { it('does not block a goal that downstream paused before rejecting its prompt', async () => { const test = await harness([]) - test.ctx.on('agent/pre-step', async (agent, messages, _context, next) => { + test.ctx.on('agent/pre-step', async ({ agent, messages }, next) => { if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0)) { return next() } @@ -432,7 +432,7 @@ describe('same-session goal driving', () => { test.agent.inbox.prepend('next-step', roundZeroContext) }) let edited = false - test.ctx.on('agent/pre-step', async (agent, messages, _context, next) => { + test.ctx.on('agent/pre-step', async ({ agent, messages }, next) => { const decision = await next() if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0) || edited) return decision edited = true @@ -513,8 +513,10 @@ describe('same-session goal driving', () => { const test = await harness([]) test.ctx.on('session/flush', () => Promise.reject(new Error('clear checkpoint failed'))) agentEvents(test.ctx, test.agent).emit('goal/changed', { - operation: 'clear', - ref: { id: GoalId('cleared-goal'), revision: 2 }, + change: { + operation: 'clear', + ref: { id: GoalId('cleared-goal'), revision: 2 }, + }, }) await new Promise((resolve) => { setImmediate(resolve) }) @@ -529,7 +531,7 @@ describe('same-session goal driving', () => { ]) // The llm-retry shape: schedule one retry for the failed goal-round request. let retried = false - test.ctx.on('agent/request-error', async (_subject) => { + test.ctx.on('agent/request-error', async (_payload) => { if (!retried) { retried = true return { kind: 'retry' } @@ -552,7 +554,7 @@ describe('same-session goal driving', () => { // attempt through cancel-requested) and THEN throws: the catch finds no // matching reservation and must not reschedule a paused goal. let fired = false - test.ctx.on('agent/pre-step', async (agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', async ({ agent, messages }, next) => { if (messages[0]?.source.kind === 'goal' && !fired) { fired = true agent.cancel({ kind: 'user' }) @@ -576,7 +578,7 @@ describe('same-session goal driving', () => { // Registered after goal-session's own listener: the throw propagates back // through goal-session's next() await, dropping the whole step proposal. let threw = false - test.ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', async ({ messages }, next) => { if (messages[0]?.source.kind === 'goal' && !threw) { threw = true throw new Error('downstream pre-step hook exploded') @@ -598,7 +600,7 @@ describe('same-session goal driving', () => { textResponse('goal round ran'), ]) let retried = false - test.ctx.on('agent/request-error', async (_subject) => { + test.ctx.on('agent/request-error', async (_payload) => { if (!retried) { retried = true return { kind: 'retry' } @@ -721,7 +723,7 @@ describe('same-session goal driving', () => { it('fails a post-hook read closed before the prompt can enter history', async () => { const test = await harness([]) let armed = true - test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', ({ messages }, next) => { if (messages[0]?.source.kind === 'goal' && armed) { armed = false vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { @@ -809,7 +811,7 @@ describe('same-session goal driving', () => { it('rejects the step when downstream cancellation clears the reservation', async () => { const test = await harness([]) let cancelled = false - test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', ({ agent, messages }, next) => { if (messages[0]?.source.kind === 'goal' && !cancelled) { cancelled = true agent.cancel({ kind: 'user' }) @@ -864,7 +866,7 @@ describe('same-session goal driving', () => { it('resets process-local scheduling state at a session-start edge', async () => { const test = await harness([textResponse('after explicit resume')]) const created = test.ctx.goals.create(test.agent, { objective: 'restart safely', maxGoalRounds: 1 }) - agentEvents(test.ctx, test.agent).emit('agent/session-start', 'resume') + agentEvents(test.ctx, test.agent).emit('agent/session-start', { source: 'resume' }) await Promise.resolve() expect(test.ctx.goals.get(test.agent)).toMatchObject({ activation: 'disarmed', roundsStarted: 0 }) @@ -898,7 +900,7 @@ describe('same-session goal driving', () => { const test = await harness([textResponse('round one')]) test.ctx.on('session/event', (session, event) => { if (session === test.agent.session && event.type === 'turn/end') { - agentEvents(test.ctx, test.agent).emit('agent/error', event.data.turn, 1, new Error('post-turn flush failed')) + agentEvents(test.ctx, test.agent).emit('agent/error', { turn: event.data.turn, step: 1, error: new Error('post-turn flush failed') }) } }) test.ctx.goals.create(test.agent, { objective: 'stop when durability is lost', maxGoalRounds: 8 }) @@ -923,7 +925,7 @@ describe('same-session goal driving', () => { await handle.dispose() const warn = vi.spyOn(test.ctx.logger, 'warn') - agentEvents(test.ctx, handle.agent).emit('agent/error', closed.data.turn, 1, new Error('late flush failure')) + agentEvents(test.ctx, handle.agent).emit('agent/error', { turn: closed.data.turn, step: 1, error: new Error('late flush failure') }) expect(test.ctx.agents.get(handle.agent.id)).toBeUndefined() expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('goal-session')) @@ -959,7 +961,7 @@ describe('same-session goal driving', () => { it('waits for work queued by a pause observer before considering the next round', async () => { const test = await harness(['hang', textResponse('inspection answer')]) - test.ctx.on('goal/changed', (agent, change) => { + test.ctx.on('goal/changed', ({ agent, change }) => { if (agent === test.agent && change.operation === 'pause') { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the pause' }], source: { kind: 'user' } })) } @@ -982,7 +984,7 @@ describe('same-session goal driving', () => { it('does not re-block a goal the downstream veto already saw cancelled', async () => { const test = await harness([]) let vetoed = false - test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', ({ agent, messages }, next) => { if (messages[0]?.source.kind === 'goal' && !vetoed) { vetoed = true agent.cancel({ kind: 'user' }) @@ -1007,7 +1009,7 @@ describe('same-session goal driving', () => { it('awaits a claimed reservation stuck in pre-step during teardown without cancelling', async () => { const test = await harness([]) let release: (() => void) | undefined - test.ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', async ({ messages }, next) => { if (messages[0]?.source.kind === 'goal' && release === undefined) { await new Promise((resolve) => { release = resolve }) } diff --git a/packages/goal/goal/src/domain.ts b/packages/goal/goal/src/domain.ts index 377ba402e2..fec44de2f3 100644 --- a/packages/goal/goal/src/domain.ts +++ b/packages/goal/goal/src/domain.ts @@ -134,10 +134,10 @@ declare module 'cordis' { * Goal mutation accepted by one live agent. The matching `goal/change` * session event has already committed. Listener failures are contained. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @param agent - agent whose session owns the goal. - * @param change - fresh current projection or clear tombstone. + * @param payload.agent - agent whose session owns the goal. + * @param payload.change - fresh current projection or clear tombstone. * @mode emit */ - 'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped, agent: Agent, change: GoalChanged): void + 'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped, payload: { agent: Agent; change: GoalChanged }): void } } diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index f6a4a99fc6..1cd3c6074a 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -193,7 +193,7 @@ export class GoalService extends Service { this.resolved = { defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256), } - ctx.on('agent/session-start', (agent) => { + ctx.on('agent/session-start', ({ agent }) => { this.cache(agent.session).activation = 'disarmed' }) // The `goal` projection unit: last-wins fold of goal/change whole values @@ -547,7 +547,7 @@ export class GoalService extends Service { ref: { ...ref }, ...goal === undefined ? {} : { goal }, } - agentEvents(this.ctx, agent).emit('goal/changed', notification) + agentEvents(this.ctx, agent).emit('goal/changed', { change: notification }) } /** Build a detached current view. */ diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 58661481cf..38eea7cf61 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -83,7 +83,7 @@ describe('GoalService creation and replay', () => { vi.setSystemTime(1_700_000_000_000) const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 }) const seen: string[] = [] - ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) }) + ctx.on('goal/changed', ({ change }) => { seen.push(change.operation) }) const goal = ctx.goals.create(agent, { objective: ' finish the feature ' }) @@ -191,7 +191,7 @@ describe('GoalService creation and replay', () => { const { ctx, agent, session } = await harness() let goal = ctx.goals.create(agent, { objective: 'stay stopped after resume' }) expect(goal.activation).toBe('armed') - agentEvents(ctx, agent).emit('agent/session-start', 'resume') + agentEvents(ctx, agent).emit('agent/session-start', { source: 'resume' }) expect(ctx.goals.get(agent)?.activation).toBe('disarmed') goal = ctx.goals.resume(agent, goal) expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 2 }) @@ -223,7 +223,7 @@ describe('GoalService creation and replay', () => { await fiber.dispose() expect(ctx.get('goals')).toBeUndefined() - agentEvents(ctx, stub.agent).emit('agent/session-start', 'resume') + agentEvents(ctx, stub.agent).emit('agent/session-start', { source: 'resume' }) expect(first.get(stub.agent)).toMatchObject({ id: goal.id, activation: 'armed' }) await ctx.plugin(GoalService) @@ -384,7 +384,7 @@ describe('GoalService mutations', () => { const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const seen: string[] = [] ctx.on('goal/changed', () => { throw new Error('broken observer') }) - ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) }) + ctx.on('goal/changed', ({ change }) => { seen.push(change.operation) }) expect(ctx.goals.create(agent, { objective: 'notify' }).phase).toBe('active') expect(seen).toEqual(['create']) expect(warn).toHaveBeenCalledWith(expect.stringContaining('broken observer')) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index c9e3ade8fe..d8bc950019 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -404,7 +404,7 @@ describe('goal tool state transitions', () => { let turn = openTurn(root, { kind: 'user' }) const created = ctx.goals.create(root.agent, { objective: 'continue later' }) closeTurn(root, turn) - agentEvents(ctx, root.agent).emit('agent/session-start', 'resume') + agentEvents(ctx, root.agent).emit('agent/session-start', { source: 'resume' }) expect(ctx.goals.get(root.agent)?.activation).toBe('disarmed') turn = openTurn(root, { kind: 'user' }, '继续') const resumed = await execute(ctx, 'update_goal', { diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index f07d59e417..464dd318da 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -226,7 +226,7 @@ export function apply(ctx: Context, config: Config): void { // A user interjection changes the context; repetition across it is not a // loop. Pure reset hook: always delegates (attaching nothing, vetoing // nothing). - ctx.on('agent/pre-step', (agent, messages, _context, next): Promise => { + ctx.on('agent/pre-step', ({ agent, messages }, next): Promise => { if (messages.some(message => message.source.kind === 'user')) chains.delete(agent) return next() }) diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index d6858f6d1a..40382218d8 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -32,7 +32,7 @@ async function harness(config: Config = {}): Promise { } function waitForIdle(ctx: Context, agent: Agent): Promise { - return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) + return new Promise((resolve) => { const d = ctx.on('agent/status', ({ agent: s, status: st }) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) } /** Every injected-context user message in the agent's log, flattened to joined text + source for terse assertions. */ diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 344c42e94f..77b3a2711b 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -203,7 +203,7 @@ export function apply(ctx: Context, config: Config): void { // SessionStart injects context when its detached hook resolves; a slow hook // may miss the first request. // TODO(session-start-gating): add a startup gate before promising first-turn delivery. - ctx.on('agent/session-start', (agent, source) => { + ctx.on('agent/session-start', ({ agent, source }) => { detached.track(runPoint('SessionStart', source, sessionStartPayload(ctx, agent, source), { agent, signal: detached.signal }) .then((merged) => { const context = contextFrom(merged) @@ -216,7 +216,7 @@ export function apply(ctx: Context, config: Config): void { // --- UserPromptSubmit → PreStepDecision. The prompt text is the payload; no // matcher subject (CC ignores matchers for this event). --- - ctx.on('agent/pre-step', async (agent, messages, { turn, signal }, next): Promise => { + ctx.on('agent/pre-step', async ({ agent, messages, turn, signal }, next): Promise => { if (messages.length === 0) return next() const content = messages.flatMap(message => message.content) const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, turn, signal }) @@ -267,7 +267,7 @@ export function apply(ctx: Context, config: Config): void { // A blocking Stop hook steers at the stopping boundary, which makes the // machine observe pending input and run another step. // TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile. - ctx.on('agent/turn-stopping', async (agent, turn, signal): Promise => { + ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise => { const merged = await runPoint('Stop', '', stopPayload(ctx, agent), { agent, turn, signal }) if (merged.decision === 'deny') { // A blocking Stop hook forces continuation. diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index f69d876d11..b7ea693583 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -520,7 +520,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(path, adapter) - ctx.on('agent/pre-step', async (_agent, messages) => ({ + ctx.on('agent/pre-step', async ({ messages }) => ({ kind: 'enter' as const, messages: [{ ...messages[0]!, diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index e96c1a555a..304deef63c 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -185,7 +185,7 @@ export function apply(ctx: Context, config: Config): void { // SessionStart injects plain stdout when its detached hook resolves; a slow // hook may miss the first request. // TODO(session-start-gating): add a startup gate before promising first-turn delivery. - ctx.on('agent/session-start', (agent, source) => { + ctx.on('agent/session-start', ({ agent, source }) => { detached.track(runPoint('SessionStart', source, { ...base(ctx, agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal }) .then((merged) => { const context = contextFrom(merged) @@ -196,7 +196,7 @@ export function apply(ctx: Context, config: Config): void { }) // UserPromptSubmit → PreStepDecision. Codex supports reject, not rewrite or ask. - ctx.on('agent/pre-step', async (agent, messages, { turn, signal }, next): Promise => { + ctx.on('agent/pre-step', async ({ agent, messages, turn, signal }, next): Promise => { if (messages.length === 0) return next() const payload = { ...base(ctx, agent, 'UserPromptSubmit', model), @@ -257,7 +257,7 @@ export function apply(ctx: Context, config: Config): void { // TODO(stop-loop-guard): Codex supplies `stop_hook_active` so a Stop hook can // avoid continuing the same turn indefinitely. It is always false here, so an // unconditionally blocking hook force-continues every step until it self-limits. - ctx.on('agent/turn-stopping', async (agent, turn, signal): Promise => { + ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise => { const merged = await runPoint('Stop', '', { ...turnBase(ctx, agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn, signal }) /* jscpd:ignore-end */ if (merged.decision === 'deny') { diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 664f3cb2b4..942feaaa05 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -128,7 +128,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.on('agent/pre-step', async (_agent, messages) => ({ + ctx.on('agent/pre-step', async ({ messages }) => ({ kind: 'enter' as const, messages: [{ ...messages[0]!, diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 928ecc7b55..f528b2297e 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2619,10 +2619,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ctx.on('session/disposed', (session: Session) => { queue.push(frame({ type: 'host/session-removed', sessionId: session.id })) }), - ctx.on('agent/status', (agent: Agent, status: AgentStatus) => { + ctx.on('agent/status', ({ agent, status }: { agent: Agent; status: AgentStatus }) => { queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' })) }), - ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: unknown) => { + ctx.on('agent/error', ({ agent, error }: { agent: Agent; error: unknown }) => { queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: errorChain(error) })) }), ctx.on('domain/changed', (change) => { diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index 9f6ef65e2f..83955f2d8b 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -280,7 +280,7 @@ describe('sessions.fork', () => { }) const fallback: LlmCallConfig = { provider: 'default-provider', model: 'default-model' } await expect(agentEvents(child.ctx, child).waterfall( - 'agent/request', 1, 0, new AbortController().signal, () => Promise.resolve(fallback), + 'agent/request', { turn: 1, step: 0, signal: new AbortController().signal }, () => Promise.resolve(fallback), )).resolves.toMatchObject({ provider: 'inherited-provider', model: 'inherited-model', diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 2a4754f144..c2dfdae7a7 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -181,13 +181,13 @@ describe('Web session model selection', () => { reasoningEffort: 'max', }) await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 1, 0, signal, () => Promise.resolve(seed), + 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed), )).resolves.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' }) expect((await ctx.systemPrompt.assemble()).variables) .toMatchObject({ provider: 'deepseek-official', model: 'private-preview' }) await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 1, 1, signal, () => Promise.resolve(seed), + 'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(seed), )).resolves.toMatchObject({ provider: 'deepseek-official', model: 'private-preview', diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index fd756a39cc..620e367742 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -5,9 +5,9 @@ * @module @deepseek-ai/dsh-llm-retry */ -import type { Context } from 'cordis' +import type { Context, Events } from 'cordis' import z from 'schemastery' -import type { Agent, RequestErrorAction, RequestFailureContext } from '@deepseek-ai/dsh-agent' +import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -172,12 +172,9 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna } async function recover( - agent: Agent, - context: RequestFailureContext, - signal: AbortSignal, + { agent, turn, step, provider, failure, retryPolicy: policy, signal }: Parameters[0], next: () => Promise, ): Promise { - const { turn, step, provider, failure, retryPolicy: policy } = context if (policy === undefined) return next() if (policy.mode === 'always') { if (signal.aborted || lifetime.signal.aborted) return @@ -228,16 +225,14 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna } const disposeListener = ctx.on('agent/request-error', ( - agent: Agent, - context: RequestFailureContext, - signal: AbortSignal, + payload, next: () => Promise, ) => { // A waterfall may have captured this callback before its registration was // removed. Lifetime cancellation must prevent that stale callback from // entering a downstream policy after disposal. if (lifetime.signal.aborted) return Promise.resolve(undefined) - return track(recover(agent, context, signal, next)) + return track(recover(payload, next)) }) ctx.effect(() => async () => { diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index d1500fa781..ac0ed687fa 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -506,7 +506,7 @@ describe('provider-routed retry policy', () => { ;({ ctx: context } = await harness(adapter, { other: alwaysConfig({ initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }), }, (ctx) => { - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ + ctx.on('agent/request', async (_payload, next) => ({ ...await next(), provider: 'other', })) @@ -543,7 +543,7 @@ describe('provider-routed retry policy', () => { backoff: { initialDelayMs: 1, maxDelayMs: 1 }, }), }, (ctx) => { - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ + ctx.on('agent/request', async (_payload, next) => ({ ...await next(), provider: adapter.requests.length === 0 ? 'mock' : 'other', })) @@ -881,7 +881,7 @@ describe('provider-routed retry policy', () => { context = mounted.ctx const downstream = Promise.withResolvers() const entered = Promise.withResolvers() - context.on('agent/request-error', (agent) => { + context.on('agent/request-error', ({ agent }) => { agent.cancel({ kind: 'user' }) entered.resolve(undefined) return downstream.promise @@ -917,7 +917,7 @@ describe('provider-routed retry policy', () => { const captured = Promise.withResolvers() let invokeCaptured: (() => Promise) | undefined const mounted = await harness(adapter, {}, (ctx) => { - ctx.on('agent/request-error', (_agent, _context, _signal, next) => { + ctx.on('agent/request-error', (_payload, next) => { return new Promise((resolve) => { invokeCaptured = async () => { resolve(await next()) } captured.resolve(undefined) @@ -926,7 +926,7 @@ describe('provider-routed retry policy', () => { }) context = mounted.ctx let downstreamCalls = 0 - context.on('agent/request-error', async (_agent, _context, _signal, next) => { + context.on('agent/request-error', async (_payload, next) => { downstreamCalls += 1 return next() }) @@ -980,7 +980,7 @@ describe('provider-routed retry policy', () => { textResponse('must not run'), ]) ;({ ctx: context } = await harness(adapter, { mock: policy }, (ctx) => { - ctx.on('agent/request-error', async (agent, _context, _signal, next) => { + ctx.on('agent/request-error', async ({ agent }, next) => { agent.cancel({ kind: 'user' }) return next() }) diff --git a/packages/llm/token-meter/src/breakdown-projection.ts b/packages/llm/token-meter/src/breakdown-projection.ts index 036f80647f..c83879c63a 100644 --- a/packages/llm/token-meter/src/breakdown-projection.ts +++ b/packages/llm/token-meter/src/breakdown-projection.ts @@ -33,10 +33,11 @@ const breakdownSchema = z.object({ * * Envelope figures are last-wins per `request/header`; the message figure * rides {@link foldSurfaceProjection} — the same O(1) fold the occupancy - * projection uses — so it equals `measure().surfaceTokens` at every event - * boundary and compaction shrinks it by its logged shadow price, the way it - * shrinks the next request. The state is a fixed handful of numbers, so the - * persisted checkpoint stays O(1) over the session's life. + * projection uses — so fully metered logs equal `measure().surfaceTokens` at + * every event boundary and compaction shrinks the figure by its logged shadow + * price. A replacement without a claim preserves the previous total. The + * state is a fixed handful of numbers, so the persisted checkpoint stays + * O(1) over the session's life. */ export const contextBreakdownProjectionDefinition: ProjectionDefinition<'contextBreakdown', ContextBreakdownState> = { diff --git a/packages/llm/token-meter/src/surface-fold.ts b/packages/llm/token-meter/src/surface-fold.ts index e4dfacc254..2848025b19 100644 --- a/packages/llm/token-meter/src/surface-fold.ts +++ b/packages/llm/token-meter/src/surface-fold.ts @@ -3,9 +3,10 @@ * surface `measure()` serves and compaction plans against. The projection * units deliberately do NOT share this fold — their state must stay O(1) * for the persisted checkpoint, so they ride `surface-projection.ts`'s - * shadow-price protocol instead. The two stay in agreement by construction: - * both price through `estimate.ts`, and every logged shadow price is derived - * from THIS fold's nodes by the replace producer. + * shadow-price protocol instead. Fully metered logs stay in agreement by + * construction: both price through `estimate.ts`, and every logged shadow + * price is derived from THIS fold's nodes by the replace producer. A + * projection replacement without a claim deliberately folds with zero delta. * * @module @deepseek-ai/dsh-token-meter/surface-fold */ diff --git a/packages/llm/token-meter/src/surface-projection.ts b/packages/llm/token-meter/src/surface-projection.ts index dcc8181370..9c42d5248e 100644 --- a/packages/llm/token-meter/src/surface-projection.ts +++ b/packages/llm/token-meter/src/surface-projection.ts @@ -10,7 +10,9 @@ * heuristic price of the exact replaced range, so the fold keeps a running * total plus at most one pending claim and never retains per-node prices. * The counts are exact by construction: producers derive them from the same - * fixed estimator this module prices appends with. + * fixed estimator this module prices appends with. A replacement without an + * armed claim folds with zero delta because bounded state cannot reconstruct + * the replaced range; this preserves replay at the cost of possible drift. * * @module @deepseek-ai/dsh-token-meter/surface-projection */ @@ -47,16 +49,19 @@ export interface SurfaceTokensFold { * Fold one committed event onto a running surface-token total. * * A shadow-price event arms a claim; any other event expires it, and a - * surface `replace` must consume a claim naming its exact range — the + * surface `replace` consumes the claim naming its exact range — the * producers append the metering event and the replacement synchronously * adjacent, so a surviving claim always prices the very next event. + * A replace with no claim folds with zero delta because the bounded state + * cannot reconstruct the replaced range. An armed claim for another range + * still fails because the adjacent events contradict each other. * @param claim - the claim armed by the immediately preceding event, if any. * @param event - the next committed session event. * @returns the signed token delta and the claim state after this event. - * @throws when a replacement arrives without a claim for its exact range — - * every in-repo replace producer meters its replacement, so an unpriced - * replacement is a shadow-price contract violation and must fail loud - * rather than let the total drift. + * @throws when a replacement arrives with an armed claim for a different + * range — the metering event was adjacent, so this is a live producer's + * shadow-price contract violation, not historical data, and must fail + * loud rather than let the total drift. */ export function foldSurfaceProjection( claim: ShadowPriceClaim | undefined, @@ -74,10 +79,15 @@ export function foldSurfaceProjection( const tokens = message === null ? 0 : estimateMessage(message) const op = event.surfaceOp if (op === 'append') return { deltaTokens: tokens, claim: undefined } - if (claim === undefined || claim.start !== op.start || claim.end !== op.end) { + // Sessions recorded before the shadow-price protocol log replacements with + // no adjacent metering event; the bounded state cannot reconstruct the + // replaced range's price, so fold those neutrally — historical replay + // degrades to drift instead of failing. + if (claim === undefined) return { deltaTokens: 0, claim: undefined } + if (claim.start !== op.start || claim.end !== op.end) { throw new Error( `token surface: replace at seq ${event.seq} over range ${op.start}-${op.end} has no adjacent shadow price` - + (claim === undefined ? '' : ` (armed claim covers ${claim.start}-${claim.end})`), + + ` (armed claim covers ${claim.start}-${claim.end})`, ) } return { deltaTokens: tokens - claim.tokens, claim: undefined } diff --git a/packages/llm/token-meter/src/usage-projection.ts b/packages/llm/token-meter/src/usage-projection.ts index 0d5db509b5..a7fc9debf0 100644 --- a/packages/llm/token-meter/src/usage-projection.ts +++ b/packages/llm/token-meter/src/usage-projection.ts @@ -155,9 +155,10 @@ ProjectionDefinition<'tokenUsage', TokenUsageState> = { * `projectedTokens` — the sample plus the surface's signed movement since it * was taken — so occupancy answers for the next request rather than the last * one. The total rides {@link foldSurfaceProjection}, so the state stays O(1) - * and a replacement shrinks it by its logged shadow price. A usage sample is - * stamped BEFORE the same event joins the surface, so an `assistant/message` - * anchors against the surface its own request saw. + * and a replacement shrinks it by its logged shadow price. A replacement + * without a claim preserves the previous total. A usage sample is stamped + * BEFORE the same event joins the surface, so an `assistant/message` anchors + * against the surface its own request saw. */ export const contextPressureProjectionDefinition: ProjectionDefinition<'contextPressure', ContextPressureState> = { diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index b7e4850fd4..20cb2cc819 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -180,7 +180,7 @@ describe('contextBreakdown session projection', () => { expect(agree()).toBeLessThan(grown) }) - it('fails loud on a replacement without an adjacent matching shadow price', () => { + it('folds a replacement without a claim at zero and fails on a mismatched claim', () => { const definition = contextBreakdownProjectionDefinition const replace = (start: number, end: number): SessionEvent => ({ type: 'user/message', @@ -206,15 +206,17 @@ describe('contextBreakdown session projection', () => { let state = definition.init() state = definition.apply(state, append(1)) state = definition.apply(state, append(3)) - // No metering event at all. - expect(() => definition.apply(state, replace(1, 3))).toThrow('no adjacent shadow price') - // A claim for a different range does not price this replacement. + // No metering event: the replacement contributes zero instead of throwing. + expect(definition.view(definition.apply(state, replace(1, 3))).messageTokens) + .toBe(definition.view(state).messageTokens) + // An adjacent claim for another range contradicts the replacement. const mismatched = definition.apply(state, meter(1, 1, 8)) expect(() => definition.apply(mismatched, replace(1, 3))).toThrow('no adjacent shadow price') - // A claim expires after one intervening event instead of lingering. + // A claim expires after one intervening event, so replacement delta is zero. let expired = definition.apply(state, meter(1, 3, 8)) expired = definition.apply(expired, { type: 'todo/write', seq: 9, time: 0, data: { todos: [] } } as unknown as SessionEvent) - expect(() => definition.apply(expired, replace(1, 3))).toThrow('no adjacent shadow price') + expect(definition.view(definition.apply(expired, replace(1, 3))).messageTokens) + .toBe(definition.view(state).messageTokens) // The armed claim prices exactly the next event's matching replacement. const armed = definition.apply(state, meter(1, 3, 8)) expect(definition.view(definition.apply(armed, replace(1, 3))).messageTokens) diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index 07261576eb..0307b96f46 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -419,6 +419,25 @@ describe('contextPressure session projection', () => { expect(compacted.projectedTokens).toBeLessThan(beforeCompaction!) }) + it('folds a replacement without a claim at zero', async () => { + const { ctx, session } = await harness() + const question = appendUser(session, 'a question from an unmetered log') + startStep(session, 1, 1) + usageChunk(session, { inputTokens: 100, outputTokens: 1 }, 1, 1) + session.append('step/end', { turn: 1, step: 1 }) + const before = pressure(ctx, session) + + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'summary without a preceding claim' }], + source: { kind: 'plugin', plugin: 'test' }, + }), { + surfaceOp: { op: 'replace', start: question, end: question }, + sourceEventSeqs: [question], + }) + + expect(pressure(ctx, session)).toEqual(before) + }) + it('clamps a projection that heuristic error drove below zero', async () => { const { ctx, session } = await harness() recordContext(session, 'large', 128_000) diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 75b8ffd36b..aac726d128 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -202,9 +202,7 @@ export class PlanModeService extends Service { // the session. A failed append remains pending for a later boundary, and // policy cannot block the step. ctx.on('agent/pre-step', async ( - agent, - _messages, - { signal }, + { agent, signal }, next, ): Promise => { const decision = await next() diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 6e614a36a0..34678714fa 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -42,7 +42,7 @@ async function harness(adapter: MockAdapter): Promise { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -139,7 +139,7 @@ describe('plan mode through the agent loop', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request-error', async (subject, _context, _signal, next) => { + ctx.on('agent/request-error', async ({ agent: subject }, next) => { if (subject !== agent) return next() ctx.planMode.set(agent, true) return { kind: 'retry' } diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 87a295e90c..63abed59ea 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -45,7 +45,7 @@ async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { acti // Seeded plan state lands before the creation announcement, matching resume. if (active !== undefined) session.append('plan/mode', { active }) // The loop announces creation after publication. - ctx.emit('agent/created', agent) + ctx.emit('agent/created', { agent }) return agent } @@ -74,8 +74,7 @@ async function boundary(ctx: Context, agent: Agent & { session: Session }, type: const signal = new AbortController().signal const decision = await events.waterfall( 'agent/pre-step', - [message], - { turn: 1, step: 1, signal }, + { messages: [message], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [message] }), ) if (decision.kind === 'enter') { diff --git a/packages/session-persistence/session-checkpoint-policy/src/index.ts b/packages/session-persistence/session-checkpoint-policy/src/index.ts index c26a65e8ad..804ed0dcb1 100644 --- a/packages/session-persistence/session-checkpoint-policy/src/index.ts +++ b/packages/session-persistence/session-checkpoint-policy/src/index.ts @@ -76,7 +76,7 @@ export function apply(ctx: Context): void { // Before each request, persist everything committed by the preceding step; // the first step's call is an intentional no-op beyond any prompt intake. - ctx.on('agent/pre-step', async (agent, _messages, _context, next): Promise => { + ctx.on('agent/pre-step', async ({ agent }, next): Promise => { await ctx.sessions.flush(agent.session) return next() }) diff --git a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts index b619871156..dde59610c5 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -228,7 +228,7 @@ describe('session-checkpoint-policy tool and step boundaries', () => { ctx.on('session/flush', (current) => { flushed.push(current.id) }) const signal = new AbortController().signal await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', [], { turn: 1, step: 1, signal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [] }), ) expect(flushed).toEqual([session.id]) diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 634824d6c2..ddc45d18e9 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -163,9 +163,7 @@ export function apply(ctx: Context, config: Config = {}): void { // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. ctx.on('agent/pre-step', async ( - agent: Agent, - _messages, - { signal }, + { agent, signal }, next, ): Promise => { const decision = await next() diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 5d14b7c523..0755e398a0 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -86,8 +86,7 @@ async function fireStep(ctx: Context, agent: Agent, turn: number, step: number): const signal = new AbortController().signal const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - [], - { turn, step, signal }, + { messages: [], turn, step, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) if (decision.kind === 'enter') { @@ -105,8 +104,7 @@ async function proposeStep( const signal = new AbortController().signal return await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - messages, - { turn: 1, step: 1, signal }, + { messages, turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter' as const, messages }), ) } @@ -138,8 +136,7 @@ async function composePrefix(ctx: Context, cwd: string, signal = new AbortContro async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new AbortController().signal): Promise { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - [], - { turn: 1, step: 1, signal }, + { messages: [], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) if (decision.kind === 'enter') { @@ -241,7 +238,7 @@ describe('dsh-tool-skill', () => { source: 'runtime', content: 'User-only body.', }) - ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => { + ctx.on('agent/pre-step', async (_payload, next) => { const decision = await next() if (decision.kind === 'reject') return decision return { diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 04ce4e23f9..acb4e4d36e 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -75,7 +75,7 @@ function prePublicationAbort(): Error { /** Append one one-shot descriptor inside the child's initial turn before its first request. */ function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void { let appended = false - childCtx.on('agent/pre-step', async (agent, _messages, _context, next) => { + childCtx.on('agent/pre-step', async ({ agent }, next) => { const decision = await next() if (!appended && decision.kind === 'enter') { appended = true diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index 389ef5e2a7..33de6d0cc6 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -42,7 +42,7 @@ export async function spawnHarness(workdir: string): Promise { export function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 4b038871a1..212536e713 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -287,7 +287,7 @@ export class SubagentContinuationManager { // child-first ordering. const scope = ctx.plugin(function activationOwner() {}) this.ownerCtx = scope.ctx - ctx.on('agent/disposed', (agent) => { + ctx.on('agent/disposed', ({ agent }) => { this.closingScopes.delete(agent) }) ctx.effect(function* (this: SubagentContinuationManager) { @@ -859,12 +859,12 @@ export class SubagentContinuationManager { // quiet Agent from one whose accepted turn has not been admitted yet. // Registered through the child's own scoped context, so scope filtering // already restricts both listeners to this exact agent. - handle.agent.ctx.on('agent/inbox/claimed', (_agent, { message }) => { + handle.agent.ctx.on('agent/inbox/claimed', ({ message }) => { /* v8 ignore next -- a claim of an id this manager never admitted needs * another sender on the same child, which no current path allows. */ if (activation.accepted.delete(message.id)) this.wake(activation) }) - handle.agent.ctx.on('agent/inbox/discarded', (_agent, { message }) => { + handle.agent.ctx.on('agent/inbox/discarded', ({ message }) => { if (activation.accepted.delete(message.id)) this.wake(activation) }) // Agent creation committed setup at its publication boundary; diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 7b7a2ab541..dca06add38 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -159,10 +159,10 @@ describe('SubagentService.startContinuable', () => { it('returns both identities at inbox acceptance, without waiting for the turn or the log', async () => { const { ctx, parent, adapter } = await setup([textResponse('first answer')]) const enqueued: { id: MessageId; loggedYet: boolean }[] = [] - ctx.on('agent/inbox/inserted', (agent, accepted) => { + ctx.on('agent/inbox/inserted', ({ agent, message }) => { // Acceptance is the boundary `startContinuable` resolves at, so observe // the log state exactly there rather than after later microtasks. - enqueued.push({ id: accepted.message.id, loggedYet: hasUserText(agent.session.events, 'child task') }) + enqueued.push({ id: message.id, loggedYet: hasUserText(agent.session.events, 'child task') }) }) const started = await ctx.subagents.startContinuable(startSpec(parent)) @@ -231,7 +231,7 @@ describe('SubagentService.startContinuable', () => { const { ctx, parent } = await setup([textResponse('unused')]) const controller = new AbortController() // Abort inside the child's creation window: setup runs before publication. - ctx.on('agent/created', (child) => { + ctx.on('agent/created', ({ agent: child }) => { if (child !== parent) controller.abort('caller gave up') }) @@ -753,7 +753,7 @@ describe('continuable durability and teardown', () => { await vi.waitFor(() => { expect(ctx.agents.get(grandchild.childId)).toBeDefined() }) const disposals: SessionId[] = [] - ctx.on('agent/disposed', (agent) => { disposals.push(agent.id) }) + ctx.on('agent/disposed', ({ agent }) => { disposals.push(agent.id) }) const drained = drainManager(ctx) // Let the held model call observe its cancellation so quiescence can settle. hold.resolve(undefined) @@ -984,7 +984,7 @@ describe('continuable durability and teardown', () => { const drains: Promise[] = [] const accepted: MessageId[] = [] ctx.on('subagent/start', () => { drains.push(drainManager(ctx)) }) - ctx.on('agent/inbox/inserted', (_agent, item) => { accepted.push(item.message.id) }) + ctx.on('agent/inbox/inserted', ({ message }) => { accepted.push(message.id) }) await expect(ctx.subagents.startContinuable(startSpec(parent))) .rejects.toMatchObject({ code: 'DRAINING' }) @@ -998,12 +998,12 @@ describe('continuable durability and teardown', () => { const { ctx, parent } = await setup([]) const order: string[] = [] const drains: Promise[] = [] - ctx.on('agent/created', (child) => { + ctx.on('agent/created', ({ agent: child }) => { if (child === parent) return const draining = drainManager(ctx).then(() => { order.push('drain') }) drains.push(draining) }) - ctx.on('agent/disposed', (child) => { + ctx.on('agent/disposed', ({ agent: child }) => { if (child !== parent) order.push('disposed') }) @@ -1025,8 +1025,8 @@ describe('continuable durability and teardown', () => { await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! const order: string[] = [] - child.ctx.on('agent/inbox/inserted', (_agent, accepted) => { - if (accepted.message.content.some(block => block.type === 'text' && block.text === 'before drain')) { + child.ctx.on('agent/inbox/inserted', ({ message }) => { + if (message.content.some(block => block.type === 'text' && block.text === 'before drain')) { order.push('enqueue') } }) @@ -1208,7 +1208,7 @@ describe('continuable review regressions', () => { const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', (info) => { ends.push(info) }) // Block the resumed prompt so this epoch produces nothing of its own. - ctx.on('agent/pre-step', async (subject, _messages, _context, next) => { + ctx.on('agent/pre-step', async ({ agent: subject }, next) => { if (subject === parent) return next() return { kind: 'reject' } }) @@ -1356,8 +1356,8 @@ describe('continuable review regressions', () => { // Cancel from the synchronous enqueue observer: the discard fires after the // id is recorded but before `followup()` returns. - const off = child.ctx.on('agent/inbox/inserted', (_agent, accepted) => { - if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) { + const off = child.ctx.on('agent/inbox/inserted', ({ message }) => { + if (message.content.some(block => block.type === 'text' && block.text === 'doomed')) { child.cancel({ kind: 'user' }) } }) @@ -1388,8 +1388,8 @@ describe('continuable review regressions', () => { await followup(ctx, parent, started.childId, message('queued')) expect(activation.accepted.size).toBe(1) - const off = child.ctx.on('agent/inbox/inserted', (_agent, accepted) => { - if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) { + const off = child.ctx.on('agent/inbox/inserted', ({ message }) => { + if (message.content.some(block => block.type === 'text' && block.text === 'doomed')) { child.cancel({ kind: 'user' }) } }) @@ -1406,7 +1406,7 @@ describe('continuable review regressions', () => { const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', (info) => { ends.push(info) }) // Block admission so the child's only turn never opens. - ctx.on('agent/pre-step', async (subject, _messages, _context, next) => { + ctx.on('agent/pre-step', async ({ agent: subject }, next) => { if (subject === parent) return next() return { kind: 'reject' } }) @@ -1428,7 +1428,7 @@ describe('continuable review regressions', () => { const registeredAtEnqueue: boolean[] = [] // A synchronous inbox observer runs before the admitting microtask, the // exact window where `Agent.status` is still idle. - ctx.on('agent/inbox/inserted', (agent) => { + ctx.on('agent/inbox/inserted', ({ agent }) => { if (agent.session.header.parentSession !== undefined) { registeredAtEnqueue.push(ctx.agents.get(agent.id) === agent) } diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 64c29d5122..ac90b4612b 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -164,9 +164,9 @@ describe('dsh-tool-subagent-report', () => { const { started, child } = await startChild(ctx, parent) const parentRequests = adapter.requests.filter(request => request.sessionId === parent.id).length const enqueues: string[] = [] - ctx.on('agent/inbox/inserted', (agent, item) => { + ctx.on('agent/inbox/inserted', ({ agent, message }) => { if (agent === parent) { - enqueues.push(agent.inbox.nextTurn.some(message => message.id === item.message.id) ? 'queued' : 'steering') + enqueues.push(agent.inbox.nextTurn.some(queued => queued.id === message.id) ? 'queued' : 'steering') } }) @@ -190,9 +190,9 @@ describe('dsh-tool-subagent-report', () => { const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'wakeup' } }) const { child } = await startChild(ctx, parent) const enqueues: string[] = [] - ctx.on('agent/inbox/inserted', (agent, item) => { + ctx.on('agent/inbox/inserted', ({ agent, message }) => { if (agent === parent) { - enqueues.push(agent.inbox.nextTurn.some(message => message.id === item.message.id) ? 'queued' : 'steering') + enqueues.push(agent.inbox.nextTurn.some(queued => queued.id === message.id) ? 'queued' : 'steering') } }) diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index 0bebbcc561..5cc17ddb79 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -89,7 +89,7 @@ export class TelemetryCoordinator { this.hintFlush(session) }) }) - ctx.on('agent/error', (agent, turn, step, error) => { + ctx.on('agent/error', ({ agent, turn, step, error }) => { this.contain(() => { this.relayAgentError(agent, turn, step, error) }) diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index 02ca434c0d..8bdf71ff7b 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -427,7 +427,7 @@ describe('TelemetryCoordinator lifecycle and containment', () => { const session = liveSession(ctx, 'erring') // Only the members the relay reads; the full Agent surface is irrelevant here. const agent = { id: 'agent-1', session } as Agent - ctx.emit('agent/error', agent, 3, 2, error) + ctx.emit('agent/error', { agent, turn: 3, step: 2, error }) const record = backend.records.find(r => r.channel === 'ops')! expect(record.severity).toBe('error') expect(record.attributes).toMatchObject({ diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index aff2958de3..f8be1ec27f 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -25,7 +25,7 @@ async function harness(adapter: MockAdapter): Promise { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index e44b171c37..797e0cbfea 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -72,7 +72,7 @@ export class HarnessSdkServer { const payload: SessionEventNotification = { sessionId: String(session.id), event } this.transport.notify('session.event', payload) })) - this.disposers.push(ctx.on('agent/status', (agent, status) => { + this.disposers.push(ctx.on('agent/status', ({ agent, status }) => { this.transport.notify('session.status', { sessionId: String(agent.session.id), status }) })) this.disposers.push(ctx.on('session/created', (session) => { diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 78f3e11983..c9d1944781 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -254,8 +254,8 @@ describe('HarnessSdkServer', () => { session, } satisfies Pick) as Agent - ctx.emit('agent/status', agent, 'running') - ctx.emit('agent/status', agent, 'idle') + ctx.emit('agent/status', { agent, status: 'running' }) + ctx.emit('agent/status', { agent, status: 'idle' }) expect(transport.notifications.filter(notification => notification.method === 'session.status')) .toEqual([ diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 2d15ec03e5..603ad20d8e 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -141,11 +141,6 @@ "symbol": "Agent", "source": "packages/core/agent/src/types.ts" }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "PreStepContext", - "source": "packages/core/agent/src/types.ts" - }, { "doc": "docs/core-data-structures/core.md", "symbol": "PreStepDecision",