Merge remote-tracking branch 'origin/master' into worktree/turn-tail-actions

This commit is contained in:
creatixchu
2026-08-06 19:47:08 +08:00
132 changed files with 1012 additions and 710 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # 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 # 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.md: ca56c77a097e3008a50c2aec24040a4f4b6f0ba3
2026-07-16-explicit-turn-cancellation.zh.md: 6f8b83fdb42af03c97dc2e8a9345a01acc6019fc 2026-07-16-explicit-turn-cancellation.zh.md: bf410e5c7284a9c9914edbd14445074e71dd6943

View File

@@ -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 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. `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. **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. **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.

View File

@@ -20,7 +20,7 @@ AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它
对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作属于下一个轮次。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `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 传递。 `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;超时或替代只有在拥有明确归属策略和唯一终态含义时才应进入联合类型。 **现在就定义推测性的 `superseded`、`timeout` 和 `shutdown` 变体。** 当前没有 Agent 取消生产方实现这些语义。`shutdown` 已经属于生命周期 dispose;超时或替代只有在拥有明确归属策略和唯一终态含义时才应进入联合类型。
**公开轮次或步骤上下文包装类型。** 现有位置参数 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。 **公开轮次或步骤上下文包装类型。** 现有 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。
**在宽限期后放弃不协作的工作。** 同进程工作仍在运行时就报告空闲状态,会破坏资源清理与资源归属保证。硬终止需要 worker 或进程隔离边界,不属于该控制 seam。 **在宽限期后放弃不协作的工作。** 同进程工作仍在运行时就报告空闲状态,会破坏资源清理与资源归属保证。硬终止需要 worker 或进程隔离边界,不属于该控制 seam。

View File

@@ -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

View File

@@ -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.

View File

@@ -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 的热路径保持零分配。

View File

@@ -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

View File

@@ -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.

View File

@@ -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` 在一次未计价替换前后保持不动。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # 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 # 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.md: 26e6e2468c7bea661d85c8fb994adf8b109105ee
2026-06-18-compaction-capability-seam.zh.md: 1fe9ece2861bd6d75633a866a4a11eaadbf7ef26 2026-06-18-compaction-capability-seam.zh.md: 8f9cd1f6bc31e648a5b923e816cad75ebc1a0bd8

View File

@@ -119,7 +119,7 @@ The lifecycle boundary makes crash state unambiguous:
## Consequences ## 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. - **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. - **`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-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. - **`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.

View File

@@ -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` 独立拥有回放感知的测量。 - **包**:`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`,因此事件分类门禁无需新增条目。 - **`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-compact`** 拥有 `COMPACT_CHECKPOINT_SOURCE`、`isCompactCheckpointSource(source)`、`toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`。该标记用于跨后端实现识别替换摘要。带缓存的 surface 边缘检查会防止 `compactRegion` 和 `compactIfNeeded` 拆分工具调用/结果对,按 seq 校验当前成员关系,从每个切割点的一条平衡序列回答两侧边缘,并拒绝陈旧或缺失的 seq 与孤立结果。
- **`dsh-session`** 通过唯一的 surface 管理器校验位置替换、完整溯源信息和仅内容的单节点 `tool/result` 重写。其不变式配套插件将新追加的工具结果视为执行,要求存在已打开的步骤与待处理调用,而压缩配套组件拥有数字轮次归属与独立 `null` 归属标记对之间的关系。 - **`dsh-session`** 通过唯一的 surface 管理器校验位置替换、完整溯源信息和仅内容的单节点 `tool/result` 重写。其不变式配套插件将新追加的工具结果视为执行,要求存在已打开的步骤与待处理调用,而压缩配套组件拥有数字轮次归属与独立 `null` 归属标记对之间的关系。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # 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 # 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.md: c318e41cfb1d64230b6151f1febad85d75b1451d
2026-06-30-interception-seams.zh.md: d6958c9d1e7a8af8fa06d859d1905719a19cd43d 2026-06-30-interception-seams.zh.md: 1b274fae4bc7fde326dbb0eeec54d57f73987803

View File

@@ -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. 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 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/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/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. **`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.

View File

@@ -15,8 +15,8 @@ harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那
规范表面将可变换策略、环绕调度控制与仅观测通知分离。策略 waterfall(瀑布式事件)返回小型的、seam 专属的**类型化 Decision 联合类型**;包装层返回规范化结果;通知接收不可变快照,无法影响结果。覆盖的钩子点包括 `session-start`、`prompt-submit`、`pre-tool`、`post-tool`、通过 continuation 实现的 `stop`,同时将非钩子的执行策略留作独立可组合。 规范表面将可变换策略、环绕调度控制与仅观测通知分离。策略 waterfall(瀑布式事件)返回小型的、seam 专属的**类型化 Decision 联合类型**;包装层返回规范化结果;通知接收不可变快照,无法影响结果。覆盖的钩子点包括 `session-start`、`prompt-submit`、`pre-tool`、`post-tool`、通过 continuation 实现的 `stop`,同时将非钩子的执行策略留作独立可组合。
**Agent 事件**(`dsh-agent`): **Agent 事件**(`dsh-agent`):
- `agent/session-start(agent, source)` ——emit,在第 1 轮次之前触发一次,携带 `SessionStartSource`(`startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知,不能阻塞启动(这是有意的空白:桥接可以记录/注入,但不管控启动)。监听器通过 `agent.inject()` 注入上下文。 - `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/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,继续执行或关闭轮次。 **`agent/turn-stopping`** 是自然停止边界上的一次 awaited 通知。需要再执行一步的监听器调用 `agent.steer()`,传入来源显式的 steering(中途引导)内容供模型使用;循环随后重新读取 outbox,继续执行或关闭轮次。

View File

@@ -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

View File

@@ -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.

View File

@@ -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` 的行为。精确执行语义保持不变。

View File

@@ -111,7 +111,7 @@ export async function runHeadless(task: string): Promise<void> {
const abort = new AbortController() const abort = new AbortController()
const frames = api.events.mux({}, abort.signal) const frames = api.events.mux({}, abort.signal)
const idle = new Promise<void>((resolve) => { const idle = new Promise<void>((resolve) => {
ctx.on('agent/status', (agent, status) => { ctx.on('agent/status', ({ agent, status }) => {
if (agent.id === created.sessionId && status === 'idle') resolve() if (agent.id === created.sessionId && status === 'idle') resolve()
}) })
}) })

View File

@@ -26,6 +26,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', impor
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.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') const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md')
// Post-reload golden: the same settled conversation rebuilt purely from // Post-reload golden: the same settled conversation rebuilt purely from
// persistence + history — byte-equal rendering is exactly the recovery claim. // 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( expect(Math.abs(
launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height, launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height,
)).toBeLessThan(1) )).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 input.fill('')
await expect.poll(() => menu.count()).toBe(0) 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 () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([]) expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [ 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',
]) ])
}) })
}) })

View File

@@ -0,0 +1,3 @@
- listbox "Trigger suggestions":
- text: Commands
- option "compact Compact older conversation history" [selected]

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/architecture.md # pnpm run verify-translation-pairing --write docs/architecture.md
architecture.md: 9b84c1482cb379fd796e21db45f128ba49750c54 architecture.md: 40c20a1c9eeabe5ecbbc6edacde81c20071b8a04
architecture.zh.md: 84708fcae24623e50b0157782cf459c35a55844b architecture.zh.md: 6fddaa883775cf8345aba01af52575c0f0e1aaa0

View File

@@ -83,7 +83,7 @@ forever:
-> 'turn/start' -> 'turn/start'
claim next-step input plus one next-turn message claim next-step input plus one next-turn message
-> emit agent/inbox/claimed({ message, turn }) for each claimed 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 reject, empty input, cancellation, or listener failure
-> the claimed batch stays removed; close the no-step turn; stop the driver -> the claimed batch stays removed; close the no-step turn; stop the driver
enter -> step loop: 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)). 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. 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.

View File

@@ -83,7 +83,7 @@ forever:
-> 'turn/start' -> 'turn/start'
claim next-step input plus one next-turn message claim next-step input plus one next-turn message
-> emit agent/inbox/claimed({ message, turn }) for each claimed 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 reject, empty input, cancellation, or listener failure
-> the claimed batch stays removed; close the no-step turn; stop the driver -> the claimed batch stays removed; close the no-step turn; stop the driver
enter -> step loop: 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))。 每个步骤都会组装有序的提示词片段、工具 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)定义。 裁剪先于摘要;溢出重试必须取得持久进展。`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)定义。

View File

@@ -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 * Synchronous listener failure vetoes publication, while returned-promise
* rejection is reported. Detach requested during dispatch waits until every * rejection is reported. Detach requested during dispatch waits until every
* creation listener has observed the stable entry. * 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. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/created'(this: Scoped<Agent>, agent: Agent): void 'agent/created'(this: Scoped<Agent>, payload: { agent: Agent }): void
``` ```
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) 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 ### `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 * An agent left the registry; AgentLoop emits this after driver quiescence
* and scoped-registration unwind, but before session detachment. Custom * and scoped-registration unwind, but before session detachment. Custom
* registry users own their driver-ordering contract. * 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. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void 'agent/disposed'(this: Scoped<Agent>, payload: { agent: Agent }): void
``` ```
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) 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 ### `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 * A step or turn errored. The machine reports a failure here even when
* the error has no in-turn position for a durable record. * the error has no in-turn position for a durable record.
* @param agent - the agent whose turn errored. * @param payload.agent - the agent whose turn errored.
* @param turn - the turn in which the failure surfaced. * @param payload.turn - the turn in which the failure surfaced.
* @param step - the step at which the failure surfaced. * @param payload.step - the step at which the failure surfaced.
* @param error - the failure, verbatim. * @param payload.error - the failure, verbatim.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: unknown): void 'agent/error'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; error: unknown }): void
``` ```
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) 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 ### `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 * 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 * 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. * re-emitted as a user/message, and the turn closes without a step.
* @param agent - the agent whose inbox changed. * @param payload.agent - the agent whose inbox changed.
* @param event - the claimed message and owning turn. * @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. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/inbox/claimed'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage; turn: number }): void 'agent/inbox/claimed'(this: Scoped<Agent>, 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) 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 ### `agent/inbox/discarded` — emit
@@ -105,17 +106,17 @@ One message was discarded from the live inbox.
```ts cordis-catalog ```ts cordis-catalog
/** /**
* One message was discarded from the live inbox. * One message was discarded from the live inbox.
* @param agent - the agent whose inbox changed. * @param payload.agent - the agent whose inbox changed.
* @param event - the discarded message. * @param payload.message - the discarded message.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/inbox/discarded'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void 'agent/inbox/discarded'(this: Scoped<Agent>, 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) 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 ### `agent/inbox/inserted` — emit
@@ -124,17 +125,17 @@ One message entered the live inbox.
```ts cordis-catalog ```ts cordis-catalog
/** /**
* One message entered the live inbox. * One message entered the live inbox.
* @param agent - the agent whose inbox changed. * @param payload.agent - the agent whose inbox changed.
* @param event - the inserted message. * @param payload.message - the inserted message.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/inbox/inserted'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void 'agent/inbox/inserted'(this: Scoped<Agent>, 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) 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 ### `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 * Reject a proposed step or replace the messages that enter it. Calling
* `next()` preserves the current messages. * `next()` preserves the current messages.
* @param agent - the agent proposing the step. * @param payload.agent - the agent proposing the step.
* @param messages - messages removed from the inbox for this step. * @param payload.messages - messages removed from the inbox for this step.
* @param context - proposed turn and step coordinates plus cancellation. * @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. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall * @mode waterfall
*/ */
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise<PreStepDecision>): Promise<PreStepDecision> 'agent/pre-step'(this: Scoped<Agent>, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>
``` ```
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 ### `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 * the machine would use (agent options on the first request, the logged
* header afterwards); return a replacement to switch. Model-visible * header afterwards); return a replacement to switch. Model-visible
* content must use logged channels; this seam cannot mutate messages. * content must use logged channels; this seam cannot mutate messages.
* @param agent - the agent making the model call. * @param payload.agent - the agent making the model call.
* @param turn - the open turn number. * @param payload.turn - the open turn number.
* @param step - the step whose request this is. * @param payload.step - the step whose request this is.
* @param signal - the current turn's explicit abort signal. * @param payload.signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall * @mode waterfall
*/ */
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig> 'agent/request'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
``` ```
Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) 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 ### `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()` * its step. A listener returns `{ kind: 'retry' }` without calling `next()`
* when it owns recovery, or calls `next()` to delegate. The default * when it owns recovery, or calls `next()` to delegate. The default
* `undefined` leaves the failure terminal. * `undefined` leaves the failure terminal.
* @param agent - the agent whose request failed. * @param payload.agent - the agent whose request failed.
* @param context - request coordinates, provider, normalized failure, and serving policy. * @param payload.turn - the turn containing the failed request.
* @param signal - the turn abort signal. * @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. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall * @mode waterfall
*/ */
'agent/request-error'(this: Scoped<Agent>, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction> 'agent/request-error'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; provider: string; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined; signal: AbortSignal }, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>
``` ```
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 ### `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 * `agent.inject()` to seed model-facing context. This is a notification, not
* a veto; disposal requested by a lifecycle owner is rechecked before the * a veto; disposal requested by a lifecycle owner is rechecked before the
* driver starts. * driver starts.
* @param agent - the agent whose session lifecycle began. * @param payload.agent - the agent whose session lifecycle began.
* @param source - why the session started (fresh startup, resume, …). * @param payload.source - why the session started (fresh startup, resume, …).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/session-start'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void 'agent/session-start'(this: Scoped<Agent>, 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) 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 ### `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 * Agent status changed (`idle` ⇄ `running`). A waking delivery enters
* `running` synchronously after reserving cancellation; `idle` means no * `running` synchronously after reserving cancellation; `idle` means no
* driver remains scheduled or active. * driver remains scheduled or active.
* @param agent - the agent whose status flipped. * @param payload.agent - the agent whose status flipped.
* @param status - the status just entered (the transition's destination). * @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. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void 'agent/status'(this: Scoped<Agent>, 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) 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 ### `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 * never short-circuits already-submitted next-step work: same-step
* `additionalContexts` or racing steering still runs, and the turn * `additionalContexts` or racing steering still runs, and the turn
* closes only when that inbox drains. * closes only when that inbox drains.
* @param agent - the agent whose turn is at its stop boundary. * @param payload.agent - the agent whose turn is at its stop boundary.
* @param turn - the turn about to close. * @param payload.turn - the turn about to close.
* @param signal - the current turn's explicit abort signal. * @param payload.signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode serial * @mode serial
*/ */
'agent/turn-stopping'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<void> | void 'agent/turn-stopping'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; signal: AbortSignal }): Promise<void> | void
``` ```
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) 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/*` ## `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 * Consumers that buffer work for the configured identity use this
* transient signal to reject that work instead of waiting forever. Normal * transient signal to reject that work instead of waiting forever. Normal
* factory teardown suppresses failures from the cancelled startup attempt. * factory teardown suppresses failures from the cancelled startup attempt.
* @param sessionId - exact shared agent/session identity that failed startup. * @param payload.sessionId - exact shared agent/session identity that failed startup.
* @param error - persistence, setup, or publication failure. * @param payload.error - persistence, setup, or publication failure.
* @mode emit * @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) 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` * Goal mutation accepted by one live agent. The matching `goal/change`
* session event has already committed. Listener failures are contained. * session event has already committed. Listener failures are contained.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @param agent - agent whose session owns the goal. * @param payload.agent - agent whose session owns the goal.
* @param change - fresh current projection or clear tombstone. * @param payload.change - fresh current projection or clear tombstone.
* @mode emit * @mode emit
*/ */
'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, agent: Agent, change: GoalChanged): void 'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, 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) Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/core-data-structures/core.md # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md
core.md: 6c9b778f2ec6a0b4e3ffdf7d69e5e4d34df0b4a9 core.md: 52e77be89d939eefa2b42ef5586c5798e194a303
core.zh.md: 4912060cad1f3b9fce910ec8c24b8b50dafd6ed1 core.zh.md: 5f0134a4c8b3dead49830b41f62e8b7238327cfa

View File

