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;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md
2026-07-16-explicit-turn-cancellation.md: cce649976c9f4f596d5306b9fe8c3fd49a0e1adc
2026-07-16-explicit-turn-cancellation.zh.md: 6f8b83fdb42af03c97dc2e8a9345a01acc6019fc
2026-07-16-explicit-turn-cancellation.md: ca56c77a097e3008a50c2aec24040a4f4b6f0ba3
2026-07-16-explicit-turn-cancellation.zh.md: bf410e5c7284a9c9914edbd14445074e71dd6943

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 explicit event signatures keep their positional form and place `signal` inside `PreStepContext` or immediately before a waterfall's final `next`. Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn.
The explicit event signatures pass a single payload object: agent-scoped events carry `agent` and `signal` in the payload with `next` last, and the remaining seams keep `signal` immediately before a waterfall's final `next`. `PreStepContext` and `RequestFailureContext` are retired, with their fields folded into the `agent/pre-step` and `agent/request-error` payloads ([payload-object events](2026-08-06-agent-event-payload-objects.md)). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn.
`ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a canonical frozen cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam.
@@ -44,7 +44,7 @@ Initiator-scope tests assert that every hook still observes the exact Agent and
**Define speculative `superseded`, `timeout`, and `shutdown` variants now.** No current Agent cancellation producer implements those semantics. `shutdown` is already lifecycle disposal, and timeout or supersession should enter the union only with an owning policy and unique terminal meaning.
**Expose public turn or step context wrappers.** Existing positional seams already identify Agent, turn, and step. A wrapper would widen every API, duplicate ownership, and tempt callers to treat a captured object as durable authority.
**Expose public turn or step context wrappers.** Existing seams already identify Agent, turn, and step. A wrapper would widen every API, duplicate ownership, and tempt callers to treat a captured object as durable authority.
**Abandon uncooperative work after a grace period.** Returning idle while same-process work still runs breaks teardown and resource-ownership guarantees. Hard termination requires a worker or process isolation boundary and is outside this control seam.

View File

@@ -20,7 +20,7 @@ AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它
对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering中途引导工作或中止持有者通知失败不能阻止此次停止空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除而稍后由 signal 中止观察者加入队列的工作属于下一个轮次。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。
显式事件签名保留位置参数形式,并把 `signal` 放入 `PreStepContext`,或放在 waterfall瀑布式事件的最后一个参数 `next` 之前。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()``AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。
显式事件签名传递单个 payload 对象agent 作用域事件在 payload 中携带 `agent``signal``next` 位于最后;其余 seam 保持 `signal` 紧邻 waterfall瀑布式事件的最 `next` 之前。`PreStepContext``RequestFailureContext` 已退役,其字段并入 `agent/pre-step``agent/request-error` 的 payload[payload-object 事件](2026-08-06-agent-event-payload-objects.md))。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()``AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。
`ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal子驱动会遮蔽父发起方而父请求 signal 仍通过 subagent seam 传递。
@@ -44,7 +44,7 @@ Agent dispose资源释放会在活跃持有者上请求仅用于运行时
**现在就定义推测性的 `superseded`、`timeout` 和 `shutdown` 变体。** 当前没有 Agent 取消生产方实现这些语义。`shutdown` 已经属于生命周期 dispose超时或替代只有在拥有明确归属策略和唯一终态含义时才应进入联合类型。
**公开轮次或步骤上下文包装类型。** 现有位置参数 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属并诱导调用方把捕获的对象当成持久权限。
**公开轮次或步骤上下文包装类型。** 现有 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属并诱导调用方把捕获的对象当成持久权限。
**在宽限期后放弃不协作的工作。** 同进程工作仍在运行时就报告空闲状态,会破坏资源清理与资源归属保证。硬终止需要 worker 或进程隔离边界,不属于该控制 seam。

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;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md
2026-06-18-compaction-capability-seam.md: 27dbde9f2349681cf47c4d25b16399b26ed9e1ca
2026-06-18-compaction-capability-seam.zh.md: 1fe9ece2861bd6d75633a866a4a11eaadbf7ef26
2026-06-18-compaction-capability-seam.md: 26e6e2468c7bea661d85c8fb994adf8b109105ee
2026-06-18-compaction-capability-seam.zh.md: 8f9cd1f6bc31e648a5b923e816cad75ebc1a0bd8

View File

@@ -119,7 +119,7 @@ The lifecycle boundary makes crash state unambiguous:
## Consequences
- **Packages**: `packages/compact/compact` supplies the interface, `compact-basic` supplies the backend, `compact-tool-result-prune` supplies optional deterministic rewriting, and `command-compact` supplies human `/compact`. `packages/llm/token-meter` owns replay-aware measurement independently.
- **Automatic seams**: `agent/pre-step` (`@mode waterfall`) handles pressure before request derivation and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. Pre-step receives the claimed batch and `PreStepContext`, with no compaction-only prompt/prefix payload.
- **Automatic seams**: `agent/pre-step` (`@mode waterfall`) handles pressure before request derivation and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. The pre-step payload carries the claimed batch, turn, step, and signal (see the [payload-object events decision](../architecture/2026-08-06-agent-event-payload-objects.md)), with no compaction-only prompt/prefix payload.
- **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry.
- **`dsh-compact`** owns `COMPACT_CHECKPOINT_SOURCE`, `isCompactCheckpointSource(source)`, `toolPairingBalancedBefore(session, seq)`, and `toolPairingBalancedAfter(session, seq)`. The marker identifies replacement summaries across backend implementations. The cached surface-edge checks prevent `compactRegion` and `compactIfNeeded` from splitting a tool-call/result pair, validate current membership by seq, answer both edges from one per-cut balance sequence, and reject stale or missing seqs and orphan results.
- **`dsh-session`** validates positional replacement, complete provenance, and content-only single-node `tool/result` rewrites through its one surface manager. Its invariant companion treats fresh appended tool results as executions that require an open step and pending call, while the compaction companion owns numeric-turn versus standalone-null bracket relations.

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` 独立拥有回放感知的测量。
- **自动 seam**`agent/pre-step``@mode waterfall`)在请求派生前处理压力,`agent/request-error``@mode waterfall`处理失败步骤关闭后的最终请求失败。pre-step 接收已领取批次与 `PreStepContext`,不携带压缩专属的提示词/前缀 payload。
- **自动 seam**`agent/pre-step``@mode waterfall`)在请求派生前处理压力,`agent/request-error``@mode waterfall`处理失败步骤关闭后的最终请求失败。pre-step 的 payload 携带已领取批次、轮次、步骤与 signal参见 [payload-object 事件决策](../architecture/2026-08-06-agent-event-payload-objects.md),不携带压缩专属的提示词/前缀 payload。
- **`SessionEventMap`** 通过可合并扩展的声明合并获得 `compact/start` / `compact/summary` / `compact/end``SurfaceEventType` **未被**触及。这些是会话事件,不是 cordis `Events`,因此事件分类门禁无需新增条目。
- **`dsh-compact`** 拥有 `COMPACT_CHECKPOINT_SOURCE``isCompactCheckpointSource(source)``toolPairingBalancedBefore(session, seq)``toolPairingBalancedAfter(session, seq)`。该标记用于跨后端实现识别替换摘要。带缓存的 surface 边缘检查会防止 `compactRegion``compactIfNeeded` 拆分工具调用/结果对,按 seq 校验当前成员关系,从每个切割点的一条平衡序列回答两侧边缘,并拒绝陈旧或缺失的 seq 与孤立结果。
- **`dsh-session`** 通过唯一的 surface 管理器校验位置替换、完整溯源信息和仅内容的单节点 `tool/result` 重写。其不变式配套插件将新追加的工具结果视为执行,要求存在已打开的步骤与待处理调用,而压缩配套组件拥有数字轮次归属与独立 `null` 归属标记对之间的关系。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-interception-seams.md
2026-06-30-interception-seams.md: 629a1aed509bd9bce9a2da89ce84b17a1db8e6b6
2026-06-30-interception-seams.zh.md: d6958c9d1e7a8af8fa06d859d1905719a19cd43d
2026-06-30-interception-seams.md: c318e41cfb1d64230b6151f1febad85d75b1451d
2026-06-30-interception-seams.zh.md: 1b274fae4bc7fde326dbb0eeec54d57f73987803

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.
**Agent events** (`dsh-agent`):
- `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`.
- `agent/pre-step(agent, messages, context, next) → PreStepDecision` — waterfall, fired before every proposed step after the loop has atomically removed its exclusive inbox batch. `PreStepContext` carries that request's `turn`, `step`, and cancellation `signal`; `messages` is empty for a tool continuation with no intervening input. `enter` returns the complete message batch, including any current-request context a listener contributes; `reject` opens no step and leaves the claimed messages removed.
- `agent/session-start({ agent, source })` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`.
- `agent/pre-step({ agent, messages, turn, step, signal }, next) → PreStepDecision` — waterfall, fired before every proposed step after the loop has atomically removed its exclusive inbox batch. The payload carries the request's `turn`, `step`, and cancellation `signal` (the retired `PreStepContext` fields live in the payload; see the [payload-object events decision](../architecture/2026-08-06-agent-event-payload-objects.md)); `messages` is empty for a tool continuation with no intervening input. `enter` returns the complete message batch, including any current-request context a listener contributes; `reject` opens no step and leaves the claimed messages removed.
**`agent/turn-stopping`** is an awaited notification at the natural stop boundary. A listener that needs another step calls `agent.steer()` with explicitly sourced model-facing content; the loop then re-reads the outbox and either continues or closes the turn.