@@ -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) 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: `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:
```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
}
```
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: 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:

View File

@@ -736,19 +736,7 @@ pre-step 决策使用与持久 user-role 输入相同、带标识的 `UserMessag
源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)
`agent/pre-step` 接收独占的已领取批次,以及拟进入步骤的坐标与取消 signal。首次提案在已打开的轮次内、任何步骤开始前运行;工具 continuation 可以在步骤之间提交空的已领取批次: `agent/pre-step` 接收一个 payload,携带独占的已领取批次(`messages`)、拟进入步骤的坐标(`turn`、`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
}
```
它返回 `PreStepDecision`。reject 不会打开步骤。enter 提供在 `step/start` 后追加的完整消息批次;最终决策省略的已领取消息保持已删除,而领取后插入的输入仍留待后续处理: 它返回 `PreStepDecision`。reject 不会打开步骤。enter 提供在 `step/start` 后追加的完整消息批次;最终决策省略的已领取消息保持已删除,而领取后插入的输入仍留待后续处理:

View File

@@ -8,18 +8,18 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event | Mode | Declared in | Dispatchers | Listeners | | 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-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/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: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/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: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/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: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/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: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/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:205`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | | `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: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/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:260`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `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: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/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: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/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: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/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: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/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` | | `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` | | `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) | | `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) |

View File

@@ -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/*` ### `approval/*`

View File