View File

@@ -15,8 +15,8 @@ harness 需要一套钩子子系统:用户像 Claude CodeCC和 Codex 那
规范表面将可变换策略、环绕调度控制与仅观测通知分离。策略 waterfall瀑布式事件返回小型的、seam 专属的**类型化 Decision 联合类型**;包装层返回规范化结果;通知接收不可变快照,无法影响结果。覆盖的钩子点包括 `session-start``prompt-submit``pre-tool``post-tool`、通过 continuation 实现的 `stop`,同时将非钩子的执行策略留作独立可组合。
**Agent 事件**`dsh-agent`
- `agent/session-start(agent, source)` ——emit在第 1 轮次之前触发一次,携带 `SessionStartSource``startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知,不能阻塞启动(这是有意的空白:桥接可以记录/注入,但不管控启动)。监听器通过 `agent.inject()` 注入上下文。
- `agent/pre-step(agent, messages, context, next) → PreStepDecision` ——waterfall在每个拟议步骤之前、循环原子移除其独占 inbox 批次后触发。`PreStepContext` 携带该请求的 `turn``step` 与取消 `signal`;没有中途输入的工具续步会收到空批次。`enter` 返回完整消息批次,其中包括监听器为当前请求贡献的上下文;`reject` 不打开步骤,并让已领取消息保持已删除。
- `agent/session-start({ agent, source })` ——emit在第 1 轮次之前触发一次,携带 `SessionStartSource``startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知,不能阻塞启动(这是有意的空白:桥接可以记录/注入,但不管控启动)。监听器通过 `agent.inject()` 注入上下文。
- `agent/pre-step({ agent, messages, turn, step, signal }, next) → PreStepDecision` ——waterfall在每个拟议步骤之前、循环原子移除其独占 inbox 批次后触发。payload 携带该请求的 `turn``step` 与取消 `signal`(已退役的 `PreStepContext` 字段位于 payload 中;参见 [payload-object 事件决策](../architecture/2026-08-06-agent-event-payload-objects.md);没有中途输入的工具续步会收到空批次。`enter` 返回完整消息批次,其中包括监听器为当前请求贡献的上下文;`reject` 不打开步骤,并让已领取消息保持已删除。
**`agent/turn-stopping`** 是自然停止边界上的一次 awaited 通知。需要再执行一步的监听器调用 `agent.steer()`,传入来源显式的 steering中途引导内容供模型使用循环随后重新读取 outbox继续执行或关闭轮次。

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 frames = api.events.mux({}, abort.signal)
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()
})
})

View File

@@ -26,6 +26,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', impor
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md')
const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md')
// Post-reload golden: the same settled conversation rebuilt purely from
// persistence + history — byte-equal rendering is exactly the recovery claim.
@@ -83,6 +84,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
expect(Math.abs(
launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height,
)).toBeLessThan(1)
await input.fill('/cpt')
await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual([
'compactCompact older conversation history',
])
const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(FUZZY_COMMAND_MENU_EXPECTED, fuzzySnapshot, MODE)
await input.fill('')
await expect.poll(() => menu.count()).toBe(0)
})
@@ -258,7 +265,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
'session.jsonl', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
])
})
})

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;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/architecture.md
architecture.md: 9b84c1482cb379fd796e21db45f128ba49750c54
architecture.zh.md: 84708fcae24623e50b0157782cf459c35a55844b
architecture.md: 40c20a1c9eeabe5ecbbc6edacde81c20071b8a04
architecture.zh.md: 6fddaa883775cf8345aba01af52575c0f0e1aaa0

View File

@@ -83,7 +83,7 @@ forever:
-> 'turn/start'
claim next-step input plus one next-turn message
-> emit agent/inbox/claimed({ message, turn }) for each claimed message
-> agent/pre-step(messages, { turn, step, signal })
-> agent/pre-step({ agent, messages, turn, step, signal })
reject, empty input, cancellation, or listener failure
-> the claimed batch stays removed; close the no-step turn; stop the driver
enter -> step loop:
@@ -112,7 +112,7 @@ idle inject:
Each step assembles ordered prompt sections, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona; the loop supplies `provider`, `model`, and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)).
`inject()` queues non-waking `next-step` context; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. Post-tool `additionalContexts` use the same inbox. `agent/pre-step` receives the exclusive claimed batch and upcoming turn, step, and signal. Reject opens no step; enter supplies the complete batch appended after `step/start`. Empty tool continuations still traverse the waterfall, whose final value settles all rewrites.
`inject()` queues non-waking `next-step` context; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. Post-tool `additionalContexts` use the same inbox. The `agent/pre-step` payload carries the exclusive claimed batch and the upcoming turn, step, and signal. Reject opens no step; enter supplies the complete batch appended after `step/start`. Empty tool continuations still traverse the waterfall, whose final value settles all rewrites.
Pruning precedes summaries; overflow retries require durable progress. `agent/request-error` may authorize a same-step retry of the frozen prompt; cancellation wins. Adapter `retryPolicy` bounds normal mode, while always mode retries after specialized recovery ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry foundation](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md), [provider policy](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md)). The generated [agent lifecycle](agent-lifecycle.md) owns exact event order, and the [agent-loop README](../packages/core/agent-loop/README.md) owns queue, steering, retry, and cancellation mechanics.

View File

@@ -83,7 +83,7 @@ forever:
-> 'turn/start'
claim next-step input plus one next-turn message
-> emit agent/inbox/claimed({ message, turn }) for each claimed message
-> agent/pre-step(messages, { turn, step, signal })
-> agent/pre-step({ agent, messages, turn, step, signal })
reject, empty input, cancellation, or listener failure
-> the claimed batch stays removed; close the no-step turn; stop the driver
enter -> step loop:
@@ -112,7 +112,7 @@ idle inject:
每个步骤都会组装有序的提示词片段、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `provider``model``cwd`[提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。
`inject()` 将不会唤醒驱动器的上下文排入 `next-step`;空闲驱动器会让它保持待处理,直至 `followup()``steer()` 唤醒。工具执行后的 `additionalContexts` 使用同一个 inbox。`agent/pre-step` 接收独占的已领取批次,以及即将使用的轮次、步骤和信号。拒绝则不进入步骤;进入则提供在 `step/start` 后追加的完整批次。空的工具续跑仍会经过 waterfall其最终值一次性结算所有改写。
`inject()` 将不会唤醒驱动器的上下文排入 `next-step`;空闲驱动器会让它保持待处理,直至 `followup()``steer()` 唤醒。工具执行后的 `additionalContexts` 使用同一个 inbox。`agent/pre-step` 的 payload 携带独占的已领取批次,以及即将使用的轮次、步骤和信号。拒绝则不进入步骤;进入则提供在 `step/start` 后追加的完整批次。空的工具续跑仍会经过 waterfall其最终值一次性结算所有改写。
裁剪先于摘要;溢出重试必须取得持久进展。`agent/request-error` 可以授权使用冻结提示词进行同步骤重试;取消优先。适配器的 `retryPolicy` 使 normal mode 保持有界always mode 则在专门恢复后重试([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试基础](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)、[提供方策略](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md))。精确事件顺序由生成的 [agent 生命周期](agent-lifecycle.md)定义队列、steering、重试与取消机制由 [agent-loop README](../packages/core/agent-loop/README.md)定义。

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
* rejection is reported. Detach requested during dispatch waits until every
* creation listener has observed the stable entry.
* @param agent - the newly registered agent with its live session and completed setup.
* @param payload.agent - the newly registered agent with its live session and completed setup.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/created'(this: Scoped<Agent>, agent: Agent): void
'agent/created'(this: Scoped<Agent>, payload: { agent: Agent }): void
```
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts)
### `agent/disposed` — emit
@@ -44,16 +44,16 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco
* An agent left the registry; AgentLoop emits this after driver quiescence
* and scoped-registration unwind, but before session detachment. Custom
* registry users own their driver-ordering contract.
* @param agent - the exact agent removed from the registry.
* @param payload.agent - the exact agent removed from the registry.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
'agent/disposed'(this: Scoped<Agent>, payload: { agent: Agent }): void
```
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:187`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts)
### `agent/error` — emit
@@ -63,19 +63,19 @@ A step or turn errored. The machine reports a failure here even when the error h
/**
* A step or turn errored. The machine reports a failure here even when
* the error has no in-turn position for a durable record.
* @param agent - the agent whose turn errored.
* @param turn - the turn in which the failure surfaced.
* @param step - the step at which the failure surfaced.
* @param error - the failure, verbatim.
* @param payload.agent - the agent whose turn errored.
* @param payload.turn - the turn in which the failure surfaced.
* @param payload.step - the step at which the failure surfaced.
* @param payload.error - the failure, verbatim.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/error'(this: Scoped<Agent>, agent: 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)
Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts)
### `agent/inbox/claimed` — emit
@@ -86,17 +86,18 @@ One message left the inbox inside its open turn. If the proposed step is rejecte
* One message left the inbox inside its open turn. If the proposed step
* is rejected, the claimed message ends here: it is neither discarded nor
* re-emitted as a user/message, and the turn closes without a step.
* @param agent - the agent whose inbox changed.
* @param event - the claimed message and owning turn.
* @param payload.agent - the agent whose inbox changed.
* @param payload.message - the claimed message.
* @param payload.turn - the owning turn.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/claimed'(this: Scoped<Agent>, agent: 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)
Source: [`packages/core/agent/src/types.ts:215`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:192`](../../packages/core/agent/src/types.ts)
### `agent/inbox/discarded` — emit
@@ -105,17 +106,17 @@ One message was discarded from the live inbox.
```ts cordis-catalog
/**
* One message was discarded from the live inbox.
* @param agent - the agent whose inbox changed.
* @param event - the discarded message.
* @param payload.agent - the agent whose inbox changed.
* @param payload.message - the discarded message.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/discarded'(this: Scoped<Agent>, agent: 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)
Source: [`packages/core/agent/src/types.ts:223`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:200`](../../packages/core/agent/src/types.ts)
### `agent/inbox/inserted` — emit
@@ -124,17 +125,17 @@ One message entered the live inbox.
```ts cordis-catalog
/**
* One message entered the live inbox.
* @param agent - the agent whose inbox changed.
* @param event - the inserted message.
* @param payload.agent - the agent whose inbox changed.
* @param payload.message - the inserted message.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/inserted'(this: Scoped<Agent>, agent: 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)
Source: [`packages/core/agent/src/types.ts:205`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:181`](../../packages/core/agent/src/types.ts)
### `agent/pre-step` — waterfall
@@ -144,18 +145,20 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p
/**
* Reject a proposed step or replace the messages that enter it. Calling
* `next()` preserves the current messages.
* @param agent - the agent proposing the step.
* @param messages - messages removed from the inbox for this step.
* @param context - proposed turn and step coordinates plus cancellation.
* @param payload.agent - the agent proposing the step.
* @param payload.messages - messages removed from the inbox for this step.
* @param payload.turn - the turn that will own the step.
* @param payload.step - the step proposed by the loop.
* @param payload.signal - the current turn's cancellation signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/pre-step'(this: Scoped<Agent>, agent: 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
@@ -167,19 +170,19 @@ Replace the frozen call configuration. `await next()` yields the config the mach
* the machine would use (agent options on the first request, the logged
* header afterwards); return a replacement to switch. Model-visible
* content must use logged channels; this seam cannot mutate messages.
* @param agent - the agent making the model call.
* @param turn - the open turn number.
* @param step - the step whose request this is.
* @param signal - the current turn's explicit abort signal.
* @param payload.agent - the agent making the model call.
* @param payload.turn - the open turn number.
* @param payload.step - the step whose request this is.
* @param payload.signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/request'(this: Scoped<Agent>, agent: 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)
Source: [`packages/core/agent/src/types.ts:260`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts)
### `agent/request-error` — waterfall
@@ -191,18 +194,22 @@ Handle one failed model-request attempt before the loop retries or closes its st
* its step. A listener returns `{ kind: 'retry' }` without calling `next()`
* when it owns recovery, or calls `next()` to delegate. The default
* `undefined` leaves the failure terminal.
* @param agent - the agent whose request failed.
* @param context - request coordinates, provider, normalized failure, and serving policy.
* @param signal - the turn abort signal.
* @param payload.agent - the agent whose request failed.
* @param payload.turn - the turn containing the failed request.
* @param payload.step - the step containing the failed request attempt.
* @param payload.provider - the provider selected for the failed request.
* @param payload.failure - serializable facts normalized at the final adapter boundary.
* @param payload.retryPolicy - the policy of the adapter registration that served the failed request.
* @param payload.signal - the turn abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/request-error'(this: Scoped<Agent>, agent: 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
@@ -214,17 +221,17 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to
* `agent.inject()` to seed model-facing context. This is a notification, not
* a veto; disposal requested by a lifecycle owner is rechecked before the
* driver starts.
* @param agent - the agent whose session lifecycle began.
* @param source - why the session started (fresh startup, resume, …).
* @param payload.agent - the agent whose session lifecycle began.
* @param payload.source - why the session started (fresh startup, resume, …).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/session-start'(this: Scoped<Agent>, agent: 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)
Source: [`packages/core/agent/src/types.ts:235`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts)
### `agent/status` — emit
@@ -235,17 +242,17 @@ Agent status changed (`idle` ⇄ `running`). A waking delivery enters `running`
* Agent status changed (`idle` ⇄ `running`). A waking delivery enters
* `running` synchronously after reserving cancellation; `idle` means no
* driver remains scheduled or active.
* @param agent - the agent whose status flipped.
* @param status - the status just entered (the transition's destination).
* @param payload.agent - the agent whose status flipped.
* @param payload.status - the status just entered (the transition's destination).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/status'(this: Scoped<Agent>, agent: 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)
Source: [`packages/core/agent/src/types.ts:197`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:173`](../../packages/core/agent/src/types.ts)
### `agent/turn-stopping` — serial
@@ -263,18 +270,18 @@ The turn is about to close: the model owes no response (no live tool calls, no f
* never short-circuits already-submitted next-step work: same-step
* `additionalContexts` or racing steering still runs, and the turn
* closes only when that inbox drains.
* @param agent - the agent whose turn is at its stop boundary.
* @param turn - the turn about to close.
* @param signal - the current turn's explicit abort signal.
* @param payload.agent - the agent whose turn is at its stop boundary.
* @param payload.turn - the turn about to close.
* @param payload.signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode serial
*/
'agent/turn-stopping'(this: Scoped<Agent>, agent: 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)
Source: [`packages/core/agent/src/types.ts:290`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts)
## `agent-loop/*`
@@ -288,11 +295,11 @@ A declarative agent entry failed before it could publish a live agent. Consumers
* Consumers that buffer work for the configured identity use this
* transient signal to reject that work instead of waiting forever. Normal
* factory teardown suppresses failures from the cancelled startup attempt.
* @param sessionId - exact shared agent/session identity that failed startup.
* @param error - persistence, setup, or publication failure.
* @param payload.sessionId - exact shared agent/session identity that failed startup.
* @param payload.error - persistence, setup, or publication failure.
* @mode emit
*/
'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void
'agent-loop/config-start-failed'(payload: { sessionId: SessionId; error: unknown }): void
```
Types: [SessionId](../core-data-structures/core.md)
@@ -456,11 +463,11 @@ Goal mutation accepted by one live agent. The matching `goal/change` session eve
* Goal mutation accepted by one live agent. The matching `goal/change`
* session event has already committed. Listener failures are contained.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @param agent - agent whose session owns the goal.
* @param change - fresh current projection or clear tombstone.
* @param payload.agent - agent whose session owns the goal.
* @param payload.change - fresh current projection or clear tombstone.
* @mode emit
*/
'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, agent: 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)

View File

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

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)
`agent/pre-step` receives the exclusive claimed batch and the proposed step's coordinates and cancellation signal. The initial proposal runs inside an open turn before any step; a tool continuation may submit an empty claimed batch between steps:
```ts type-equiv
/** Coordinates and cancellation for a proposed step. */
interface PreStepContext {
/** Turn that will own the step. */
readonly turn: number
/** Step proposed by the loop. */
readonly step: number
/** Current turn cancellation signal. */
readonly signal: AbortSignal
}
```
`agent/pre-step` receives one payload carrying the exclusive claimed batch (`messages`), the proposed step's coordinates (`turn`, `step`), and the current turn's cancellation `signal`. The initial proposal runs inside an open turn before any step; a tool continuation may submit an empty claimed batch between steps:
It returns a `PreStepDecision`. Reject opens no step. Enter supplies the complete message batch appended after `step/start`; claimed messages omitted by the final decision remain removed, while input inserted after the claim stays pending:

View File

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

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 |
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:178`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:187`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:215`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:223`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:205`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:247`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:272`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:235`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:197`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`jsonrpc`](../packages/ui/jsonrpc) |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:290`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:154`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:192`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:200`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:173`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`jsonrpc`](../packages/ui/jsonrpc) |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` |
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy` |
| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) |

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

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
// child enqueue is the initial delegation, which also pins the real child id.
let accepted = 0
ctx.on('agent/inbox/inserted', (agent) => {
ctx.on('agent/inbox/inserted', ({ agent }) => {
if (agent.session.header.parentSession === undefined) return
if (realChildId === undefined) realChildId = agent.session.header.id
accepted += 1
if (accepted >= 3) followupsAccepted.resolve(undefined)
})
ctx.on('agent/pre-step', async (agent, _messages, _context, next) => {
ctx.on('agent/pre-step', async ({ agent }, next) => {
if (agent.session.header.parentSession !== undefined) await followupsAccepted.promise
return next()
})

View File

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

View File

@@ -59,7 +59,7 @@ export const inject = ['llm']
/** Register the keyless `cli-mock` adapter. */
export function apply(ctx: Context): void {
ctx.llm.registerAdapter(['cli-mock'], new CliMockAdapter())
ctx.on('agent/request', async (_agent, _turn, step, _signal, next) => {
ctx.on('agent/request', async ({ step }, next) => {
const config = await next()
return step === 2 ? { ...config, reasoningEffort: OFF } : config
})

View File

@@ -7,7 +7,7 @@ export const name = 'seed-goal'
export const inject = ['goals']
export function apply(ctx: Context): void {
ctx.on('agent/pre-step', (agent, _messages, _context, next) => {
ctx.on('agent/pre-step', ({ agent }, next) => {
if (ctx.goals.get(agent) === undefined) {
ctx.goals.create(agent, {
objective: 'Prove the composed goal survives in the session log',

View File

@@ -84,7 +84,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio
export function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
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()

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 inflight = record?.inflight
if (inflight !== undefined && inflight.messageId === message.id) inflight.turn = turn
})
ctx.on('agent/error', (agent, turn, _step, error) => {
ctx.on('agent/error', ({ agent, turn, error }) => {
const record = ownedRecord(agent)
const inflight = record?.inflight
if (record === undefined || inflight === undefined || inflight.turn === turn) return

View File

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

View File

@@ -48,7 +48,7 @@ afterEach(() => {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
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()

View File

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

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.
Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md).
`PopupSelectController` (`src/client/popup.ts`) is the headless shell state: `PopupSelectView` self-registers into `conversation.input.overlay` (the SlotMap key is ui-conversation's; this package pulls the declaration in with a type-only import — no runtime edge). The shell is a transient layer holding focus while open; token-segment consumption after onSelect runs both branches through `consumeTokenSegment` (menu-path span CAS, enter-path bare-token equality) against the draft face the wiring layer binds via `bindDraft`.
The `/client` export surface is the plugin body (`apply`/`inject`), `CommandService`, the directory and popup classes with their state types, and the frozen contract types; the shell component itself is internal to the overlay registration.

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 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和 contribution 顺序打破平局。此行为只影响命令发现space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。
`PopupSelectController``src/client/popup.ts`)是无头的壳状态:`PopupSelectView` 自行注册进 `conversation.input.overlay`SlotMap key 归 ui-conversation 所有;本包只以 type-only 导入引入该声明——没有运行时依赖边。壳是打开期间持有焦点的瞬态层onSelect 之后的 token 片段消费在两条分支上都经 `consumeTokenSegment` 执行(菜单路径做 span CAS回车路径做裸 token 相等比较),作用于接线层经 `bindDraft` 绑定的草稿表层。
`/client` 导出表层是插件主体(`apply``inject`)、`CommandService`、目录类和 popup 类及其状态类型,以及冻结的契约类型;壳组件本身是 overlay 注册的内部实现。

View File

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

View File

@@ -164,13 +164,33 @@ describe('candidates', () => {
expect(b.listCalls).toEqual([])
})
it('pulls the session catalog; prefix filter and hint mapping apply', async () => {
it('pulls the session catalog; fuzzy filter and hint mapping apply', async () => {
const { source, listCalls } = await bench()
const list = await source.candidates(proj('s1'), req('g'))
expect(listCalls).toEqual([{ sessionId: sid('s1') }])
expect(list).toEqual([{ name: 'goal', description: 'leadingInput kind', hint: 'goal text' }])
})
it('matches case-insensitive subsequences and ranks prefixes, boundaries, adjacency, gaps, then source order', async () => {
const commands: CommandDescriptor[] = [
{ name: 'q-xylophone', description: '' },
{ name: 'qx-long', description: '' },
{ name: 'fabulous', description: '' },
{ name: 'foo-bar', description: '' },
{ name: 'zuv', description: '' },
{ name: 'zu1v', description: '' },
{ name: 'yu1v', description: '' },
{ name: 'zu12v', description: '' },
]
const { source } = await bench({ commands: () => Promise.resolve({ commands }) })
const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name)
await expect(names('QX')).resolves.toEqual(['qx-long', 'q-xylophone'])
await expect(names('fb')).resolves.toEqual(['foo-bar', 'fabulous'])
await expect(names('uv')).resolves.toEqual(['zuv', 'zu1v', 'yu1v', 'zu12v'])
await expect(names('zzz')).resolves.toEqual([])
await expect(names('query-longer-than-every-name')).resolves.toEqual([])
})
it('catalogs are per session: another session pulls its own key', async () => {
const { source, listCalls } = await bench()
const names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
@@ -195,10 +215,10 @@ describe('candidates', () => {
expect(s2Names).not.toContain('theme')
})
it('contribution rows ride the same query prefix filter', async () => {
it('contribution rows ride the same fuzzy query filter', async () => {
const { command, source } = await bench()
command.register(themeContribution())
const names = (await source.candidates(proj('s1'), req('th'))).map(c => c.name)
const names = (await source.candidates(proj('s1'), req('tm'))).map(c => c.name)
expect(names).toEqual(['theme'])
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1225,92 +1225,92 @@ export const EVENT_API: readonly EventApiEntry[] = [
{
name: 'agent-loop/config-start-failed',
mode: 'emit',
signature: '\'agent-loop/config-start-failed\'(sessionId: SessionId, error: unknown): void',
jsDoc: '/**\n * A declarative agent entry failed before it could publish a live agent.\n * Consumers that buffer work for the configured identity use this\n * transient signal to reject that work instead of waiting forever. Normal\n * factory teardown suppresses failures from the cancelled startup attempt.\n * @param sessionId - exact shared agent/session identity that failed startup.\n * @param error - persistence, setup, or publication failure.\n * @mode emit\n */',
signature: '\'agent-loop/config-start-failed\'(payload: { sessionId: SessionId; error: unknown }): void',
jsDoc: '/**\n * A declarative agent entry failed before it could publish a live agent.\n * Consumers that buffer work for the configured identity use this\n * transient signal to reject that work instead of waiting forever. Normal\n * factory teardown suppresses failures from the cancelled startup attempt.\n * @param payload.sessionId - exact shared agent/session identity that failed startup.\n * @param payload.error - persistence, setup, or publication failure.\n * @mode emit\n */',
summary: 'A declarative agent entry failed before it could publish a live agent.',
},
{
name: 'agent/created',
mode: 'emit',
signature: '\'agent/created\'(this: Scoped<Agent>, agent: Agent): void',
jsDoc: '/**\n * A fully configured agent and live session were published. Setup is\n * composition-only; `agent/session-start` is the first startup-driving seam.\n * Synchronous listener failure vetoes publication, while returned-promise\n * rejection is reported. Detach requested during dispatch waits until every\n * creation listener has observed the stable entry.\n * @param agent - the newly registered agent with its live session and completed setup.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
signature: '\'agent/created\'(this: Scoped<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 payload.agent - the newly registered agent with its live session and completed setup.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'A fully configured agent and live session were published.',
},
{
name: 'agent/disposed',
mode: 'emit',
signature: '\'agent/disposed\'(this: Scoped<Agent>, agent: Agent): void',
jsDoc: '/**\n * An agent left the registry; AgentLoop emits this after driver quiescence\n * and scoped-registration unwind, but before session detachment. Custom\n * registry users own their driver-ordering contract.\n * @param agent - the exact agent removed from the registry.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
signature: '\'agent/disposed\'(this: Scoped<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 payload.agent - the exact agent removed from the registry.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'An agent left the registry; AgentLoop emits this after driver quiescence and scoped-registration unwind, but before session detachment.',
},
{
name: 'agent/error',
mode: 'emit',
signature: '\'agent/error\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: unknown): void',
jsDoc: '/**\n * A step or turn errored. The machine reports a failure here even when\n * the error has no in-turn position for a durable record.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
signature: '\'agent/error\'(this: Scoped<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 payload.agent - the agent whose turn errored.\n * @param payload.turn - the turn in which the failure surfaced.\n * @param payload.step - the step at which the failure surfaced.\n * @param payload.error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'A step or turn errored.',
},
{
name: 'agent/inbox/claimed',
mode: 'emit',
signature: '\'agent/inbox/claimed\'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage; turn: number }): void',
jsDoc: '/**\n * One message left the inbox inside its open turn. If the proposed step\n * is rejected, the claimed message ends here: it is neither discarded nor\n * re-emitted as a user/message, and the turn closes without a step.\n * @param agent - the agent whose inbox changed.\n * @param event - the claimed message and owning turn.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
signature: '\'agent/inbox/claimed\'(this: Scoped<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 payload.agent - the agent whose inbox changed.\n * @param payload.message - the claimed message.\n * @param payload.turn - the owning turn.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'One message left the inbox inside its open turn.',
},
{
name: 'agent/inbox/discarded',
mode: 'emit',
signature: '\'agent/inbox/discarded\'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void',
jsDoc: '/**\n * One message was discarded from the live inbox.\n * @param agent - the agent whose inbox changed.\n * @param event - the discarded message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
signature: '\'agent/inbox/discarded\'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage }): void',
jsDoc: '/**\n * One message was discarded from the live inbox.\n * @param payload.agent - the agent whose inbox changed.\n * @param payload.message - the discarded message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'One message was discarded from the live inbox.',
},
{
name: 'agent/inbox/inserted',
mode: 'emit',
signature: '\'agent/inbox/inserted\'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void',
jsDoc: '/**\n * One message entered the live inbox.\n * @param agent - the agent whose inbox changed.\n * @param event - the inserted message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
signature: '\'agent/inbox/inserted\'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage }): void',
jsDoc: '/**\n * One message entered the live inbox.\n * @param payload.agent - the agent whose inbox changed.\n * @param payload.message - the inserted message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'One message entered the live inbox.',
},
{
name: 'agent/pre-step',
mode: 'waterfall',
signature: '\'agent/pre-step\'(this: Scoped<Agent>, agent: Agent, messages: UserMessage[], context: PreStepContext, 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 */',
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 payload.agent - the agent proposing the step.\n * @param payload.messages - messages removed from the inbox for this step.\n * @param payload.turn - the turn that will own the step.\n * @param payload.step - the step proposed by the loop.\n * @param payload.signal - the current turn\'s cancellation signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
summary: 'Reject a proposed step or replace the messages that enter it.',
},
{
name: 'agent/request',
mode: 'waterfall',
signature: '\'agent/request\'(this: Scoped<Agent>, agent: 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*/',
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 payload.agent - the agent making the model call.\n * @param payload.turn - the open turn number.\n * @param payload.step - the step whose request this is.\n * @param payload.signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n*/',
summary: 'Replace the frozen call configuration.',
},
{
name: 'agent/request-error',
mode: 'waterfall',
signature: '\'agent/request-error\'(this: Scoped<Agent>, agent: Agent, context: RequestFailureContext, 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 */',
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 payload.agent - the agent whose request failed.\n * @param payload.turn - the turn containing the failed request.\n * @param payload.step - the step containing the failed request attempt.\n * @param payload.provider - the provider selected for the failed request.\n * @param payload.failure - serializable facts normalized at the final adapter boundary.\n * @param payload.retryPolicy - the policy of the adapter registration that served the failed request.\n * @param payload.signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
summary: 'Handle one failed model-request attempt before the loop retries or closes its step.',
},
{
name: 'agent/session-start',
mode: 'emit',
signature: '\'agent/session-start\'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void',
jsDoc: '/**\n * The session lifecycle began, once before the first turn. Use\n * `agent.inject()` to seed model-facing context. This is a notification, not\n * a veto; disposal requested by a lifecycle owner is rechecked before the\n * driver starts.\n * @param agent - the agent whose session lifecycle began.\n * @param source - why the session started (fresh startup, resume, …).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
signature: '\'agent/session-start\'(this: Scoped<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 payload.agent - the agent whose session lifecycle began.\n * @param payload.source - why the session started (fresh startup, resume, …).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'The session lifecycle began, once before the first turn.',
},
{
name: 'agent/status',
mode: 'emit',
signature: '\'agent/status\'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void',
jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`). A waking delivery enters\n * `running` synchronously after reserving cancellation; `idle` means no\n * driver remains scheduled or active.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
signature: '\'agent/status\'(this: Scoped<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 payload.agent - the agent whose status flipped.\n * @param payload.status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'Agent status changed (`idle` ⇄ `running`).',
},
{
name: 'agent/turn-stopping',
mode: 'serial',
signature: '\'agent/turn-stopping\'(this: Scoped<Agent>, agent: 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 */',
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 payload.agent - the agent whose turn is at its stop boundary.\n * @param payload.turn - the turn about to close.\n * @param payload.signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */',
summary: 'The turn is about to close: the model owes no response (no live tool calls, no fresh steering).',
},
{
@@ -1365,8 +1365,8 @@ export const EVENT_API: readonly EventApiEntry[] = [
{
name: 'goal/changed',
mode: 'emit',
signature: '\'goal/changed\'(this: import(\'@deepseek-ai/dsh-scope\').Scoped<Agent>, agent: Agent, change: GoalChanged): void',
jsDoc: '/**\n * Goal mutation accepted by one live agent. The matching `goal/change`\n * session event has already committed. Listener failures are contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - agent whose session owns the goal.\n * @param change - fresh current projection or clear tombstone.\n * @mode emit\n */',
signature: '\'goal/changed\'(this: import(\'@deepseek-ai/dsh-scope\').Scoped<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 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.',
},
{

View File

@@ -28,7 +28,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
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()

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -41,7 +41,7 @@ async function harness(adapter: MockAdapter) {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
@@ -65,7 +65,7 @@ describe('agent/pre-step', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const seen: string[] = []
ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => {
ctx.on('agent/pre-step', async ({ messages }, next) => {
seen.push(messages[0]!.content.map(b => (b.type === 'text' ? b.text : '')).join(''))
return next()
})
@@ -92,8 +92,8 @@ describe('agent/pre-step', () => {
}))
const agent = ctx.agentLoop.create(SessionId('prompt-coordinates'), { provider: 'mock', model: 'mock' })
const seen: Array<{ turn: number; step: number; messages: number }> = []
ctx.on('agent/pre-step', async (_agent, messages, context, next) => {
seen.push({ turn: context.turn, step: context.step, messages: messages.length })
ctx.on('agent/pre-step', async ({ messages, turn, step }, next) => {
seen.push({ turn, step, messages: messages.length })
return next()
})
@@ -113,7 +113,7 @@ describe('agent/pre-step', () => {
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<PreStepDecision>()
const observed: UserMessage[] = []
ctx.on('agent/pre-step', async (subject, messages) => {
ctx.on('agent/pre-step', async ({ agent: subject, messages }) => {
if (subject !== agent) return { kind: 'enter', messages }
const message = messages[0]!
expect(Object.isFrozen(message)).toBe(true)
@@ -161,7 +161,7 @@ describe('agent/pre-step', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/pre-step', async (_agent, messages): Promise<PreStepDecision> =>
ctx.on('agent/pre-step', async ({ messages }): Promise<PreStepDecision> =>
({
kind: 'enter',
messages: [{ ...messages[0]!, content: [{ type: 'text', text: 'REWRITTEN' }] }],
@@ -182,7 +182,7 @@ describe('agent/pre-step', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/pre-step', async (_agent, messages): Promise<PreStepDecision> =>
ctx.on('agent/pre-step', async ({ messages }): Promise<PreStepDecision> =>
({
kind: 'enter',
messages: [...messages, createUserMessage({
@@ -211,15 +211,15 @@ describe('agent/pre-step', () => {
provider: 'mock',
model: 'mock',
})
ctx.on('agent/turn-stopping', (subject) => {
ctx.on('agent/turn-stopping', ({ agent: subject }) => {
subject.inject(createUserMessage({
content: [{ type: 'text', text: 'pending context' }],
source: { kind: 'plugin', plugin: 'test' },
}))
})
ctx.on('agent/pre-step', async (_subject, _messages, context, next) => {
ctx.on('agent/pre-step', async ({ step }, next) => {
const decision = await next()
return context.step === 1 || decision.kind === 'reject'
return step === 1 || decision.kind === 'reject'
? decision
: { kind: 'enter', messages: [] }
})
@@ -262,7 +262,7 @@ describe('agent/pre-step', () => {
const decision = Promise.withResolvers<PreStepDecision>()
let claimed: UserMessage[] = []
let firstProposal = true
ctx.on('agent/pre-step', async (_agent, messages) => {
ctx.on('agent/pre-step', async ({ messages }) => {
if (!firstProposal) return { kind: 'enter', messages }
firstProposal = false
claimed = messages
@@ -372,14 +372,14 @@ describe('agent/pre-step', () => {
provider: 'mock',
model: 'mock',
})
ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => {
ctx.on('agent/pre-step', async ({ messages }, next) => {
const decision = await next()
return messages.some(message =>
message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))
? { kind: 'reject' as const }
: decision
})
ctx.on('agent/pre-step', async (subject, messages, _signal, next) => {
ctx.on('agent/pre-step', async ({ agent: subject, messages }, next) => {
if (messages.some(message =>
message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))) {
subject.inject(createUserMessage({
@@ -482,7 +482,7 @@ describe('agent/pre-step', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise<PreStepDecision> => {
ctx.on('agent/pre-step', async ({ messages }, next): Promise<PreStepDecision> => {
const text = messages.flatMap(message => message.content)
.map(b => (b.type === 'text' ? b.text : '')).join('')
return text === 'secret'
@@ -519,17 +519,17 @@ describe('agent/pre-step', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let threw = false
ctx.on('agent/pre-step', async (_agent, messages) => {
ctx.on('agent/pre-step', async ({ messages }) => {
if (!threw) { threw = true; throw new Error('prompt hook broke') }
return { kind: 'enter' as const, messages }
})
const errors: Error[] = []
const reasons: TurnEndReason[] = []
const statuses: string[] = []
ctx.on('agent/error', (_a, _t, _s, error) => {
ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(error)
})
ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) })
ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent) statuses.push(status) })
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
})
@@ -559,7 +559,7 @@ describe('agent/session-start', () => {
const ctx = await harness(adapter)
const sources: SessionStartSource[] = []
ctx.on('agent/session-start', (_agent, source) => void sources.push(source))
ctx.on('agent/session-start', ({ source }) => void sources.push(source))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// fires synchronously at create, before any turn
@@ -576,7 +576,7 @@ describe('agent/session-start', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
ctx.on('agent/session-start', (agent) => {
ctx.on('agent/session-start', ({ agent }) => {
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'session preamble' }], source: { kind: 'plugin', plugin: 'test' } }))
})
@@ -724,11 +724,11 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
name: 'native-guard',
apply(ctx: Context) {
// 1. SessionStart: seed a standing instruction.
ctx.on('agent/session-start', (agent, source) => {
ctx.on('agent/session-start', ({ agent, source }) => {
agent.inject(createUserMessage({ content: [{ type: 'text', text: `policy active (started: ${source})` }], source: { kind: 'plugin', plugin: 'native-guard' } }))
})
// 2. PreStep: reject a forbidden prompt, annotate the rest.
ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise<PreStepDecision> => {
ctx.on('agent/pre-step', async ({ messages }, next): Promise<PreStepDecision> => {
const text = messages.flatMap(message => message.content)
.map(b => (b.type === 'text' ? b.text : '')).join('')
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. */
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
@@ -216,7 +216,7 @@ describe('agent loop', () => {
const adapter = new MockAdapter([textResponse('ok after rescue')])
const ctx = await harness(adapter, 'In {{cwd}}.')
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => {
ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(error)
})
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -263,7 +263,7 @@ describe('agent loop', () => {
assembly.variables['model'] = 'mock'
return next()
})
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => {
ctx.on('agent/request', async (_payload, next) => {
const config = await next()
return { ...config, provider: 'mock', model: 'mock' }
})
@@ -553,7 +553,7 @@ describe('agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' })
let fail = true
ctx.on('agent/pre-step', (subject, _messages, _context, next) => {
ctx.on('agent/pre-step', ({ agent: subject }, next) => {
if (subject !== agent || !fail) return next()
fail = false
subject.steer(createUserMessage({ content: [{ type: 'text', text: 'pending steering' }], source: { kind: 'user' } }))
@@ -713,7 +713,7 @@ describe('agent loop', () => {
let steps = 0
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
ctx.on('agent/turn-stopping', (subject) => {
ctx.on('agent/turn-stopping', ({ agent: subject }) => {
if (steps < 3) {
subject.steer(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'plugin', plugin: 'loop-test' } }))
}
@@ -785,7 +785,7 @@ describe('agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => {
ctx.on('agent/request', async (_payload, next) => {
const config = await next()
// The seed is frozen — config is not a mutable per-call knob; a switch
// is proposed by returning a replacement, and the loop logs it.
@@ -816,7 +816,7 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const fires: { turn: number; step: number; signal: AbortSignal }[] = []
ctx.on('agent/pre-step', (subject, _messages, { turn, step, signal }, next) => {
ctx.on('agent/pre-step', ({ agent: subject, turn, step, signal }, next) => {
if (subject === agent) fires.push({ turn, step, signal })
return next()
})
@@ -837,7 +837,7 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let boundaryOpen = true
ctx.on('agent/pre-step', (subject, _messages, _context, next) => {
ctx.on('agent/pre-step', ({ agent: subject }, next) => {
if (subject === agent) boundaryOpen = subject.session.events.at(-1)?.type === 'step/start'
return next()
})
@@ -855,13 +855,13 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let throwOnce = true
ctx.on('agent/pre-step', (_agent, _messages, _context, next) => {
ctx.on('agent/pre-step', (_payload, next) => {
if (throwOnce) { throwOnce = false; throw new Error('boom in pre-step') }
return next()
})
const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => {
ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(error)
})
@@ -933,7 +933,7 @@ describe('agent loop', () => {
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
// Force exactly one continuation (step 1 → step 2), then defer to default
// (step 2 is a plain stop with no tool calls → stops).
ctx.on('agent/turn-stopping', (subject) => {
ctx.on('agent/turn-stopping', ({ agent: subject }) => {
if (steps < 2) {
subject.steer(createUserMessage({ content: [{ type: 'text', text: 'continue after truncation' }], source: { kind: 'plugin', plugin: 'max-tokens-test' } }))
}
@@ -1296,7 +1296,7 @@ describe('agent loop', () => {
const errors: unknown[] = []
const reasons: TurnEndReason[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => {
ctx.on('agent/error', ({ error }) => {
errors.push(error)
})
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })

View File

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

View File

@@ -59,7 +59,7 @@ async function loopHarness(): Promise<Context> {
function waitForIdle(context: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = context.on('agent/status', (subject, status) => {
const dispose = context.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,7 @@ async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
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() }
})
})

View File

@@ -33,7 +33,7 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
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()

View File

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

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

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 自行拥有任何更强的驱动器顺序契约。
大多数拦截点都是协作式 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 Noteagent 决策记录)](../../../.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 Noteagent 决策记录)](../../../.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 不会保留它们;领取后插入的消息仍等待后续边界。

View File

@@ -1,7 +1,8 @@
/**
* Agent-scoped dispatch and prompt assembly helpers. Ordinary events use the
* fused dispatcher so subject and scope key cannot diverge; registry lifecycle
* code instead captures one stable carrier for both edges.
* Agent-scoped dispatch and prompt assembly helpers. The fused dispatcher
* {@link agentEvents} couples the agent subject to its scope carrier, so the
* scope key and the payload's `agent` cannot diverge; repeat dispatchers (the
* loop driver) build it once in the agent's constructor and reuse it.
* @module @deepseek-ai/dsh-agent/dispatch
*/
@@ -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
/**
* The event names whose subject is an agent: handler parameters start with an
* `Agent` AND the handler declares a `Scoped<Agent>` `this` (the scope-carrier
* contract). The `this` check keeps accidental first-parameter-happens-to-be-
* an-Agent events (or zero-arg events, whose parameter tuple would satisfy a
* bare rest-tuple check via callability) out of the fused-dispatch surface.
* The event names whose subject is an agent: the handler's first parameter is
* a payload object carrying the `agent` subject AND the handler declares a
* `Scoped<Agent>` `this` (the scope-carrier contract). The `this` check keeps
* accidental payload-happens-to-carry-an-Agent events (or zero-arg events,
* whose parameter tuple would satisfy a bare rest-tuple check via callability)
* out of the fused-dispatch surface.
*/
export type AgentSubjectEvent = {
[K in keyof Events]: Events[K] extends (this: Scoped<Agent>, ...args: infer P) => unknown
? P extends [Agent, ...unknown[]] ? K : never
? P extends [infer Payload, ...unknown[]]
? Payload extends { agent: Agent } ? K : never
: never
: never
}[keyof Events]
/** The event arguments AFTER the injected agent subject. */
type Tail<K extends AgentSubjectEvent> = Params<Events[K]> extends [Agent, ...infer R] ? R : never
/** The full payload object of one agent-subject event. */
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
* named agent-subject event with the agent's scope carrier as `thisArg` and
* the agent itself injected as the first event argument.
* the agent itself injected into the payload.
*/
export interface AgentEventDispatch {
/**
@@ -44,30 +58,36 @@ export interface AgentEventDispatch {
* contained per listener, so a notification cannot veto lifecycle progress
* or starve a later observer.
* @param name - the agent-subject event to emit.
* @param rest - the event's arguments after the injected agent.
* @param payload - the event's payload fields; `agent` is injected.
*/
emit<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.
* @param name - the agent-subject event to dispatch.
* @param rest - the event's arguments after the injected agent.
* @param payload - the event's payload fields; `agent` is injected.
* @returns the serial chain's result (the first bail value, if any).
*/
serial<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
* declared event parameters already end with the `next` callback, so `rest`
* is exactly the event's arguments after the injected agent — the final
* element being the innermost `next` (the default the listener chain wraps).
* is exactly the event's arguments after the payload — the final element
* being the innermost `next` (the default the listener chain wraps).
* @param name - the agent-subject event to dispatch.
* @param rest - the event's arguments after the injected agent.
* @param payload - the event's payload fields; `agent` is injected.
* @param rest - the event's arguments after the payload (the `next` callback).
* @returns the waterfall's composed result.
*/
waterfall<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.
* @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.
* @param ctx - the context to dispatch through (any context of the app).
* @param agent - the subject agent; also the scope-carrier key.
* @param carrier - the scope carrier to dispatch through; defaults to
* {@link agentCarrier} for the agent. Pass a constructor-built carrier to
* avoid rebuilding it for every dispatch.
* @returns the fused dispatcher.
*/
export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
const carrier = agentCarrier(agent)
export function agentEvents(ctx: Context, agent: Agent, carrier: Scoped<Agent> = agentCarrier(agent)): AgentEventDispatch {
// The ordinary dispatch methods forward through Cordis' variadic mixins. The
// fused (carrier, name, agent, ...rest) tuple is provably a valid argument
// fused (carrier, name, payload, ...rest) tuple is provably a valid argument
// list for the matching thisArg overload, but TypeScript cannot relate the
// generic Tail<K> spread back to that overload's conditional parameter
// 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 {
emit(name, ...rest) {
emit(name, payload) {
// Cordis emit invokes callbacks through Array.map: one synchronous throw
// starves later listeners, and returned promises are discarded. Agent
// notifications are non-vetoing, so resolve the same filtered callback
// set ourselves and contain both failure modes independently.
const args: unknown[] = [carrier, name, agent, ...rest]
const args: unknown[] = [carrier, name, fused(payload)]
const callbacks = ctx.events.dispatch('emit', args)
for (const callback of callbacks) {
try {
@@ -107,15 +135,15 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
}
}
},
async serial(name, ...rest) {
async serial(name, payload) {
// oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function
const serial = ctx.serial as (thisArg: Scoped<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
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 agent - the subject agent and scope key.
* @param name - the agent-subject event to emit.
* @param rest - the event arguments after the injected agent.
* @param payload - the event's payload fields; `agent` is injected.
*/
export function emitAgentEvent<K extends AgentSubjectEvent>(
ctx: Context,
agent: Agent,
name: K,
...rest: Tail<K>
payload: PayloadRest<K>,
): 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. */
private emitDisposed(entry: AgentEntry): void {
const args: unknown[] = [entry.carrier, 'agent/disposed', entry.agent]
const args: unknown[] = [entry.carrier, 'agent/disposed', { agent: entry.agent }]
for (const callback of this.ctx.events.dispatch('emit', args)) {
try {
const returned: unknown = callback(...args)
@@ -530,7 +530,7 @@ export class AgentRegistry extends Service {
// lifecycle edge; detach still pairs a partially delivered first edge.
entry.announcing = true
entry.announced = true
const args: unknown[] = [entry.carrier, 'agent/created', entry.agent]
const args: unknown[] = [entry.carrier, 'agent/created', { agent: entry.agent }]
try {
for (const callback of this.ctx.events.dispatch('emit', args)) {
// A synchronous creation failure vetoes publication and rolls back.

View File

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

View File

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

View File

@@ -48,35 +48,11 @@ export interface CancelOptions {
*/
export type AgentStatus = 'idle' | 'running'
/** Coordinates and cancellation for a proposed step. */
export interface PreStepContext {
/** Turn that will own the step. */
readonly turn: number
/** Step proposed by the loop. */
readonly step: number
/** Current turn cancellation signal. */
readonly signal: AbortSignal
}
/** Whether and with which messages the loop enters a proposed step. */
export type PreStepDecision =
| { kind: 'reject' }
| { kind: 'enter'; messages: UserMessage[] }
/** One failed model-request attempt presented to recovery listeners. */
export interface RequestFailureContext {
/** Turn containing the failed request. */
readonly turn: number
/** Step containing the failed request attempt. */
readonly step: number
/** Provider selected for the failed request. */
readonly provider: string
/** Serializable facts normalized at the final adapter boundary. */
readonly failure: LlmFailure
/** Policy of the adapter registration that served the failed request. */
readonly retryPolicy: ResolvedRetryPolicy | undefined
}
/** Action returned by a listener that owns model-request recovery. */
export type RequestErrorAction = { kind: 'retry' } | undefined
@@ -171,105 +147,112 @@ declare module 'cordis' {
* Synchronous listener failure vetoes publication, while returned-promise
* rejection is reported. Detach requested during dispatch waits until every
* creation listener has observed the stable entry.
* @param agent - the newly registered agent with its live session and completed setup.
* @param payload.agent - the newly registered agent with its live session and completed setup.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/created'(this: Scoped<Agent>, agent: Agent): void
'agent/created'(this: Scoped<Agent>, payload: { agent: Agent }): void
/**
* An agent left the registry; AgentLoop emits this after driver quiescence
* and scoped-registration unwind, but before session detachment. Custom
* registry users own their driver-ordering contract.
* @param agent - the exact agent removed from the registry.
* @param payload.agent - the exact agent removed from the registry.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
'agent/disposed'(this: Scoped<Agent>, payload: { agent: Agent }): void
/**
* Agent status changed (`idle` ⇄ `running`). A waking delivery enters
* `running` synchronously after reserving cancellation; `idle` means no
* driver remains scheduled or active.
* @param agent - the agent whose status flipped.
* @param status - the status just entered (the transition's destination).
* @param payload.agent - the agent whose status flipped.
* @param payload.status - the status just entered (the transition's destination).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
'agent/status'(this: Scoped<Agent>, payload: { agent: Agent; status: AgentStatus }): void
/**
* One message entered the live inbox.
* @param agent - the agent whose inbox changed.
* @param event - the inserted message.
* @param payload.agent - the agent whose inbox changed.
* @param payload.message - the inserted message.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/inserted'(this: Scoped<Agent>, agent: 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
* is rejected, the claimed message ends here: it is neither discarded nor
* re-emitted as a user/message, and the turn closes without a step.
* @param agent - the agent whose inbox changed.
* @param event - the claimed message and owning turn.
* @param payload.agent - the agent whose inbox changed.
* @param payload.message - the claimed message.
* @param payload.turn - the owning turn.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/claimed'(this: Scoped<Agent>, agent: 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.
* @param agent - the agent whose inbox changed.
* @param event - the discarded message.
* @param payload.agent - the agent whose inbox changed.
* @param payload.message - the discarded message.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/discarded'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void
'agent/inbox/discarded'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage }): void
// ---- session lifecycle (emit) ----
/**
* The session lifecycle began, once before the first turn. Use
* `agent.inject()` to seed model-facing context. This is a notification, not
* a veto; disposal requested by a lifecycle owner is rechecked before the
* driver starts.
* @param agent - the agent whose session lifecycle began.
* @param source - why the session started (fresh startup, resume, …).
* @param payload.agent - the agent whose session lifecycle began.
* @param payload.source - why the session started (fresh startup, resume, …).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/session-start'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void
'agent/session-start'(this: Scoped<Agent>, payload: { agent: Agent; source: SessionStartSource }): void
// ---- the machine's extension seams ----
/**
* Reject a proposed step or replace the messages that enter it. Calling
* `next()` preserves the current messages.
* @param agent - the agent proposing the step.
* @param messages - messages removed from the inbox for this step.
* @param context - proposed turn and step coordinates plus cancellation.
* @param payload.agent - the agent proposing the step.
* @param payload.messages - messages removed from the inbox for this step.
* @param payload.turn - the turn that will own the step.
* @param payload.step - the step proposed by the loop.
* @param payload.signal - the current turn's cancellation signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/pre-step'(this: Scoped<Agent>, agent: 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
* the machine would use (agent options on the first request, the logged
* header afterwards); return a replacement to switch. Model-visible
* content must use logged channels; this seam cannot mutate messages.
* @param agent - the agent making the model call.
* @param turn - the open turn number.
* @param step - the step whose request this is.
* @param signal - the current turn's explicit abort signal.
* @param payload.agent - the agent making the model call.
* @param payload.turn - the open turn number.
* @param payload.step - the step whose request this is.
* @param payload.signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/request'(this: Scoped<Agent>, agent: 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
* its step. A listener returns `{ kind: 'retry' }` without calling `next()`
* when it owns recovery, or calls `next()` to delegate. The default
* `undefined` leaves the failure terminal.
* @param agent - the agent whose request failed.
* @param context - request coordinates, provider, normalized failure, and serving policy.
* @param signal - the turn abort signal.
* @param payload.agent - the agent whose request failed.
* @param payload.turn - the turn containing the failed request.
* @param payload.step - the step containing the failed request attempt.
* @param payload.provider - the provider selected for the failed request.
* @param payload.failure - serializable facts normalized at the final adapter boundary.
* @param payload.retryPolicy - the policy of the adapter registration that served the failed request.
* @param payload.signal - the turn abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/request-error'(this: Scoped<Agent>, agent: 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
* calls, no fresh steering). Awaited before the boundary commits — a
@@ -281,25 +264,25 @@ declare module 'cordis' {
* never short-circuits already-submitted next-step work: same-step
* `additionalContexts` or racing steering still runs, and the turn
* closes only when that inbox drains.
* @param agent - the agent whose turn is at its stop boundary.
* @param turn - the turn about to close.
* @param signal - the current turn's explicit abort signal.
* @param payload.agent - the agent whose turn is at its stop boundary.
* @param payload.turn - the turn about to close.
* @param payload.signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode serial
*/
'agent/turn-stopping'(this: Scoped<Agent>, agent: 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) ----
/**
* A step or turn errored. The machine reports a failure here even when
* the error has no in-turn position for a durable record.
* @param agent - the agent whose turn errored.
* @param turn - the turn in which the failure surfaced.
* @param step - the step at which the failure surfaced.
* @param error - the failure, verbatim.
* @param payload.agent - the agent whose turn errored.
* @param payload.turn - the turn in which the failure surfaced.
* @param payload.step - the step at which the failure surfaced.
* @param payload.error - the failure, verbatim.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/error'(this: Scoped<Agent>, agent: 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,
AgentCancelCause,
AgentFactory,
AgentStatus,
CreateAgentOptions,
ResumeAgentOptions,
} from '@deepseek-ai/dsh-agent'
@@ -145,8 +146,8 @@ describe('AgentRegistry', () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const lifecycle: string[] = []
ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`))
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`))
ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`))
const agent = stubAgent('a1')
const dispose = ctx.agents.register(agent)
@@ -195,9 +196,9 @@ describe('AgentRegistry', () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const lifecycle: string[] = []
ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`))
ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`))
ctx.on('agent/created', () => { throw new Error('creation veto') })
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`))
expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto')
expect(ctx.agents.get(SessionId('vetoed'))).toBeUndefined()
@@ -213,7 +214,7 @@ describe('AgentRegistry', () => {
ctx.on('agent/created', () => Promise.reject(new Error('created async')) as never)
ctx.on('agent/disposed', () => { throw new Error('disposed sync') })
ctx.on('agent/disposed', () => Promise.reject(new Error('disposed async')) as never)
ctx.on('agent/disposed', agent => void heard.push(agent.id))
ctx.on('agent/disposed', ({ agent }) => void heard.push(agent.id))
const dispose = ctx.agents.register(stubAgent('contained'))
await Promise.resolve()
@@ -232,8 +233,8 @@ describe('AgentRegistry', () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const lifecycle: string[] = []
ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`))
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`))
ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`))
const first = stubAgent('split')
const detachFirst = ctx.agents.enter(first, undefined)
@@ -280,9 +281,9 @@ describe('agentEvents()', () => {
const agent = stubAgent('event')
ctx.on('agent/status', () => { throw new Error('sync listener') })
ctx.on('agent/status', () => Promise.reject(new Error('async listener')) as never)
ctx.on('agent/status', (_agent, status) => void heard.push(status))
ctx.on('agent/status', ({ status }) => void heard.push(status))
agentEvents(ctx, agent).emit('agent/status', 'running')
agentEvents(ctx, agent).emit('agent/status', { status: 'running' })
await Promise.resolve()
expect(heard).toEqual(['running'])
expect(warnings).toEqual([
@@ -296,15 +297,30 @@ describe('agentEvents()', () => {
const agent = stubAgent('serial-event')
const signal = new AbortController().signal
const heard: Array<{ agent: Agent; turn: number; signal: AbortSignal }> = []
ctx.on('agent/turn-stopping', async (subject, turn, receivedSignal) => {
ctx.on('agent/turn-stopping', async ({ agent: subject, turn, signal: receivedSignal }) => {
await Promise.resolve()
heard.push({ agent: subject, turn, signal: receivedSignal })
})
await agentEvents(ctx, agent).serial('agent/turn-stopping', 3, signal)
await agentEvents(ctx, agent).serial('agent/turn-stopping', { turn: 3, signal })
expect(heard).toEqual([{ agent, turn: 3, signal }])
})
it('injects the fused subject even when the payload carries a conflicting agent field', async () => {
const ctx = new Context()
const agent = stubAgent('fused-subject')
const other = stubAgent('payload-agent')
const heard: Agent[] = []
ctx.on('agent/status', ({ agent: subject }) => void heard.push(subject))
// A structurally acceptable payload may carry an extra `agent` field; the
// dispatcher's injected subject must win over it.
const payload: { status: AgentStatus; agent: Agent } = { status: 'running', agent: other }
agentEvents(ctx, agent).emit('agent/status', payload)
expect(heard).toEqual([agent])
})
})
describe('explicit cancellation contract', () => {

View File

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

View File

@@ -21,7 +21,7 @@ describe('installAgentLlmTarget()', () => {
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 0, signal, () => Promise.resolve(seed),
'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
)).resolves.toBe(seed)
target.current = {
@@ -32,7 +32,7 @@ describe('installAgentLlmTarget()', () => {
expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' })
target.current = { provider: 'beta', model: 'b1' }
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 0, signal, () => Promise.resolve(seed),
'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
)).resolves.toEqual({
provider: 'alpha',
model: 'a1',
@@ -48,13 +48,13 @@ describe('installAgentLlmTarget()', () => {
temperature: 0.2,
}
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 1, signal, () => Promise.resolve(inherited),
'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(inherited),
)).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
dispose()
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 2, 0, signal, () => Promise.resolve(seed),
'agent/request', { turn: 2, step: 0, signal }, () => Promise.resolve(seed),
)).resolves.toBe(seed)
await ctx.fiber.dispose()
})

View File

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

View File

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

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 signal = new AbortController().signal
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', [], { turn: 1, step: 1, signal },
'agent/pre-step', { messages: [], turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter', messages: [] }),
)
if (decision.kind === 'enter') {

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 signal = new AbortController().signal
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', [], { turn: 1, step: 1, signal },
'agent/pre-step', { messages: [], turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter', messages: [] }),
)
if (decision.kind === 'enter') {

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 signal = new AbortController().signal
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', [], { turn: 1, step: 1, signal },
'agent/pre-step', { messages: [], turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter', messages: [] }),
)
if (decision.kind === 'enter') {

View File

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

View File

@@ -25,7 +25,7 @@ export async function fsHarness(fsCwd: string, persona = ''): Promise<Context> {
export function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
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()

View File

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

View File

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

View File

@@ -134,10 +134,10 @@ declare module 'cordis' {
* Goal mutation accepted by one live agent. The matching `goal/change`
* session event has already committed. Listener failures are contained.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @param agent - agent whose session owns the goal.
* @param change - fresh current projection or clear tombstone.
* @param payload.agent - agent whose session owns the goal.
* @param payload.change - fresh current projection or clear tombstone.
* @mode emit
*/
'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, agent: 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 = {
defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256),
}
ctx.on('agent/session-start', (agent) => {
ctx.on('agent/session-start', ({ agent }) => {
this.cache(agent.session).activation = 'disarmed'
})
// The `goal` projection unit: last-wins fold of goal/change whole values
@@ -547,7 +547,7 @@ export class GoalService extends Service {
ref: { ...ref },
...goal === undefined ? {} : { goal },
}
agentEvents(this.ctx, agent).emit('goal/changed', notification)
agentEvents(this.ctx, agent).emit('goal/changed', { change: notification })
}
/** Build a detached current view. */

View File

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

View File

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

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
// loop. Pure reset hook: always delegates (attaching nothing, vetoing
// 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)
return next()
})

View File

@@ -32,7 +32,7 @@ async function harness(config: Config = {}): Promise<Context> {
}
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. */

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

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