@@ -84,13 +84,13 @@ export function apply(ctx: Context): void {
// runs, so the queued FIFO order is what the transcript records. The first // 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. // child enqueue is the initial delegation, which also pins the real child id.
let accepted = 0 let accepted = 0
ctx.on('agent/inbox/inserted', (agent) => { ctx.on('agent/inbox/inserted', ({ agent }) => {
if (agent.session.header.parentSession === undefined) return if (agent.session.header.parentSession === undefined) return
if (realChildId === undefined) realChildId = agent.session.header.id if (realChildId === undefined) realChildId = agent.session.header.id
accepted += 1 accepted += 1
if (accepted >= 3) followupsAccepted.resolve(undefined) 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 if (agent.session.header.parentSession !== undefined) await followupsAccepted.promise
return next() return next()
}) })

View File

@@ -302,7 +302,7 @@ describe('Code Mode typed values: keyless real-worker contracts', () => {
function waitForIdle(harness: Context, agent: Agent): Promise<void> { function waitForIdle(harness: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { 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') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()

View File

@@ -59,7 +59,7 @@ export const inject = ['llm']
/** Register the keyless `cli-mock` adapter. */ /** Register the keyless `cli-mock` adapter. */
export function apply(ctx: Context): void { export function apply(ctx: Context): void {
ctx.llm.registerAdapter(['cli-mock'], new CliMockAdapter()) 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() const config = await next()
return step === 2 ? { ...config, reasoningEffort: OFF } : config return step === 2 ? { ...config, reasoningEffort: OFF } : config
}) })

View File

@@ -7,7 +7,7 @@ export const name = 'seed-goal'
export const inject = ['goals'] export const inject = ['goals']
export function apply(ctx: Context): void { 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) { if (ctx.goals.get(agent) === undefined) {
ctx.goals.create(agent, { ctx.goals.create(agent, {
objective: 'Prove the composed goal survives in the session log', objective: 'Prove the composed goal survives in the session log',

View File

@@ -84,7 +84,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio
export function waitForIdle(ctx: Context, agent: Agent): Promise<void> { export function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { 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') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()

View File

@@ -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 record = ownedRecord(agent)
const inflight = record?.inflight const inflight = record?.inflight
if (inflight !== undefined && inflight.messageId === message.id) inflight.turn = turn 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 record = ownedRecord(agent)
const inflight = record?.inflight const inflight = record?.inflight
if (record === undefined || inflight === undefined || inflight.turn === turn) return if (record === undefined || inflight === undefined || inflight.turn === turn) return

View File

@@ -87,7 +87,7 @@ describe('ACP prompt lifecycle', () => {
const sessionId = await newSession(harness) const sessionId = await newSession(harness)
const agent = harness.ctx.agents.get(SessionId(sessionId))! const agent = harness.ctx.agents.get(SessionId(sessionId))!
let injected = false 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) { if (subject === agent && message.source.kind === 'user' && !injected) {
injected = true injected = true
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } })) 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')] }) harness = await makeBridgeHarness({ script: [errorResponse('transient boom'), textResponse('recovered')] })
// A recovery policy: schedule one retry for the failed request. // A recovery policy: schedule one retry for the failed request.
let retried = false let retried = false
harness.ctx.on('agent/request-error', async (_subject) => { harness.ctx.on('agent/request-error', async () => {
if (!retried) { if (!retried) {
retried = true retried = true
return { kind: 'retry' } return { kind: 'retry' }
@@ -272,7 +272,7 @@ describe('ACP prompt lifecycle', () => {
it('cancels a prompt removed before its turn claims it', async () => { it('cancels a prompt removed before its turn claims it', async () => {
harness = await makeBridgeHarness({ script: [] }) harness = await makeBridgeHarness({ script: [] })
const sessionId = await newSession(harness) 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) if (message.source.kind === 'user') agent.inbox.remove(message.id)
}) })

View File

@@ -48,7 +48,7 @@ afterEach(() => {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { 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') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-command/README.md # pnpm run verify-translation-pairing --write packages/client/ui-command/README.md
README.md: a19bfe7135acc5408448dc73d04813ed4104dd48 README.md: bc7386c8fca3b5c623473328bee6322fa7295277
README.zh.md: 79d903b916728c1200ab1311055f2be190008787 README.zh.md: 478ee4ccee4558c70075baa45ab34ac6e3d71617

View File

@@ -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. `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`. `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. 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.

View File

@@ -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 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 `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` 绑定的草稿表层。 `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 注册的内部实现。 `/client` 导出表层是插件主体(`apply`/`inject`)、`CommandService`、目录类和 popup 类及其状态类型,以及冻结的契约类型;壳组件本身是 overlay 注册的内部实现。

View File

@@ -2,9 +2,10 @@
* CommandService (`ctx.command`): the '/' command source over the * CommandService (`ctx.command`): the '/' command source over the
* session-keyed directory, the client-contribution registry, and the * session-keyed directory, the client-contribution registry, and the
* per-session popupSelect controllers. Candidate synthesis merges the host * per-session popupSelect controllers. Candidate synthesis merges the host
* catalog with contributions by availability, then query/position filtering; * catalog with contributions by availability, then fuzzy query/position
* a host/contribution name collision fails loud. Every execute addresses the * filtering; a host/contribution name collision fails loud. Every execute
* session's agent by sessionId — sessions are always agent-backed. * addresses the session's agent by sessionId — sessions are always
* agent-backed.
*/ */
import { Service } from 'cordis' import { Service } from 'cordis'
import type { Context } from 'cordis' import type { Context } from 'cordis'
@@ -27,6 +28,69 @@ interface LiveState {
readonly popups: Map<SessionId, PopupSelectController<ClientSessionContext>> readonly popups: Map<SessionId, PopupSelectController<ClientSessionContext>>
} }
/** 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<number>(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<number>(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. */ /** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */
export class CommandService extends Service implements CommandServiceContract { export class CommandService extends Service implements CommandServiceContract {
static inject = ['slash', 'sessions', 'connection'] 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<readonly SlashCandidate[]> { private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise<readonly SlashCandidate[]> {
const list = await this.directory.ensureReady(session.sessionId, req.signal) const list = await this.directory.ensureReady(session.sessionId, req.signal)
const rows: SlashCandidate[] = [] const rows: SlashCandidate[] = []
@@ -163,9 +227,10 @@ export class CommandService extends Service implements CommandServiceContract {
} }
rows.push({ name: contribution.name, description: contribution.description }) rows.push({ name: contribution.name, description: contribution.description })
} }
return rows return fuzzyCandidates(
.filter(c => c.name.startsWith(req.query)) rows.filter(c => req.position === 'leading' || c.hint === undefined),
.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. */ /** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */

View File

@@ -164,13 +164,33 @@ describe('candidates', () => {
expect(b.listCalls).toEqual([]) 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 { source, listCalls } = await bench()
const list = await source.candidates(proj('s1'), req('g')) const list = await source.candidates(proj('s1'), req('g'))
expect(listCalls).toEqual([{ sessionId: sid('s1') }]) expect(listCalls).toEqual([{ sessionId: sid('s1') }])
expect(list).toEqual([{ name: 'goal', description: 'leadingInput kind', hint: 'goal text' }]) 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 () => { it('catalogs are per session: another session pulls its own key', async () => {
const { source, listCalls } = await bench() const { source, listCalls } = await bench()
const names = (await source.candidates(proj('s2'), req(''))).map(c => c.name) const names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
@@ -195,10 +215,10 @@ describe('candidates', () => {
expect(s2Names).not.toContain('theme') 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() const { command, source } = await bench()
command.register(themeContribution()) 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']) expect(names).toEqual(['theme'])
}) })

View File

@@ -144,9 +144,7 @@ export class BasicCompactService extends CompactService {
} }
ctx.on('agent/pre-step', async ( ctx.on('agent/pre-step', async (
agent: Agent, { agent, signal },
_messages,
{ signal },
next, next,
): Promise<PreStepDecision> => { ): Promise<PreStepDecision> => {
if (!signal.aborted) { if (!signal.aborted) {
@@ -165,7 +163,7 @@ export class BasicCompactService extends CompactService {
return next() return next()
}) })
ctx.on('agent/status', (agent, status) => { ctx.on('agent/status', ({ agent, status }) => {
if (status === 'idle') this.overflowRetries.delete(agent) if (status === 'idle') this.overflowRetries.delete(agent)
}) })
@@ -178,12 +176,9 @@ export class BasicCompactService extends CompactService {
}) })
ctx.on('agent/request-error', async ( ctx.on('agent/request-error', async (
agent, { agent, failure, signal },
context,
signal,
next, next,
) => { ) => {
const { failure } = context
if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next() if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next()
this.overflowAgents.set(agent.session, agent) this.overflowAgents.set(agent.session, agent)
const target = routedTarget(agent.session) const target = routedTarget(agent.session)

View File

@@ -1372,7 +1372,7 @@ describe('default one-shot summarizer', () => {
describe('automatic listener and loader composition', () => { describe('automatic listener and loader composition', () => {
function preStep(ctx: Context, owner: Agent, signal = SIGNAL) { function preStep(ctx: Context, owner: Agent, signal = SIGNAL) {
return agentEvents(ctx, owner).waterfall( 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: [] }), () => 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 const turn = owner.session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 1
return agentEvents(ctx, owner).waterfall( return agentEvents(ctx, owner).waterfall(
'agent/request-error', 'agent/request-error',
{ turn, step: 1, provider: 'test', failure, retryPolicy: undefined }, { turn, step: 1, provider: 'test', failure, retryPolicy: undefined, signal },
signal,
next, next,
).then(action => action?.kind === 'retry') ).then(action => action?.kind === 'retry')
} }

View File

@@ -175,7 +175,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { 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') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()
@@ -217,7 +217,7 @@ function overflowHistorySeed(): SessionEvent[] {
describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => { 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 () => { it('uses the model actually routed by agent/request for post-step pressure', async () => {
const { ctx } = await harness(8) 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', ...await next(), provider: 'mock', model: 'mock',
})) }))
try { try {
@@ -315,7 +315,7 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeterService) await ctx.plugin(TokenMeterService)
ctx.llm.registerAdapter(['mock'], adapter) 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 next(), provider: 'mock', model: 'mock',
})) }))
await ctx.plugin(BasicCompactService, { await ctx.plugin(BasicCompactService, {

View File

@@ -157,9 +157,7 @@ export function apply(ctx: Context, config: Config): void {
const resolvedTimeZone = formatter.resolvedOptions().timeZone const resolvedTimeZone = formatter.resolvedOptions().timeZone
ctx.on('agent/pre-step', async ( ctx.on('agent/pre-step', async (
agent: Agent, { agent, turn, step, signal },
_messages,
{ turn, step, signal },
next, next,
): Promise<PreStepDecision> => { ): Promise<PreStepDecision> => {
const decision = await next() const decision = await next()

View File

@@ -82,8 +82,7 @@ async function fire(
): Promise<void> { ): Promise<void> {
const decision = await agentEvents(ctx, agent).waterfall( const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', 'agent/pre-step',
[], { messages: [], turn, step, signal },
{ turn, step, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }), () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
) )
if (decision.kind === 'enter') { 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) => { ] as const)('does not commit a preparation reading when a downstream pre-step listener %s', async (mode) => {
const adapter = new ScriptedAdapter([textResponse('unused')]) const adapter = new ScriptedAdapter([textResponse('unused')])
const ctx = await loopHarness(adapter) 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') if (mode === 'throws') throw new Error('later pre-step failure')
subject.cancel({ kind: 'user' }) subject.cancel({ kind: 'user' })
return next() return next()

View File

@@ -216,9 +216,7 @@ export function apply(ctx: Context, config: Config): void {
validateRefreshInterval(refreshIntervalMs) validateRefreshInterval(refreshIntervalMs)
ctx.on('agent/pre-step', async ( ctx.on('agent/pre-step', async (
agent: Agent, { agent, turn, step, signal },
_messages,
{ turn, step, signal },
next, next,
): Promise<PreStepDecision> => { ): Promise<PreStepDecision> => {
const decision = await next() const decision = await next()

View File

@@ -138,8 +138,7 @@ async function fire(
): Promise<void> { ): Promise<void> {
const decision = await agentEvents(ctx, agent).waterfall( const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', 'agent/pre-step',
[], { messages: [], turn, step, signal },
{ turn, step, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }), () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
) )
if (decision.kind === 'enter') { if (decision.kind === 'enter') {

View File

@@ -213,9 +213,7 @@ export function apply(ctx: Context, config: Config): void {
} }
ctx.on('agent/pre-step', async ( ctx.on('agent/pre-step', async (
agent: Agent, { agent, messages, step, signal },
messages,
{ step, signal },
next, next,
): Promise<PreStepDecision> => { ): Promise<PreStepDecision> => {
const decision = await next() const decision = await next()

View File

@@ -57,7 +57,7 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { 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') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()

View File

@@ -209,8 +209,7 @@ async function workspaceContextOf(agent: Agent): Promise<UserMessage> {
async function syncWorkspaceContext(ctx: Context, agent: Agent): Promise<void> { async function syncWorkspaceContext(ctx: Context, agent: Agent): Promise<void> {
await agentEvents(ctx, agent).waterfall( await agentEvents(ctx, agent).waterfall(
'agent/pre-step', [], 'agent/pre-step', { messages: [], turn: 1, step: 1, signal: testToolSignal },
{ turn: 1, step: 1, signal: testToolSignal },
async () => ({ kind: 'enter' as const, messages: [] }), async () => ({ kind: 'enter' as const, messages: [] }),
) )
} }
@@ -245,15 +244,13 @@ async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise<Messag
const signal = AbortSignal.timeout(1000) const signal = AbortSignal.timeout(1000)
await agentEvents(ctx, agent).waterfall( await agentEvents(ctx, agent).waterfall(
'agent/pre-step', 'agent/pre-step',
[], { messages: [], turn: 1, step: 1, signal },
{ turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }), () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
) )
const claimed = agent.inbox.claim('next-step', 1) const claimed = agent.inbox.claim('next-step', 1)
const decision = await agentEvents(ctx, agent).waterfall( const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', 'agent/pre-step',
claimed, { messages: claimed, turn: 1, step: 2, signal },
{ turn: 1, step: 2, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: claimed }), () => Promise.resolve({ kind: 'enter' as const, messages: claimed }),
) )
const entered = decision.kind === 'enter' ? decision.messages : [] const entered = decision.kind === 'enter' ? decision.messages : []
@@ -969,8 +966,7 @@ describe('workspace context request injection', () => {
const original = stubAgent(root) const original = stubAgent(root)
await agentEvents(ctx, original).waterfall( await agentEvents(ctx, original).waterfall(
'agent/pre-step', 'agent/pre-step',
[], { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }), () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
) )
const inserted = original.inbox.nextStep[0] const inserted = original.inbox.nextStep[0]
@@ -979,12 +975,11 @@ describe('workspace context request injection', () => {
await fiber.dispose() await fiber.dispose()
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const resumed = stubAgent(root, [...original.session.events]) 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 claimed = resumed.inbox.claim('next-step', 1)
const decision = await agentEvents(ctx, resumed).waterfall( const decision = await agentEvents(ctx, resumed).waterfall(
'agent/pre-step', 'agent/pre-step',
claimed, { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve({ kind: 'enter' as const, messages: claimed }), () => Promise.resolve({ kind: 'enter' as const, messages: claimed }),
) )
if (decision.kind !== 'enter') throw new Error('recovered baseline was rejected') if (decision.kind !== 'enter') throw new Error('recovered baseline was rejected')
@@ -1016,8 +1011,7 @@ describe('workspace context request injection', () => {
const original = stubAgent(root) const original = stubAgent(root)
await agentEvents(ctx, original).waterfall( await agentEvents(ctx, original).waterfall(
'agent/pre-step', 'agent/pre-step',
[], { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }), () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
) )
const stale = original.inbox.nextStep[0] const stale = original.inbox.nextStep[0]
@@ -1027,12 +1021,11 @@ describe('workspace context request injection', () => {
await fiber.dispose() await fiber.dispose()
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const resumed = stubAgent(root, [...original.session.events]) 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 staleClaim = resumed.inbox.claim('next-step', 1)
const staleDecision = await agentEvents(ctx, resumed).waterfall( const staleDecision = await agentEvents(ctx, resumed).waterfall(
'agent/pre-step', 'agent/pre-step',
staleClaim, { messages: staleClaim, turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve({ kind: 'enter' as const, messages: staleClaim }), () => Promise.resolve({ kind: 'enter' as const, messages: staleClaim }),
) )
@@ -1071,8 +1064,7 @@ describe('workspace context request injection', () => {
const original = stubAgent(root) const original = stubAgent(root)
await agentEvents(originalCtx, original).waterfall( await agentEvents(originalCtx, original).waterfall(
'agent/pre-step', 'agent/pre-step',
[], { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }), () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
) )
const stale = original.inbox.nextStep[0] const stale = original.inbox.nextStep[0]
@@ -1082,12 +1074,11 @@ describe('workspace context request injection', () => {
if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' }) if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' })
await resumedCtx.plugin(workspaceContext, { dshHome: home, maxBytes }) await resumedCtx.plugin(workspaceContext, { dshHome: home, maxBytes })
const resumed = stubAgent(root, [...original.session.events]) 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 claimed = resumed.inbox.claim('next-step', 1)
const decision = await agentEvents(resumedCtx, resumed).waterfall( const decision = await agentEvents(resumedCtx, resumed).waterfall(
'agent/pre-step', 'agent/pre-step',
claimed, { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve({ kind: 'enter' as const, messages: claimed }), () => Promise.resolve({ kind: 'enter' as const, messages: claimed }),
) )
@@ -1191,8 +1182,7 @@ describe('workspace context request injection', () => {
const decision = await agentEvents(ctx, agent).waterfall( const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', 'agent/pre-step',
[prompt], { messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve(downstream), () => Promise.resolve(downstream),
) )
@@ -1249,8 +1239,7 @@ describe('workspace context request injection', () => {
const decision = await agentEvents(ctx, agent).waterfall( const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', 'agent/pre-step',
[], { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve(downstream), () => Promise.resolve(downstream),
) )
@@ -1356,7 +1345,7 @@ describe('workspace context request injection', () => {
const resumed = stubAgent(root, [...original.session.events]) const resumed = stubAgent(root, [...original.session.events])
// Resume announces its lifecycle start before the first step. // 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) await composeBaselinePrefix(ctx, resumed)
const baselines = baselineEvents(resumed) const baselines = baselineEvents(resumed)
@@ -1404,7 +1393,7 @@ describe('workspace context request injection', () => {
await write(join(root, 'AGENTS.md'), 'repo rule') await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context() const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) 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() const decision = await next()
if (decision.kind === 'reject') return decision if (decision.kind === 'reject') return decision
return { return {
@@ -1678,8 +1667,7 @@ describe('workspace context request injection', () => {
const reason = new Error('cancel prefix') const reason = new Error('cancel prefix')
const pending = agentEvents(ctx, stubAgent(root)).waterfall( const pending = agentEvents(ctx, stubAgent(root)).waterfall(
'agent/pre-step', 'agent/pre-step',
[], { messages: [], turn: 1, step: 1, signal: controller.signal },
{ turn: 1, step: 1, signal: controller.signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }), () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
) )
@@ -3868,8 +3856,7 @@ describe('workspace context inbox synchronization', () => {
controller.abort(new Error('abort pre-step reconciliation')) controller.abort(new Error('abort pre-step reconciliation'))
await expect(agentEvents(ctx, agent).waterfall( await expect(agentEvents(ctx, agent).waterfall(
'agent/pre-step', [], 'agent/pre-step', { messages: [], turn: 1, step: 1, signal: controller.signal },
{ turn: 1, step: 1, signal: controller.signal },
async () => ({ kind: 'enter' as const, messages: [] }), async () => ({ kind: 'enter' as const, messages: [] }),
)).rejects.toThrow('abort pre-step reconciliation') )).rejects.toThrow('abort pre-step reconciliation')
@@ -3979,8 +3966,7 @@ describe('workspace context inbox synchronization', () => {
const downstream = { kind: 'enter' as const, messages: claimed } const downstream = { kind: 'enter' as const, messages: claimed }
const decision = await agentEvents(ctx, agent).waterfall( const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', claimed, 'agent/pre-step', { messages: claimed, turn: 1, step: 1, signal: testToolSignal },
{ turn: 1, step: 1, signal: testToolSignal },
async () => downstream, async () => downstream,
) )

View File

@@ -1225,92 +1225,92 @@ export const EVENT_API: readonly EventApiEntry[] = [
{ {
name: 'agent-loop/config-start-failed', name: 'agent-loop/config-start-failed',
mode: 'emit', mode: 'emit',
signature: '\'agent-loop/config-start-failed\'(sessionId: SessionId, error: unknown): void', 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 sessionId - exact shared agent/session identity that failed startup.\n * @param error - persistence, setup, or publication failure.\n * @mode emit\n */', 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.', summary: 'A declarative agent entry failed before it could publish a live agent.',
}, },
{ {
name: 'agent/created', name: 'agent/created',
mode: 'emit', mode: 'emit',
signature: '\'agent/created\'(this: Scoped<Agent>, agent: Agent): void', signature: '\'agent/created\'(this: Scoped<Agent>, 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 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 */', 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.', summary: 'A fully configured agent and live session were published.',
}, },
{ {
name: 'agent/disposed', name: 'agent/disposed',
mode: 'emit', mode: 'emit',
signature: '\'agent/disposed\'(this: Scoped<Agent>, agent: Agent): void', signature: '\'agent/disposed\'(this: Scoped<Agent>, 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 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 */', 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.', summary: 'An agent left the registry; AgentLoop emits this after driver quiescence and scoped-registration unwind, but before session detachment.',
}, },
{ {
name: 'agent/error', name: 'agent/error',
mode: 'emit', mode: 'emit',
signature: '\'agent/error\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: unknown): void', signature: '\'agent/error\'(this: Scoped<Agent>, 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 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 */', 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.', summary: 'A step or turn errored.',
}, },
{ {
name: 'agent/inbox/claimed', name: 'agent/inbox/claimed',
mode: 'emit', mode: 'emit',
signature: '\'agent/inbox/claimed\'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage; turn: number }): void', signature: '\'agent/inbox/claimed\'(this: Scoped<Agent>, 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 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 */', 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.', summary: 'One message left the inbox inside its open turn.',
}, },
{ {
name: 'agent/inbox/discarded', name: 'agent/inbox/discarded',
mode: 'emit', mode: 'emit',
signature: '\'agent/inbox/discarded\'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void', signature: '\'agent/inbox/discarded\'(this: Scoped<Agent>, payload: { agent: Agent; 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 */', 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.', summary: 'One message was discarded from the live inbox.',
}, },
{ {
name: 'agent/inbox/inserted', name: 'agent/inbox/inserted',
mode: 'emit', mode: 'emit',
signature: '\'agent/inbox/inserted\'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void', signature: '\'agent/inbox/inserted\'(this: Scoped<Agent>, payload: { agent: Agent; 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 */', 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.', summary: 'One message entered the live inbox.',
}, },
{ {
name: 'agent/pre-step', name: 'agent/pre-step',
mode: 'waterfall', mode: 'waterfall',
signature: '\'agent/pre-step\'(this: Scoped<Agent>, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>', signature: '\'agent/pre-step\'(this: Scoped<Agent>, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>',
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 */', 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.', summary: 'Reject a proposed step or replace the messages that enter it.',
}, },
{ {
name: 'agent/request', name: 'agent/request',
mode: 'waterfall', mode: 'waterfall',
signature: '\'agent/request\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>', signature: '\'agent/request\'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>',
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*/', 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.', summary: 'Replace the frozen call configuration.',
}, },
{ {
name: 'agent/request-error', name: 'agent/request-error',
mode: 'waterfall', mode: 'waterfall',
signature: '\'agent/request-error\'(this: Scoped<Agent>, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>', signature: '\'agent/request-error\'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; provider: string; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined; signal: AbortSignal }, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>',
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 */', 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.', summary: 'Handle one failed model-request attempt before the loop retries or closes its step.',
}, },
{ {
name: 'agent/session-start', name: 'agent/session-start',
mode: 'emit', mode: 'emit',
signature: '\'agent/session-start\'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void', signature: '\'agent/session-start\'(this: Scoped<Agent>, 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 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 */', 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.', summary: 'The session lifecycle began, once before the first turn.',
}, },
{ {
name: 'agent/status', name: 'agent/status',
mode: 'emit', mode: 'emit',
signature: '\'agent/status\'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void', signature: '\'agent/status\'(this: Scoped<Agent>, 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 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 */', 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`).', summary: 'Agent status changed (`idle` ⇄ `running`).',
}, },
{ {
name: 'agent/turn-stopping', name: 'agent/turn-stopping',
mode: 'serial', mode: 'serial',
signature: '\'agent/turn-stopping\'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<void> | void', signature: '\'agent/turn-stopping\'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; signal: AbortSignal }): Promise<void> | 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 */', 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).', 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', name: 'goal/changed',
mode: 'emit', mode: 'emit',
signature: '\'goal/changed\'(this: import(\'@deepseek-ai/dsh-scope\').Scoped<Agent>, agent: Agent, change: GoalChanged): void', signature: '\'goal/changed\'(this: import(\'@deepseek-ai/dsh-scope\').Scoped<Agent>, 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 agent - agent whose session owns the goal.\n * @param change - fresh current projection or clear tombstone.\n * @mode emit\n */', 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.', summary: 'Goal mutation accepted by one live agent.',
}, },
{ {

View File

@@ -28,7 +28,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { 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') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()

View File

@@ -7,13 +7,15 @@
import type { import type {
Agent, Agent,
AgentCancelCause, AgentCancelCause,
AgentEventDispatch,
AgentOptions, AgentOptions,
AgentStatus, AgentStatus,
CancelOptions, CancelOptions,
InboxTarget, InboxTarget,
PreStepDecision,
RequestErrorAction, RequestErrorAction,
} from '@deepseek-ai/dsh-agent' } 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 type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm'
import { import {
BlockAssembler, BlockAssembler,
@@ -68,6 +70,9 @@ export class ReactLoopAgent implements Agent {
readonly scope: Scope readonly scope: Scope
readonly ctx: Context 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. */ /** Whether this loop instance has appended its initial/resume request anchor. */
private requestHeaderLogged = false private requestHeaderLogged = false
private readonly runtimeContext: RuntimeContextProjection private readonly runtimeContext: RuntimeContextProjection
@@ -78,10 +83,11 @@ export class ReactLoopAgent implements Agent {
public readonly options: AgentOptions, public readonly options: AgentOptions,
public readonly session: Session, public readonly session: Session,
) { ) {
this.dispatch = agentEvents(loopCtx, this)
this.inbox = new Inbox(session, { this.inbox = new Inbox(session, {
inserted: (message) => { emitAgentEvent(loopCtx, this, 'agent/inbox/inserted', { message }) }, inserted: (message) => { this.dispatch.emit('agent/inbox/inserted', { message }) },
discarded: (message) => { emitAgentEvent(loopCtx, this, 'agent/inbox/discarded', { message }) }, discarded: (message) => { this.dispatch.emit('agent/inbox/discarded', { message }) },
claimed: (message, turn) => { emitAgentEvent(loopCtx, this, 'agent/inbox/claimed', { message, turn }) }, claimed: (message, turn) => { this.dispatch.emit('agent/inbox/claimed', { message, turn }) },
}) })
const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0 const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0
this.phase = { kind: 'idle', lastTurn } this.phase = { kind: 'idle', lastTurn }
@@ -100,7 +106,7 @@ export class ReactLoopAgent implements Agent {
this.phase = next this.phase = next
const status = this.status const status = this.status
if (status !== previousStatus) { 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 { private throwError(error: unknown): never {
const turn = this.phase.kind === 'running' ? this.phase.turn : this.phase.lastTurn const turn = this.phase.kind === 'running' ? this.phase.turn : this.phase.lastTurn
const step = this.phase.kind === 'running' ? this.phase.step : 0 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 throw error
} }
@@ -204,9 +210,9 @@ export class ReactLoopAgent implements Agent {
signal.throwIfAborted() signal.throwIfAborted()
const sections = renderContextSections(assembly) const sections = renderContextSections(assembly)
const context = this.runtimeContext.project(joinContextSections(sections), sections) const context = this.runtimeContext.project(joinContextSections(sections), sections)
const decision = await agentEvents(this.loopCtx, this).waterfall( const decision = await this.dispatch.waterfall(
'agent/pre-step', claimed, { ...position, signal }, 'agent/pre-step', { messages: claimed, ...position, signal },
() => Promise.resolve({ (): Promise<PreStepDecision> => Promise.resolve<PreStepDecision>({
kind: 'enter', kind: 'enter',
messages: context === undefined ? claimed : [...claimed, context], messages: context === undefined ? claimed : [...claimed, context],
}), }),
@@ -266,7 +272,7 @@ export class ReactLoopAgent implements Agent {
} }
signal.throwIfAborted() signal.throwIfAborted()
if (turnEnds && this.inbox.nextStep.length === 0) { 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() signal.throwIfAborted()
} }
if (turnEnds && this.inbox.nextStep.length === 0) break if (turnEnds && this.inbox.nextStep.length === 0) break
@@ -323,14 +329,15 @@ export class ReactLoopAgent implements Agent {
signal.throwIfAborted() signal.throwIfAborted()
const finish = assembler.finish const finish = assembler.finish
if (finish.kind === 'error' || finish.kind === 'aborted') { if (finish.kind === 'error' || finish.kind === 'aborted') {
const action = await this.loopCtx.waterfall( const action = await this.dispatch.waterfall(
agentCarrier(this), 'agent/request-error', this, { 'agent/request-error', {
turn, turn,
step, step,
provider: request.provider, provider: request.provider,
failure: finish.failure, failure: finish.failure,
retryPolicy: preparedCall?.retryPolicy, retryPolicy: preparedCall?.retryPolicy,
}, signal, signal,
},
() => Promise.resolve<RequestErrorAction>(undefined), () => Promise.resolve<RequestErrorAction>(undefined),
) )
signal.throwIfAborted() signal.throwIfAborted()
@@ -405,8 +412,8 @@ export class ReactLoopAgent implements Agent {
...maxTokens === undefined ? {} : { maxTokens }, ...maxTokens === undefined ? {} : { maxTokens },
}, },
)) ))
const proposedConfig = await this.loopCtx.waterfall( const proposedConfig = await this.dispatch.waterfall(
agentCarrier(this), 'agent/request', this, turn, step, signal, 'agent/request', { turn, step, signal },
() => Promise.resolve(seedConfig), () => Promise.resolve(seedConfig),
) )
signal.throwIfAborted() signal.throwIfAborted()

View File

@@ -175,11 +175,11 @@ declare module 'cordis' {
* Consumers that buffer work for the configured identity use this * Consumers that buffer work for the configured identity use this
* transient signal to reject that work instead of waiting forever. Normal * transient signal to reject that work instead of waiting forever. Normal
* factory teardown suppresses failures from the cancelled startup attempt. * factory teardown suppresses failures from the cancelled startup attempt.
* @param sessionId - exact shared agent/session identity that failed startup. * @param payload.sessionId - exact shared agent/session identity that failed startup.
* @param error - persistence, setup, or publication failure. * @param payload.error - persistence, setup, or publication failure.
* @mode emit * @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 { ): void {
if (!this.ownership.isActive()) return if (!this.ownership.isActive()) return
this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${errorChain(error)}`) 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)) { for (const callback of this.ctx.events.dispatch('emit', args)) {
try { try {
const returned: unknown = callback(...args) const returned: unknown = callback(...args)
@@ -400,7 +400,7 @@ export class AgentLoop extends Service implements AgentFactory {
released.resolve() released.resolve()
} }
} }
const disposeAgentListener = ownerCtx.on('agent/disposed', checkReleased) const disposeAgentListener = ownerCtx.on('agent/disposed', () => { checkReleased() })
const disposeSessionListener = ownerCtx.on('session/disposed', checkReleased) const disposeSessionListener = ownerCtx.on('session/disposed', checkReleased)
try { try {
checkReleased() checkReleased()
@@ -525,7 +525,7 @@ export class AgentLoop extends Service implements AgentFactory {
// A synchronous announce/session-start listener may have started // A synchronous announce/session-start listener may have started
// teardown; the machine is already live (delivery works from the // teardown; the machine is already live (delivery works from the
// session-start seam), so only the liveness recheck is owed. // 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() assertLive()
return { agent, dispose } return { agent, dispose }
}, },

View File

@@ -31,7 +31,7 @@ async function harness(adapter: LlmAdapter): Promise<Harness> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { 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') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()
@@ -164,18 +164,18 @@ describe('AgentLoop initiator scope', () => {
if (context.agent === agent) capture(context.signal) if (context.agent === agent) capture(context.signal)
return next() 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) { if (subject === agent) {
expect(ctx.agents.requireInitiator()).toBe(agent) expect(ctx.agents.requireInitiator()).toBe(agent)
preStepSignals.push(signal) preStepSignals.push(signal)
} }
return next() 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) if (subject === agent) capture(signal)
return next() return next()
}) })
ctx.on('agent/turn-stopping', (subject, _turn, signal) => { ctx.on('agent/turn-stopping', ({ agent: subject, signal }) => {
if (subject === agent) capture(signal) if (subject === agent) capture(signal)
}) })
ctx.tools.register(defineContentToolFixture({ ctx.tools.register(defineContentToolFixture({

View File

@@ -60,17 +60,17 @@ describe('Agent', () => {
ctx.on('session/event', (session, event) => { ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'turn/start') lifecycle.push('turn/start') if (session === agent.session && event.type === 'turn/start') lifecycle.push('turn/start')
}) })
ctx.on('agent/inbox/inserted', (subject, event) => { ctx.on('agent/inbox/inserted', ({ agent: subject, message }) => {
if (subject === agent) inserted.push(event) 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) { if (subject === agent) {
lifecycle.push('agent/inbox/claimed') lifecycle.push('agent/inbox/claimed')
claimed.push(event) claimed.push({ message, turn })
} }
}) })
ctx.on('agent/inbox/discarded', (subject, event) => { ctx.on('agent/inbox/discarded', ({ agent: subject, message }) => {
if (subject === agent) discarded.push(event) if (subject === agent) discarded.push({ message })
}) })
const context = createUserMessage({ const context = createUserMessage({
content: [{ type: 'text', text: 'discard me' }], content: [{ type: 'text', text: 'discard me' }],
@@ -114,7 +114,7 @@ describe('Agent', () => {
const ctx = await harness(new MockAdapter([textResponse('ok')])) const ctx = await harness(new MockAdapter([textResponse('ok')]))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const statuses: string[] = [] const statuses: string[] = []
ctx.on('agent/status', (subject, status) => { ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent) statuses.push(status) if (subject === agent) statuses.push(status)
}) })
@@ -152,7 +152,7 @@ describe('Agent', () => {
const ctx = await harness(new MockAdapter([textResponse('ok')])) const ctx = await harness(new MockAdapter([textResponse('ok')]))
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) 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`) throw new Error(`bad ${status} listener`)
}) })

View File

@@ -40,7 +40,7 @@ function send(agent: Agent, text: string) {
/** Resolve on the agent's next idle transition (event-based, not status poll). */ /** Resolve on the agent's next idle transition (event-based, not status poll). */
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { 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() } if (subject === agent && status === 'idle') { dispose(); resolve() }
}) })
}) })
@@ -156,7 +156,7 @@ describe('Agent.cancel()', () => {
const running = Promise.withResolvers<undefined>() const running = Promise.withResolvers<undefined>()
let disposalDone: Promise<void> | undefined let disposalDone: Promise<void> | undefined
ctx.on('agent/status', (subject, status) => { ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject !== agent || status !== 'running') return if (subject !== agent || status !== 'running') return
disposalDone = handle.dispose() disposalDone = handle.dispose()
running.resolve(undefined) running.resolve(undefined)
@@ -200,7 +200,7 @@ describe('Agent.cancel()', () => {
const replacementRegistered = Promise.withResolvers<undefined>() const replacementRegistered = Promise.withResolvers<undefined>()
let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined 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 if (subject !== agent || status !== 'idle' || replacementObservation !== undefined) return
send(agent, 'cancelled replacement') send(agent, 'cancelled replacement')
replacementObservation = agent.whenIdle().then(() => ({ replacementObservation = agent.whenIdle().then(() => ({
@@ -239,7 +239,7 @@ describe('Agent.cancel()', () => {
const replacementRegistered = Promise.withResolvers<undefined>() const replacementRegistered = Promise.withResolvers<undefined>()
let replacementIdle: Promise<void> | undefined let replacementIdle: Promise<void> | undefined
ctx.on('agent/status', (subject, status) => { ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject !== agent || status !== 'idle' || replacementIdle !== undefined) return if (subject !== agent || status !== 'idle' || replacementIdle !== undefined) return
send(agent, 'cancelled replacement') send(agent, 'cancelled replacement')
agent.cancel({ kind: 'user' }) agent.cancel({ kind: 'user' })
@@ -440,7 +440,7 @@ describe('Agent.cancel()', () => {
}) })
let cancelled = false let cancelled = false
ctx.on('agent/turn-stopping', (subject) => { ctx.on('agent/turn-stopping', ({ agent: subject }) => {
if (subject === agent && !cancelled) { if (subject === agent && !cancelled) {
cancelled = true cancelled = true
agent.cancel({ kind: 'user' }) agent.cancel({ kind: 'user' })
@@ -465,7 +465,7 @@ describe('Agent.cancel()', () => {
// durable turn-start commit and must drop the reserved work. // durable turn-start commit and must drop the reserved work.
let streamed = false let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true }) 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' }) 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' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let replaced = false 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 if (subject !== agent || status !== 'running' || replaced) return
replaced = true replaced = true
agent.cancel({ kind: 'user' }) agent.cancel({ kind: 'user' })
@@ -664,7 +664,7 @@ describe('Agent.cancel()', () => {
switch (stage) { switch (stage) {
case 'pre-step': 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) if (subject === agent) await blockUntilAbort(signal)
return next() return next()
}) })
@@ -679,13 +679,13 @@ describe('Agent.cancel()', () => {
}) })
break break
case 'request': 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) if (subject === agent) await blockUntilAbort(signal)
return next() return next()
}) })
break break
case 'stopping': 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) if (subject === agent) await blockUntilAbort(signal)
}) })
break break

View File

@@ -19,7 +19,7 @@ afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive:
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { 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() } if (subject === agent && status === 'idle') { dispose(); resolve() }
}) })
}) })
@@ -170,7 +170,7 @@ describe('config-driven session id', () => {
await cleanupStarted.promise await cleanupStarted.promise
expect(first.status).toBe('idle') expect(first.status).toBe('idle')
const failures: unknown[] = [] 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) const secondLoop = await ctx.plugin(AgentLoop, config)
await new Promise(resolve => setTimeout(resolve, 0)) await new Promise(resolve => setTimeout(resolve, 0))
expect(ctx.agents.get(sessionId)).toBe(first) expect(ctx.agents.get(sessionId)).toBe(first)
@@ -234,7 +234,7 @@ describe('config-driven session id', () => {
const failures: { sessionId: SessionId; error: unknown }[] = [] const failures: { sessionId: SessionId; error: unknown }[] = []
ctx.on('agent-loop/config-start-failed', () => { throw listenerFailure }) 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', () => 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 }) failures.push({ sessionId, error })
}) })
vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(failure) 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. // Deliberately violate the normal Error-only rejection rule to exercise the unknown boundary.
// oxlint-disable-next-line typescript/prefer-promise-reject-errors // 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', () => 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) vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(unrenderable)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
@@ -307,7 +307,7 @@ describe('config-driven session id', () => {
const released = vi.fn() const released = vi.fn()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const failures: unknown[] = [] 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, { const loop = await ctx.plugin(AgentLoop, {
agents: [{ id: 'main', sessionId: SessionId('config-exact-dispose'), model: 'mock' }], agents: [{ id: 'main', sessionId: SessionId('config-exact-dispose'), model: 'mock' }],
@@ -479,7 +479,7 @@ describe('startup reporting after factory teardown', () => {
gate.promise.catch(() => undefined) gate.promise.catch(() => undefined)
vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(gate.promise) vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(gate.promise)
const failures: unknown[] = [] 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 warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const loop = await ctx.plugin(AgentLoop, { const loop = await ctx.plugin(AgentLoop, {

View File

@@ -40,7 +40,7 @@ async function harness(adapter: MockAdapter) {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { 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') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()
@@ -191,7 +191,7 @@ describe('abort during tool execution ends the turn', () => {
const adapter = new MockAdapter([textResponse('must not run')]) const adapter = new MockAdapter([textResponse('must not run')])
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-empty-batch'), { provider: 'mock', model: 'mock' }) 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() if (subject !== agent) return next()
return Promise.resolve({ kind: 'enter', messages: [] }) 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') send(agent, 'leave an unmatched historical call')
await waitForIdle(ctx, agent) 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() const decision = await next()
if (subject === agent && turn === 2 && decision.kind === 'enter') { if (subject === agent && turn === 2 && decision.kind === 'enter') {
disposeInjection() disposeInjection()
@@ -382,7 +382,7 @@ describe('disposal leaves the two-state status contract balanced', () => {
const statuses: string[] = [] const statuses: string[] = []
const reasons: TurnEndReason[] = [] 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) }) ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go') 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' }) agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
ctx.on('agent/status', (_agent, status) => { ctx.on('agent/status', ({ status }) => {
if (status === 'idle') throw new Error('broken status listener') 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 ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides 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' } 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) }) 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' } })) forked.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }))
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
ctx2.on('agent/status', (subject, status) => { ctx2.on('agent/status', ({ agent: subject, status }) => {
if (subject === forked && status === 'idle') resolve() 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 reasons: TurnEndReason[] = []
const errors: unknown[] = [] 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 }) expect({ turn, step }).toEqual({ turn: 1, step: 1 })
errors.push(error) 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') } if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') }
}) })
const errors: Error[] = [] const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => { ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(error) if (error instanceof Error) errors.push(error)
}) })
@@ -743,7 +743,7 @@ describe('turn and step boundary recovery', () => {
} }
}) })
const errors: Error[] = [] const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => { ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(error) if (error instanceof Error) errors.push(error)
}) })
@@ -800,7 +800,7 @@ describe('turn and step boundary recovery', () => {
} }
}) })
const errors: Error[] = [] const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => { ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(error) if (error instanceof Error) errors.push(error)
}) })
@@ -893,14 +893,14 @@ describe('turn and step boundary recovery', () => {
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
let threw = false let threw = false
ctx.on('agent/pre-step', (_subject, _messages, _context, next) => { ctx.on('agent/pre-step', (_payload, next) => {
if (threw) return next() if (threw) return next()
threw = true threw = true
void fiber.dispose() void fiber.dispose()
throw new Error('boom pre-step during disposal') throw new Error('boom pre-step during disposal')
}) })
const errorEmits: Error[] = [] const errorEmits: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => { ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errorEmits.push(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') } if (!threw && event.type === 'turn/start') { threw = true; throw new Error('boom turn/start append') }
}) })
const errors: Error[] = [] const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => { ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(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') } if (event.type === 'step/end' && !threw) { threw = true; throw new Error('boom step-end') }
}) })
const errors: Error[] = [] const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => { ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(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') } if (!threw && event.type === 'step/end') { threw = true; throw new Error('boom step/end listener') }
}) })
const errors: Error[] = [] const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => { ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(error) if (error instanceof Error) errors.push(error)
}) })
@@ -1215,7 +1215,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
await mountInvariants(ctx) await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter) 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 await blocker
return next() return next()
}) })
@@ -1261,7 +1261,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
await mountInvariants(ctx) await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter) 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 await blocker
return next() return next()
}) })

View File

@@ -28,7 +28,7 @@ async function harness(adapter: MockAdapter) {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { 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') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()
@@ -120,7 +120,7 @@ describe('thrown-value propagation', () => {
}) })
const errors: unknown[] = [] 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, 'fails before turn start')
send(agent, 'survives as the next item') 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' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let threwOnce = false let threwOnce = false
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { ctx.on('agent/request', async (_payload, next) => {
if (!threwOnce) { if (!threwOnce) {
threwOnce = true threwOnce = true
throw { code: 500 } throw { code: 500 }
@@ -167,7 +167,7 @@ describe('durable error rendering', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let threwOnce = false let threwOnce = false
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { ctx.on('agent/request', async (_payload, next) => {
if (!threwOnce) { if (!threwOnce) {
threwOnce = true threwOnce = true
throw new LlmError('server overloaded', 'RATE_LIMIT') throw new LlmError('server overloaded', 'RATE_LIMIT')
@@ -250,7 +250,7 @@ describe('request-error action edges', () => {
]) ])
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('retry-after-cancel'), { provider: 'mock', model: 'mock' }) 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' }) subject.cancel({ kind: 'user' })
return { kind: 'retry' } return { kind: 'retry' }
}) })
@@ -271,7 +271,7 @@ describe('request-error action edges', () => {
]) ])
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('retry-raced'), { provider: 'mock', model: 'mock' }) 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() await next()
subject.cancel({ kind: 'user' }) subject.cancel({ kind: 'user' })
expect(signal.aborted).toBe(true) 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') if (event.type === 'step/end') throw new Error('step close permanently rejected')
}) })
const statuses: string[] = [] 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') send(agent, 'go')
await agent.whenIdle() await agent.whenIdle()
@@ -406,7 +406,7 @@ describe('turn close failure containment', () => {
} }
}) })
const errors: unknown[] = [] 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') send(agent, 'go')
await agent.whenIdle() await agent.whenIdle()
@@ -484,11 +484,11 @@ describe('driver bookkeeping edges', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('reject-next-step'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('reject-next-step'), { provider: 'mock', model: 'mock' })
let proposals = 0 let proposals = 0
ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => { ctx.on('agent/pre-step', async (_payload, next) => {
proposals += 1 proposals += 1
return proposals === 2 ? { kind: 'reject' } : next() return proposals === 2 ? { kind: 'reject' } : next()
}) })
ctx.on('agent/turn-stopping', (subject) => { ctx.on('agent/turn-stopping', ({ agent: subject }) => {
subject.inject(createUserMessage({ subject.inject(createUserMessage({
content: [{ type: 'text', text: 'do not enter the next step' }], content: [{ type: 'text', text: 'do not enter the next step' }],
source: { kind: 'plugin', plugin: 'test' }, source: { kind: 'plugin', plugin: 'test' },

View File

@@ -41,7 +41,7 @@ async function harness(adapter: MockAdapter) {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { 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') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()
@@ -65,7 +65,7 @@ describe('agent/pre-step', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const seen: string[] = [] 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('')) seen.push(messages[0]!.content.map(b => (b.type === 'text' ? b.text : '')).join(''))
return next() return next()
}) })
@@ -92,8 +92,8 @@ describe('agent/pre-step', () => {
})) }))
const agent = ctx.agentLoop.create(SessionId('prompt-coordinates'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('prompt-coordinates'), { provider: 'mock', model: 'mock' })
const seen: Array<{ turn: number; step: number; messages: number }> = [] const seen: Array<{ turn: number; step: number; messages: number }> = []
ctx.on('agent/pre-step', async (_agent, messages, context, next) => { ctx.on('agent/pre-step', async ({ messages, turn, step }, next) => {
seen.push({ turn: context.turn, step: context.step, messages: messages.length }) seen.push({ turn, step, messages: messages.length })
return next() return next()
}) })
@@ -113,7 +113,7 @@ describe('agent/pre-step', () => {
const entered = Promise.withResolvers<undefined>() const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<PreStepDecision>() const decision = Promise.withResolvers<PreStepDecision>()
const observed: UserMessage[] = [] 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 } if (subject !== agent) return { kind: 'enter', messages }
const message = messages[0]! const message = messages[0]!
expect(Object.isFrozen(message)).toBe(true) expect(Object.isFrozen(message)).toBe(true)
@@ -161,7 +161,7 @@ describe('agent/pre-step', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/pre-step', async (_agent, messages): Promise<PreStepDecision> => ctx.on('agent/pre-step', async ({ messages }): Promise<PreStepDecision> =>
({ ({
kind: 'enter', kind: 'enter',
messages: [{ ...messages[0]!, content: [{ type: 'text', text: 'REWRITTEN' }] }], messages: [{ ...messages[0]!, content: [{ type: 'text', text: 'REWRITTEN' }] }],
@@ -182,7 +182,7 @@ describe('agent/pre-step', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/pre-step', async (_agent, messages): Promise<PreStepDecision> => ctx.on('agent/pre-step', async ({ messages }): Promise<PreStepDecision> =>
({ ({
kind: 'enter', kind: 'enter',
messages: [...messages, createUserMessage({ messages: [...messages, createUserMessage({
@@ -211,15 +211,15 @@ describe('agent/pre-step', () => {
provider: 'mock', provider: 'mock',
model: 'mock', model: 'mock',
}) })
ctx.on('agent/turn-stopping', (subject) => { ctx.on('agent/turn-stopping', ({ agent: subject }) => {
subject.inject(createUserMessage({ subject.inject(createUserMessage({
content: [{ type: 'text', text: 'pending context' }], content: [{ type: 'text', text: 'pending context' }],
source: { kind: 'plugin', plugin: 'test' }, 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() const decision = await next()
return context.step === 1 || decision.kind === 'reject' return step === 1 || decision.kind === 'reject'
? decision ? decision
: { kind: 'enter', messages: [] } : { kind: 'enter', messages: [] }
}) })
@@ -262,7 +262,7 @@ describe('agent/pre-step', () => {
const decision = Promise.withResolvers<PreStepDecision>() const decision = Promise.withResolvers<PreStepDecision>()
let claimed: UserMessage[] = [] let claimed: UserMessage[] = []
let firstProposal = true let firstProposal = true
ctx.on('agent/pre-step', async (_agent, messages) => { ctx.on('agent/pre-step', async ({ messages }) => {
if (!firstProposal) return { kind: 'enter', messages } if (!firstProposal) return { kind: 'enter', messages }
firstProposal = false firstProposal = false
claimed = messages claimed = messages
@@ -372,14 +372,14 @@ describe('agent/pre-step', () => {
provider: 'mock', provider: 'mock',
model: '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() const decision = await next()
return messages.some(message => return messages.some(message =>
message.content.some(block => block.type === 'text' && block.text === 'blocked prompt')) message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))
? { kind: 'reject' as const } ? { kind: 'reject' as const }
: decision : 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 => if (messages.some(message =>
message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))) { message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))) {
subject.inject(createUserMessage({ subject.inject(createUserMessage({
@@ -482,7 +482,7 @@ describe('agent/pre-step', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise<PreStepDecision> => { ctx.on('agent/pre-step', async ({ messages }, next): Promise<PreStepDecision> => {
const text = messages.flatMap(message => message.content) const text = messages.flatMap(message => message.content)
.map(b => (b.type === 'text' ? b.text : '')).join('') .map(b => (b.type === 'text' ? b.text : '')).join('')
return text === 'secret' return text === 'secret'
@@ -519,17 +519,17 @@ describe('agent/pre-step', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let threw = false 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') } if (!threw) { threw = true; throw new Error('prompt hook broke') }
return { kind: 'enter' as const, messages } return { kind: 'enter' as const, messages }
}) })
const errors: Error[] = [] const errors: Error[] = []
const reasons: TurnEndReason[] = [] const reasons: TurnEndReason[] = []
const statuses: string[] = [] const statuses: string[] = []
ctx.on('agent/error', (_a, _t, _s, error) => { ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(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) => { ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason) 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 ctx = await harness(adapter)
const sources: SessionStartSource[] = [] 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' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// fires synchronously at create, before any turn // fires synchronously at create, before any turn
@@ -576,7 +576,7 @@ describe('agent/session-start', () => {
const adapter = new MockAdapter([textResponse('ok')]) const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter) 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' } })) 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', name: 'native-guard',
apply(ctx: Context) { apply(ctx: Context) {
// 1. SessionStart: seed a standing instruction. // 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' } })) 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. // 2. PreStep: reject a forbidden prompt, annotate the rest.
ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise<PreStepDecision> => { ctx.on('agent/pre-step', async ({ messages }, next): Promise<PreStepDecision> => {
const text = messages.flatMap(message => message.content) const text = messages.flatMap(message => message.content)
.map(b => (b.type === 'text' ? b.text : '')).join('') .map(b => (b.type === 'text' ? b.text : '')).join('')
if (text.includes('rm -rf')) { if (text.includes('rm -rf')) {

View File

@@ -28,7 +28,7 @@ async function harness(adapter: MockAdapter, persona = '') {
/** Wait for the agent's next transition to idle after a waking send. */ /** Wait for the agent's next transition to idle after a waking send. */
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { 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') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()
@@ -216,7 +216,7 @@ describe('agent loop', () => {
const adapter = new MockAdapter([textResponse('ok after rescue')]) const adapter = new MockAdapter([textResponse('ok after rescue')])
const ctx = await harness(adapter, 'In {{cwd}}.') const ctx = await harness(adapter, 'In {{cwd}}.')
const errors: Error[] = [] const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => { ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(error) if (error instanceof Error) errors.push(error)
}) })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -263,7 +263,7 @@ describe('agent loop', () => {
assembly.variables['model'] = 'mock' assembly.variables['model'] = 'mock'
return next() return next()
}) })
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { ctx.on('agent/request', async (_payload, next) => {
const config = await next() const config = await next()
return { ...config, provider: 'mock', model: 'mock' } return { ...config, provider: 'mock', model: 'mock' }
}) })
@@ -553,7 +553,7 @@ describe('agent loop', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' })
let fail = true 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() if (subject !== agent || !fail) return next()
fail = false fail = false
subject.steer(createUserMessage({ content: [{ type: 'text', text: 'pending steering' }], source: { kind: 'user' } })) subject.steer(createUserMessage({ content: [{ type: 'text', text: 'pending steering' }], source: { kind: 'user' } }))
@@ -713,7 +713,7 @@ describe('agent loop', () => {
let steps = 0 let steps = 0
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) 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) { if (steps < 3) {
subject.steer(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'plugin', plugin: 'loop-test' } })) 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 ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) 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() const config = await next()
// The seed is frozen — config is not a mutable per-call knob; a switch // 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. // 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 agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const fires: { turn: number; step: number; signal: AbortSignal }[] = [] 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 }) if (subject === agent) fires.push({ turn, step, signal })
return next() return next()
}) })
@@ -837,7 +837,7 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let boundaryOpen = true 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' if (subject === agent) boundaryOpen = subject.session.events.at(-1)?.type === 'step/start'
return next() return next()
}) })
@@ -855,13 +855,13 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let throwOnce = true 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') } if (throwOnce) { throwOnce = false; throw new Error('boom in pre-step') }
return next() return next()
}) })
const errors: Error[] = [] const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => { ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(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++ }) ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
// Force exactly one continuation (step 1 → step 2), then defer to default // Force exactly one continuation (step 1 → step 2), then defer to default
// (step 2 is a plain stop with no tool calls → stops). // (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) { if (steps < 2) {
subject.steer(createUserMessage({ content: [{ type: 'text', text: 'continue after truncation' }], source: { kind: 'plugin', plugin: 'max-tokens-test' } })) 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 errors: unknown[] = []
const reasons: TurnEndReason[] = [] const reasons: TurnEndReason[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => { ctx.on('agent/error', ({ error }) => {
errors.push(error) errors.push(error)
}) })
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })

View File

@@ -50,7 +50,7 @@ async function harness() {
/** Resolve on the agent's next transition to idle (event-based, not polled). */ /** Resolve on the agent's next transition to idle (event-based, not polled). */
function nextIdle(ctx: Context, agent: Agent): Promise<void> { function nextIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { 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') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()
@@ -63,7 +63,7 @@ function nextIdle(ctx: Context, agent: Agent): Promise<void> {
* the seen list plus a disposer for the listener (per the registry convention). */ * the seen list plus a disposer for the listener (per the registry convention). */
function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: () => void } { function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: () => void } {
const seen: string[] = [] 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) if (subject === agent) seen.push(status)
}) })
return { seen, dispose } return { seen, dispose }

View File

@@ -59,7 +59,7 @@ async function loopHarness(): Promise<Context> {
function waitForIdle(context: Context, agent: Agent): Promise<void> { function waitForIdle(context: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { 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') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()

View File

@@ -62,12 +62,12 @@ describe('agent/request-error', () => {
retryPolicy: ResolvedRetryPolicy | undefined retryPolicy: ResolvedRetryPolicy | undefined
}[] = [] }[] = []
const statuses: string[] = [] const statuses: string[] = []
ctx.on('agent/status', (subject, status) => { ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent) statuses.push(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) expect(subject).toBe(agent)
seen.push(context) seen.push({ turn, step, failure, retryPolicy })
return { kind: 'retry' } return { kind: 'retry' }
}) })
@@ -102,7 +102,7 @@ describe('agent/request-error', () => {
const adapter = new MockAdapter([fail('busy', 'RATE_LIMIT'), textResponse('unused')]) const adapter = new MockAdapter([fail('busy', 'RATE_LIMIT'), textResponse('unused')])
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('request-error-cancel'), { provider: 'mock', model: 'mock' }) 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' }) subject.cancel({ kind: 'user' })
return { kind: 'retry' } return { kind: 'retry' }
}) })

View File

@@ -38,7 +38,7 @@ async function harnessRoutes(
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { 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') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()
@@ -122,7 +122,7 @@ describe('request stability across the loop', () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')], reasoning) const adapter = new MockAdapter([textResponse('one'), textResponse('two')], reasoning)
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('effort'), { provider: 'mock', model: 'mock' }) 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() const config = await next()
return turn === 2 ? { ...config, reasoningEffort: ReasoningEffortId('max') } : config return turn === 2 ? { ...config, reasoningEffort: ReasoningEffortId('max') } : config
}) })
@@ -198,7 +198,7 @@ describe('request stability across the loop', () => {
provider: 'deepseek', provider: 'deepseek',
model: 'deepseek-model', model: 'deepseek-model',
}) })
ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => { ctx.on('agent/request', async ({ turn }, next) => {
const config = await next() const config = await next()
return turn === 2 return turn === 2
? { ...config, provider: 'other', model: 'other-model' } ? { ...config, provider: 'other', model: 'other-model' }
@@ -232,7 +232,7 @@ describe('request stability across the loop', () => {
model: 'deepseek-model', model: 'deepseek-model',
maxTokens: 4_096, maxTokens: 4_096,
}) })
ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => { ctx.on('agent/request', async ({ turn }, next) => {
const config = await next() const config = await next()
return turn === 2 return turn === 2
? { ...config, provider: 'other', model: 'other-model' } ? { ...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' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let injected = false let injected = false
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { ctx.on('agent/request', async (_payload, next) => {
if (!injected) { if (!injected) {
injected = true injected = true
agent.inject(createUserMessage({ content: [{ type: 'text', text: '[late context]' }], source: { kind: 'plugin', plugin: 'test' } })) 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 ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) 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() const config = await next()
// next() resolves the SAME frozen seed — in-place shaping after // next() resolves the SAME frozen seed — in-place shaping after
// delegation is unrepresentable, so a "mutate what next() returned" // delegation is unrepresentable, so a "mutate what next() returned"
@@ -576,7 +576,7 @@ describe('request stability across the loop', () => {
send(agent, 'go') send(agent, 'go')
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'now with guidance' }) 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: ['<END>'], ...await next(), temperature: 0.5, maxTokens: 99, stop: ['<END>'],
})) }))
send(agent, 'again') send(agent, 'again')
@@ -658,7 +658,7 @@ describe('request/context capacity records', () => {
send(agent, 'first') send(agent, 'first')
await waitForIdle(ctx, agent) 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' }) ? Promise.resolve({ provider: 'mock', model: 'large' })
: next()) : next())
send(agent, 'second') send(agent, 'second')
@@ -686,7 +686,7 @@ describe('request/context capacity records', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('capacity-clear'), { provider: 'mock', model: 'known' }) const agent = ctx.agentLoop.create(SessionId('capacity-clear'), { provider: 'mock', model: 'known' })
let 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 }) ? Promise.resolve({ provider: 'mock', model })
: next()) : next())

View File

@@ -66,7 +66,7 @@ function preparationFromSnapshot(
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { 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() } 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 adapter1 = new MockAdapter([textResponse('a')])
const { ctx: ctx1, root } = await persistentHarness(adapter1) const { ctx: ctx1, root } = await persistentHarness(adapter1)
const sources1: string[] = [] 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 const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent
expect(sources1).toEqual(['startup']) expect(sources1).toEqual(['startup'])
a1.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })) 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 }) await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2) ctx2.llm.registerAdapter(['mock'], adapter2)
const sources2: string[] = [] 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') }) await ctx2.agents.resume({ resumeSessionId: SessionId('start-sess') })
expect(sources2).toEqual(['resume']) expect(sources2).toEqual(['resume'])
await ctx2.fiber.dispose() 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) expect(ctx.agents.get(sessionId)?.session).toBe(session)
order.push('session/created') order.push('session/created')
}) })
ctx.on('agent/created', (agent) => { ctx.on('agent/created', ({ agent }) => {
expect(agent.status).toBe('idle') expect(agent.status).toBe('idle')
order.push('agent/created') order.push('agent/created')
}) })
ctx.on('agent/session-start', (agent) => { ctx.on('agent/session-start', ({ agent }) => {
expect(() => { agent.cancel({ kind: 'user' }) }).not.toThrow() expect(() => { agent.cancel({ kind: 'user' }) }).not.toThrow()
order.push('agent/session-start') order.push('agent/session-start')
}) })
@@ -882,7 +882,7 @@ describe('configured-start failure edges', () => {
configured.llm.registerAdapter(['mock'], new MockAdapter([])) configured.llm.registerAdapter(['mock'], new MockAdapter([]))
configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal) configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal)
const configFailures: unknown[] = [] 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 configWarnings: string[] = []
const configWarn = configured.logger.warn.bind(configured.logger) const configWarn = configured.logger.warn.bind(configured.logger)
configured.logger.warn = ((...args: unknown[]) => { configured.logger.warn = ((...args: unknown[]) => {
@@ -915,7 +915,7 @@ describe('configured-start failure edges', () => {
return gate.promise return gate.promise
} }
const failures: unknown[] = [] 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() const configured = new Context()
await configured.plugin(LlmService) await configured.plugin(LlmService)
@@ -926,7 +926,7 @@ describe('configured-start failure edges', () => {
await configured.plugin(SessionPersistenceJsonl, { root }) await configured.plugin(SessionPersistenceJsonl, { root })
configured.llm.registerAdapter(['mock'], new MockAdapter([])) configured.llm.registerAdapter(['mock'], new MockAdapter([]))
configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal) 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, { const loop = await configured.plugin(AgentLoop, {
agents: [{ id: 'main', resumeSessionId: sessionId, provider: 'mock', model: 'mock' }], agents: [{ id: 'main', resumeSessionId: sessionId, provider: 'mock', model: 'mock' }],
}) })

View File

@@ -31,7 +31,7 @@ async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok'
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { 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') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()
@@ -199,7 +199,7 @@ describe('agent scope lifecycle', () => {
const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' }) const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' })
const heard: string[] = [] 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) => { a.ctx.on('session/event', (_s, event) => {
if (event.type === 'user/message') heard.push('a-sees:user-message') 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 () => { it('runs setup in the guaranteed slot: scoped world complete before session-start and the first assembly', async () => {
const ctx = await harness() const ctx = await harness()
const order: string[] = [] const order: string[] = []
ctx.on('agent/session-start', (agent) => { ctx.on('agent/session-start', ({ agent }) => {
order.push('session-start') order.push('session-start')
// The scoped section is already registered by the time session-start fires. // The scoped section is already registered by the time session-start fires.
void ctx.systemPrompt.assemble(assembleContextFor(agent)).then((assembly) => { void ctx.systemPrompt.assemble(assembleContextFor(agent)).then((assembly) => {
@@ -673,19 +673,19 @@ describe('agent scope lifecycle', () => {
ctx.on('session/created', (session) => { ctx.on('session/created', (session) => {
if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created') 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 if (agent.id !== SessionId('agent-created-barrier-s')) return
lifecycle.push('agent-created:dispose') lifecycle.push('agent-created:dispose')
disposeCurrentLifecycle(ownerCtx) disposeCurrentLifecycle(ownerCtx)
}) })
ctx.on('agent/created', (agent) => { ctx.on('agent/created', ({ agent }) => {
if (agent.id !== SessionId('agent-created-barrier-s')) return if (agent.id !== SessionId('agent-created-barrier-s')) return
expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.agents.get(agent.id)).toBe(agent)
expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') }) agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
lifecycle.push('agent-created:observer') 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') if (agent.id === SessionId('agent-created-barrier-s')) lifecycle.push('agent-disposed')
}) })
ctx.on('session/disposed', (session) => { ctx.on('session/disposed', (session) => {
@@ -720,8 +720,8 @@ describe('agent scope lifecycle', () => {
const starts: string[] = [] const starts: string[] = []
let ownerCtx!: Context let ownerCtx!: Context
let creating!: ReturnType<typeof ctx.agents.create> let creating!: ReturnType<typeof ctx.agents.create>
ctx.on('agent/session-start', agent => void starts.push(agent.id)) ctx.on('agent/session-start', ({ agent }) => void starts.push(agent.id))
ctx.on('agent/created', (agent) => { ctx.on('agent/created', ({ agent }) => {
if (agent.id === SessionId('listener-dispose-s')) disposeCurrentLifecycle(ownerCtx) if (agent.id === SessionId('listener-dispose-s')) disposeCurrentLifecycle(ownerCtx)
}) })
@@ -749,15 +749,15 @@ describe('agent scope lifecycle', () => {
const statuses: string[] = [] const statuses: string[] = []
let scopeDisposed = false let scopeDisposed = false
let observerSawLive = 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) 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 if (agent.id !== SessionId('session-start-dispose-s')) return
announced = agent announced = agent
disposeCurrentLifecycle(ownerCtx) disposeCurrentLifecycle(ownerCtx)
}) })
ctx.on('agent/session-start', (agent) => { ctx.on('agent/session-start', ({ agent }) => {
if (agent.id !== SessionId('session-start-dispose-s')) return if (agent.id !== SessionId('session-start-dispose-s')) return
expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.agents.get(agent.id)).toBe(agent)
expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
@@ -840,7 +840,7 @@ describe('agent scope lifecycle', () => {
const ctx = await harness() const ctx = await harness()
let boom = true let boom = true
const disposed: string[] = [] 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', () => { ctx.on('session/created', () => {
if (boom) { boom = false; throw new Error('boom created') } if (boom) { boom = false; throw new Error('boom created') }
}) })
@@ -861,11 +861,11 @@ describe('agent scope lifecycle', () => {
const lifecycle: string[] = [] const lifecycle: string[] = []
ctx.on('session/created', (session) => { lifecycle.push(`session-created:${session.id}`) }) ctx.on('session/created', (session) => { lifecycle.push(`session-created:${session.id}`) })
ctx.on('session/disposed', (session) => { lifecycle.push(`session-disposed:${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}`) lifecycle.push(`agent-created:${agent.id}`)
throw new Error('agent observer failed') 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({ await expect(ctx.agents.create({
sessionId: SessionId('partial-session'), sessionId: SessionId('partial-session'),
@@ -911,10 +911,10 @@ describe('agent scope lifecycle', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' }) const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' })
const heard: string[] = [] 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, other).emit('agent/error', { turn: 1, step: 0, error: new Error('not for a1') })
agentEvents(ctx, agent).emit('agent/error', 2, 0, new Error('for a1')) agentEvents(ctx, agent).emit('agent/error', { turn: 2, step: 0, error: new Error('for a1') })
expect(heard).toEqual(['a1:2']) expect(heard).toEqual(['a1:2'])
}) })
@@ -1064,7 +1064,7 @@ describe('agent scope lifecycle', () => {
}) })
const agent = handle.agent const agent = handle.agent
let reentered = false let reentered = false
ctx.on('agent/status', (subject, status) => { ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject !== agent || status !== 'idle' || reentered) return if (subject !== agent || status !== 'idle' || reentered) return
reentered = true reentered = true
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'reentrant' }], source: { kind: 'user' } })) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'reentrant' }], source: { kind: 'user' } }))

View File

@@ -31,7 +31,7 @@ async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { 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() } if (subject === agent && status === 'idle') { dispose(); resolve() }
}) })
}) })

View File

@@ -33,7 +33,7 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { 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') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent/README.md # pnpm run verify-translation-pairing --write packages/core/agent/README.md
README.md: c3d6e6c24480894b6059417c1ab89db7aa0d7fa2 README.md: 2a69ab380eaad3929e27039582807037969eba64
README.zh.md: 16ee8f5e6c483555839b0c3ab174e2e2356b1359 README.zh.md: 176f3f75cf0f6e3309b2f5d34afb4d562105608e

View File

@@ -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. 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. `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.

View File

@@ -50,7 +50,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器完全停稳后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。 生命周期边有两个重要的本地注意事项。`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 不会保留它们;领取后插入的消息仍等待后续边界。 `PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages }`。enter 分支是拟进入步骤的完整、带标识且冻结的批次。包装下游 enter 的监听器会保留该批次,除非有意替换它;新增消息遵循 waterfall 的自然返回顺序。领取操作已经把候选消息从 inbox 删除,因此 reject 不会保留它们;领取后插入的消息仍等待后续边界。

View File

@@ -1,7 +1,8 @@
/** /**
* Agent-scoped dispatch and prompt assembly helpers. Ordinary events use the * Agent-scoped dispatch and prompt assembly helpers. The fused dispatcher
* fused dispatcher so subject and scope key cannot diverge; registry lifecycle * {@link agentEvents} couples the agent subject to its scope carrier, so the
* code instead captures one stable carrier for both edges. * 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 * @module @deepseek-ai/dsh-agent/dispatch
*/ */
@@ -17,25 +18,38 @@ type Params<F> = F extends (...args: infer P) => unknown ? P : never
type Return<F> = F extends (...args: never[]) => infer R ? R : never type Return<F> = F extends (...args: never[]) => infer R ? R : never
/** /**
* The event names whose subject is an agent: handler parameters start with an * The event names whose subject is an agent: the handler's first parameter is
* `Agent` AND the handler declares a `Scoped<Agent>` `this` (the scope-carrier * a payload object carrying the `agent` subject AND the handler declares a
* contract). The `this` check keeps accidental first-parameter-happens-to-be- * `Scoped<Agent>` `this` (the scope-carrier contract). The `this` check keeps
* an-Agent events (or zero-arg events, whose parameter tuple would satisfy a * accidental payload-happens-to-carry-an-Agent events (or zero-arg events,
* bare rest-tuple check via callability) out of the fused-dispatch surface. * whose parameter tuple would satisfy a bare rest-tuple check via callability)
* out of the fused-dispatch surface.
*/ */
export type AgentSubjectEvent = { export type AgentSubjectEvent = {
[K in keyof Events]: Events[K] extends (this: Scoped<Agent>, ...args: infer P) => unknown [K in keyof Events]: Events[K] extends (this: Scoped<Agent>, ...args: infer P) => unknown
? P extends [Agent, ...unknown[]] ? K : never ? P extends [infer Payload, ...unknown[]]
? Payload extends { agent: Agent } ? K : never
: never
: never : never
}[keyof Events] }[keyof Events]
/** The event arguments AFTER the injected agent subject. */ /** The full payload object of one agent-subject event. */
type Tail<K extends AgentSubjectEvent> = Params<Events[K]> extends [Agent, ...infer R] ? R : never type PayloadOf<K extends AgentSubjectEvent> = Params<Events[K]> extends [infer Payload, ...unknown[]] ? Payload : never
/** The event arguments AFTER the payload: the waterfall `next` when present. */
type Tail<K extends AgentSubjectEvent> = Params<Events[K]> 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<K extends AgentSubjectEvent> = Omit<PayloadOf<K> & object, 'agent'>
/** /**
* The fused dispatcher {@link agentEvents} returns: each method dispatches the * The fused dispatcher {@link agentEvents} returns: each method dispatches the
* named agent-subject event with the agent's scope carrier as `thisArg` and * 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 { export interface AgentEventDispatch {
/** /**
@@ -44,30 +58,36 @@ export interface AgentEventDispatch {
* contained per listener, so a notification cannot veto lifecycle progress * contained per listener, so a notification cannot veto lifecycle progress
* or starve a later observer. * or starve a later observer.
* @param name - the agent-subject event to emit. * @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<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): void emit<K extends AgentSubjectEvent>(name: K, payload: PayloadRest<K>): void
/** /**
* Awaited in-order dispatch (Cordis `serial`) in the agent's scope. * Awaited in-order dispatch (Cordis `serial`) in the agent's scope.
* @param name - the agent-subject event to dispatch. * @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). * @returns the serial chain's result (the first bail value, if any).
*/ */
serial<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Promise<Awaited<Return<Events[K]>>> serial<K extends AgentSubjectEvent>(name: K, payload: PayloadRest<K>): Promise<Awaited<Return<Events[K]>>>
/** /**
* Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The * Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The
* declared event parameters already end with the `next` callback, so `rest` * declared event parameters already end with the `next` callback, so `rest`
* is exactly the event's arguments after the injected agent — the final * is exactly the event's arguments after the payload — the final element
* element being the innermost `next` (the default the listener chain wraps). * being the innermost `next` (the default the listener chain wraps).
* @param name - the agent-subject event to dispatch. * @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. * @returns the waterfall's composed result.
*/ */
waterfall<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Return<Events[K]> waterfall<K extends AgentSubjectEvent>(name: K, payload: PayloadRest<K>, ...rest: Tail<K>): Return<Events[K]>
} }
/** /**
* 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. * @param agent - the subject agent and scope key.
* @returns the carrier passed as the event dispatcher `this` value. * @returns the carrier passed as the event dispatcher `this` value.
*/ */
@@ -79,22 +99,30 @@ export function agentCarrier(agent: Agent): Scoped<Agent> {
* Build a dispatcher that couples the agent subject to its scope carrier. * 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 ctx - the context to dispatch through (any context of the app).
* @param agent - the subject agent; also the scope-carrier key. * @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. * @returns the fused dispatcher.
*/ */
export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { export function agentEvents(ctx: Context, agent: Agent, carrier: Scoped<Agent> = agentCarrier(agent)): AgentEventDispatch {
const carrier = agentCarrier(agent)
// The ordinary dispatch methods forward through Cordis' variadic mixins. The // 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 // list for the matching thisArg overload, but TypeScript cannot relate the
// generic Tail<K> spread back to that overload's conditional parameter // generic Tail<K> spread back to that overload's conditional parameter
// tuple — hence one contained, shape-preserving cast per method. // tuple — hence one contained, shape-preserving cast per method.
const fused = <K extends AgentSubjectEvent>(payload: PayloadRest<K>): PayloadOf<K> =>
// The dispatcher owns the subject injection; callers pass PayloadRest, so
// the fused record is exactly the declared payload. The spread comes
// first, so a structurally acceptable payload that happens to carry an
// `agent` field can never override the injected subject.
({ ...payload, agent } as PayloadOf<K>)
return { return {
emit(name, ...rest) { emit(name, payload) {
// Cordis emit invokes callbacks through Array.map: one synchronous throw // Cordis emit invokes callbacks through Array.map: one synchronous throw
// starves later listeners, and returned promises are discarded. Agent // starves later listeners, and returned promises are discarded. Agent
// notifications are non-vetoing, so resolve the same filtered callback // notifications are non-vetoing, so resolve the same filtered callback
// set ourselves and contain both failure modes independently. // 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) const callbacks = ctx.events.dispatch('emit', args)
for (const callback of callbacks) { for (const callback of callbacks) {
try { 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 // oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function
const serial = ctx.serial as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => Promise<never> const serial = ctx.serial as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => Promise<never>
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 // oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function
const waterfall = ctx.waterfall as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => never const waterfall = ctx.waterfall as (thisArg: Scoped<Agent>, 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 ctx - the context to dispatch through.
* @param agent - the subject agent and scope key. * @param agent - the subject agent and scope key.
* @param name - the agent-subject event to emit. * @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<K extends AgentSubjectEvent>( export function emitAgentEvent<K extends AgentSubjectEvent>(
ctx: Context, ctx: Context,
agent: Agent, agent: Agent,
name: K, name: K,
...rest: Tail<K> payload: PayloadRest<K>,
): void { ): void {
agentEvents(ctx, agent).emit(name, ...rest) agentEvents(ctx, agent).emit(name, payload)
} }
/** /**

View File

@@ -498,7 +498,7 @@ export class AgentRegistry extends Service {
/** Emit the paired disposal edge through the entry's stable carrier. */ /** Emit the paired disposal edge through the entry's stable carrier. */
private emitDisposed(entry: AgentEntry): void { 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)) { for (const callback of this.ctx.events.dispatch('emit', args)) {
try { try {
const returned: unknown = callback(...args) const returned: unknown = callback(...args)
@@ -530,7 +530,7 @@ export class AgentRegistry extends Service {
// lifecycle edge; detach still pairs a partially delivered first edge. // lifecycle edge; detach still pairs a partially delivered first edge.
entry.announcing = true entry.announcing = true
entry.announced = true entry.announced = true
const args: unknown[] = [entry.carrier, 'agent/created', entry.agent] const args: unknown[] = [entry.carrier, 'agent/created', { agent: entry.agent }]
try { try {
for (const callback of this.ctx.events.dispatch('emit', args)) { for (const callback of this.ctx.events.dispatch('emit', args)) {
// A synchronous creation failure vetoes publication and rolls back. // A synchronous creation failure vetoes publication and rolls back.

View File

@@ -14,7 +14,7 @@ export const inject = ['invariants']
/** Install the agent contribution into its child registration fiber. */ /** Install the agent contribution into its child registration fiber. */
const install: InvariantInstaller = (ctx, fail) => { const install: InvariantInstaller = (ctx, fail) => {
const lastStatus = new WeakMap<Agent, AgentStatus>() const lastStatus = new WeakMap<Agent, AgentStatus>()
ctx.on('agent/status', (agent, status) => { ctx.on('agent/status', ({ agent, status }) => {
const previous = lastStatus.get(agent) const previous = lastStatus.get(agent)
if (previous === status) { if (previous === status) {
fail(`agent/status repeated ${status} (no-op transition)`) fail(`agent/status repeated ${status} (no-op transition)`)

View File

@@ -53,7 +53,7 @@ export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetR
}) })
const disposeRequest = agentCtx.on( const disposeRequest = agentCtx.on(
'agent/request', 'agent/request',
async (_agent, _turn, _step, _signal, next): Promise<LlmCallConfig> => { async (_payload, next): Promise<LlmCallConfig> => {
const resolved = await next() const resolved = await next()
const selected = target.assembled const selected = target.assembled
if (selected === undefined) return resolved if (selected === undefined) return resolved

View File

@@ -48,35 +48,11 @@ export interface CancelOptions {
*/ */
export type AgentStatus = 'idle' | 'running' 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. */ /** Whether and with which messages the loop enters a proposed step. */
export type PreStepDecision = export type PreStepDecision =
| { kind: 'reject' } | { kind: 'reject' }
| { kind: 'enter'; messages: UserMessage[] } | { 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. */ /** Action returned by a listener that owns model-request recovery. */
export type RequestErrorAction = { kind: 'retry' } | undefined export type RequestErrorAction = { kind: 'retry' } | undefined
@@ -171,105 +147,112 @@ declare module 'cordis' {
* Synchronous listener failure vetoes publication, while returned-promise * Synchronous listener failure vetoes publication, while returned-promise
* rejection is reported. Detach requested during dispatch waits until every * rejection is reported. Detach requested during dispatch waits until every
* creation listener has observed the stable entry. * 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. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/created'(this: Scoped<Agent>, agent: Agent): void 'agent/created'(this: Scoped<Agent>, payload: { agent: Agent }): void
/** /**
* An agent left the registry; AgentLoop emits this after driver quiescence * An agent left the registry; AgentLoop emits this after driver quiescence
* and scoped-registration unwind, but before session detachment. Custom * and scoped-registration unwind, but before session detachment. Custom
* registry users own their driver-ordering contract. * 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. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void 'agent/disposed'(this: Scoped<Agent>, payload: { agent: Agent }): void
/** /**
* Agent status changed (`idle` ⇄ `running`). A waking delivery enters * Agent status changed (`idle` ⇄ `running`). A waking delivery enters
* `running` synchronously after reserving cancellation; `idle` means no * `running` synchronously after reserving cancellation; `idle` means no
* driver remains scheduled or active. * driver remains scheduled or active.
* @param agent - the agent whose status flipped. * @param payload.agent - the agent whose status flipped.
* @param status - the status just entered (the transition's destination). * @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. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void 'agent/status'(this: Scoped<Agent>, payload: { agent: Agent; status: AgentStatus }): void
/** /**
* One message entered the live inbox. * One message entered the live inbox.
* @param agent - the agent whose inbox changed. * @param payload.agent - the agent whose inbox changed.
* @param event - the inserted message. * @param payload.message - the inserted message.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/inbox/inserted'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void 'agent/inbox/inserted'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage }): void
/** /**
* One message left the inbox inside its open turn. If the proposed step * 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 * 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. * re-emitted as a user/message, and the turn closes without a step.
* @param agent - the agent whose inbox changed. * @param payload.agent - the agent whose inbox changed.
* @param event - the claimed message and owning turn. * @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. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/inbox/claimed'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage; turn: number }): void 'agent/inbox/claimed'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage; turn: number }): void
/** /**
* One message was discarded from the live inbox. * One message was discarded from the live inbox.
* @param agent - the agent whose inbox changed. * @param payload.agent - the agent whose inbox changed.
* @param event - the discarded message. * @param payload.message - the discarded message.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/inbox/discarded'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void 'agent/inbox/discarded'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage }): void
// ---- session lifecycle (emit) ---- // ---- session lifecycle (emit) ----
/** /**
* The session lifecycle began, once before the first turn. Use * The session lifecycle began, once before the first turn. Use
* `agent.inject()` to seed model-facing context. This is a notification, not * `agent.inject()` to seed model-facing context. This is a notification, not
* a veto; disposal requested by a lifecycle owner is rechecked before the * a veto; disposal requested by a lifecycle owner is rechecked before the
* driver starts. * driver starts.
* @param agent - the agent whose session lifecycle began. * @param payload.agent - the agent whose session lifecycle began.
* @param source - why the session started (fresh startup, resume, …). * @param payload.source - why the session started (fresh startup, resume, …).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/session-start'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void 'agent/session-start'(this: Scoped<Agent>, payload: { agent: Agent; source: SessionStartSource }): void
// ---- the machine's extension seams ---- // ---- the machine's extension seams ----
/** /**
* Reject a proposed step or replace the messages that enter it. Calling * Reject a proposed step or replace the messages that enter it. Calling
* `next()` preserves the current messages. * `next()` preserves the current messages.
* @param agent - the agent proposing the step. * @param payload.agent - the agent proposing the step.
* @param messages - messages removed from the inbox for this step. * @param payload.messages - messages removed from the inbox for this step.
* @param context - proposed turn and step coordinates plus cancellation. * @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. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall * @mode waterfall
*/ */
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise<PreStepDecision>): Promise<PreStepDecision> 'agent/pre-step'(this: Scoped<Agent>, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>
/** /**
* Replace the frozen call configuration. `await next()` yields the config * Replace the frozen call configuration. `await next()` yields the config
* the machine would use (agent options on the first request, the logged * the machine would use (agent options on the first request, the logged
* header afterwards); return a replacement to switch. Model-visible * header afterwards); return a replacement to switch. Model-visible
* content must use logged channels; this seam cannot mutate messages. * content must use logged channels; this seam cannot mutate messages.
* @param agent - the agent making the model call. * @param payload.agent - the agent making the model call.
* @param turn - the open turn number. * @param payload.turn - the open turn number.
* @param step - the step whose request this is. * @param payload.step - the step whose request this is.
* @param signal - the current turn's explicit abort signal. * @param payload.signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall * @mode waterfall
*/ */
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig> 'agent/request'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
/** /**
* Handle one failed model-request attempt before the loop retries or closes * Handle one failed model-request attempt before the loop retries or closes
* its step. A listener returns `{ kind: 'retry' }` without calling `next()` * its step. A listener returns `{ kind: 'retry' }` without calling `next()`
* when it owns recovery, or calls `next()` to delegate. The default * when it owns recovery, or calls `next()` to delegate. The default
* `undefined` leaves the failure terminal. * `undefined` leaves the failure terminal.
* @param agent - the agent whose request failed. * @param payload.agent - the agent whose request failed.
* @param context - request coordinates, provider, normalized failure, and serving policy. * @param payload.turn - the turn containing the failed request.
* @param signal - the turn abort signal. * @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. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall * @mode waterfall
*/ */
'agent/request-error'(this: Scoped<Agent>, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction> 'agent/request-error'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; provider: string; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined; signal: AbortSignal }, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>
/** /**
* The turn is about to close: the model owes no response (no live tool * The turn is about to close: the model owes no response (no live tool
* calls, no fresh steering). Awaited before the boundary commits — a * 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 * never short-circuits already-submitted next-step work: same-step
* `additionalContexts` or racing steering still runs, and the turn * `additionalContexts` or racing steering still runs, and the turn
* closes only when that inbox drains. * closes only when that inbox drains.
* @param agent - the agent whose turn is at its stop boundary. * @param payload.agent - the agent whose turn is at its stop boundary.
* @param turn - the turn about to close. * @param payload.turn - the turn about to close.
* @param signal - the current turn's explicit abort signal. * @param payload.signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode serial * @mode serial
*/ */
'agent/turn-stopping'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<void> | void 'agent/turn-stopping'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; signal: AbortSignal }): Promise<void> | void
// ---- error notifications (emit) ---- // ---- error notifications (emit) ----
/** /**
* A step or turn errored. The machine reports a failure here even when * A step or turn errored. The machine reports a failure here even when
* the error has no in-turn position for a durable record. * the error has no in-turn position for a durable record.
* @param agent - the agent whose turn errored. * @param payload.agent - the agent whose turn errored.
* @param turn - the turn in which the failure surfaced. * @param payload.turn - the turn in which the failure surfaced.
* @param step - the step at which the failure surfaced. * @param payload.step - the step at which the failure surfaced.
* @param error - the failure, verbatim. * @param payload.error - the failure, verbatim.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: unknown): void 'agent/error'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; error: unknown }): void
} }
} }

View File

@@ -11,6 +11,7 @@ import type {
Agent, Agent,
AgentCancelCause, AgentCancelCause,
AgentFactory, AgentFactory,
AgentStatus,
CreateAgentOptions, CreateAgentOptions,
ResumeAgentOptions, ResumeAgentOptions,
} from '@deepseek-ai/dsh-agent' } from '@deepseek-ai/dsh-agent'
@@ -145,8 +146,8 @@ describe('AgentRegistry', () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(AgentRegistry) await ctx.plugin(AgentRegistry)
const lifecycle: string[] = [] 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/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`))
const agent = stubAgent('a1') const agent = stubAgent('a1')
const dispose = ctx.agents.register(agent) const dispose = ctx.agents.register(agent)
@@ -195,9 +196,9 @@ describe('AgentRegistry', () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(AgentRegistry) await ctx.plugin(AgentRegistry)
const lifecycle: string[] = [] 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/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.register(stubAgent('vetoed'))).toThrow('creation veto')
expect(ctx.agents.get(SessionId('vetoed'))).toBeUndefined() 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/created', () => Promise.reject(new Error('created async')) as never)
ctx.on('agent/disposed', () => { throw new Error('disposed sync') }) 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', () => 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')) const dispose = ctx.agents.register(stubAgent('contained'))
await Promise.resolve() await Promise.resolve()
@@ -232,8 +233,8 @@ describe('AgentRegistry', () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(AgentRegistry) await ctx.plugin(AgentRegistry)
const lifecycle: string[] = [] 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/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`))
const first = stubAgent('split') const first = stubAgent('split')
const detachFirst = ctx.agents.enter(first, undefined) const detachFirst = ctx.agents.enter(first, undefined)
@@ -280,9 +281,9 @@ describe('agentEvents()', () => {
const agent = stubAgent('event') const agent = stubAgent('event')
ctx.on('agent/status', () => { throw new Error('sync listener') }) 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', () => 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() await Promise.resolve()
expect(heard).toEqual(['running']) expect(heard).toEqual(['running'])
expect(warnings).toEqual([ expect(warnings).toEqual([
@@ -296,15 +297,30 @@ describe('agentEvents()', () => {
const agent = stubAgent('serial-event') const agent = stubAgent('serial-event')
const signal = new AbortController().signal const signal = new AbortController().signal
const heard: Array<{ agent: Agent; turn: number; signal: AbortSignal }> = [] 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() await Promise.resolve()
heard.push({ agent: subject, turn, signal: receivedSignal }) 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 }]) 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', () => { describe('explicit cancellation contract', () => {

View File

@@ -21,17 +21,17 @@ describe('agent status invariants', () => {
const ctx = await setup() const ctx = await setup()
const agent = mockAgent('a1') const agent = mockAgent('a1')
expect(() => { expect(() => {
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, 'running') ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'running' })
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'idle' })
}).not.toThrow() }).not.toThrow()
}) })
it('rejects a no-op transition', async () => { it('rejects a no-op transition', async () => {
const ctx = await setup() const ctx = await setup()
const agent = mockAgent('a3') const agent = mockAgent('a3')
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, 'running') }) expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'running' }) })
.toThrow(/no-op transition/) .toThrow(/no-op transition/)
}) })
@@ -39,7 +39,7 @@ describe('agent status invariants', () => {
const ctx = await setup() const ctx = await setup()
const a = mockAgent('a5') const a = mockAgent('a5')
const b = mockAgent('b5') const b = mockAgent('b5')
ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running') ctx.emit(scopeTarget(a, a), 'agent/status', { agent: a, status: 'running' })
expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow() expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', { agent: b, status: 'running' }) }).not.toThrow()
}) })
}) })

View File

@@ -21,7 +21,7 @@ describe('installAgentLlmTarget()', () => {
expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
await expect(agentEvents(ctx, agent).waterfall( 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) )).resolves.toBe(seed)
target.current = { target.current = {
@@ -32,7 +32,7 @@ describe('installAgentLlmTarget()', () => {
expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' }) expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' })
target.current = { provider: 'beta', model: 'b1' } target.current = { provider: 'beta', model: 'b1' }
await expect(agentEvents(ctx, agent).waterfall( 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({ )).resolves.toEqual({
provider: 'alpha', provider: 'alpha',
model: 'a1', model: 'a1',
@@ -48,13 +48,13 @@ describe('installAgentLlmTarget()', () => {
temperature: 0.2, temperature: 0.2,
} }
await expect(agentEvents(ctx, agent).waterfall( 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 }) )).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
dispose() dispose()
expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
await expect(agentEvents(ctx, agent).waterfall( 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) )).resolves.toBe(seed)
await ctx.fiber.dispose() await ctx.fiber.dispose()
}) })

View File

@@ -8,20 +8,20 @@
type ScopedSubjectResolver = (args: readonly unknown[]) => unknown type ScopedSubjectResolver = (args: readonly unknown[]) => unknown
const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | null>> = Object.freeze({ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | null>> = Object.freeze({
'agent/created': args => args[0], 'agent/created': args => (args[0] as Record<string, unknown>)['agent'],
'agent/disposed': args => args[0], 'agent/disposed': args => (args[0] as Record<string, unknown>)['agent'],
'agent/error': args => args[0], 'agent/error': args => (args[0] as Record<string, unknown>)['agent'],
'agent/inbox/claimed': args => args[0], 'agent/inbox/claimed': args => (args[0] as Record<string, unknown>)['agent'],
'agent/inbox/discarded': args => args[0], 'agent/inbox/discarded': args => (args[0] as Record<string, unknown>)['agent'],
'agent/inbox/inserted': args => args[0], 'agent/inbox/inserted': args => (args[0] as Record<string, unknown>)['agent'],
'agent/pre-step': args => args[0], 'agent/pre-step': args => (args[0] as Record<string, unknown>)['agent'],
'agent/request': args => args[0], 'agent/request': args => (args[0] as Record<string, unknown>)['agent'],
'agent/request-error': args => args[0], 'agent/request-error': args => (args[0] as Record<string, unknown>)['agent'],
'agent/session-start': args => args[0], 'agent/session-start': args => (args[0] as Record<string, unknown>)['agent'],
'agent/status': args => args[0], 'agent/status': args => (args[0] as Record<string, unknown>)['agent'],
'agent/turn-stopping': args => args[0], 'agent/turn-stopping': args => (args[0] as Record<string, unknown>)['agent'],
'approval/request': args => (args[0] as Record<string, unknown>)['agent'], 'approval/request': args => (args[0] as Record<string, unknown>)['agent'],
'goal/changed': args => args[0], 'goal/changed': args => (args[0] as Record<string, unknown>)['agent'],
'session/created': null, 'session/created': null,
'session/disposed': null, 'session/disposed': null,
'session/event': null, 'session/event': null,

View File

@@ -28,7 +28,7 @@ describe('scoped-dispatch invariants', () => {
const ctx = await setup() const ctx = await setup()
expect(() => { emit(ctx, undefined, 'ordinary/event', []) }).not.toThrow() expect(() => { emit(ctx, undefined, 'ordinary/event', []) }).not.toThrow()
const agent = { id: 'a1' } 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/) .toThrow(/dispatched without a scope carrier/)
}) })
@@ -45,34 +45,34 @@ describe('scoped-dispatch invariants', () => {
source: { kind: 'user' }, source: { kind: 'user' },
}) })
const agentRows = { const agentRows = {
'agent/created': [agent], 'agent/created': [{ agent }],
'agent/disposed': [agent], 'agent/disposed': [{ agent }],
'agent/status': [agent, 'idle'], 'agent/status': [{ agent, status: 'idle' }],
'agent/inbox/inserted': [agent, { message }], 'agent/inbox/inserted': [{ agent, message }],
'agent/inbox/claimed': [agent, { message, turn: 1 }], 'agent/inbox/claimed': [{ agent, message, turn: 1 }],
'agent/inbox/discarded': [agent, { message }], 'agent/inbox/discarded': [{ agent, message }],
'agent/session-start': [agent, 'startup'], 'agent/session-start': [{ agent, source: 'startup' }],
'agent/pre-step': [agent, [message], { turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [message] })], 'agent/pre-step': [{ agent, messages: [message], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [message] })],
'agent/request': [agent, 1, 1, signal, () => Promise.resolve(config)], 'agent/request': [{ agent, turn: 1, step: 1, signal }, () => Promise.resolve(config)],
'agent/request-error': [ 'agent/request-error': [
agent,
{ {
agent,
turn: 1, turn: 1,
step: 1, step: 1,
provider: 'p', provider: 'p',
failure: { message: 'request', code: 'UNKNOWN' }, failure: { message: 'request', code: 'UNKNOWN' },
retryPolicy: undefined, retryPolicy: undefined,
signal,
}, },
signal,
() => Promise.resolve(undefined), () => Promise.resolve(undefined),
], ],
'agent/turn-stopping': [agent, 1, signal], 'agent/turn-stopping': [{ agent, turn: 1, signal }],
'agent/error': [agent, 1, 0, new Error('x')], 'agent/error': [{ agent, turn: 1, step: 0, error: new Error('x') }],
} satisfies { [K in AgentEventName]: EventArgs<K> } } satisfies { [K in AgentEventName]: EventArgs<K> }
const rows: Array<[string, unknown[]]> = [ const rows: Array<[string, unknown[]]> = [
...Object.entries(agentRows), ...Object.entries(agentRows),
['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]], ['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 }]], ['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/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 })]], ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]],

View File

@@ -48,7 +48,7 @@ async function composePrefix(ctx: Context): Promise<Message[]> {
const agent = ctx.agentLoop.create(SessionId(`acp-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' }) const agent = ctx.agentLoop.create(SessionId(`acp-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' })
const signal = new AbortController().signal const signal = new AbortController().signal
const decision = await agentEvents(ctx, agent).waterfall( 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: [] }), () => Promise.resolve({ kind: 'enter', messages: [] }),
) )
if (decision.kind === 'enter') { if (decision.kind === 'enter') {

View File

@@ -41,7 +41,7 @@ async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
const agent = ctx.agentLoop.create(SessionId('agent-spine-prefix'), {}, { cwd }) const agent = ctx.agentLoop.create(SessionId('agent-spine-prefix'), {}, { cwd })
const signal = new AbortController().signal const signal = new AbortController().signal
const decision = await agentEvents(ctx, agent).waterfall( 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: [] }), () => Promise.resolve({ kind: 'enter', messages: [] }),
) )
if (decision.kind === 'enter') { if (decision.kind === 'enter') {

View File

@@ -45,7 +45,7 @@ async function composePrefix(ctx: Context): Promise<Message[]> {
const agent = ctx.agentLoop.create(SessionId(`cli-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' }) const agent = ctx.agentLoop.create(SessionId(`cli-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' })
const signal = new AbortController().signal const signal = new AbortController().signal
const decision = await agentEvents(ctx, agent).waterfall( 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: [] }), () => Promise.resolve({ kind: 'enter', messages: [] }),
) )
if (decision.kind === 'enter') { if (decision.kind === 'enter') {

View File

@@ -401,7 +401,7 @@ describe('runOneShot and executeCli', () => {
if (session === agent.session && event.type === 'assistant/message' if (session === agent.session && event.type === 'assistant/message'
&& event.data.turn === 1) startupStarted() && 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 if (subject === agent && turn === 1) await releaseStartup.promise
}) })
agent.followup(createUserMessage({ agent.followup(createUserMessage({
@@ -432,7 +432,7 @@ describe('runOneShot and executeCli', () => {
} }
let replacementQueued = false let replacementQueued = false
ctx.on('agent/status', (subject, status) => { ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject !== agent || status !== 'idle' || replacementQueued) return if (subject !== agent || status !== 'idle' || replacementQueued) return
replacementQueued = true replacementQueued = true
agent.followup(createUserMessage({ agent.followup(createUserMessage({

View File

@@ -25,7 +25,7 @@ export async function fsHarness(fsCwd: string, persona = ''): Promise<Context> {
export function waitForIdle(ctx: Context, agent: Agent): Promise<void> { export function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { 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') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()

View File

@@ -243,20 +243,20 @@ export function apply(ctx: Context): void {
// One composite effect keeps the step fence installed until this // One composite effect keeps the step fence installed until this
// plugin's own scheduling tasks settle. // plugin's own scheduling tasks settle.
ctx.effect(function* () { ctx.effect(function* () {
ctx.on('agent/error', (agent) => { ctx.on('agent/error', ({ agent }) => {
const state = stateFor(agent) const state = stateFor(agent)
disarm(state) disarm(state)
}) })
ctx.on('agent/created', (agent) => { stateFor(agent) }) ctx.on('agent/created', ({ agent }) => { stateFor(agent) })
ctx.on('agent/disposed', (agent) => { states.delete(agent) }) ctx.on('agent/disposed', ({ agent }) => { states.delete(agent) })
ctx.on('agent/session-start', (agent) => { ctx.on('agent/session-start', ({ agent }) => {
const state = stateFor(agent) const state = stateFor(agent)
state.attempt = undefined state.attempt = undefined
state.competingQueued = false state.competingQueued = false
state.needsCheckpoint = false state.needsCheckpoint = false
}) })
ctx.on('agent/status', (agent, status) => { ctx.on('agent/status', ({ agent, status }) => {
const state = stateFor(agent) const state = stateFor(agent)
if (status === 'idle') { if (status === 'idle') {
state.competingQueued = false state.competingQueued = false
@@ -275,13 +275,13 @@ export function apply(ctx: Context): void {
requestDrive(state) requestDrive(state)
} }
}) })
ctx.on('goal/changed', (agent) => { ctx.on('goal/changed', ({ agent }) => {
const state = stateFor(agent) const state = stateFor(agent)
state.needsCheckpoint = true state.needsCheckpoint = true
requestDrive(state) 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 if (!agent.inbox.nextTurn.some(candidate => candidate.id === message.id)) return
const state = stateFor(agent) const state = stateFor(agent)
const attempt = state.attempt const attempt = state.attempt
@@ -289,14 +289,14 @@ export function apply(ctx: Context): void {
state.competingQueued = true state.competingQueued = true
if (attempt?.phase === 'queued') attempt.stale = 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 state = stateFor(agent)
const attempt = state.attempt const attempt = state.attempt
if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) { if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) {
attempt.phase = 'claimed' attempt.phase = 'claimed'
} }
}) })
ctx.on('agent/inbox/discarded', (agent, { message }) => { ctx.on('agent/inbox/discarded', ({ agent, message }) => {
const state = stateFor(agent) const state = stateFor(agent)
const attempt = state.attempt const attempt = state.attempt
if (attempt !== undefined && sameQueued(message.content, message.source, 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 && source.round === goal.roundsStarted + 1
} }
ctx.on('agent/pre-step', async (agent, messages, { signal }, next): Promise<PreStepDecision> => { ctx.on('agent/pre-step', async ({ agent, messages, signal }, next): Promise<PreStepDecision> => {
const submitted = messages.find((message): message is UserMessage & { source: GoalMessageSource } => const submitted = messages.find((message): message is UserMessage & { source: GoalMessageSource } =>
isGoalRoundSource(message.source)) isGoalRoundSource(message.source))
if (submitted === undefined) return next() if (submitted === undefined) return next()

View File

@@ -107,7 +107,7 @@ function onInboxMessage(
agent: Agent, agent: Agent,
listener: (message: UserMessage) => void, listener: (message: UserMessage) => void,
): () => void { ): () => void {
return ctx.on('agent/inbox/inserted', (subject, { message }) => { return ctx.on('agent/inbox/inserted', ({ agent: subject, message }) => {
if (subject === agent) listener(message) if (subject === agent) listener(message)
}) })
} }
@@ -118,7 +118,7 @@ function onClaimedMessage(
agent: Agent, agent: Agent,
listener: (message: UserMessage) => void, listener: (message: UserMessage) => void,
): () => void { ): () => void {
return ctx.on('agent/inbox/claimed', (subject, { message }) => { return ctx.on('agent/inbox/claimed', ({ agent: subject, message }) => {
if (subject === agent) listener(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 () => { it('maps a downstream step rejection to blocked without entering the round', async () => {
const test = await harness([]) 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 }) ? Promise.resolve({ kind: 'reject' as const })
: next()) : next())
test.ctx.goals.create(test.agent, { objective: 'respect policy' }) 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 () => { it('does not reserve again when a stopped-goal observer queues cancel-scoped work', async () => {
const test = await harness([textResponse('human follow-up')]) 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 }) ? Promise.resolve({ kind: 'reject' as const })
: next()) : 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' } })) 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' }) 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 () => { it('rechecks revision after downstream prompt hooks before admitting', async () => {
const test = await harness([textResponse('new revision')]) const test = await harness([textResponse('new revision')])
let edited = false 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) { if (messages[0]?.source.kind === 'goal' && !edited) {
edited = true edited = true
const current = test.ctx.goals.get(agent) 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 () => { it('does not block a goal that downstream paused before rejecting its prompt', async () => {
const test = await harness([]) 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)) { if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0)) {
return next() return next()
} }
@@ -432,7 +432,7 @@ describe('same-session goal driving', () => {
test.agent.inbox.prepend('next-step', roundZeroContext) test.agent.inbox.prepend('next-step', roundZeroContext)
}) })
let edited = false 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() const decision = await next()
if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0) || edited) return decision if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0) || edited) return decision
edited = true edited = true
@@ -513,8 +513,10 @@ describe('same-session goal driving', () => {
const test = await harness([]) const test = await harness([])
test.ctx.on('session/flush', () => Promise.reject(new Error('clear checkpoint failed'))) test.ctx.on('session/flush', () => Promise.reject(new Error('clear checkpoint failed')))
agentEvents(test.ctx, test.agent).emit('goal/changed', { agentEvents(test.ctx, test.agent).emit('goal/changed', {
operation: 'clear', change: {
ref: { id: GoalId('cleared-goal'), revision: 2 }, operation: 'clear',
ref: { id: GoalId('cleared-goal'), revision: 2 },
},
}) })
await new Promise<void>((resolve) => { setImmediate(resolve) }) await new Promise<void>((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. // The llm-retry shape: schedule one retry for the failed goal-round request.
let retried = false let retried = false
test.ctx.on('agent/request-error', async (_subject) => { test.ctx.on('agent/request-error', async (_payload) => {
if (!retried) { if (!retried) {
retried = true retried = true
return { kind: 'retry' } return { kind: 'retry' }
@@ -552,7 +554,7 @@ describe('same-session goal driving', () => {
// attempt through cancel-requested) and THEN throws: the catch finds no // attempt through cancel-requested) and THEN throws: the catch finds no
// matching reservation and must not reschedule a paused goal. // matching reservation and must not reschedule a paused goal.
let fired = false 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) { if (messages[0]?.source.kind === 'goal' && !fired) {
fired = true fired = true
agent.cancel({ kind: 'user' }) agent.cancel({ kind: 'user' })
@@ -576,7 +578,7 @@ describe('same-session goal driving', () => {
// Registered after goal-session's own listener: the throw propagates back // Registered after goal-session's own listener: the throw propagates back
// through goal-session's next() await, dropping the whole step proposal. // through goal-session's next() await, dropping the whole step proposal.
let threw = false 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) { if (messages[0]?.source.kind === 'goal' && !threw) {
threw = true threw = true
throw new Error('downstream pre-step hook exploded') throw new Error('downstream pre-step hook exploded')
@@ -598,7 +600,7 @@ describe('same-session goal driving', () => {
textResponse('goal round ran'), textResponse('goal round ran'),
]) ])
let retried = false let retried = false
test.ctx.on('agent/request-error', async (_subject) => { test.ctx.on('agent/request-error', async (_payload) => {
if (!retried) { if (!retried) {
retried = true retried = true
return { kind: 'retry' } 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 () => { it('fails a post-hook read closed before the prompt can enter history', async () => {
const test = await harness([]) const test = await harness([])
let armed = true 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) { if (messages[0]?.source.kind === 'goal' && armed) {
armed = false armed = false
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { 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 () => { it('rejects the step when downstream cancellation clears the reservation', async () => {
const test = await harness([]) const test = await harness([])
let cancelled = false 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) { if (messages[0]?.source.kind === 'goal' && !cancelled) {
cancelled = true cancelled = true
agent.cancel({ kind: 'user' }) 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 () => { it('resets process-local scheduling state at a session-start edge', async () => {
const test = await harness([textResponse('after explicit resume')]) const test = await harness([textResponse('after explicit resume')])
const created = test.ctx.goals.create(test.agent, { objective: 'restart safely', maxGoalRounds: 1 }) 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() await Promise.resolve()
expect(test.ctx.goals.get(test.agent)).toMatchObject({ activation: 'disarmed', roundsStarted: 0 }) 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')]) const test = await harness([textResponse('round one')])
test.ctx.on('session/event', (session, event) => { test.ctx.on('session/event', (session, event) => {
if (session === test.agent.session && event.type === 'turn/end') { 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 }) 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() await handle.dispose()
const warn = vi.spyOn(test.ctx.logger, 'warn') 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(test.ctx.agents.get(handle.agent.id)).toBeUndefined()
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('goal-session')) 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 () => { it('waits for work queued by a pause observer before considering the next round', async () => {
const test = await harness(['hang', textResponse('inspection answer')]) 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') { if (agent === test.agent && change.operation === 'pause') {
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the pause' }], source: { kind: 'user' } })) 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 () => { it('does not re-block a goal the downstream veto already saw cancelled', async () => {
const test = await harness([]) const test = await harness([])
let vetoed = false 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) { if (messages[0]?.source.kind === 'goal' && !vetoed) {
vetoed = true vetoed = true
agent.cancel({ kind: 'user' }) 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 () => { it('awaits a claimed reservation stuck in pre-step during teardown without cancelling', async () => {
const test = await harness([]) const test = await harness([])
let release: (() => void) | undefined 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) { if (messages[0]?.source.kind === 'goal' && release === undefined) {
await new Promise<void>((resolve) => { release = resolve }) await new Promise<void>((resolve) => { release = resolve })
} }

View File

@@ -134,10 +134,10 @@ declare module 'cordis' {
* Goal mutation accepted by one live agent. The matching `goal/change` * Goal mutation accepted by one live agent. The matching `goal/change`
* session event has already committed. Listener failures are contained. * session event has already committed. Listener failures are contained.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @param agent - agent whose session owns the goal. * @param payload.agent - agent whose session owns the goal.
* @param change - fresh current projection or clear tombstone. * @param payload.change - fresh current projection or clear tombstone.
* @mode emit * @mode emit
*/ */
'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, agent: Agent, change: GoalChanged): void 'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, payload: { agent: Agent; change: GoalChanged }): void
} }
} }

View File

@@ -193,7 +193,7 @@ export class GoalService extends Service {
this.resolved = { this.resolved = {
defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256), defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256),
} }
ctx.on('agent/session-start', (agent) => { ctx.on('agent/session-start', ({ agent }) => {
this.cache(agent.session).activation = 'disarmed' this.cache(agent.session).activation = 'disarmed'
}) })
// The `goal` projection unit: last-wins fold of goal/change whole values // The `goal` projection unit: last-wins fold of goal/change whole values
@@ -547,7 +547,7 @@ export class GoalService extends Service {
ref: { ...ref }, ref: { ...ref },
...goal === undefined ? {} : { goal }, ...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. */ /** Build a detached current view. */

View File

@@ -83,7 +83,7 @@ describe('GoalService creation and replay', () => {
vi.setSystemTime(1_700_000_000_000) vi.setSystemTime(1_700_000_000_000)
const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 }) const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 })
const seen: string[] = [] 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 ' }) 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() const { ctx, agent, session } = await harness()
let goal = ctx.goals.create(agent, { objective: 'stay stopped after resume' }) let goal = ctx.goals.create(agent, { objective: 'stay stopped after resume' })
expect(goal.activation).toBe('armed') 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') expect(ctx.goals.get(agent)?.activation).toBe('disarmed')
goal = ctx.goals.resume(agent, goal) goal = ctx.goals.resume(agent, goal)
expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 2 }) expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 2 })
@@ -223,7 +223,7 @@ describe('GoalService creation and replay', () => {
await fiber.dispose() await fiber.dispose()
expect(ctx.get('goals')).toBeUndefined() 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' }) expect(first.get(stub.agent)).toMatchObject({ id: goal.id, activation: 'armed' })
await ctx.plugin(GoalService) await ctx.plugin(GoalService)
@@ -384,7 +384,7 @@ describe('GoalService mutations', () => {
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const seen: string[] = [] const seen: string[] = []
ctx.on('goal/changed', () => { throw new Error('broken observer') }) 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(ctx.goals.create(agent, { objective: 'notify' }).phase).toBe('active')
expect(seen).toEqual(['create']) expect(seen).toEqual(['create'])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('broken observer')) expect(warn).toHaveBeenCalledWith(expect.stringContaining('broken observer'))

View File

@@ -404,7 +404,7 @@ describe('goal tool state transitions', () => {
let turn = openTurn(root, { kind: 'user' }) let turn = openTurn(root, { kind: 'user' })
const created = ctx.goals.create(root.agent, { objective: 'continue later' }) const created = ctx.goals.create(root.agent, { objective: 'continue later' })
closeTurn(root, turn) 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') expect(ctx.goals.get(root.agent)?.activation).toBe('disarmed')
turn = openTurn(root, { kind: 'user' }, '继续') turn = openTurn(root, { kind: 'user' }, '继续')
const resumed = await execute(ctx, 'update_goal', { const resumed = await execute(ctx, 'update_goal', {

View File

@@ -226,7 +226,7 @@ export function apply(ctx: Context, config: Config): void {
// A user interjection changes the context; repetition across it is not a // A user interjection changes the context; repetition across it is not a
// loop. Pure reset hook: always delegates (attaching nothing, vetoing // loop. Pure reset hook: always delegates (attaching nothing, vetoing
// nothing). // nothing).
ctx.on('agent/pre-step', (agent, messages, _context, next): Promise<PreStepDecision> => { ctx.on('agent/pre-step', ({ agent, messages }, next): Promise<PreStepDecision> => {
if (messages.some(message => message.source.kind === 'user')) chains.delete(agent) if (messages.some(message => message.source.kind === 'user')) chains.delete(agent)
return next() return next()
}) })

View File

@@ -32,7 +32,7 @@ async function harness(config: Config = {}): Promise<Context> {
} }
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
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. */ /** Every injected-context user message in the agent's log, flattened to joined text + source for terse assertions. */

View File

@@ -203,7 +203,7 @@ export function apply(ctx: Context, config: Config): void {
// SessionStart injects context when its detached hook resolves; a slow hook // SessionStart injects context when its detached hook resolves; a slow hook
// may miss the first request. // may miss the first request.
// TODO(session-start-gating): add a startup gate before promising first-turn delivery. // 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 }) detached.track(runPoint('SessionStart', source, sessionStartPayload(ctx, agent, source), { agent, signal: detached.signal })
.then((merged) => { .then((merged) => {
const context = contextFrom(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 // --- UserPromptSubmit → PreStepDecision. The prompt text is the payload; no
// matcher subject (CC ignores matchers for this event). --- // matcher subject (CC ignores matchers for this event). ---
ctx.on('agent/pre-step', async (agent, messages, { turn, signal }, next): Promise<PreStepDecision> => { ctx.on('agent/pre-step', async ({ agent, messages, turn, signal }, next): Promise<PreStepDecision> => {
if (messages.length === 0) return next() if (messages.length === 0) return next()
const content = messages.flatMap(message => message.content) const content = messages.flatMap(message => message.content)
const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, turn, signal }) 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 // A blocking Stop hook steers at the stopping boundary, which makes the
// machine observe pending input and run another step. // machine observe pending input and run another step.
// TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile. // TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile.
ctx.on('agent/turn-stopping', async (agent, turn, signal): Promise<void> => { ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise<void> => {
const merged = await runPoint('Stop', '', stopPayload(ctx, agent), { agent, turn, signal }) const merged = await runPoint('Stop', '', stopPayload(ctx, agent), { agent, turn, signal })
if (merged.decision === 'deny') { if (merged.decision === 'deny') {
// A blocking Stop hook forces continuation. // A blocking Stop hook forces continuation.

Some files were not shown because too many files have changed in this diff Show More