diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml index 3b07958faa..a9795ab9fc 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml @@ -1,6 +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 -2026-06-18-agent-lifecycle-and-ownership-seams.md: f190b4ba2b7f22d29f473c8a2725401ff371488e -2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: dcaa319232baa8951a4f515abc6bce5611da5576 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +2026-06-18-agent-lifecycle-and-ownership-seams.md: 93247a6da7446a5a67db33423d2b766ce4cf3308 +2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: 0705862c6091be0143750e5a518688dec4995156 diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index f190b4ba2b..93247a6da7 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -47,4 +47,4 @@ The bash owner-token comparison relies on the shared `Agent.id`/`SessionId` bein ## Consequences -This touched public interfaces (`Agent`, `AgentFactory`, the bash seam) deliberately, not as a local ACP patch. The simple synchronous `Agent.send()` ergonomics were preserved; the async lifecycle path is additive, for owners that need it. +This touched public interfaces (`Agent`, `AgentFactory`, the bash seam) deliberately, not as a local ACP patch. Synchronous agent delivery remains simple; the async lifecycle path is additive for owners that need it. diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md index dcaa319232..0705862c60 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md @@ -47,4 +47,4 @@ bash 所有者 token 比较依赖共享的 `Agent.id`/`SessionId` 在存活 agen ## 后果 -本变更有意触及公开接口(`Agent`、`AgentFactory`、bash seam),而非作为 ACP 的局部补丁。同步 `Agent.send()` 的简洁易用性得以保留;异步生命周期路径是增量添加的,供需要它的所有者使用。 +本变更有意触及公开接口(`Agent`、`AgentFactory`、bash seam),而非作为 ACP 的局部补丁。同步 agent 交付仍然简单;异步生命周期路径是增量添加的,供需要它的所有者使用。 diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml index 0d6a23bebe..8372358c88 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md -2026-06-21-bounded-llm-request-recovery.md: 83d47e3a7d91bbcd2ceaf7b11cf13316142eb3ed -2026-06-21-bounded-llm-request-recovery.zh.md: 00dcbad3d1023ad33a22297bfe938b94bce839d4 +2026-06-21-bounded-llm-request-recovery.md: 3efb0bb62e10b3ee34af6358902a48f15b835245 +2026-06-21-bounded-llm-request-recovery.zh.md: 5477c8ea3bb4fc019fb99d4da616b3cc15f72044 diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index 83d47e3a7d..3efb0bb62e 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -4,11 +4,11 @@ Status: implemented English | [中文](2026-06-21-bounded-llm-request-recovery.zh.md) -The [per-provider request retry policy](../feature/2026-07-24-provider-retry-policies.md) extends this foundation with exact-provider configuration and an explicit unbounded mode. This note continues to own structured failure facts, the closed-step recovery boundary, normal mode's transient defaults, visible single attempts, and durable retry status. +The [per-provider request retry policy](../feature/2026-07-24-provider-retry-policies.md) extends this foundation with exact-provider configuration and an explicit unbounded mode. This note continues to own structured failure facts, the closed-step recovery boundary, normal mode's transient defaults, visible single attempts, and durable retry status. [Terminal LLM stream failures](2026-07-29-terminal-llm-stream-failures.md) supersedes its thrown-error identity and stream-sidecar mechanism. ## Problem -`dsh-llm` can report provider failures either by throwing during adapter dispatch or iteration or by ending with `finish { kind: 'error' | 'aborted' }`. The final adapter boundary tags thrown failures so `dsh-agent-loop` can distinguish them from middleware and result-processing defects, and the loop normalizes both delivery forms into `agent/request-error` after closing the failed step. An unhandled failure is terminal; a handling listener repairs policy-owned state, returns `{ kind: 'retry' }`, and stops waterfall delegation. The [retry-action decision](../simplification/2026-07-27-request-error-retry-action.md) owns this return contract. +Provider adapters can fail by throwing during dispatch or iteration or by ending with `finish { kind: 'error' | 'aborted' }`. The final adapter boundary normalizes thrown values to that terminal finish protocol before `dsh-agent-loop` receives them; middleware and result-processing defects remain thrown. The loop offers a terminal model-request failure to `agent/request-error`. An unhandled failure is terminal; a handling listener repairs policy-owned state, returns `{ kind: 'retry' }`, and stops waterfall delegation. The [retry-action decision](../simplification/2026-07-27-request-error-retry-action.md) owns this return contract. That boundary is already safe for another request attempt. Raw `assistant/chunk` events carry the failed `turn` and `step`, message derivation ignores them unless a successful `assistant/message` cites them, tool calls are dispatched only after a successful terminal finish and assembly, and a retry opens a new numbered turn from the durable log. The harness therefore does not need a second response lifecycle or tentative-output protocol to keep two attempts separate. @@ -40,9 +40,9 @@ interface LlmFailure { `code` remains the provider-neutral machine-routing taxonomy established by `HarnessError`; the new fields are observations from the provider boundary. `ProviderRequestId` is owned and constructed by `dsh-llm`, then serializes as its provider-issued string. The payload deliberately has no `retryable`, `failover`, `partialOutput`, provider, model, phase, or route id fields. Retryability belongs to policy, provider/model are already in the durable request header, and partial output is derived from the failed step's `assistant/chunk` events. -`LlmError` carries `failure: LlmFailure` and preserves `failure.code === error.code`. `FinishReasonMap.error` and `FinishReasonMap.aborted` carry the same payload instead of parallel failure shapes. An adapter-thrown `Error` keeps its exact object identity: the final-adapter scope associates the normalized facts with that object in call-local sidecar state and rethrows it unchanged; a non-`Error` throw is wrapped as today. `llmFailureOf(stream, error)` retrieves those facts alongside the existing provenance check, while an in-band finish without an error object becomes a new `LlmError`. This preserves listeners that key on error type or identity while giving all final-adapter failures, including unknown SDK exceptions, an `UNKNOWN` terminal payload. +`LlmError` carries `failure: LlmFailure` and preserves `failure.code === error.code`. `FinishReasonMap.error` and `FinishReasonMap.aborted` carry the same payload instead of parallel failure shapes. The final adapter boundary detaches those facts from adapter-thrown values and emits the appropriate terminal finish; unknown SDK exceptions receive an `UNKNOWN` payload. Exact thrown-object identity does not cross the LLM stream seam. -The agent loop keeps `RequestError` as that exact error object and passes `LlmFailure` as a separate argument to `agent/request-error`; it does not mutate possibly frozen third-party errors. It also uses the payload when converting an in-band finish and when recording an unrecovered `turn/end.reason`. +The agent loop passes the terminal finish's `LlmFailure` to `agent/request-error` and uses the same payload when recording an unrecovered `turn/end.reason`. Adapters extract structured facts before falling back to message inspection. They validate HTTP status, parse `Retry-After` seconds or dates into a positive finite millisecond delay, brand the provider request id when exposed, and distinguish their own timeout from the caller's abort. Provider-specific codes and messages may refine a mapping, but no recovery listener parses them. @@ -106,8 +106,8 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` ## Verification -- `LlmFailure` is the single serializable payload for thrown, error-finish, and aborted-finish final-adapter failures; normalization preserves stable code, status, retry delay, branded provider request id, error cause, and caller-abort versus adapter-timeout classification where available. -- An adapter-thrown `Error` reaches `agent/request-error` as the exact same object while its sidecar `LlmFailure` reaches the adjacent argument; tests retain the existing identity assertion for extensible and frozen third-party errors. +- `LlmFailure` is the single serializable payload for adapter throws, error finishes, and aborted finishes; normalization preserves stable code, status, retry delay, branded provider request id, and caller-abort versus adapter-timeout classification where available. +- Adapter throws become terminal failure chunks before reaching consumers; middleware and consumer exceptions remain thrown outside model-request recovery. - DeepSeek and pi-ai adapter tests cover representative 400, 401/403, 429, 5xx, connection, malformed/truncated stream, timeout, abort, retry-after seconds/date, request-id, and unknown-SDK-error paths without recovery policy parsing message text. - Pi-ai pins the SDK option to zero retries and performs one observed wire attempt for a retryable provider response; separate tests make removing either boundary fail. - `agent/request-error` carries current failure facts, immutable prior-retried failure facts, and the serving registration's immutable retry policy; a success clears the history, and alternating transient/context-overflow integration tests prove the two policies consume only their own finite budgets. diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md index 00dcbad3d1..5477c8ea3b 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md @@ -4,11 +4,11 @@ Status: implemented [English](2026-06-21-bounded-llm-request-recovery.md) | 中文 -[按提供方配置的请求重试策略](../feature/2026-07-24-provider-retry-policies.md)在此基础上增加了确切提供方配置与显式无界 mode。本说明继续负责结构化失败事实、已关闭步骤的恢复边界、normal mode 的暂时性默认值、可见的单次尝试和持久重试状态。 +[按提供方配置的请求重试策略](../feature/2026-07-24-provider-retry-policies.md)在此基础上增加了确切提供方配置与显式无界 mode。本说明继续负责结构化失败事实、已关闭步骤的恢复边界、normal mode 的暂时性默认值、可见的单次尝试和持久重试状态。[LLM 流的终止失败](2026-07-29-terminal-llm-stream-failures.md)取代了其中关于抛出错误身份和 stream sidecar 的机制。 ## 问题 -`dsh-llm` 可能在适配器分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束,以这两种形式报告提供方失败。最终适配器边界会标记抛出的失败,使 `dsh-agent-loop` 能将其与中间件和结果处理缺陷区分开。循环关闭失败步骤后,会把两种交付形式统一规范化为 `agent/request-error`。未被处理的失败是终态;处理失败的监听器修复策略自有状态,返回 `{ kind: 'retry' }`,并停止 waterfall 委托。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.md)规定这一返回契约。 +提供方适配器可能在分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束。最终适配器边界会在 `dsh-agent-loop` 接收前把抛出值规范化为该终止 finish 协议;middleware 与结果处理缺陷仍会抛出。loop 会将终止模型请求失败交给 `agent/request-error`。未被处理的失败是终态;处理失败的监听器修复策略自有状态,返回 `{ kind: 'retry' }`,并停止 waterfall 委托。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.md)规定这一返回契约。 该边界已能安全地再次发起请求。原始 `assistant/chunk` 事件携带失败的 `turn` 和 `step`;除非某条成功的 `assistant/message` 引用这些事件,否则消息派生会忽略它们。只有终止性 finish 成功且组装完成后,系统才会分发工具调用;重试则会从持久日志开启新的编号轮次。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试。 @@ -40,9 +40,9 @@ interface LlmFailure { `code` 仍是 `HarnessError` 建立的提供方无关机器路由分类体系;新字段是在提供方边界观测到的事实。`ProviderRequestId` 由 `dsh-llm` 拥有并构造,序列化后为提供方发放的字符串。该载荷有意不包含 `retryable`、`failover`、`partialOutput`、提供方、模型、阶段或路由 id 字段。是否可重试属于策略,提供方/模型已位于持久请求头中,部分输出则从失败步骤的 `assistant/chunk` 事件派生。 -`LlmError` 携带 `failure: LlmFailure`,并保持 `failure.code === error.code`。`FinishReasonMap.error` 和 `FinishReasonMap.aborted` 携带同一载荷,而不是并行的失败形状。适配器抛出的 `Error` 保留其精确的对象标识:最终适配器 scope 在调用局部的伴随状态中把规范化事实与该对象关联,然后原样重新抛出;非 `Error` 抛出值则依旧被包装。`llmFailureOf(stream, error)` 会在现有来源检查旁取回这些事实,而没有错误对象的带内 finish 则会成为新的 `LlmError`。这既保留了按错误类型或标识分流的监听器,又使所有最终适配器失败(包括未知 SDK 异常)都获得 `UNKNOWN` 终止载荷。 +`LlmError` 携带 `failure: LlmFailure`,并保持 `failure.code === error.code`。`FinishReasonMap.error` 和 `FinishReasonMap.aborted` 携带同一载荷,而不是并行的失败形状。最终适配器边界会从适配器抛出值中分离这些事实,并发出相应的终止 finish;未知 SDK 异常会获得 `UNKNOWN` 载荷。精确的抛出对象身份不会跨越 LLM stream seam。 -agent loop(智能体循环)会保留 `RequestError` 作为该精确的错误对象,并将 `LlmFailure` 作为独立参数传给 `agent/request-error`;它不会改动可能已冻结的第三方错误。在转换带内 finish 以及记录未恢复的 `turn/end.reason` 时,循环也会使用该载荷。 +agent loop(智能体循环)会将终止 finish 的 `LlmFailure` 传给 `agent/request-error`,并在记录未恢复的 `turn/end.reason` 时使用同一载荷。 适配器会先提取结构化事实,再回退到消息检查。它们会验证 HTTP 状态,将 `Retry-After` 的秒数或日期解析为正的有限毫秒延迟,在提供方公开请求 id 时将其品牌化,并区分自身超时与调用方中止。提供方专用 code 和消息可以细化映射,但恢复监听器不会解析它们。 @@ -106,8 +106,8 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 ## 验证 -- `LlmFailure` 是最终适配器抛出失败、错误 finish 和中止 finish 使用的唯一可序列化载荷;在可用时,规范化保留稳定 code、状态、重试延迟、品牌化的提供方请求 id、错误原因,以及调用方中止与适配器超时之间的分类。 -- 适配器抛出的 `Error` 会以完全相同的对象抵达 `agent/request-error`,其伴随的 `LlmFailure` 则抵达相邻参数;测试保留针对可扩展及冻结第三方错误的现有对象标识断言。 +- `LlmFailure` 是适配器抛出、错误 finish 和中止 finish 使用的唯一可序列化载荷;在可用时,规范化保留稳定 code、状态、重试延迟、品牌化的提供方请求 id,以及调用方中止与适配器超时之间的分类。 +- 适配器抛出值会在抵达消费方前成为终止失败 chunk;middleware 与消费方异常仍在模型请求恢复之外抛出。 - DeepSeek 和 pi-ai 适配器测试覆盖具有代表性的 400、401/403、429、5xx、连接、格式错误/截断流、超时、中止、Retry-After 秒数/日期、请求 id 和未知 SDK 错误路径,恢复策略无需解析消息文本。 - Pi 将 SDK 选项固定为零次重试,并针对可重试的提供方响应执行一次可观测的实际网络请求;独立测试确保移除任一边界都会失败。 - `agent/request-error` 携带当前失败事实、不可变的先前已重试失败事实,以及实际服务注册所对应的不可变重试策略;成功会清除历史,暂时性失败/上下文溢出交替发生的集成测试证明两种策略只消耗各自的有限预算。 diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml index 1904a25158..3d45205e46 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 51d488db28c57426c75c9ed1cfc90892261c0224 -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: ae33cf5c2e944e584cd3d3c6ff76d93619adf7dc +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 04f11d0a2b33d1a2ddd9c782489622a4f9e76d13 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 981ed87864cc82821a411a3a0ac1f511e3ac514b diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md index 51d488db28..04f11d0a2b 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md @@ -22,7 +22,7 @@ The loop fires awaited serial `agent/post-step(agent, turn, step, signal)` after ### Request recovery is limited to the final model boundary -`RequestError` and the `agent/request-error` waterfall represent failures after the final adapter has been selected. Each returned stream handle owns a private failure set that preserves the original thrown error identity across dispatch, iterator construction, and iteration without leaking nested-call provenance into an outer call. Terminal in-band `error` or `aborted` finishes enter the same path. Prompt assembly, request middleware, request logging, result processing, tools, step listeners, and cleanup remain ordinary failures. +`agent/request-error` represents terminal failures from the final adapter boundary. Adapter selection, dispatch, iterator construction, and iteration throws become terminal `error` or `aborted` finishes before the agent loop consumes them; adapter-emitted terminal finishes enter the same path. Prompt assembly, request middleware, request logging, result processing, tools, step listeners, and cleanup remain ordinary failures. [Terminal LLM stream failures](2026-07-29-terminal-llm-stream-failures.md) owns this normalization boundary. The failed step closes before recovery runs. A handling listener repairs durable state, returns `{ kind: 'retry' }`, and stops waterfall delegation. The loop then closes the failed turn and opens one retry turn from the durable log without an intervening idle notification. Retry policy and attempt counts remain plugin-owned; compact-basic clears its per-agent overflow count when the chain reaches terminal `agent/settled`. Both DeepSeek adapters normalize recognized provider context-limit failures to `CONTEXT_WINDOW_EXCEEDED`. The [retry-action decision](../simplification/2026-07-27-request-error-retry-action.md) owns the return boundary. @@ -42,7 +42,7 @@ The default summarizer resolves explicit configuration, then the latest logged r ## Testing -Unit tests cover final-adapter failure provenance and identity, closed-turn retry numbering and reset, cancellation and disposal, step-boundary ordering, routed-envelope pressure, pressure-gated pruning, pruning-only relief, pruned-input summarization, balanced overflow reduction, durable prune progress before later failure, generation proof, caps, delegation, and auxiliary-call routing. Real-loop tests cover thrown and in-band overflow through pruning or summary compaction to a reconstructed retry request. +Unit tests cover the final-adapter normalization boundary, closed-turn retry numbering and reset, cancellation and disposal, step-boundary ordering, routed-envelope pressure, pressure-gated pruning, pruning-only relief, pruned-input summarization, balanced overflow reduction, durable prune progress before later failure, generation proof, caps, delegation, and auxiliary-call routing. Real-loop tests cover thrown and in-band overflow through pruning or summary compaction to a reconstructed retry request. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md index ae33cf5c2e..981ed87864 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md @@ -22,7 +22,7 @@ Status: implemented ### 请求恢复只覆盖最终模型边界 -`RequestError` 与 `agent/request-error` waterfall 表示最终适配器已经选定之后的失败。每个返回的流句柄都绑定一个私有失败集合;该集合在分发、异步迭代器构造与迭代过程中保留原始抛出错误的身份,同时防止把嵌套调用的错误来源误归到外层调用。终止性的带内 `error` 或 `aborted` finish 进入同一路径。提示词装配、请求中间件、请求日志、结果处理、工具、step 监听器与清理仍属于普通失败。 +`agent/request-error` 表示来自最终适配器边界的终止失败。适配器选择、分发、iterator 构造与迭代抛出会在 agent loop 消费前成为终止 `error` 或 `aborted` finish;适配器直接发出的终止 finish 进入同一路径。提示词装配、请求 middleware、请求日志、结果处理、工具、step 监听器与清理仍属于普通失败。[LLM 流的终止失败](2026-07-29-terminal-llm-stream-failures.md)规定这一规范化边界。 恢复运行前,失败 step 已经关闭。负责处理的监听器修复持久状态、返回 `{ kind: 'retry' }`,并停止 waterfall 委托。循环随后关闭失败 turn,并从持久日志开启一个重试 turn,中间不发布空闲通知。重试策略与尝试计数由插件自己拥有;compact-basic 在链路到达终态 `agent/settled` 时清除对应 agent 的溢出计数。两个 DeepSeek 适配器都把识别出的提供方上下文限制错误规范化为 `CONTEXT_WINDOW_EXCEEDED`。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.md)规定这一返回边界。 @@ -42,7 +42,7 @@ Status: implemented ## 测试 -单元测试覆盖最终适配器失败的来源与身份、已关闭 turn 的重试编号与重置、取消与销毁、step 边界顺序、已路由信封压力、压力门控剪枝、剪枝独立解除压力、从已剪枝输入生成摘要、平衡溢出缩减、后续失败前已落盘的剪枝进展、generation 证明、上限、委托与辅助调用路由。真实循环测试覆盖抛出式和带内溢出,并验证剪枝或摘要压缩后的重试请求从替换表层重建。 +单元测试覆盖最终适配器规范化边界、已关闭 turn 的重试编号与重置、取消与销毁、step 边界顺序、已路由信封压力、压力门控剪枝、剪枝独立解除压力、从已剪枝输入生成摘要、平衡溢出缩减、后续失败前已落盘的剪枝进展、generation 证明、上限、委托与辅助调用路由。真实循环测试覆盖抛出式和带内溢出,并验证剪枝或摘要压缩后的重试请求从替换表层重建。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index 46b84213b9..75cdc976cb 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md -2026-07-22-unified-send-and-coalesced-user-messages.md: ed171735cf483938c70291963a6e68dc02d7bde2 -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 8b2a3ebabb493954e653255e876255b9c0810c19 +2026-07-22-unified-send-and-coalesced-user-messages.md: d4e5b4ba3de023ca08496073c81731c2c2456036 +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: c5da61184b4b4c39924a4795aa86fd9b3848c3b8 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index ed171735cf..d4e5b4ba3d 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -1,4 +1,4 @@ -# Agent Note: Unify agent delivery on send(target × wakeup) and coalesce injected context into user/message +# Agent Note: Unify agent delivery routing and coalesce injected context into user/message Status: implemented @@ -12,7 +12,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj ## Decision -**One primitive, three preset aliases.** The `Agent` interface's `send(message, { target, wakeup })` covers the (`target` × `wakeup`) matrix. Its complete `UserMessage` owns identity, role, model-facing `content`, and producer `source`; the complete `SendOptions` owns only routing policy. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) each accept that one message and fix the policy. `wakeup` means "make the model run": wake a parked driver for a `next-turn` item, or force a continuation for a running `next-step` item. `next-turn`/no-wakeup (queue without waking) is representable with no alias and no current caller. +**One private primitive, three public operations.** `ReactLoopAgent` routes `followup` (queued turn), `steer` (nearest step), and `inject` (context without execution) through one private `send` helper. Each public method accepts a complete `UserMessage` that owns identity, role, model-facing `content`, and producer `source`. The plugin-facing `Agent` interface exposes semantic intent rather than the underlying (`target` × `wakeup`) matrix; the [private-routing decision](../simplification/2026-07-30-private-agent-send.md) owns that public-surface boundary. **inject keeps its mechanism.** The `next-step`/no-wakeup path is exactly the old `inject`: durable model-facing context appended at the current log position, deferred while prompt admission or a turn owns the next safe boundary, and appended directly outside that window. It bypasses the FIFOs entirely, while its required `UserMessage.source` preserves the caller's explicit provenance. @@ -20,7 +20,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj **Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` whose source carries the complete change; a positive round is an admitted continuation prompt. `decodeGoalEvent` takes a `user/message` and fails loud when goal-state content and its typed source disagree. -**`send` does not return identity.** Callers already own the complete message and its opaque `MessageId`; creation and freezing are owned by the [identified immutable message decision](2026-07-28-identified-immutable-message-values.md), not by routing. +**Delivery does not return identity.** Callers already own the complete message and its opaque `MessageId`; creation and freezing are owned by the [identified immutable message decision](2026-07-28-identified-immutable-message-values.md), not by routing. **Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) carry the accepted `UserMessage`. Enqueue and dequeue also carry the resolved `queued | steering` placement captured at acceptance, so observers and reconnect mirrors retire repeated message identities from the correct FIFO without reconstructing routing from later status or session history. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including steering submitted by an `agent/turn-stopping` listener, so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. @@ -41,9 +41,9 @@ Separately, `context/message` and `user/message` had converged: the surface proj ## Consequences -The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The `Agent` contract remains an interface, so alternate implementations and object-literal test fakes implement the same minimal structural surface. The goal fold's channel split moved from event type to `source.round`, and every consumer that filtered `context/message` now filters `user/message` by source. An idle injection appends `user/message` between turns without opening a turn or running the model. +The concrete driver keeps one routing primitive while the public interface exposes three self-documenting operations. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The `Agent` contract remains an interface, so alternate implementations and object-literal test fakes implement the same minimal structural surface. The goal fold's channel split moved from event type to `source.round`, and every consumer that filtered `context/message` now filters `user/message` by source. An idle injection appends `user/message` between turns without opening a turn or running the model. -`wakeup` is the "should the model run" signal, so the inbox distinguishes waking queued work from anything available to dequeue: a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal. Every FIFO exit publishes exactly one lifecycle event, while domain-specific durable facts travel in typed message sources rather than a parallel metadata channel. The direct pending-item representation keeps public lifecycle events correlated without maintaining a second steering wrapper or allowing its durable data to diverge. +The private `wakeup` flag records whether delivery requests model execution; public follow-ups and steering wake the driver, while injection does not. Every FIFO exit publishes exactly one lifecycle event, while domain-specific durable facts travel in typed message sources rather than a parallel metadata channel. The direct pending-item representation keeps public lifecycle events correlated without maintaining a second steering wrapper or allowing its durable data to diverge. ## Related @@ -51,3 +51,4 @@ The delivery surface is now one primitive plus three self-documenting presets, a - [remove-agent-steering-mirror](../../archived/simplification/2026-07-04-remove-agent-steering-mirror.md) — the precedent for collapsing a mirrored live event. - [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md) — the cancel-cause signal `keepInbox` extends. - [identified immutable message values](2026-07-28-identified-immutable-message-values.md) — the message identity and representation contract that now underlies this routing decision. +- [private agent routing](../simplification/2026-07-30-private-agent-send.md) — the public-surface simplification that keeps the routing matrix inside the concrete driver. diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index 8b2a3ebabb..c5da61184b 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 将 agent 投递统一到 send(target × wakeup) 并把注入的上下文合并进 user/message +# Agent Note: 统一 agent 投递路由并把注入的上下文合并进 user/message Status: implemented @@ -12,7 +12,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` ## 决策 -**一个原语,三个预设别名。** `Agent` 接口的 `send(message, { target, wakeup })` 覆盖 (`target` × `wakeup`) 矩阵。完整的 `UserMessage` 持有标识、角色、模型可见 `content` 与生产方 `source`;完整的 `SendOptions` 只持有路由策略。`followup`(`next-turn`/wakeup)、`steer`(`next-step`/wakeup)和 `inject`(`next-step`/no-wakeup)都接收这一条消息并固定策略。`wakeup` 意为“让模型运行”:为一个 `next-turn` 队列项唤醒处于停泊状态的驱动器,或为一个运行中的 `next-step` 队列项强制继续执行。`next-turn`/no-wakeup(入队但不唤醒)可以表达,只是没有别名,也没有当前调用方。 +**一个私有原语,三个公开操作。** `ReactLoopAgent` 通过一个私有 `send` 辅助方法路由 `followup`(排队轮次)、`steer`(最近的步骤)和 `inject`(不执行模型的上下文)。每个公开方法都接收一条完整的 `UserMessage`,由它持有标识、角色、模型可见 `content` 与生产方 `source`。面向插件的 `Agent` 接口公开语义意图,而不是底层的(`target` × `wakeup`)矩阵;该公开接口边界由[私有路由决策](../simplification/2026-07-30-private-agent-send.md)规定。 **inject 保留其机制。** `next-step`/no-wakeup 路径正是旧的 `inject`:持久的面向模型上下文会追加到当前日志位置;提示词准入或一个轮次占有下一个安全边界时,它会延迟处理,而在该窗口之外则直接追加。它完全绕过 FIFO 队列,而必填的 `UserMessage.source` 会保留调用方显式提供的来源信息。 @@ -20,7 +20,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` **goal 回放靠轮次而非类型来区分。** 一次 goal 状态变更是一条第 0 轮、来源为 goal 的 `user/message`,其 source 携带完整变更;正数轮次则是一条已准入的继续执行提示词。`decodeGoalEvent` 接收一条 `user/message`,并在 goal 状态内容与其类型化 source 不一致时立即报错。 -**`send` 不返回标识。** 调用方已经持有完整消息及其不透明的 `MessageId`;消息的创建与冻结由[带标识的不可变消息值决策](2026-07-28-identified-immutable-message-values.md)负责,而不是由路由负责。 +**投递不返回标识。** 调用方已经持有完整消息及其不透明的 `MessageId`;消息的创建与冻结由[带标识的不可变消息值决策](2026-07-28-identified-immutable-message-values.md)负责,而不是由路由负责。 **三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都会携带已接受的 `UserMessage`。enqueue 和 dequeue 还会携带生产方在接受消息时捕获的已解析 `queued | steering` 放置方式,因此观察方和重连镜像可以从正确的 FIFO 中结算重复出现的消息标识,无需根据后续状态或会话历史重建路由。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括 `agent/turn-stopping` 监听器提交的 steering,因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 @@ -41,9 +41,9 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` ## 后果 -投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。`Agent` 契约仍是接口,因此其他实现和对象字面量形式的测试替身只需实现同一个最小结构接口。goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。空闲状态下的注入会在两个轮次之间追加 `user/message`,既不打开轮次,也不运行模型。 +具体驱动器保留一个路由原语,公开接口则提供三个自解释的操作。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。`Agent` 契约仍是接口,因此其他实现和对象字面量形式的测试替身只需实现同一个最小结构接口。goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。空闲状态下的注入会在两个轮次之间追加 `user/message`,既不打开轮次,也不运行模型。 -`wakeup` 是“模型是否应当运行”的信号,因此 inbox 会区分能唤醒的排队工作与任何可 dequeue 的项:一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默。每一次 FIFO 退出都恰好发布一个生命周期事件,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。直接使用待处理项的表示方式,使公开生命周期事件保持可关联,既无需维护第二个 steering 包装层,也避免其持久数据发生分歧。 +私有 `wakeup` 标志记录投递是否要求执行模型;公开的后续消息与 steering 会唤醒驱动器,注入则不会。每一次 FIFO 退出都恰好发布一个生命周期事件,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。直接使用待处理项的表示方式,使公开生命周期事件保持可关联,既无需维护第二个 steering 包装层,也避免其持久数据发生分歧。 ## 相关 @@ -51,3 +51,4 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` - [remove-agent-steering-mirror](../../archived/simplification/2026-07-04-remove-agent-steering-mirror.md)——折叠镜像实时事件的先例。 - [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md)——`keepInbox` 所扩展的取消原因信号。 - [带标识的不可变消息值](2026-07-28-identified-immutable-message-values.md)——本路由决策现在所依托的消息标识与表示契约。 +- [private agent routing](../simplification/2026-07-30-private-agent-send.md)——把路由矩阵保留在具体驱动器内的公开接口简化决策。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml index 397258fe18..f654a8d4e1 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md -2026-07-24-separate-context-injection-from-turn-execution.md: bf3ae2ecbd2205a4c49e8004ffc694f89a2460a3 -2026-07-24-separate-context-injection-from-turn-execution.zh.md: a805eb651c5c77f3d37c92dacd116bb41f154ed7 +2026-07-24-separate-context-injection-from-turn-execution.md: 83eb542cb78bf38042d79015153f55622fe46d43 +2026-07-24-separate-context-injection-from-turn-execution.zh.md: cd748e5cf9a9019427f862b3127d36256fc4e4f4 diff --git a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md index bf3ae2ecbd..83eb542cb7 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md +++ b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md @@ -18,7 +18,7 @@ Idle `inject()` exposed a second mismatch. Injection did not request model execu `inject()` is the only caller-facing operation for supplementary model-facing input, and a turn means one execution of the model loop. -`SendOptions` contains only `target` and `wakeup`. A caller that owns context delivers an identified, frozen `UserMessage` through `inject()` and submits the direct message independently with `send()` or `steer()`. +A caller that owns context delivers an identified, frozen `UserMessage` through `inject()` and submits the direct message independently with `followup()` or `steer()`. Prompt and tool extension points still return `additionalContexts`. These values are outputs of the extension point, not attachments captured from a caller's inbox item. Prompt admission runs before `run()` opens a turn. An allowed prompt and its returned additional contexts enter the new turn as separate messages; a blocked prompt writes neither and opens no turn. Tool-produced additional contexts enter the outbox after the corresponding tool results. @@ -38,7 +38,7 @@ The session invariant permits `user/message` between turns while continuing to r `PromptDecision.content` continues to replace only the direct prompt. `PromptDecision.additionalContexts` and tool-result `additionalContexts` retain FIFO order and individual provenance, but no longer select placement. A waterfall listener that delegates with `next()` must preserve downstream prompt content and additional contexts unless it intentionally returns replacements. -Caller-driven injection and hook-produced additional context deliberately have different admission ownership. A hook's additional contexts materialize only after that hook allows the prompt or tool result. Outside a next-step acceptance window, a caller that invokes `inject(context)` and then `send(prompt)` commits context independently; callers requiring all-or-nothing behavior use a domain-specific admission wrapper. +Caller-driven injection and hook-produced additional context deliberately have different admission ownership. A hook's additional contexts materialize only after that hook allows the prompt or tool result. Outside a next-step acceptance window, a caller that invokes `inject(context)` and then `followup(prompt)` commits context independently; callers requiring all-or-nothing behavior use a domain-specific admission wrapper. Cross-session references use that domain composition: TUI prepares the snapshot, then either adds it to the prompt's admission decision outside an acceptance window or injects it beside steering during one. The target log contains two simple messages, so later source mutation cannot change replay and transcript consumers do not need a prompt envelope. This supersedes the attachment mechanism in the [cross-session reference decision](../feature/2026-07-21-cross-session-references.md) while retaining its snapshot and trust-boundary rules. @@ -58,7 +58,7 @@ This decision preserves the caller-owned framing decision from [unwrapped inject ## Verification -- `SendOptions` and steering inbox records contain no attached contexts; `agent/inbox/enqueue` reports only the message plus its resolved queued-or-steering placement. +- Delivery inputs and steering inbox records contain no attached contexts; `agent/inbox/enqueue` reports only the message plus its resolved queued-or-steering placement. - `UserMessage` is the shared identified, frozen shape across prompt interception, tool execution, hook bridges, guards, and context producers. - Prompt-prefix placement, prompt envelopes, and `context/message` are absent from public types, durable events, projection, and UI replay. - Idle `inject()` appends one sourced `user/message` without a turn or model call. @@ -70,5 +70,5 @@ This decision preserves the caller-owned framing decision from [unwrapped inject - One surface event is valid outside turns, so persistence scanning, crash repair, forking, compaction, and session queries distinguish execution enclosure from session history. - Consecutive user-role messages replace one baked prompt message; provider adapters preserve that ordering. -- Outside an acceptance window, `inject()` followed by a blocked `send()` leaves context without its intended direct prompt unless the caller supplies domain-specific admission ownership. +- Outside an acceptance window, `inject()` followed by a blocked `followup()` leaves context without its intended direct prompt unless the caller supplies domain-specific admission ownership. - The public delivery contract and inbox records remain small: no context attachment, context-placement metadata, prompt envelope, or duplicate durable event type. diff --git a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md index a805eb651c..cd748e5cf9 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md @@ -18,7 +18,7 @@ agent API 曾用三种相互重叠的方式表示面向模型的补充输入: `inject()` 是调用方交付补充模型输入的唯一操作,而轮次表示一次模型循环执行。 -`SendOptions` 只包含 `target` 和 `wakeup`。拥有上下文的调用方通过 `inject()` 交付带标识且冻结的 `UserMessage`,再独立使用 `send()` 或 `steer()` 提交直接消息。 +拥有上下文的调用方通过 `inject()` 交付带标识且冻结的 `UserMessage`,再独立使用 `followup()` 或 `steer()` 提交直接消息。 提示词和工具扩展点仍可返回 `additionalContexts`。这些值是扩展点的输出,而不是从调用方收件箱条目捕获的附件。提示词准入在 `run()` 打开轮次之前执行。获准的提示词及其返回的额外上下文会作为独立消息进入新轮次;提示词被阻止时,两者都不写入,也不打开轮次。工具产生的额外上下文则在对应工具结果之后进入 outbox。 @@ -38,7 +38,7 @@ agent API 曾用三种相互重叠的方式表示面向模型的补充输入: `PromptDecision.content` 仍只替换直接提示词。`PromptDecision.additionalContexts` 和工具结果的 `additionalContexts` 保留 FIFO 顺序及各自来源,但不再选择放置方式。waterfall(瀑布式事件)监听器调用 `next()` 委托时,必须保留下游返回的提示词内容和额外上下文,除非它有意返回替代值。 -调用方主动注入与钩子产生的额外上下文具有不同的准入归属。钩子的额外上下文只会在该钩子允许提示词或工具结果后落入日志。在 next-step 接受窗口之外,调用方执行 `inject(context)` 后再执行 `send(prompt)` 时,会独立提交上下文;需要全有或全无语义的调用方应使用领域专用的准入包装层。 +调用方主动注入与钩子产生的额外上下文具有不同的准入归属。钩子的额外上下文只会在该钩子允许提示词或工具结果后落入日志。在 next-step 接受窗口之外,调用方执行 `inject(context)` 后再执行 `followup(prompt)` 时,会独立提交上下文;需要全有或全无语义的调用方应使用领域专用的准入包装层。 跨会话引用采用这种领域组合方式:TUI 先准备快照,然后在接受窗口之外将其加入提示词准入决策,或在窗口期间将其注入到 steering 旁。目标日志包含两条简单消息,因此来源会话后续变化不会改变回放,transcript 消费方也不需要提示词封套。本决策取代[跨会话引用决策](../feature/2026-07-21-cross-session-references.md)中的附件机制,但保留其快照与信任边界规则。 @@ -58,7 +58,7 @@ agent API 曾用三种相互重叠的方式表示面向模型的补充输入: ## 验证 -- `SendOptions` 与 steering 收件箱记录不包含附加上下文;`agent/inbox/enqueue` 只报告消息及其已解析的 queued 或 steering 放置方式。 +- 投递输入与 steering 收件箱记录不包含附加上下文;`agent/inbox/enqueue` 只报告消息及其已解析的 queued 或 steering 放置方式。 - `UserMessage` 是提示词拦截、工具执行、hook bridge、guard 和上下文生产方共享的带标识且冻结的形状。 - 公共类型、持久事件、投影和 UI 回放中均不存在 prompt-prefix 放置方式、提示词封套与 `context/message`。 - 空闲 `inject()` 在不产生轮次或模型调用的情况下,追加一条带来源的 `user/message`。 @@ -70,5 +70,5 @@ agent API 曾用三种相互重叠的方式表示面向模型的补充输入: - 一个表层事件可以合法位于轮次之外,因此持久化扫描、崩溃恢复、fork、压缩和会话查询需要区分执行封闭与会话历史。 - 两条连续的 user-role 消息会取代一条烘焙后的提示词消息;提供方适配器会保留这一顺序。 -- 在接受窗口之外,`inject()` 后跟一个被阻止的 `send()` 会留下缺少预期直接提示词的上下文,除非调用方提供领域专用的准入归属。 +- 在接受窗口之外,`inject()` 后跟一个被阻止的 `followup()` 会留下缺少预期直接提示词的上下文,除非调用方提供领域专用的准入归属。 - 公共投递契约和收件箱记录保持精简:没有上下文附件、上下文放置元数据、提示词封套或重复的持久事件类型。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.i18n.yaml index 4c701a99f3..85ccc38306 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.md -2026-07-28-identified-immutable-message-values.md: cdb0f1aadc4796b5aa0642a3994d3e3e4ab67bd9 -2026-07-28-identified-immutable-message-values.zh.md: 3e1732cb5b7f49fb9349b2e1790cf5b3ec1474be +2026-07-28-identified-immutable-message-values.md: 66c11cfddae2ce122e248032af6b0349dde8995e +2026-07-28-identified-immutable-message-values.zh.md: c0ed3bd87b1dfc411896868a2e0f8014a6af0a22 diff --git a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.md b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.md index cdb0f1aadc..66c11cfdda 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.md +++ b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.md @@ -18,7 +18,7 @@ This made identity a routing side effect rather than a message invariant. Produc The helpers live in `dsh-llm` beside the base message vocabulary because their complete contracts depend only on that vocabulary. `createToolResultMessage()` belongs with the other creation helpers: it couples a tool call id to the exact user-role tool-result block and source without depending on session state or events. `dsh-session` consumes complete messages rather than owning their construction. -The `Agent` interface accepts a complete `UserMessage`. `send`, `followup`, `steer`, and `inject` never allocate or return identity; they freeze an imported value whose id the caller already holds. Prompt admission receives that message directly. A content rewrite creates a frozen replacement with the same id, while an additional context is a separately created `UserMessage` with its own id. +The `Agent` interface accepts a complete `UserMessage` through `followup`, `steer`, and `inject`. These operations never allocate or return identity; they freeze an imported value whose id the caller already holds. Prompt admission receives that message directly. A content rewrite creates a frozen replacement with the same id, while an additional context is a separately created `UserMessage` with its own id. Durable message-producing events store complete messages. `user/message` stores its `UserMessage` directly; `assistant/message`, `tool/result`, and `steering/message` wrap their role-specialized message beside event-local position, usage, failure, or presentation facts. Session derivation returns those frozen values instead of reconstructing anonymous messages. Assistant assembly creates a model-sourced message when a response completes, and tool execution creates a tool-sourced message when a result is committed. @@ -28,7 +28,7 @@ Any operation that changes only the representation of an existing semantic messa **Keep ids optional on the base message.** This would minimize fixture migration and allow provider or persistence shapes to remain anonymous. It would also preserve the original ambiguity: every consumer would need to branch on whether identity exists, and no type would prove that admission, logging, or projection retained it. -**Let `Agent.send()` allocate the id.** This keeps identity scoped to inbox correlation but makes the agent call the earliest point at which a producer can name its own message. Prompt construction, UI attachments, and synchronous enqueue/discard coordination then need content matching or an out-of-band token before `send()` returns. +**Let agent delivery allocate the id.** This keeps identity scoped to inbox correlation but makes the agent call the earliest point at which a producer can name its own message. Prompt construction, UI attachments, and synchronous enqueue/discard coordination then need content matching or an out-of-band token before delivery returns. **Let each durable event allocate a new id.** This gives persisted messages identities but deliberately breaks correlation with the live input and makes replayed requests appear to contain different messages. Identity belongs to the semantic value, not to each envelope that carries it. @@ -46,5 +46,5 @@ The message and helper unit tests pin immediate identity, detachment, deep immut ## Related -- [Unify agent delivery on send(target × wakeup) and coalesce injected context into user/message](2026-07-22-unified-send-and-coalesced-user-messages.md) — this note supersedes its input-representation and agent-assigned-id details while retaining its routing decision. +- [Unified agent delivery routing and coalesced injected context](2026-07-22-unified-send-and-coalesced-user-messages.md) — this note supersedes its input-representation and agent-assigned-id details while retaining its routing decision. - [Reconstructable requests](2026-07-05-reconstructable-requests.md) — the session log remains the authority for every model-visible input. diff --git a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.zh.md b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.zh.md index 3e1732cb5b..c0ed3bd87b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.zh.md @@ -18,7 +18,7 @@ harness 曾存在多种形似消息的表示,各自采用不同的标识规则 这些辅助函数位于基础消息词汇旁的 `dsh-llm` 中,因为它们的完整契约只依赖该词汇。`createToolResultMessage()` 与其他创建辅助函数同属此处:它使用同一个工具调用 id,将工具来源与确切的 user-role 工具结果块耦合起来,不依赖会话状态或事件。`dsh-session` 只消费完整消息,不负责构造它们。 -`Agent` 接口接收完整的 `UserMessage`。`send`、`followup`、`steer` 和 `inject` 绝不会分配或返回标识;它们会冻结导入的值,而调用方已经持有该值的 id。提示词准入会直接接收该消息。改写内容时会创建具有相同 id 的冻结替代值,而每个附加上下文都是单独创建的 `UserMessage`,拥有自己的 id。 +`Agent` 接口通过 `followup`、`steer` 和 `inject` 接收完整的 `UserMessage`。这些操作绝不会分配或返回标识;它们会冻结导入的值,而调用方已经持有该值的 id。提示词准入会直接接收该消息。改写内容时会创建具有相同 id 的冻结替代值,而每个附加上下文都是单独创建的 `UserMessage`,拥有自己的 id。 产生持久消息的事件会存储完整消息。`user/message` 直接存储其 `UserMessage`;`assistant/message`、`tool/result` 和 `steering/message` 则将各自角色专用的消息与事件本地的位置、用量、失败或呈现事实包装在一起。会话派生会返回这些冻结值,而不是重建匿名消息。assistant 组装会在响应完成时创建模型来源的消息,工具执行会在提交结果时创建工具来源的消息。 @@ -28,7 +28,7 @@ harness 曾存在多种形似消息的表示,各自采用不同的标识规则 **让基础消息的 id 保持可选。** 这能减少 fixture(测试前置数据)迁移,并允许提供方或持久化形状继续保持匿名,但也会保留原有歧义:每个消费方都必须根据标识是否存在执行分支,且没有任何类型能证明准入、记录或投影保留了标识。 -**让 `Agent.send()` 分配 id。** 这会将标识限定在 inbox 关联范围内,却也会让 agent 调用成为生产方可以标识自身消息的最早时机。这样一来,在 `send()` 返回前,提示词构造、UI 附件和同步入队/丢弃协调都需要进行内容匹配,或使用带外 token。 +**让 agent 交付分配 id。** 这会将标识限定在 inbox 关联范围内,却也会让 agent 调用成为生产方可以标识自身消息的最早时机。这样一来,在交付返回前,提示词构造、UI 附件和同步入队/丢弃协调都需要进行内容匹配,或使用带外 token。 **让每个持久事件分配新 id。** 这能为持久消息提供标识,却会有意切断它与实时输入的关联,并让回放请求表现得像包含了不同消息。标识属于语义值,而不是承载它的每个封装。 @@ -46,5 +46,5 @@ harness 曾存在多种形似消息的表示,各自采用不同的标识规则 ## 相关 -- [统一通过 send(target × wakeup) 交付 agent 消息,并将注入上下文合并到 user/message](2026-07-22-unified-send-and-coalesced-user-messages.md)——本记录取代其中的输入表示和由 agent 分配 id 的细节,同时保留其路由决策。 +- [统一 agent 交付路由,并合并注入上下文](2026-07-22-unified-send-and-coalesced-user-messages.md)——本记录取代其中的输入表示和由 agent 分配 id 的细节,同时保留其路由决策。 - [可重建的请求](2026-07-05-reconstructable-requests.md)——会话日志仍是每项模型可见输入的权威来源。 diff --git a/.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.i18n.yaml new file mode 100644 index 0000000000..2bef24442d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.md +2026-07-29-terminal-llm-stream-failures.md: 1e26973360f07c212016c6a44103448a3510a75b +2026-07-29-terminal-llm-stream-failures.zh.md: d3eeb0534f6cb8d4d1ad167cca089314eb02b513 diff --git a/.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.md b/.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.md new file mode 100644 index 0000000000..1e26973360 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.md @@ -0,0 +1,37 @@ +# Agent Note: Terminal LLM stream failures + +Status: implemented + +English | [中文](2026-07-29-terminal-llm-stream-failures.zh.md) + +This note supersedes only the thrown-error identity and call-local sidecar mechanism in [bounded LLM request recovery](2026-06-21-bounded-llm-request-recovery.md) and [after-call context-overflow recovery](2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md). Those notes continue to own structured failure facts, retry policy, durable attempts, and compaction recovery. + +## Problem + +An adapter failure had two public representations: an exception from selection, dispatch, iterator construction, or iteration, and an in-band `finish { kind: 'error' | 'aborted' }`. `LlmService` tagged thrown objects in a stream-keyed sidecar so the agent loop could distinguish them from middleware and consumer failures. The consumer still needed a catch around iteration, signal checks, chunk logging, and assembly; correctness therefore depended on proving which statement threw and consulting metadata attached to the exact returned iterable. + +Retry policy had the same indirect ownership. It was discovered through the stream sidecar after dispatch even though `prepareCall()` had already captured the serving registration. A wrapper-owned route and an adapter-owned route consequently shared one opaque lookup API despite having different authority. + +## Decision + +`LlmService` is the normalization boundary for one adapter attempt. It catches only final-adapter selection, synchronous dispatch, iterator construction, and `next()` failures, converts the thrown value to immutable `LlmFailure`, and emits one terminal `finish`. Caller cancellation or an `ABORTED` failure selects the aborted reason; every other adapter failure selects error. An adapter may also emit either terminal reason directly. + +The adapter-owned catch ends before each yielded chunk. Errors from `llm/stream` middleware, nested calls, adapter cleanup, chunk consumers, logging, signal checks, and assembly remain thrown as defects or lifecycle failures; they never enter model-request recovery. A transport failure after partial deltas may leave blocks open, so the stream invariant permits open blocks only for terminal error or aborted finishes. No assistant message or tool call is assembled from that incomplete output. + +`PreparedLlmCall` exposes the immutable retry policy captured with its config and registration. One-shot reuse and config mismatch remain synchronous `INVALID_PREPARED_CALL` misuse errors. A route served entirely by `llm/stream` middleware has no prepared registration and therefore no serving policy. + +The agent loop consumes one failure representation. It iterates and logs chunks without a classification catch, inspects the terminal finish, and passes its failure facts plus the prepared policy to `agent/request-error`. The public `isLlmAdapterFailure`, `llmFailureOf`, and `llmRetryPolicyOf` sidecar APIs are absent. + +## Alternatives considered + +**Keep call-local error tagging.** This preserves thrown object identity, but makes every consumer catch a region containing its own fallible work and couples classification to the identity of an iterable wrapper. The original error object has no durable role in recovery; normalized facts are the useful boundary value. + +**Require every adapter to emit failure chunks and forbid throws.** Library iterators, transports, and JavaScript dispatch can still throw. Requiring every adapter to reproduce the same catch boundary duplicates ownership and does not protect a direct `LlmService` consumer from an incomplete implementation. + +**Catch every iteration error in the agent loop.** The loop cannot reliably distinguish provider failure from middleware, session append, cancellation, or assembly failure without restoring the same sidecar provenance mechanism. Classification belongs where the adapter call is made. + +**Return a `Result` before streaming.** A pre-stream result cannot represent a transport failure after partial output without adding a second response lifecycle. The existing terminal chunk already represents both early and late attempt outcomes. + +## Consequences + +All `LlmService.stream()` consumers receive adapter operational failures through one typed terminal protocol, while programming and lifecycle failures retain ordinary exception semantics. Recovery gives up exact thrown-object identity and exposes only detached provider-neutral facts. The stream service owns slightly more adapter plumbing, but consumers delete provenance catches and stream-keyed metadata. Prepared calls carry their policy explicitly, and middleware-only routing remains visibly policy-free. diff --git a/.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.zh.md b/.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.zh.md new file mode 100644 index 0000000000..d3eeb0534f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.zh.md @@ -0,0 +1,37 @@ +# Agent Note: LLM 流的终止失败 + +Status: implemented + +[English](2026-07-29-terminal-llm-stream-failures.md) | 中文 + +本说明仅取代[有界 LLM 请求恢复](2026-06-21-bounded-llm-request-recovery.md)与[调用后上下文溢出恢复](2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)中关于抛出错误身份和调用局部 sidecar 的机制。上述说明继续规定结构化失败事实、重试策略、持久尝试与压缩恢复。 + +## Problem + +适配器失败曾有两种公共表示:选择、分发、iterator 构造或迭代抛出的异常,以及带内的 `finish { kind: 'error' | 'aborted' }`。`LlmService` 会在以 stream 为 key 的 sidecar 中标记抛出对象,使 agent loop 能将其与 middleware 和消费方失败区分开。消费方仍需用 catch 包围迭代、signal 检查、chunk 日志记录和组装;正确性因此取决于证明是哪条语句抛错,并查询附着于精确返回 iterable 的元数据。 + +重试策略也采用同样的间接归属。尽管 `prepareCall()` 已捕获服务注册,策略仍要在分发后通过 stream sidecar 查找。因此,由 wrapper 提供服务的路由与由适配器提供服务的路由共用一个不透明查询 API,尽管两者的权威不同。 + +## Decision + +`LlmService` 是一次适配器尝试的规范化边界。它只捕获最终适配器选择、同步分发、iterator 构造与 `next()` 失败,将抛出值转换为不可变 `LlmFailure`,并发出一个终止 `finish`。调用方取消或 `ABORTED` 失败选择 aborted reason;其他适配器失败选择 error。适配器也可以直接发出这两种终止 reason。 + +适配器所属的 catch 会在每个 chunk 被 yield 前结束。来自 `llm/stream` middleware、嵌套调用、适配器清理、chunk 消费方、日志记录、signal 检查与组装的错误仍作为缺陷或生命周期失败抛出;它们绝不进入模型请求恢复。部分 delta 之后的传输失败可能留下未关闭块,因此流 invariant 只允许终止 error 或 aborted finish 带有未关闭块。不会从这些不完整输出组装 assistant 消息或工具调用。 + +`PreparedLlmCall` 公开随其配置和注册捕获的不可变重试策略。一次性句柄复用与配置不匹配仍是同步的 `INVALID_PREPARED_CALL` 误用错误。完全由 `llm/stream` middleware 提供服务的路由没有准备完成的注册,因此也没有服务策略。 + +agent loop 只消费一种失败表示。它不再使用分类 catch,而是直接迭代并记录 chunk、检查终止 finish,再把其中的失败事实与准备完成的策略传给 `agent/request-error`。公共的 `isLlmAdapterFailure`、`llmFailureOf` 和 `llmRetryPolicyOf` sidecar API 不再存在。 + +## Alternatives considered + +**保留调用局部错误标记。** 这会保留抛出对象身份,但要求每个消费方捕获一段包含自身易失败工作的区域,并让分类依赖 iterable wrapper 的身份。原始错误对象在持久恢复中没有作用;规范化事实才是有用的边界值。 + +**要求所有适配器发出失败 chunk,并禁止抛出。** 库 iterator、transport 与 JavaScript 分发仍可能抛错。要求每个适配器复制同一 catch 边界会重复归属,也无法保护 `LlmService` 的直接消费方免受不完整实现影响。 + +**在 agent loop 中捕获所有迭代错误。** 如果不恢复同一套 sidecar 溯源机制,loop 无法可靠区分提供方失败与 middleware、session append、取消或组装失败。分类属于发起适配器调用的边界。 + +**在流式输出前返回 `Result`。** 流前结果无法表示部分输出之后的传输失败,除非增加第二套响应生命周期。现有终止 chunk 已能表示早期和后期尝试结果。 + +## Consequences + +所有 `LlmService.stream()` 消费方都通过一种带类型的终止协议接收适配器运行失败,而编程与生命周期失败保留普通异常语义。恢复放弃精确抛出对象身份,只暴露与原对象分离的提供方无关事实。流服务承担略多的适配器管道工作,但消费方删除了溯源 catch 与以 stream 为 key 的元数据。准备完成的调用显式携带策略,而仅由 middleware 路由的调用仍明确没有策略。 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index 3df1059e1f..cd70acc0c9 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md -2026-07-17-dedicated-full-screen-tui-front-door.md: a3f3d6b51e85ad20218ce5aebf526bd96946be55 -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 6a0c2f12815e9418a2b5bcb237d3db3616ccd133 +2026-07-17-dedicated-full-screen-tui-front-door.md: f8fa05383edc3f2abcbf9b5dd8b98e3a15c9a4a3 +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 12bf3b1633bcb3d958549be3a74bca3c1bdaa816 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index a3f3d6b51e..f8fa05383e 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -22,7 +22,7 @@ The selected front door receives the exact generated or resumed `SessionId` used The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, including fenced code with hidden Markdown markers, a dim optional language label, and a code-colored body, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Long card bodies retain a configurable head/tail preview with the hidden-line count; one terminal control expands or collapses every card. Pending chunks and tool calls update the same components that completed events settle. -Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. `/exit` and `/quit` share the same exit path: they cancel an active turn, wait for idle, and then restore and close the terminal. The idle footer derives context occupancy from `tokenMeter` and shows the selected model and explicit reasoning effort; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model, reasoning effort/default state and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; the panel's controls hint lists only actions meaningful for the current option count, omitting navigation when exactly one option is shown; agent behavior and answer logging remain owned by their existing services. +Editor input calls `agent.steer()` so it targets the nearest step whether the agent is idle or running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. `/exit` and `/quit` share the same exit path: they cancel an active turn, wait for idle, and then restore and close the terminal. The idle footer derives context occupancy from `tokenMeter` and shows the selected model and explicit reasoning effort; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model, reasoning effort/default state and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; the panel's controls hint lists only actions meaningful for the current option count, omitting navigation when exactly one option is shown; agent behavior and answer logging remain owned by their existing services. The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. Each model row owns the adapter-advertised reasoning-effort order and default: Shift+Tab cycles that row's efforts, includes provider-default behavior when the adapter advertises no default, and leaves models without selectable metadata unchanged. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model/reasoning-effort target per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local. diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index 6a0c2f1281..12bf3b1633 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -22,7 +22,7 @@ DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在事件携带 `surfaceOp` 时重新投影,因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理(其中围栏代码块隐藏 Markdown 标记、保留一个暗色的可选语言标签,并使用代码配色的正文)、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。较长的工具卡片正文会保留可配置的头尾预览,并显示隐藏行数;一个终端控制可以展开或收起全部卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。 -agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。`/exit` 和 `/quit` 共用同一条退出路径:先取消进行中的轮次,等待 agent 空闲,然后恢复并关闭终端。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型和显式选定的推理强度;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型、推理强度(或默认状态)及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;面板的操作提示只列出在当前选项数量下有意义的操作,仅有一个选项时不显示导航项;agent 行为和答案日志仍由既有服务负责。 +编辑器输入调用 `agent.steer()`,使其无论 agent 空闲还是运行中都以最近的步骤为目标。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。`/exit` 和 `/quit` 共用同一条退出路径:先取消进行中的轮次,等待 agent 空闲,然后恢复并关闭终端。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型和显式选定的推理强度;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型、推理强度(或默认状态)及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;面板的操作提示只列出在当前选项数量下有意义的操作,仅有一个选项时不显示导航项;agent 行为和答案日志仍由既有服务负责。 `/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。每个模型行都持有适配器公布的推理强度顺序和默认值:按 Shift+Tab 可循环切换该行的推理强度;如果适配器没有公布默认值,循环中还会包含提供方默认行为;没有可选元数据的模型则保持不变。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个步骤快照一次同一个提供方/模型/推理强度目标,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。 diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index 6cf16c04ad..43d32ebb80 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -1,6 +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 -2026-07-19-model-facing-goal-tools.md: bc4305af80bb13ceeff1888d489dcd8a00132f94 -2026-07-19-model-facing-goal-tools.zh.md: b07f62aa526902c4b2e9c081777a76ca53783d31 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +2026-07-19-model-facing-goal-tools.md: 19b413235052d37c58a65aefa33aba39e0e08812 +2026-07-19-model-facing-goal-tools.zh.md: 91eb7c2fa202a3781176afb8261ea484dbd46fda diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md index bc4305af80..19b4132350 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -28,7 +28,7 @@ An autonomous goal round that successfully reports completion or blocking marks Every call requires an `exec.agent` that is the exact running object in `AgentRegistry`, is the current inherited driver initiator, and has an open turn. These are execution-time checks and cannot be bypassed by prompt injection or hand-authored tool arguments. -Create, edit, pause, and resume additionally require an accepted user message or user steering event in the current turn of a runtime-root agent. Root ownership is derived from the live agent graph rather than durable fork ancestry: a resumed fork can receive direct human authority, while a live child remains a subagent and cannot mutate these states. User source is a host attestation: every `Agent.send()` or `steer()` input requires an explicit source, so the host labels direct human content `{ kind: 'user' }` and non-human producers label their own provenance. The runtime proves provenance, not whether the human's wording semantically warrants creation or resumption; that interpretation remains with the model. +Create, edit, pause, and resume additionally require an accepted user message or user steering event in the current turn of a runtime-root agent. Root ownership is derived from the live agent graph rather than durable fork ancestry: a resumed fork can receive direct human authority, while a live child remains a subagent and cannot mutate these states. User source is a host attestation: every `Agent.followup()` or `steer()` input requires an explicit source, so the host labels direct human content `{ kind: 'user' }` and non-human producers label their own provenance. The runtime proves provenance, not whether the human's wording semantically warrants creation or resumption; that interpretation remains with the model. Complete and blocked accept either direct-human authority or the exact current goal round. Goal-round authority requires a goal-sourced `user/message` whose goal id, revision, and round all equal the folded current goal. It grants only the two terminal reports. Direct human authority may stop a goal immediately. diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index b07f62aa52..91eb7c2fa2 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -28,7 +28,7 @@ Status: implemented 每次调用都要求存在 `exec.agent`,且它必须是 `AgentRegistry` 中完全相同的运行中对象、当前继承的驱动发起者,并处于开放轮次内。这些检查在执行时进行,不能通过提示词注入或手写工具参数绕过。 -创建、编辑、暂停与恢复还要求运行时根 agent 的当前轮次已经接纳一条用户消息或用户 steering 事件。根所有权来自实时 agent 图,而非持久的 fork 祖先关系:恢复后的派生会话可以接收新的直接人类权限,实时子级则仍是 subagent,不能改变这些状态。用户来源是宿主的证明:每个 `Agent.send()` 或 `steer()` 输入都必须显式提供来源,因此宿主把直接人类内容标为 `{ kind: 'user' }`,非人类生产者则标注自己的来源信息。运行时证明来源,而不判断人类措辞在语义上是否足以创建或恢复目标;该解释仍由模型完成。 +创建、编辑、暂停与恢复还要求运行时根 agent 的当前轮次已经接纳一条用户消息或用户 steering 事件。根所有权来自实时 agent 图,而非持久的 fork 祖先关系:恢复后的派生会话可以接收新的直接人类权限,实时子级则仍是 subagent,不能改变这些状态。用户来源是宿主的证明:每个 `Agent.followup()` 或 `steer()` 输入都必须显式提供来源,因此宿主把直接人类内容标为 `{ kind: 'user' }`,非人类生产者则标注自己的来源信息。运行时证明来源,而不判断人类措辞在语义上是否足以创建或恢复目标;该解释仍由模型完成。 完成与阻塞既接受直接人类权限,也接受准确的当前目标回合。目标回合权限要求存在一条来源为目标的 `user/message`,其中目标 id、修订号和回合都与折叠后的当前目标相等。它只授予这两种终止报告权限。直接人类权限可以立即停止目标。 diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml index 8b2ddb5ac1..a17ee41ccf 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml @@ -1,6 +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 -2026-07-19-plugin-command-registration.md: 343cb5d946dba9fb881adf12c197961dfd6a359b -2026-07-19-plugin-command-registration.zh.md: 27757f05afe04d7cbd4ceaf9380b6f73441cc4b9 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md +2026-07-19-plugin-command-registration.md: 5233ce511dc9798733513ccbf6824f3e1b68d2e6 +2026-07-19-plugin-command-registration.zh.md: 67ac497ff91d42900b89fbbe40e4cc0856f939bc diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md index 343cb5d946..5233ce511d 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md @@ -36,7 +36,7 @@ Expected handler failures return `CommandResult.error`. Thrown or malformed resu ### TUI mapping -The TUI registers its built-in slash commands as agent-scoped command definitions instead of switching on strings. Its autocomplete and help view read the live catalog, so plugin commands appear and disappear with their effects. Any submitted line beginning with `/` stays in the command plane; unknown input produces a terminal warning rather than falling through to `Agent.send()` or `Agent.steer()`. +The TUI registers its built-in slash commands as agent-scoped command definitions instead of switching on strings. Its autocomplete and help view read the live catalog, so plugin commands appear and disappear with their effects. Any submitted line beginning with `/` stays in the command plane; unknown input produces a terminal warning rather than falling through to `Agent.steer()`. Each submitted command owns an `AbortController`. TUI disposal aborts outstanding dispatches, removes the local definitions, and waits for the command-producing fiber before completing teardown. diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md index 27757f05af..67ac497ff9 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md @@ -36,7 +36,7 @@ TUI 拥有斜杠命令。如果命令名、帮助文本、自动补全、分派 ### TUI 映射 -TUI 把内置斜杠命令注册为智能体作用域命令定义,不再对字符串执行 switch。自动补全与帮助视图读取实时目录,因此插件命令会随其副作用出现和消失。任何以 `/` 开头的提交行都留在命令平面;未知输入产生终端警告,不会落入 `Agent.send()` 或 `Agent.steer()`。 +TUI 把内置斜杠命令注册为智能体作用域命令定义,不再对字符串执行 switch。自动补全与帮助视图读取实时目录,因此插件命令会随其副作用出现和消失。任何以 `/` 开头的提交行都留在命令平面;未知输入产生终端警告,不会落入 `Agent.steer()`。 每个提交的命令拥有一个 `AbortController`。TUI 释放会中止未完成的分派、移除本地定义,并等待命令生产者 fiber(纤程)后再完成清理。 diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml index b354999813..ca0518d268 100644 --- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml @@ -1,6 +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 -2026-07-19-same-session-goal-round-driver.md: 0e6be9fe3109336d47867ab52c585dc267309fb4 -2026-07-19-same-session-goal-round-driver.zh.md: cfd9d1aa8cbc3c17cd046df4f57a8f79a6877f5c +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md +2026-07-19-same-session-goal-round-driver.md: 6f0059e8f4a09c979e3411ecde1b643f95819c26 +2026-07-19-same-session-goal-round-driver.zh.md: 871c8c13772eeab7b91ff2be6154ce798d4aced4 diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md index 0e6be9fe31..6f0059e8f4 100644 --- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md @@ -8,7 +8,7 @@ English | [中文](2026-07-19-same-session-goal-round-driver.zh.md) The goal domain can retain an objective and the model-facing tools can mutate its lifecycle, but neither should decide when another model turn begins. A continuation driver must bridge active goal state to the ordinary agent loop without adding goal-specific branches to `dsh-agent-loop`, inventing a second conversation, or treating every human turn as an autonomous iteration. -That bridge has concurrency and durability obligations. Human input, cancellation, a goal edit, persistence failure, session restart, plugin unload, and a downstream prompt policy can all race a pending continuation. A naive `goal/changed -> agent.send()` listener can admit obsolete work, run alongside a human prompt, spend beyond the cap, or restart from replay without new authority. +That bridge has concurrency and durability obligations. Human input, cancellation, a goal edit, persistence failure, session restart, plugin unload, and a downstream prompt policy can all race a pending continuation. A naive `goal/changed -> agent.followup()` listener can admit obsolete work, run alongside a human prompt, spend beyond the cap, or restart from replay without new authority. ## Decision @@ -20,7 +20,7 @@ The plugin has no configuration. `maxGoalRounds` is resolved and persisted by `d ### Reservation and admission -When an agent is idle, has no competing queued work, and its current goal is `active` plus `armed`, the driver checkpoints pending goal mutations and rechecks every predicate after the await. If `roundsStarted` already equals `maxGoalRounds`, it records `blocked` with code `round-limit`. Otherwise it reserves the exact identity `{ goalId, revision, round: roundsStarted + 1 }` and the complete rendered prompt before calling `Agent.send()` with `GoalMessageSource`. The prompt JSON-quotes the objective so multiline or tag-like text remains an unambiguous data value inside the familiar frame. +When an agent is idle, has no competing queued work, and its current goal is `active` plus `armed`, the driver checkpoints pending goal mutations and rechecks every predicate after the await. If `roundsStarted` already equals `maxGoalRounds`, it records `blocked` with code `round-limit`. Otherwise it reserves the exact identity `{ goalId, revision, round: roundsStarted + 1 }` and the complete rendered prompt before calling `Agent.followup()` with `GoalMessageSource`. The prompt JSON-quotes the objective so multiline or tag-like text remains an unambiguous data value inside the familiar frame. The `agent/prompt-submit` waterfall is the admission fence. A positive goal source is allowed only when it exactly matches the driver's pending identity and content, the live goal still has that id and revision, activation remains armed, and the round is still the next number. The plugin checks once before delegating and again after downstream hooks return. This second check prevents an async hook from editing or pausing the goal while still admitting the old prompt. diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md index cfd9d1aa8c..871c8c1377 100644 --- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md @@ -8,7 +8,7 @@ Status: implemented 目标领域可以保留目标,模型可见工具也可以变更其生命周期,但两者都不应决定下一个模型轮次何时开始。继续执行驱动器必须把活跃目标状态连接到普通 agent(智能体)循环,同时不能向 `dsh-agent-loop` 添加目标专用分支、创建第二段对话,也不能把每个人类轮次都视为自主迭代。 -这层连接还承担并发与持久性义务。人类输入、取消、目标编辑、持久化失败、会话重启、插件卸载以及下游提示词策略都可能与待处理的继续执行发生竞争。简单的 `goal/changed -> agent.send()` 监听器可能接纳过期工作、与人类提示词同时运行、超出上限消耗资源,或在回放后未经新授权自行重启。 +这层连接还承担并发与持久性义务。人类输入、取消、目标编辑、持久化失败、会话重启、插件卸载以及下游提示词策略都可能与待处理的继续执行发生竞争。简单的 `goal/changed -> agent.followup()` 监听器可能接纳过期工作、与人类提示词同时运行、超出上限消耗资源,或在回放后未经新授权自行重启。 ## 决策 @@ -20,7 +20,7 @@ Status: implemented ### 预留与接纳 -当 agent 空闲、没有竞争中的排队工作,且当前目标为 `active` 加 `armed` 时,驱动器会先检查点持久化待处理的目标变更,并在等待之后重新校验所有条件。若 `roundsStarted` 已等于 `maxGoalRounds`,它会记录代码为 `round-limit` 的 `blocked`;否则,它会先预留精确身份 `{ goalId, revision, round: roundsStarted + 1 }` 和完整渲染提示词,再以 `GoalMessageSource` 调用 `Agent.send()`。提示词用 JSON 引号编码目标描述,使多行或类似标签的文本在熟悉框架中仍是无歧义的数据值。 +当 agent 空闲、没有竞争中的排队工作,且当前目标为 `active` 加 `armed` 时,驱动器会先检查点持久化待处理的目标变更,并在等待之后重新校验所有条件。若 `roundsStarted` 已等于 `maxGoalRounds`,它会记录代码为 `round-limit` 的 `blocked`;否则,它会先预留精确身份 `{ goalId, revision, round: roundsStarted + 1 }` 和完整渲染提示词,再以 `GoalMessageSource` 调用 `Agent.followup()`。提示词用 JSON 引号编码目标描述,使多行或类似标签的文本在熟悉框架中仍是无歧义的数据值。 `agent/prompt-submit` 瀑布是接纳栅栏。正数目标来源只有在完全匹配驱动器待处理的身份和内容、实时目标仍具有相同 id 与修订号、激活态仍为 armed,并且该回合仍是下一个编号时才会获准。插件在委托下游监听器前检查一次,在下游返回后再检查一次。第二次检查防止异步钩子编辑或暂停目标后,旧提示词仍被接纳。 diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml index 1b9aa60021..8266581269 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-cross-session-references.md -2026-07-21-cross-session-references.md: 61ea30cabb2abf4d4a9b4391891b5987affb91d0 -2026-07-21-cross-session-references.zh.md: 6fba44103942d29428fd820591815743dfb5d96d +2026-07-21-cross-session-references.md: fb6ee48a5f0c4bd89660ff24cfc523d744dacbe8 +2026-07-21-cross-session-references.zh.md: a35a44e9cf031a76947a5a662c62c27da72aba6c diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md index 61ea30cabb..fb6ee48a5f 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md @@ -18,7 +18,7 @@ The service uses `ctx.sessionQuery.readSurface(sessionId)`, which loads one live ## Snapshot and projection -Preparation deduplicates in first-appearance order, rejects the target id, enforces a configurable limit with a hard maximum of three references, and performs all reads in parallel. It returns no partially prepared context: any read, cancellation, validation, or budget error rejects the operation before `send()` or `steer()`. Cancellation races in-flight discovery and exact reads, so a host settles promptly even when a persistence backend cannot interrupt its pending operation; any late backend settlement is observed but cannot enqueue the message. A source is read before enqueue, so later source messages, compaction, deletion, or persistence replacement cannot change the target session. +Preparation deduplicates in first-appearance order, rejects the target id, enforces a configurable limit with a hard maximum of three references, and performs all reads in parallel. It returns no partially prepared context: any read, cancellation, validation, or budget error rejects the operation before `followup()` or `steer()`. Cancellation races in-flight discovery and exact reads, so a host settles promptly even when a persistence backend cannot interrupt its pending operation; any late backend settlement is observed but cannot enqueue the message. A source is read before enqueue, so later source messages, compaction, deletion, or persistence replacement cannot change the target session. Projection retains direct-user messages and steering, completed assistant text, and checkpoint user messages carrying the canonical source exported by `dsh-compact`. That marker is part of the compaction capability contract rather than a backend package name. When a source prompt already contains baked prefix context, projection reads only its model-hidden display content, so referencing that target later does not recursively propagate an earlier snapshot. Projection excludes shadowed pre-compaction nodes, tools and results, reasoning, injected context, other plugin user messages, log-only records, and incomplete assistant chunks. Repeated compaction therefore exposes only the latest folded checkpoint lineage still on the current surface plus its retained tail; there is no raw/current switch and no shadow recovery. @@ -43,10 +43,10 @@ Each of at most three references is independently capped at 65,536 UTF-8 bytes b ## Alternatives considered - **Wait for SQLite FTS5** — rejected because snapshot correctness requires exact id reads and canonical surface folding, not content search. FTS improves discovery only. -- **Put mention syntax in `Agent.send()`** — rejected because it would make the core protocol parse one host's presentation syntax and prevent typed non-text hosts from sharing the semantic layer. +- **Put mention syntax in agent delivery methods** — rejected because it would make the core protocol parse one host's presentation syntax and prevent typed non-text hosts from sharing the semantic layer. - **Implement references separately in each host** — rejected because projection, security warning, retention, and persistence would drift across hosts. - **Attach context to `SendOptions` and the inbox record** — rejected because generic delivery would own a domain transaction through admission, steering, cancellation, and observation. A domain-specific admission wrapper and the existing next-step outbox preserve the required pairing without enlarging every message. -- **Bake the prefix host-side before `send()`** — rejected because `agent/prompt-submit` must inspect and rewrite only the direct prompt. Keeping the snapshot as a separate sourced message preserves that boundary and lets TUI hide background bytes from the direct user bubble. +- **Bake the prefix host-side before `followup()`** — rejected because `agent/prompt-submit` must inspect and rewrite only the direct prompt. Keeping the snapshot as a separate sourced message preserves that boundary and lets TUI hide background bytes from the direct user bubble. - **Replay the raw source log or restore shadowed events** — rejected because compact defines the current model surface and may intentionally retire sensitive or expensive history. - **Resume or fork the source** — rejected because the feature supplies read-only background for one target message, not identity or lifecycle continuity. - **Inject at request time by rereading the source** — rejected because the reference would become nondeterministic, cancellation races could alter its bytes, and target replay would depend on external mutable state. diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md index 6fba441039..a35a44e9cf 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md @@ -18,7 +18,7 @@ TUI 用户需要把另一场对话中的相关工作带入一条新消息,但 ## 快照与投影 -准备过程按首次出现的顺序去重、拒绝目标会话自身的 id,并且执行可配置的数量限制,但引用硬上限为三个,所有读取均并行执行。该过程不会返回部分完成的上下文:任何读取、取消、校验或预算错误都会在调用 `send()` 或 `steer()` 前拒绝本次操作。取消会与进行中的候选发现和精确读取竞速,因此即使持久化后端无法中断待处理操作,宿主也能及时结束等待;后端迟到的完成结果仍会被观察,但不能让消息入队。源会话在入队前完成读取,因此源会话后续新增消息、执行压缩、被删除或替换持久化内容,都无法改变目标会话中的快照。 +准备过程按首次出现的顺序去重、拒绝目标会话自身的 id,并且执行可配置的数量限制,但引用硬上限为三个,所有读取均并行执行。该过程不会返回部分完成的上下文:任何读取、取消、校验或预算错误都会在调用 `followup()` 或 `steer()` 前拒绝本次操作。取消会与进行中的候选发现和精确读取竞速,因此即使持久化后端无法中断待处理操作,宿主也能及时结束等待;后端迟到的完成结果仍会被观察,但不能让消息入队。源会话在入队前完成读取,因此源会话后续新增消息、执行压缩、被删除或替换持久化内容,都无法改变目标会话中的快照。 投影会保留直接用户消息与 steering(中途引导)、已完成的 assistant 文本,以及携带由 `dsh-compact` 导出的规范来源标记的检查点用户消息。该标记属于压缩功能契约的一部分,而非某个后端包名称。当源提示词已包含合并写入的前缀上下文时,投影只读取其模型不可见的显示内容,因此后续引用该目标不会递归传播先前的快照。投影会排除压缩前已被遮蔽的节点、工具及其结果、推理(reasoning)、注入的上下文、其他插件用户消息、仅用于日志的记录,以及尚未完成的 assistant 分片。因此,重复压缩只会暴露当前表层仍保留的最新折叠检查点谱系及其尾部消息;系统不提供 raw/current 开关,也不恢复被遮蔽的内容。 @@ -43,10 +43,10 @@ TUI 把会话候选与现有 `@` 文件提供方组合在一起。候选查询 ## 考虑过的替代方案 - **等待 SQLite FTS5**:不予采纳,因为快照正确性依赖按准确 id 读取和规范表层折叠,而不是内容搜索。FTS 只改进候选发现。 -- **把提及标记语法放入 `Agent.send()`**:不予采纳,因为这会迫使核心协议解析某个宿主的展示语法,并阻止带类型的非文本宿主复用同一语义层。 +- **把提及标记语法放入 agent 投递方法**:不予采纳,因为这会迫使核心协议解析某个宿主的展示语法,并阻止带类型的非文本宿主复用同一语义层。 - **在每个宿主中分别实现引用**:不予采纳,因为投影、安全警告、保留策略和持久化会在不同宿主之间逐渐偏离。 - **把上下文附加到 `SendOptions` 和收件箱记录**:不予采纳,因为通用投递将不得不负责贯穿准入、steering、取消和观察的领域事务。领域专用的准入包装层和现有 next-step outbox 可以保持所需配对,而无需扩大每条消息。 -- **在调用 `send()` 前由宿主合并前缀**:不予采纳,因为 `agent/prompt-submit` 必须只检查和改写直接提示词。将快照保留为独立的带来源消息,可以维持该边界,并让 TUI 从直接用户气泡中隐藏背景字节。 +- **在调用 `followup()` 前由宿主合并前缀**:不予采纳,因为 `agent/prompt-submit` 必须只检查和改写直接提示词。将快照保留为独立的带来源消息,可以维持该边界,并让 TUI 从直接用户气泡中隐藏背景字节。 - **回放原始源日志或恢复被遮蔽的事件**:不予采纳,因为压缩定义了当前模型表层,并且可能有意淘汰敏感或开销高昂的历史内容。 - **恢复或 fork 源会话**:不予采纳,因为本功能只为一条目标消息提供只读背景,不提供身份或生命周期连续性。 - **在请求时重新读取源会话并注入**:不予采纳,因为这会让引用变得不确定,取消竞态可能改变其字节内容,目标回放也会依赖可变的外部状态。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml index 6d9112d663..f641718506 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml @@ -1,6 +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 -2026-07-21-tui-skill-slash-command.md: 8370ab61f552a6a60177b6da0b598dd142d21960 -2026-07-21-tui-skill-slash-command.zh.md: 66edec6ecd5c2a974b56e08bd9e924729302cc4a +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md +2026-07-21-tui-skill-slash-command.md: d4fd0e5dc1532e45c49b543d1411f371cfa270f8 +2026-07-21-tui-skill-slash-command.zh.md: 9db48619f23d4170eea673d4144986a16e0a2f1f diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md index 8370ab61f5..d4fd0e5dc1 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md @@ -10,7 +10,7 @@ The [skill system](2026-07-05-skill-system.md) shipped with model-initiated load ## Decision -The [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) front door owns a `/skill: [instructions]` command. On submit it loads the named skill and delivers one text block as a user turn — sent with `agent.send()` while idle and `agent.steer()` while running, the same rule as ordinary editor input. The block is `renderSkillInvocation(skill, instructions)`: a `` element wrapping the skill body, preceded by one resource-base line when the provider exposes one, with the user's trailing text appended after a blank line. The command is a TUI-only affordance; it adds no model-facing tool and changes no skill-system package contract. +The [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) front door owns a `/skill: [instructions]` command. On submit it loads the named skill and delivers one text block through `agent.steer()`, the same path as ordinary editor input. The block is `renderSkillInvocation(skill, instructions)`: a `` element wrapping the skill body, preceded by one resource-base line when the provider exposes one, with the user's trailing text appended after a blank line. The command is a TUI-only affordance; it adds no model-facing tool and changes no skill-system package contract. The TUI reads the skill service through `ctx.get('skills')`, not a declared injection, because skills mount conditionally: a deployment without the registry keeps a working front door, and `/skill:` there reports that skills are unavailable rather than failing to mount. `createTuiChat` is synchronous while `ctx.skills.list()` is async, so autocomplete seeds the static slash commands immediately and rebuilds the provider with `skill:` entries once the catalog resolves; a resolution that arrives after disposal is dropped, and a rejected lookup keeps the base commands. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md index 66edec6ecd..9db48619f2 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -[`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 前门拥有一条 `/skill: [instructions]` 命令。提交时它加载指定的 skill,并投递一个文本块作为用户轮次——空闲时用 `agent.send()` 发送、运行中用 `agent.steer()` 中途引导,与普通编辑器输入遵循同一规则。该文本块由 `renderSkillInvocation(skill, instructions)` 生成:一个包裹 skill 正文的 `` 元素,当提供方暴露资源基址时在其前加一行资源基址行,用户尾随的文本在空行之后追加。该命令是 TUI 独有的能力;它不新增任何面向模型的工具,也不改动任何 skill 系统包的契约。 +[`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 前门拥有一条 `/skill: [instructions]` 命令。提交时它加载指定的 skill,并通过 `agent.steer()` 投递一个文本块,与普通编辑器输入走同一条路径。该文本块由 `renderSkillInvocation(skill, instructions)` 生成:一个包裹 skill 正文的 `` 元素,当提供方暴露资源基址时在其前加一行资源基址行,用户尾随的文本在空行之后追加。该命令是 TUI 独有的能力;它不新增任何面向模型的工具,也不改动任何 skill 系统包的契约。 TUI 通过 `ctx.get('skills')` 读取 skill 服务,而非声明式注入,因为 skill 是条件挂载的:没有注册表的部署仍保有可用的前门,此时 `/skill:` 会报告 skill 不可用,而不是挂载失败。`createTuiChat` 是同步的,而 `ctx.skills.list()` 是异步的,所以自动补全先立即种入静态斜杠命令,待目录解析完成后再用 `skill:` 条目重建 provider(提供方);在 dispose(资源释放)之后才到达的解析结果会被丢弃,而被拒绝的查找会保留基础命令。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml index f6cc14538d..e3f93ba1f6 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml @@ -1,6 +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 -2026-06-20-public-agent-stop-surface.md: e22c4389df18f3c9ca96763fc097eabefcc5b761 -2026-06-20-public-agent-stop-surface.zh.md: e2647b498a8c906579b4fd2b50f94d1c326fe784 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md +2026-06-20-public-agent-stop-surface.md: 95c00f886360b94584f17c4221e0795be8ec1a61 +2026-06-20-public-agent-stop-surface.zh.md: 983833b53c123d0e384ad1d356808c6ca2f37edc diff --git a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md index e22c4389df..95c00f8863 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md +++ b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md @@ -36,4 +36,4 @@ A future plugin cannot abort only the current model/tool step while preserving q ## Related -This Agent Note only removes the redundant stop verb. Mid-turn steering remains an intentional message path; quiescence observation remains via `whenIdle()`. The resulting public surface is `send()`, `steer()`, `inject()`, `cancel()`, `whenIdle()`, status, options, session, and identity. +This Agent Note only removes the redundant stop verb. Mid-turn steering remains an intentional message path; quiescence observation remains via `whenIdle()`. The resulting delivery surface is `followup()`, `steer()`, and `inject()`; stopping and observation remain with `cancel()` and `whenIdle()`. diff --git a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md index e2647b498a..983833b53c 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md @@ -36,4 +36,4 @@ Status: implemented ## 相关 -本 Agent Note 只移除冗余的停止动词。轮次中途 steering 仍是一条有意保留的消息路径;完全停稳观察仍通过 `whenIdle()` 完成。最终公共表面包括 `send()`、`steer()`、`inject()`、`cancel()`、`whenIdle()`、status、options、会话和 identity。 +本 Agent Note 只移除冗余的停止动词。轮次中途 steering 仍是一条有意保留的消息路径;完全停稳观察仍通过 `whenIdle()` 完成。最终交付接口包括 `followup()`、`steer()` 和 `inject()`;停止与观察仍通过 `cancel()` 和 `whenIdle()` 完成。 diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index 60181a2ddb..63a6333e01 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md -2026-07-17-one-send-one-turn.md: dcc6c0aa483a0e53205dbaeef4e2b903f5f6a215 -2026-07-17-one-send-one-turn.zh.md: 8c12481defe6608c13ee81132b432b4d8b17b681 +2026-07-17-one-send-one-turn.md: 4787a25042f3b002d6c247202d5614a0f7bfa673 +2026-07-17-one-send-one-turn.zh.md: c44c99771b22f7ecb1b802f8758a103d2774a140 diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md index dcc6c0aa48..4787a25042 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -6,7 +6,7 @@ English | [中文](2026-07-17-one-send-one-turn.zh.md) ## Problem -Suppose a caller submits message A and then message B with two `Agent.send()` calls. Implicit batching can put A and B in one turn simply because both are waiting when the driver reads its queue. The caller made two calls, but the loop silently turns them into one unit of work. +Suppose a caller submits message A and then message B with two `Agent.followup()` calls. Implicit batching can put A and B in one turn simply because both are waiting when the driver reads its queue. The caller made two calls, but the loop silently turns them into one unit of work. That grouping depends on timing rather than caller intent. Calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks could be grouped differently even though every caller used the same API. @@ -14,15 +14,15 @@ This grouping changes behavior, not just the number of model calls. One ordinary ## Decision -The rule is simple: each successful `send()` creates one independent FIFO queue item. If that item runs, it is the only ordinary message in its turn. An item can be dropped before it starts, so the precise guarantee is at most one turn rather than exactly one; two sends are never silently combined. +The rule is simple: each successful `followup()` creates one independent FIFO queue item. If that item runs, it is the only ordinary message in its turn. An item can be dropped before it starts, so the precise guarantee is at most one turn rather than exactly one; two follow-ups are never silently combined. -Before enqueueing an item, `send()` checks the agent state and makes a detached, deeply frozen snapshot of the content and resolved source. After enqueueing it, `send()` publishes `agent/queued`. +Before enqueueing an item, `followup()` checks the agent state and makes a detached, deeply frozen snapshot of the content and resolved source. After enqueueing it, the agent publishes `agent/queued`. If messages A and B are both processed, B's turn starts only after A records `turn/end` and A's durability checkpoint settles. B's request therefore sees whatever closed result A left in the same session log. A checkpoint error is reported, but settlement only releases this ordering barrier; it does not make a failed write durable. Broad `cancel()`, disposal, or a failure before `turn/start` can instead discard an unstarted item without opening an empty turn. Prompt admission decides one message at a time before a turn opens. An allowed prompt becomes that turn's `user/message`; a blocked prompt is discarded without opening a turn or writing session history. Mixed-batch and all-blocked-batch branches do not exist. -The no-batching rule applies only to ordinary `send()`. Running `steer()` puts input in the outbox. While a turn remains open, the loop records that input at the next step boundary and steering makes another step the default. A failure before that boundary leaves the steering staged without waking the agent; a request-error retry action or a later prompt takes it, while cancellation or disposal can discard it. When the agent is idle, `steer()` delegates to `send()`, so it creates an independent ordinary queue item. +The no-batching rule applies only to ordinary `followup()`. Running `steer()` puts input in the outbox. While a turn remains open, the loop records that input at the next step boundary and steering makes another step the default. A failure before that boundary leaves the steering staged without waking the agent; a request-error retry action or a later prompt takes it, while cancellation or disposal can discard it. When the agent is idle, `steer()` creates an independent ordinary queue item. `inject()` continues to add model-facing context without submitting an ordinary message. During a turn it waits in the outbox for a safe step boundary; while idle it appends a `user/message` directly, without opening a turn or running the model. Persistence owns the resulting eager drain. `cancel()` remains a whole-agent operation that can clear all unstarted ordinary and steering input and abort the current step. `status` and `whenIdle()` also describe the whole agent, not one message. Several one-message turns can share one `running` interval, so `running` does not prove that a turn is open. diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index 8c12481def..c44c99771b 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -假设调用方连续两次调用 `Agent.send()`,先提交消息 A,再提交消息 B。隐式批处理可能只因为驱动器读取队列时两条消息都在等待,就把 A、B 放进同一个轮次。调用方明明调用了两次,agent loop(智能体循环)却悄悄把它们变成一个工作单元。 +假设调用方连续两次调用 `Agent.followup()`,先提交消息 A,再提交消息 B。隐式批处理可能只因为驱动器读取队列时两条消息都在等待,就把 A、B 放进同一个轮次。调用方明明调用了两次,agent loop(智能体循环)却悄悄把它们变成一个工作单元。 这种分组取决于运行时机,而不是调用方的意图。因此,即使所有调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。 @@ -14,15 +14,15 @@ Status: implemented ## 决策 -规则很简单:一次成功的 `send()` 创建一个独立的 FIFO 队列项。该队列项如果运行,就是所在轮次中唯一的普通消息。队列项可能在启动前被丢弃,因此精确保证是最多一个轮次,而不是必定一个轮次;两次 send 绝不会被悄悄合并。 +规则很简单:一次成功的 `followup()` 创建一个独立的 FIFO 队列项。该队列项如果运行,就是所在轮次中唯一的普通消息。队列项可能在启动前被丢弃,因此精确保证是最多一个轮次,而不是必定一个轮次;两次 follow-up 绝不会被悄悄合并。 -队列项入队之前,`send()` 会检查 agent 状态,并为内容和解析后的来源创建一份脱离调用方对象、经过深度冻结的快照。队列项入队之后,`send()` 发布 `agent/queued`。 +队列项入队之前,`followup()` 会检查 agent 状态,并为内容和解析后的来源创建一份脱离调用方对象、经过深度冻结的快照。队列项入队之后,agent 发布 `agent/queued`。 如果消息 A、B 都进入处理,B 的轮次只能在 A 记录 `turn/end` 且 A 的持久性检查点处理结束后开始。因此,B 的请求能看到 A 在同一会话日志中留下的已关闭结果。检查点错误会照常报告,但处理结束只表示解除这道顺序屏障,不表示失败的写入已经持久化。广义 `cancel()`、dispose(资源释放)或 `turn/start` 之前的失败也可能丢弃尚未启动的队列项,而不打开一个空轮次。 提示词准入会在轮次打开前,每次只决定一条消息。获准提示词成为该轮次的 `user/message`;被阻止的提示词会被丢弃,不打开轮次,也不写入会话历史。实现中不存在混合批次或全阻止批次分支。 -上述不合批规则只适用于普通 `send()`。agent 运行时,`steer()` 会把输入放入 outbox。只要当前轮次仍然打开,agent loop 就会在下一个步骤边界记录该输入,而 steering(中途引导)会默认让循环再执行一个步骤。在到达该边界前发生失败,会让 steering 保持暂存且不唤醒 agent;请求错误的重试动作或后续提示词会取走它,而取消或 dispose 可以将其丢弃。agent 空闲时,`steer()` 委托给 `send()`,因此会创建一个独立的普通队列项。 +上述不合批规则只适用于普通 `followup()`。agent 运行时,`steer()` 会把输入放入 outbox。只要当前轮次仍然打开,agent loop 就会在下一个步骤边界记录该输入,而 steering(中途引导)会默认让循环再执行一个步骤。在到达该边界前发生失败,会让 steering 保持暂存且不唤醒 agent;请求错误的重试动作或后续提示词会取走它,而取消或 dispose 可以将其丢弃。agent 空闲时,`steer()` 会创建一个独立的普通队列项。 `inject()` 继续添加面向模型的上下文,而不提交普通消息。轮次打开时,该上下文会留在 outbox 中,等待安全的步骤边界;agent 空闲时,系统会直接追加一条 `user/message`,既不打开轮次,也不运行模型。持久化层独立负责由此产生的即时排空。`cancel()` 仍是面向整个 agent 的操作,可以清空所有尚未启动的普通输入和 steering,并中止当前步骤。`status` 和 `whenIdle()` 描述的也是整个 agent,而不是某一条消息。多个单消息轮次可以共用一个 `running` 区间,因此 `running` 不表示轮次一定处于打开状态。 diff --git a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.i18n.yaml index b524e96a02..376c763206 100644 --- a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md -2026-07-24-agent-loop-observable-state-machine.md: a25657c6a41e2c0989db620046f44ea3254be151 -2026-07-24-agent-loop-observable-state-machine.zh.md: 058d89d3cb9e3d30963f95fda1510ef3c5bf281e +2026-07-24-agent-loop-observable-state-machine.md: 2024662495d8396e946e7c43f010164cd9414b83 +2026-07-24-agent-loop-observable-state-machine.zh.md: dc83a310ff2d7945e6b7e79625224102f0f4871c diff --git a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md index a25657c6a4..2024662495 100644 --- a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md +++ b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md @@ -51,7 +51,7 @@ The inbox lifecycle complements, rather than replaces, the durable session log. ## Related -- [Unify agent delivery on send(target × wakeup) and coalesce injected context into user/message](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) +- [Unify agent delivery routing and coalesce injected context into user/message](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) - [Remove implicit batching from ordinary sends](2026-07-17-one-send-one-turn.md) - [Microkernel event taxonomy](../architecture/2026-06-11-microkernel-event-taxonomy.md) - [Bounded LLM request recovery](../architecture/2026-06-21-bounded-llm-request-recovery.md) diff --git a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.zh.md b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.zh.md index 058d89d3cb..dc83a310ff 100644 --- a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.zh.md @@ -51,7 +51,7 @@ agent 生命周期、agent 整体活动状态、收件箱条目的进度以及 ## 相关内容 -- [统一通过 send(target × wakeup) 交付 agent 消息,并将注入上下文合并到 user/message](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) +- [统一 agent 交付路由,并将注入上下文合并到 user/message](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) - [移除普通发送中的隐式批处理](2026-07-17-one-send-one-turn.md) - [微内核事件分类体系](../architecture/2026-06-11-microkernel-event-taxonomy.md) - [有界 LLM 请求恢复](../architecture/2026-06-21-bounded-llm-request-recovery.md) diff --git a/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.i18n.yaml index e22645e088..b6ed2513b2 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.md -2026-07-27-request-error-retry-action.md: 3057b9fa28cf203c9374930fe97421918b4c1a6f -2026-07-27-request-error-retry-action.zh.md: bcb4e592f0c3d86f896e279cf0e3ea400741a1bb +2026-07-27-request-error-retry-action.md: 18ae9bc4ba26d1ad3cb7d1328d9e9d3e8de8377c +2026-07-27-request-error-retry-action.zh.md: a4092033eba236c95dbd237fbd93bf750f1055ab diff --git a/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.md b/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.md index 3057b9fa28..18ae9bc4ba 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.md +++ b/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.md @@ -14,7 +14,7 @@ Model-request recovery was decided inside `agent/request-error` but communicated The loop reads the action after the waterfall settles, closes the failed turn, and opens one retry turn from durable history. It rechecks the turn signal when consuming the action, so cancellation or disposal during recovery prevents the retry even if a listener returns it afterward. A thrown recovery never produces an action. -`Agent` and `ReactLoopAgent` expose no `retry()` method. Ordinary new work enters through `send()` and its `followup()`, `steer()`, and `inject()` presets; only a handled model-request failure can open a promptless retry turn. +`Agent` and `ReactLoopAgent` expose no `retry()` method. Ordinary new work enters through `followup()`, `steer()`, and `inject()`; only a handled model-request failure can open a promptless retry turn. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.zh.md b/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.zh.md index bcb4e592f0..a4092033eb 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.zh.md @@ -14,7 +14,7 @@ Status: implemented waterfall 结算后,循环读取该动作,关闭失败轮次,并从持久历史开启一个重试轮次。循环在使用该动作时会再次检查轮次信号,因此即使监听器随后返回重试动作,恢复期间发生的取消或资源释放仍会阻止重试。抛出异常的恢复不会产生动作。 -`Agent` 与 `ReactLoopAgent` 均不暴露 `retry()` 方法。普通新工作通过 `send()` 及其 `followup()`、`steer()` 和 `inject()` 预设进入;只有已处理的模型请求失败才能开启没有提示词的重试轮次。 +`Agent` 与 `ReactLoopAgent` 均不暴露 `retry()` 方法。普通新工作通过 `followup()`、`steer()` 和 `inject()` 进入;只有已处理的模型请求失败才能开启没有提示词的重试轮次。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-07-30-private-agent-send.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-30-private-agent-send.i18n.yaml new file mode 100644 index 0000000000..3245b307e5 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-30-private-agent-send.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-30-private-agent-send.md +2026-07-30-private-agent-send.md: 43353c309f98eab1bff91b8fbe0c9cfaa2bbc69b +2026-07-30-private-agent-send.zh.md: 49f7c999ec60c7305bdca570a4d59039c7c1923b diff --git a/.agents/notes/implemented/simplification/2026-07-30-private-agent-send.md b/.agents/notes/implemented/simplification/2026-07-30-private-agent-send.md new file mode 100644 index 0000000000..43353c309f --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-30-private-agent-send.md @@ -0,0 +1,27 @@ +# Agent Note: Keep agent routing private + +Status: implemented + +English | [中文](2026-07-30-private-agent-send.zh.md) + +## Problem + +The public `Agent.send()` method exposed the concrete loop's routing matrix even though production callers use only the semantic `followup()`, `steer()`, and `inject()` operations. Its fourth combination, `next-turn` with `wakeup: false`, had no consumer beyond tests. Keeping that latent capability public also required alternate `Agent` implementations and test fakes to accept implementation-level routing policy. + +## Decision + +`Agent` exposes `followup()`, `steer()`, and `inject()` as its complete delivery contract. `ReactLoopAgent` keeps a private `send()` helper that shares routing mechanics among those methods, while `SendTarget` and `SendOptions` are no longer exported from `dsh-agent`. + +The public interface cannot queue a turn without waking the driver. A follow-up always requests execution, steering requests the nearest step, and injection supplies model-facing context without requesting execution. This partially supersedes the public-surface portion of the [unified delivery decision](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) while retaining its internal routing and unified `user/message` representation. + +## Alternatives considered + +**Keep the routing matrix public.** This preserves the unused quiet-queue combination, but exposes mechanism instead of caller intent and imposes it on every alternate driver. + +**Add a public quiet-queue method.** A named method would be clearer than raw routing flags, but no production workflow currently needs work that remains parked until an unrelated delivery wakes it. + +## Consequences + +Plugins choose among three semantic operations instead of constructing routing options. Alternate drivers and structural test fakes implement a smaller contract, and the Cordis API catalog no longer advertises `send`, `SendTarget`, or `SendOptions`. + +The removed quiet-queue capability can return only with a named consumer and explicit lifecycle semantics. `cancel({ keepInbox: true })` still preserves work already pending through the supported delivery paths. diff --git a/.agents/notes/implemented/simplification/2026-07-30-private-agent-send.zh.md b/.agents/notes/implemented/simplification/2026-07-30-private-agent-send.zh.md new file mode 100644 index 0000000000..49f7c999ec --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-30-private-agent-send.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 将 agent 路由保留为私有实现 + +Status: implemented + +[English](2026-07-30-private-agent-send.md) | 中文 + +## 问题 + +公开的 `Agent.send()` 方法暴露了实体循环的路由矩阵,但生产调用方只使用语义明确的 `followup()`、`steer()` 和 `inject()` 操作。第四种组合,即 `next-turn` 配合 `wakeup: false`,除测试外没有消费方。将这项潜在能力保留为公开接口,还会迫使其他 `Agent` 实现和测试替身接受实现层的路由策略。 + +## 决策 + +`Agent` 将 `followup()`、`steer()` 和 `inject()` 作为完整的交付契约公开。`ReactLoopAgent` 保留私有的 `send()` 辅助方法,供这三个方法共用路由机制;`dsh-agent` 不再导出 `SendTarget` 和 `SendOptions`。 + +公开接口无法在不唤醒驱动器的情况下让一个轮次入队。`followup()` 始终请求执行,`steer()` 请求最近的步骤,`inject()` 则提供面向模型的上下文而不请求执行。本决策部分取代[统一交付决策](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md)中关于公开接口的内容,同时保留其内部路由与统一的 `user/message` 表示。 + +## 曾考虑的替代方案 + +**让路由矩阵保持公开。** 这会保留未使用的无唤醒排队组合,但也会暴露机制而非调用方意图,并要求每个替代驱动器都支持该机制。 + +**添加公开的无唤醒排队方法。** 使用具名方法会比原始路由标志更清晰,但目前没有生产工作流需要让工作持续处于等待状态,直到无关的交付将其唤醒。 + +## 后果 + +插件从三种语义操作中选择,不再自行构造路由选项。其他驱动器和结构型测试替身只需实现更小的契约,Cordis API 目录也不再列出 `send`、`SendTarget` 或 `SendOptions`。 + +只有出现明确的消费方并定义显式的生命周期语义后,才能恢复已移除的无唤醒排队能力。`cancel({ keepInbox: true })` 仍会保留已通过受支持交付路径进入待处理状态的工作。 diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index d7588ccae4..1d29403ee6 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -49,7 +49,7 @@ async function consumeUntilTurnEnd(frames: AsyncIterable>, if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue const event = payload.event if (targetTurn === undefined) { - if (event.type === 'turn/start' && event.data.trigger.kind === 'message') targetTurn = event.data.turn + if (event.type === 'turn/start') targetTurn = event.data.turn continue } if (event.type === 'assistant/message' && event.data.turn === targetTurn) { diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts index 558fa1bfc3..c67ff03021 100644 --- a/apps/web/tests/cordis-tool-round.e2e.ts +++ b/apps/web/tests/cordis-tool-round.e2e.ts @@ -29,9 +29,7 @@ function assertCompleteCordisLifecycle(events: readonly SessionEvent[]): void { (event): event is Extract => event.type === 'turn/end', ) const reason = turnEnd?.data.reason - const reasonSummary = reason?.kind === 'error' - ? { kind: reason.kind, code: reason.failure?.code, status: reason.failure?.status } - : { kind: reason?.kind } + const reasonSummary = { kind: reason?.kind } expect(reasonSummary).toEqual({ kind: 'completed' }) const calls = events.filter( diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index f722ead61b..8b4796ce68 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -237,26 +237,21 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { return new Promise((resolveSettled, reject) => { const timer = setTimeout(() => { off() reject(new Error(`no turn/end within ${timeoutMs}ms`)) }, timeoutMs) - const off = ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => { + const off = ctx.on('session/event', (session: Session, event: SessionEvent) => { if (event.type !== 'turn/end') return clearTimeout(timer) off() - const agent = ctx.agents.get(session.id) - if (agent === undefined) { - reject(new Error(`turn/end for ${session.id} but no live agent`)) - return - } - agent.whenIdle().then(() => { resolveSettled(session.id) }, reject) + ctx.sessions.flush(session) + .then(() => { resolveSettled(session.id) }, reject) }) }) }, diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index c296e10fb1..bfc5a70656 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 2ae982eba49b6dbd2365496915f9917071167813 -architecture.zh.md: abaef961504ff64dbcd1e8e8ba9bd002406fa7f4 +architecture.md: e7861cb5e59c30b3a20e0811fc1c644b300d8bfe +architecture.zh.md: b0b7bed4780cb3442076f874ba98503cd1255482 diff --git a/docs/architecture.md b/docs/architecture.md index 2ae982eba4..e7861cb5e5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,7 +65,7 @@ Waterfalls are around-middleware: listeners delegate with `next()`; returning wi ## Default Loop Lifecycle -A **session** is append-only. An ordinary **turn** claims one queued `send()` item; injection claims none. A successor awaits its predecessor's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model or plugins stop it; a **step** is one model request plus tools. Quotes in the [sequence below](agent-lifecycle.md) mark durable events. +A **session** is append-only. An ordinary **turn** claims one queued follow-up; injection claims none. A successor awaits its predecessor's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model or plugins stop it; a **step** is one model request plus tools. Quotes in the [sequence below](agent-lifecycle.md) mark durable events. Creation without an id mints `-session-`; `sessionId` resumes or creates, while `resumeSessionId` requires history. Resume restores lineage and delegation depth before publication. Setup failures emit `agent-loop/config-start-failed`; teardown is silent. @@ -122,7 +122,7 @@ Pruning precedes summaries; overflow retries require durable progress. `agent/re ### Failure Boundaries -Adapter failures close their step before `agent/request-error` receives the exact `Error`, normalized `LlmFailure`, and signal. A handled failure closes its turn and opens a retry turn from durable history without an idle notification; exhaustion leaves terminal `turn/end`. Failed chunks commit neither messages nor tool calls. +Final-adapter selection, dispatch, and iteration failures become terminal `finish { kind: 'error' | 'aborted', failure }` chunks before the loop handles them. `agent/request-error` receives request coordinates, normalized `LlmFailure`, the prepared registration's retry policy when available, and the signal; middleware and consumer errors remain thrown outside request recovery. Failed chunks commit neither messages nor tool calls. Other failures use `agent/error`. Cancellation and disposal beat recovery. Before request-header commit, the turn signal cancels asynchronous model-capability preparation; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. Effective `cancel(cause)` emits its cause before queue clearing and abort; observers cannot veto; idle calls emit nothing. Durability records user or parent cancellation as `aborted`, teardown as `disposed`; teardown awaits quiescence. The cause affects reporting, not late result-context handling ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). @@ -130,7 +130,7 @@ Turn and step events are turn-enclosed; idle injected `user/message` events may ### Agent Handles -`ctx.agents` owns live agents and returns `AgentHandle { agent, dispose() }`. Plugins use full `send()` options or `followup()`, `steer()`, and `inject()` presets; `cancel()` and `whenIdle()` control lifecycle. One awaited disposer coordinates teardown ownership. +`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins drive agents with `followup()`, `steer()`, and `inject()`; `cancel()` stops work, while the awaited disposer owns teardown. ### Agent Scope diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index abaef96150..b0b7bed478 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -65,7 +65,7 @@ waterfall(瀑布式事件)是环绕中间件:监听器通过 `next()` 委 ## 默认循环生命周期 -**会话**采用仅追加方式。普通**轮次**领取一项已排队的 `send()` 输入;注入不领取输入。后续轮次会等待前一轮次的检查点,但可以与其共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。模型或插件停止轮次时,该轮次结束;一个**步骤**包含一次模型请求及其工具。[下文时序](agent-lifecycle.md)中的引号标记持久事件。 +**会话**采用仅追加方式。普通**轮次**领取一条已排队的后续消息;注入不领取输入。后续轮次会等待前一轮次的检查点,但可以与其共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。模型或插件停止轮次时,该轮次结束;一个**步骤**包含一次模型请求及其工具。[下文时序](agent-lifecycle.md)中的引号标记持久事件。 创建时若未提供 id,流程会生成 `-session-`;`sessionId` 用于恢复或创建会话,而 `resumeSessionId` 要求已有历史。恢复流程在发布前还原沿袭关系和委托深度。初始化失败会发出 `agent-loop/config-start-failed`;拆卸过程保持静默。 @@ -122,7 +122,7 @@ idle inject: ### 失败边界 -适配器故障会先关闭自身步骤,再由 `agent/request-error` 接收准确的 `Error`、标准化的 `LlmFailure` 和信号。已处理的失败会关闭所在轮次,并从持久历史开启重试轮次,不发出空闲通知;重试耗尽则留下终态 `turn/end`。失败分片既不提交消息,也不提交工具调用。 +最终适配器选择、分发与迭代失败会在 loop 处理前成为终止 `finish { kind: 'error' | 'aborted', failure }` chunk。`agent/request-error` 接收请求坐标、标准化 `LlmFailure`、可用时的准备注册重试策略以及信号;middleware 与消费方错误仍在请求恢复之外抛出。失败分片既不提交消息,也不提交工具调用。 其他故障使用 `agent/error`。取消和资源释放优先于恢复。在提交请求头之前,轮次信号会取消异步模型能力准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 在清空队列和中止前发出原因;观察方不能否决;空闲调用不发事件。持久化层将用户或父级取消记录为 `aborted`,拆卸记录为 `disposed`;拆卸会等待完全停稳。原因只影响报告方式,不影响延迟完成的结果上下文处理([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 @@ -130,7 +130,7 @@ idle inject: ### Agent 句柄 -`ctx.agents` 拥有活跃 agent,并返回 `AgentHandle { agent, dispose() }`。插件使用全部 `send()` 选项,或 `followup()`、`steer()` 和 `inject()` 预设;`cancel()` 与 `whenIdle()` 控制生命周期。一个需等待完成的 disposer 协调拆卸归属。 +`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件用 `followup()`、`steer()` 和 `inject()` 驱动 agent;`cancel()` 停止工作,而拆卸由需等待完成的 disposer 负责。 ### Agent 作用域 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 967241fbf6..ac630fd39c 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -13,27 +13,6 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n ## `agent/*` -### `agent/cancel-requested` — emit - -Effective broad cancellation was requested, before queued/outbox work is cleared or the active turn is aborted. This observe-only notification cannot veto cancellation; listener failures are contained. - -```ts cordis-catalog -/** - * Effective broad cancellation was requested, before queued/outbox work - * is cleared or the active turn is aborted. This observe-only notification - * cannot veto cancellation; listener failures are contained. - * @param agent - the agent whose current work is being cancelled. - * @param cause - the explicit typed cancellation cause. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode emit - */ -'agent/cancel-requested'(this: Scoped, agent: Agent, cause: AgentCancelCause): void -``` - -Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) - -Source: [`packages/core/agent/src/types.ts:286`](../../packages/core/agent/src/types.ts) - ### `agent/created` — emit A fully configured agent and live session were published. Setup is composition-only; `agent/session-start` is the first startup-driving seam. 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. @@ -54,7 +33,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:148`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +53,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:227`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:157`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,98 +75,70 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:400`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:276`](../../packages/core/agent/src/types.ts) -### `agent/inbox/dequeue` — emit +### `agent/inbox/admitted` — emit -The driver claimed one item out of the inbox: a queued item at a turn boundary, or steering drained between steps. Fires after the item leaves its FIFO and before it becomes a durable message. +The driver admitted one inbox item for model-visible history. ```ts cordis-catalog /** - * The driver claimed one item out of the inbox: a queued item at a turn - * boundary, or steering drained between steps. Fires after the item leaves - * its FIFO and before it becomes a durable message. + * The driver admitted one inbox item for model-visible history. * @param agent - the agent whose inbox item was claimed. - * @param message - the claimed message. - * @param placement - the FIFO that claimed this occurrence; together with - * `message.id`, it matches the earliest outstanding enqueue in that FIFO. + * @param message - the admitted message. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/dequeue'( this: Scoped, agent: Agent, message: UserMessage, placement: InboxPlacement, ): void -``` - -Types: [Agent](../core-data-structures/core.md) · [InboxPlacement](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) - -Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) - -### `agent/inbox/discard` — emit - -Pending inbox items were dropped without delivering them, so every enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal, emits this after `agent/cancel-requested` when applicable and before aborting the active work. Fires once per drop with every dropped item. - -```ts cordis-catalog -/** - * Pending inbox items were dropped without delivering them, so every - * enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR - * `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal, - * emits this after `agent/cancel-requested` when applicable and before - * aborting the active work. Fires once per drop with every dropped item. - * @param agent - the agent whose inbox items were dropped. - * @param messages - the discarded messages in FIFO order (queued then steering); never empty. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode emit - */ -'agent/inbox/discard'(this: Scoped, agent: Agent, messages: UserMessage[]): void +'agent/inbox/admitted'(this: Scoped, 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:276`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) -### `agent/inbox/enqueue` — emit +### `agent/inbox/canceled` — emit -An item entered the queued or steering inbox. `placement` is the acceptance-time routing result; listeners must not reconstruct it from later agent or session state. +One pending inbox item was dropped without entering model-visible history. `cancel()` without `keepInbox`, including disposal, emits this once for each dropped item before aborting active work. ```ts cordis-catalog /** - * An item entered the queued or steering inbox. `placement` is the - * acceptance-time routing result; listeners must not reconstruct it from - * later agent or session state. - * @param agent - the owning agent. - * @param message - accepted content, source, and correlation identity. - * @param placement - resolved queued or steering placement. + * One pending inbox item was dropped without entering model-visible + * history. `cancel()` without `keepInbox`, including disposal, emits this + * once for each dropped item before aborting active work. + * @param agent - the agent whose inbox items were dropped. + * @param message - the dropped message. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/enqueue'(this: Scoped, agent: Agent, message: UserMessage, placement: InboxPlacement): void +'agent/inbox/canceled'(this: Scoped, agent: Agent, message: UserMessage): void ``` -Types: [Agent](../core-data-structures/core.md) · [InboxPlacement](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:247`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:185`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall -Allow, rewrite, or block one claimed prompt before it becomes a user message or opens a turn. Call `next()` for the unchanged default. The signal controls only this admission attempt; listeners may cooperate with it but must not retain it for a later attempt or turn. +Allow, rewrite, or block one claimed inbox batch before it becomes model-visible or opens a turn. Call `next()` for the unchanged default. The signal controls only this admission attempt; listeners may cooperate with it but must not retain it for a later attempt or turn. ```ts cordis-catalog /** - * Allow, rewrite, or block one claimed prompt before it becomes a user - * message or opens a turn. Call `next()` for the unchanged default. The + * Allow, rewrite, or block one claimed inbox batch before it becomes + * model-visible or opens a turn. Call `next()` for the unchanged default. The * signal controls only this admission attempt; listeners may cooperate with * it but must not retain it for a later attempt or turn. - * @param agent - the agent whose turn claimed the message. - * @param message - the frozen claimed message, including identity and source. + * @param agent - the agent whose driver claimed the batch. + * @param messages - the claimed messages. * @param 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/prompt-submit'(this: Scoped, agent: Agent, message: UserMessage, signal: AbortSignal, next: () => Promise): Promise +'agent/prompt-submit'(this: Scoped, agent: Agent, messages: UserMessage[], signal: AbortSignal, next: () => Promise): Promise ``` Types: [Agent](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:313`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:211`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -211,37 +162,30 @@ Replace the frozen call configuration. `await next()` yields the config the mach 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:339`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:237`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall -Handle a model-request failure after its failed step has closed but before the failed turn closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns the error, or calls `next()` to delegate. The default `undefined` leaves the failure terminal. +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. ```ts cordis-catalog /** - * Handle a model-request failure after its failed step has closed but - * before the failed turn closes. A listener returns `{ kind: 'retry' }` - * without calling `next()` when it owns the error, or calls `next()` to - * delegate. The default `undefined` leaves the failure terminal. + * 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 turn - the open turn number. - * @param step - the failed step number. - * @param error - the original model-request failure. - * @param failure - serializable facts normalized at the final adapter boundary. - * @param priorFailures - immutable failures that already authorized another - * retry turn in this consecutive sequence. - * @param retryPolicy - immutable policy of the adapter registration that served - * the failed request, or `undefined` if no final adapter served it. + * @param context - request coordinates, provider, normalized failure, and serving policy. * @param 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, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], retryPolicy: ResolvedRetryPolicy | undefined, signal: AbortSignal, next: () => Promise): Promise +'agent/request-error'(this: Scoped, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise): Promise ``` -Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) +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) -Source: [`packages/core/agent/src/types.ts:358`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:249`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -263,41 +207,17 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to 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:299`](../../packages/core/agent/src/types.ts) - -### `agent/settled` — emit - -One drain chain reached its terminal turn: that turn's `turn/end` is already committed. Automatically recovered failed turns do not emit this notification, and neither does a run that aborts or fails before its `turn/start` commits — there is no durable turn to settle against. `reason` says why; model-request recovery is exhausted when an error reaches it. - -```ts cordis-catalog -/** - * One drain chain reached its terminal turn: that turn's `turn/end` is - * already committed. Automatically recovered failed turns do not emit this - * notification, and neither does a run that aborts or fails before its - * `turn/start` commits — there is no durable turn to settle against. - * `reason` says why; model-request recovery is exhausted when an error - * reaches it. - * @param agent - the agent whose turn closed. - * @param turn - the terminal turn number. - * @param reason - why the terminal turn ended, with live error facts when it failed. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode emit - */ -'agent/settled'(this: Scoped, agent: Agent, turn: number, reason: SettleReason): void -``` - -Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md) - -Source: [`packages/core/agent/src/types.ts:387`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:197`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit -Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` synchronously; drive lifecycle from this event. +Agent status changed (`idle` ⇄ `running`). A waking delivery enters `running` synchronously after reserving cancellation; `idle` means no driver remains scheduled or active. ```ts cordis-catalog /** - * Agent status changed (`idle` ⇄ `running`). `send()` does not enter - * `running` synchronously; drive lifecycle from this event. + * 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). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. @@ -308,7 +228,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s 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:236`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts) ### `agent/step` — serial @@ -332,7 +252,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:326`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -358,7 +278,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:373`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` @@ -550,7 +470,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:58`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:56`](../../packages/llm/llm/src/index.ts) ## `session/*` @@ -575,7 +495,7 @@ Creation announcement during session publication. A synchronous throw vetoes and Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:71`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:50`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit @@ -596,7 +516,7 @@ Emitted once when an announced session leaves the store, including publication r Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:81`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:60`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -619,7 +539,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:93`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:72`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -640,7 +560,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:103`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:82`](../../packages/core/session/src/index.ts) ## `slash/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e0dd17fa02..f976556305 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -803,15 +803,13 @@ async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise /** - * Stream one model call as raw chunks (token-level deltas). Throws - * `LlmError` with code `NO_ADAPTER` if no adapter is registered for - * `options.provider`. Replay state is retained only when the same adapter - * instance owns its historical provider and the target provider. Final - * adapter selection remains fixed through asynchronous exact-model resolution - * and dispatch. Selection, dispatch, and iteration failures retain their - * original Error identity and are tagged in a call-local scope for narrow - * agent-loop request recovery; middleware and nested-call failures remain - * untagged for the outer call. + * Stream one model call as raw chunks (token-level deltas). Replay state is + * retained only when the same adapter instance owns its historical provider + * and the target provider. Final adapter selection remains fixed through + * asynchronous exact-model resolution and dispatch. Adapter selection, + * dispatch, and iteration failures become terminal `error` or `aborted` + * finish chunks; middleware, nested-call, cleanup, and consumer failures + * remain thrown. * @param options - the full request; `options.provider` selects the adapter. * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. */ @@ -1582,7 +1580,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:694`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:673`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` @@ -2166,7 +2164,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:247`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:234`](../../packages/ui/tui/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 9f6970d88d..a0804e3991 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: b9df539136c2661537775ba9a425bdf7ef1fd958 -core.zh.md: 1c75e8484dd1184077fe0194b2b6088230d1bbf5 +core.md: d837fc0977615a3afe307c56f5c97065af4a3a26 +core.zh.md: 797ec67ca0100cc44a088d453394c39cc1e343b0 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index b9df539136..d837fc0977 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -414,46 +414,12 @@ The twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, ` Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -```ts type-equiv -/** - * Which inbox queue a {@link Agent.send} item joins: - * - `next-turn` — the item becomes its own turn, claimed at a turn boundary. - * - `next-step` — during prompt admission or an open turn, the item stages for - * the next safe step boundary; otherwise it is promoted per its `wakeup` - * flag. - */ -type SendTarget = 'next-turn' | 'next-step' -``` - ```ts type-equiv /** Resolved inbox placement reported when an accepted message is enqueued. */ type InboxPlacement = 'queued' | 'steering' ``` -```ts type-equiv -/** - * Options for the unified {@link Agent.send} primitive over the - * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} - * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and - * {@link Agent.inject} (`next-step`/no-wakeup). - * - * The object is complete so routing policy is explicit. - */ -interface SendOptions { - /** Queue the item joins. */ - target: SendTarget - /** - * Whether this item makes the model run: wake a parked driver (`next-turn`) - * or force a continuation step (`next-step` while running). A `false` - * `next-turn` item queues without waking; a `false` - * `next-step` item attaches durable context without forcing another step - * (the injection preset). - */ - wakeup: boolean -} -``` - -The fixed-preset aliases own `target` and `wakeup`; their already identified `UserMessage` carries role, content, and provenance. Its `MessageId` remains stable across that message's `agent/inbox/*` events without being returned by the delivery methods. Injection bypasses the FIFOs and never appears on those events. +The delivery methods accept an already identified `UserMessage` carrying role, content, and provenance. Its `MessageId` remains stable across that message's `agent/inbox/*` events without being returned by the delivery methods. Injection bypasses the FIFOs and never appears on those events. ```ts type-equiv /** Options for {@link Agent.cancel}. */ @@ -474,10 +440,10 @@ type AgentCancelCause = | { readonly kind: 'parent' } ``` -`Agent` is an interface over the public live-agent contract. Concrete drivers own the `followup`/`steer`/`inject` aliases and route them through `send`'s (`target` × `wakeup`) matrix. +`Agent` is an interface over the public live-agent contract. Concrete drivers implement `followup`, `steer`, and `inject`; routing policy remains private to the driver. ```ts type-equiv -/** Public live-agent handle with aliases over the unified delivery primitive. */ +/** Public live-agent handle. */ interface Agent { /** The single identity shared with {@link session}. */ readonly id: SessionId @@ -496,28 +462,6 @@ interface Agent { /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context - /** - * The unified delivery primitive over the (`target` × `wakeup`) matrix. - * It routes the caller's typed content and source as follows: - * - * - `next-turn` queues an item that becomes the sole ordinary message of its - * own FIFO-ordered turn; `wakeup:true` wakes a - * parked driver, while `wakeup:false` queues without waking. - * - `next-step` with `wakeup:true` stages steering during prompt admission - * or an open turn; outside that window it falls back to a woken - * `next-turn`. - * - `next-step` with `wakeup:false` injects durable model-facing context - * without running the model: admission or an open turn stages it for the - * next safe log position, while an injection outside that window appends - * immediately without opening a turn. If admission closes without a turn, - * a context-only boundary appends immediately; context staged beside - * steering remains pending with it. - * The agent publishes or queues the identified frozen message as-is. - * @param message - identified model-facing content and its producer provenance. - * @param options - target queue and wakeup decision. - */ - send(message: UserMessage, options: SendOptions): void - /** * Clear queued and steering work — unless `keepInbox` — and abort the active * turn. An effective call first emits `agent/cancel-requested` with the @@ -533,16 +477,14 @@ interface Agent { whenIdle(): Promise /** - * Queue an ordinary follow-up turn and wake the driver — the - * `next-turn`/wakeup preset of {@link send}. The item becomes the sole - * ordinary message of its own turn. + * Queue an ordinary follow-up turn and wake the driver. The item becomes the + * sole ordinary message of its own turn. * @param message - identified prompt content and its producer provenance. */ followup(message: UserMessage): void /** - * Submit steering during prompt admission or an open turn — the - * `next-step`/wakeup preset of {@link send}. It stages for the next steering + * Submit steering during prompt admission or an open turn. It stages for the next steering * checkpoint before a request or stop decision. If the activity fails before * that boundary, the remainder stays staged without waking the agent; retry * or a later prompt takes it. Outside that window steering falls back to a diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 1c75e8484d..797ec67ca0 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -422,46 +422,12 @@ type SessionEvent = { 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -```ts type-equiv -/** - * Which inbox queue a {@link Agent.send} item joins: - * - `next-turn` — the item becomes its own turn, claimed at a turn boundary. - * - `next-step` — during prompt admission or an open turn, the item stages for - * the next safe step boundary; otherwise it is promoted per its `wakeup` - * flag. - */ -type SendTarget = 'next-turn' | 'next-step' -``` - ```ts type-equiv /** Resolved inbox placement reported when an accepted message is enqueued. */ type InboxPlacement = 'queued' | 'steering' ``` -```ts type-equiv -/** - * Options for the unified {@link Agent.send} primitive over the - * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} - * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and - * {@link Agent.inject} (`next-step`/no-wakeup). - * - * The object is complete so routing policy is explicit. - */ -interface SendOptions { - /** Queue the item joins. */ - target: SendTarget - /** - * Whether this item makes the model run: wake a parked driver (`next-turn`) - * or force a continuation step (`next-step` while running). A `false` - * `next-turn` item queues without waking; a `false` - * `next-step` item attaches durable context without forcing another step - * (the injection preset). - */ - wakeup: boolean -} -``` - -固定预设的别名方法自带 `target` 与 `wakeup`;其已有标识的 `UserMessage` 会携带角色、内容与 provenance。投递方法不会返回其 `MessageId`,但该 id 在这条消息的各个 `agent/inbox/*` 事件中保持稳定。注入绕过两个 FIFO,从不出现在这些事件中。 +投递方法接收已有标识的 `UserMessage`,由它携带角色、内容与 provenance。投递方法不会返回其 `MessageId`,但该 id 在这条消息的各个 `agent/inbox/*` 事件中保持稳定。注入绕过两个 FIFO,从不出现在这些事件中。 ```ts type-equiv /** Options for {@link Agent.cancel}. */ @@ -482,10 +448,10 @@ type AgentCancelCause = | { readonly kind: 'parent' } ``` -`Agent` 是覆盖公开活跃 agent 契约的接口。具体驱动器拥有 `followup`/`steer`/`inject` 别名方法,并将它们经由 `send` 的(`target` × `wakeup`)矩阵路由。 +`Agent` 是覆盖公开活跃 agent 契约的接口。具体驱动器实现 `followup`、`steer` 和 `inject`;路由策略仍为驱动器私有。 ```ts type-equiv -/** Public live-agent handle with aliases over the unified delivery primitive. */ +/** Public live-agent handle. */ interface Agent { /** The single identity shared with {@link session}. */ readonly id: SessionId @@ -504,28 +470,6 @@ interface Agent { /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context - /** - * The unified delivery primitive over the (`target` × `wakeup`) matrix. - * It routes the caller's typed content and source as follows: - * - * - `next-turn` queues an item that becomes the sole ordinary message of its - * own FIFO-ordered turn; `wakeup:true` wakes a - * parked driver, while `wakeup:false` queues without waking. - * - `next-step` with `wakeup:true` stages steering during prompt admission - * or an open turn; outside that window it falls back to a woken - * `next-turn`. - * - `next-step` with `wakeup:false` injects durable model-facing context - * without running the model: admission or an open turn stages it for the - * next safe log position, while an injection outside that window appends - * immediately without opening a turn. If admission closes without a turn, - * a context-only boundary appends immediately; context staged beside - * steering remains pending with it. - * The agent publishes or queues the identified frozen message as-is. - * @param message - identified model-facing content and its producer provenance. - * @param options - target queue and wakeup decision. - */ - send(message: UserMessage, options: SendOptions): void - /** * Clear queued and steering work — unless `keepInbox` — and abort the active * turn. An effective call first emits `agent/cancel-requested` with the @@ -541,16 +485,14 @@ interface Agent { whenIdle(): Promise /** - * Queue an ordinary follow-up turn and wake the driver — the - * `next-turn`/wakeup preset of {@link send}. The item becomes the sole - * ordinary message of its own turn. + * Queue an ordinary follow-up turn and wake the driver. The item becomes the + * sole ordinary message of its own turn. * @param message - identified prompt content and its producer provenance. */ followup(message: UserMessage): void /** - * Submit steering during prompt admission or an open turn — the - * `next-step`/wakeup preset of {@link send}. It stages for the next steering + * Submit steering during prompt admission or an open turn. It stages for the next steering * checkpoint before a request or stop decision. If the activity fails before * that boundary, the remainder stays staged without waking the agent; retry * or a later prompt takes it. Outside that window steering falls back to a diff --git a/docs/defensive-patterns.i18n.yaml b/docs/defensive-patterns.i18n.yaml index 96c938a74f..448b386615 100644 --- a/docs/defensive-patterns.i18n.yaml +++ b/docs/defensive-patterns.i18n.yaml @@ -1,6 +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 -defensive-patterns.md: c69094db461048f5dbca5f8bdd1fb5581b08a962 -defensive-patterns.zh.md: eb57f035ad0bd67e62e285d451502d41e4efc2bc +# pnpm run verify-translation-pairing --write docs/defensive-patterns.md +defensive-patterns.md: 4f256b2db6eee13af52c07e58a5d9d39d71694b8 +defensive-patterns.zh.md: 4e4c2cab645fcde71b969e1bda5e32b7fd472155 diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md index c69094db46..4f256b2db6 100644 --- a/docs/defensive-patterns.md +++ b/docs/defensive-patterns.md @@ -10,7 +10,7 @@ A result can be several things at once — a process can time out AND exit 0 bec ## Honor cross-seam contracts on BOTH sides -When an interface documents two valid ways to signal something — an adapter may report failure by THROWING from `stream()` or by ending the stream with a `finish {kind:'error'|'aborted'}` chunk — the consumer handles both, not just the one the first implementation used. A library-backed adapter that can't throw mid-stream relies on the in-band path; a loop that only catches throws turns a provider 401 into a normal completed turn. Document the contract where the type is defined; exercise every branch through the real consumer. +When an implementation boundary receives several representations of one outcome, normalize them before crossing the public seam. `LlmAdapter.stream()` implementations may throw or emit `finish {kind:'error'|'aborted'}`, but `LlmService.stream()` exposes model-request failures only as terminal finish chunks; middleware and consumer defects remain thrown. This keeps consumers from guessing whether a caught exception came from the provider, a wrapper, chunk logging, or their own assembly. Document the normalized contract where the type is defined; exercise every source form through the real consumer. ## Async state is not synchronous state diff --git a/docs/defensive-patterns.zh.md b/docs/defensive-patterns.zh.md index eb57f035ad..4e4c2cab64 100644 --- a/docs/defensive-patterns.zh.md +++ b/docs/defensive-patterns.zh.md @@ -10,7 +10,7 @@ ## 跨 seam 契约两侧都要遵守 -当一个接口文档记录了两种合法的信号方式时——例如适配器可以通过从 `stream()` 抛出异常来报告失败,也可以通过以 `finish {kind:'error'|'aborted'}` 分片结束流来报告——消费方必须同时处理两种路径,而不是只处理第一个实现恰好使用的那种。依赖库的适配器可能无法在流中途抛出异常,只能走带内路径;如果 agent loop(智能体循环)只捕获抛出的异常,就会把提供方的 401 错误变成一个正常完成的轮次。请在类型定义处记录契约;通过真实消费方测试每个分支。 +当一个实现边界接收到同一结果的多种表示时,应在跨越公共 seam 前将其规范化。`LlmAdapter.stream()` 的实现可以抛出异常或发出 `finish {kind:'error'|'aborted'}`,但 `LlmService.stream()` 只会通过终止 finish chunk 暴露模型请求失败;middleware 与消费方缺陷仍会抛出。这使消费方不必猜测捕获的异常究竟来自提供方、包装层、chunk 日志记录还是自身组装逻辑。请在类型定义处记录规范化契约;通过真实消费方覆盖每种来源形式。 ## 异步状态不是同步状态 diff --git a/examples/headless-agent/tests/semantic-checkpoint.snapshot.ts b/examples/headless-agent/tests/semantic-checkpoint.snapshot.ts index 6c5c7a5404..a6802ae391 100644 --- a/examples/headless-agent/tests/semantic-checkpoint.snapshot.ts +++ b/examples/headless-agent/tests/semantic-checkpoint.snapshot.ts @@ -32,7 +32,7 @@ async function seedInterruptedSession(root: string, cwd: string): Promise { delegationDepth: 0, } const events: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 10, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 10, data: { turn: 1 } }, { type: 'user/message', seq: 1, time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Tighten this session to read-only.' }], source: { kind: 'user' } }), surfaceOp: 'append' }, { type: 'sandbox/mode', seq: 2, time: 12, data: { mode: 'read-only' } }, { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'completed' } } }, diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 7ae98167de..0e620bbbdd 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -53,7 +53,7 @@ async function seedResumeSession(cwd: string): Promise { const id = SessionId('resume-target') const meta: SessionHeader = { version: 0, id, createdAt: 1_700_000_000_000, cwd: sessionCwd } const events: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1_700_000_000_001, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1_700_000_000_001, data: { turn: 1 } }, { type: 'user/message', seq: 1, time: 1_700_000_000_002, data: createUserMessage({ content: [{ type: 'text', text: 'persisted prompt' }], source: { kind: 'user' }, }), surfaceOp: 'append' }, diff --git a/packages/acp/acp/src/codec.ts b/packages/acp/acp/src/codec.ts index 8d4693d9d5..bdfa44bba5 100644 --- a/packages/acp/acp/src/codec.ts +++ b/packages/acp/acp/src/codec.ts @@ -18,7 +18,6 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason { case 'max-tokens': return 'max_tokens' case 'aborted': - case 'disposed': case 'interrupted': return 'cancelled' case 'error': diff --git a/packages/acp/acp/tests/approval.spec.ts b/packages/acp/acp/tests/approval.spec.ts index 01bcd83249..ea1ec994a4 100644 --- a/packages/acp/acp/tests/approval.spec.ts +++ b/packages/acp/acp/tests/approval.spec.ts @@ -20,7 +20,7 @@ describe('ACP machine permission policy', () => { await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const agent = harness.ctx.agents.get(SessionId(sessionId))! - agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + agent.session.append('turn/start', { turn: 1 }) return { agent, toolName: 'bash', callId: CallId('call-9'), ...overrides } } diff --git a/packages/acp/acp/tests/codec.spec.ts b/packages/acp/acp/tests/codec.spec.ts index 7f5441e4df..0136ae7a94 100644 --- a/packages/acp/acp/tests/codec.spec.ts +++ b/packages/acp/acp/tests/codec.spec.ts @@ -7,10 +7,9 @@ describe('ACP automation codec', () => { const cases: [TurnEndReason, string][] = [ [{ kind: 'completed' }, 'end_turn'], [{ kind: 'max-tokens' }, 'max_tokens'], - [{ kind: 'aborted' }, 'cancelled'], - [{ kind: 'disposed' }, 'cancelled'], + [{ kind: 'aborted', reason: { kind: 'user' } }, 'cancelled'], [{ kind: 'interrupted' }, 'cancelled'], - [{ kind: 'error', step: 1, message: 'boom' }, 'end_turn'], + [{ kind: 'error', error: new Error('boom') }, 'end_turn'], ] for (const [reason, expected] of cases) expect(turnEndToStopReason(reason)).toBe(expected) }) diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index db2234317b..867db15577 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -91,7 +91,7 @@ describe('ACP prompt lifecycle', () => { if (subject !== agent || message.source.kind !== 'user' || inserted) return inserted = true const source = { kind: 'plugin', plugin: 'test' } as const - agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source } }) + agent.session.append('turn/start', { turn: 1 }) agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'autonomous work' }], source, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index be9ba79347..7ce14b075a 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -114,7 +114,7 @@ function buildAlphaLog(): SessionEvent[] { return seq } for (let turn = 0; turn < 60; turn++) { - push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) + push({ type: 'turn/start', data: { turn } }) const userSeq = push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`)), @@ -158,7 +158,7 @@ function buildAlphaLog(): SessionEvent[] { // stays presenter-less as the unknown fallback. const toolTurn = (turn: number, name: string, args: string, resultText: string): void => { const callId = `fx-call-${turn}` - push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) + push({ type: 'turn/start', data: { turn } }) push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:${name} 样本。`)) }) push({ type: 'step/start', data: { turn, step: 0 } }) push({ @@ -186,7 +186,7 @@ function buildAlphaLog(): SessionEvent[] { + 'await tools.read({ path: "notes/missing.txt" }).catch(() => "tolerated")\n' + 'return { listing, demo }' const args = JSON.stringify({ code: program, description: 'Read the notes files and summarize' }) - push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) + push({ type: 'turn/start', data: { turn } }) push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:run_code 样本。`)) }) push({ type: 'step/start', data: { turn, step: 0 } }) push({ @@ -975,7 +975,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const turn = nextTurn.get(id) ?? 0 nextTurn.set(id, turn + 1) setRunning(id, true) - append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) + append(id, { type: 'turn/start', data: { turn } }) // Boundary flush parallel (the host's agent/step seam): an outstanding // /plan selection commits as plan/mode inside the opened turn. const plan = foldPlan(logOf(id)) diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 77af4601ab..25a3393caa 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -1,6 +1,7 @@ // Sessions remain resident after creation so they continue consuming mux frames off-screen. import type { Context } from 'cordis' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { @@ -51,9 +52,8 @@ const QUEUE_PREVIEW_CHARS = 200 /** Internal inbox-mirror entry: the snapshot row plus the retirement-matching fields the frames carry. */ interface QueuedEntry { row: QueuedMessage - steering: boolean - /** JSON-serialized MessageSource (steering retirement matches by source, the host-mirror precedent). */ - sourceJson: string + /** Stable message identity used when an admitted message retires the row. */ + messageId: MessageId } /** Single-line queue-row preview: text blocks flattened, non-text as tags, capped by code point. */ @@ -370,8 +370,7 @@ export class Session implements SessionFace { const key = 'rpcId' in message.source ? String(message.source.rpcId) : `f:${rpcId}` this.queued.push({ row: { key, preview: queuePreviewOf(message.content) }, - steering: frame.steering, - sourceJson: JSON.stringify(message.source), + messageId: message.id, }) this.queueRev++ this.notifier.markDirty() @@ -598,21 +597,16 @@ export class Session implements SessionFace { } } - /** Consumption-event retirement, mirroring the host queuedMirror rules: a message-triggered - * turn/start claims the oldest non-steering entry; a steering/message drains the oldest - * steering entry with the same source (loop-authored steering matches nothing and drops none). */ + /** Retire the oldest queued occurrence of an admitted identified message. */ private retireQueued(event: SessionEvent): void { if (this.queued.length === 0) return - let index = -1 - if (event.type === 'turn/start') { - if (event.data.trigger.kind !== 'message') return - index = this.queued.findIndex(entry => !entry.steering) - } else if (event.type === 'steering/message') { - const source = JSON.stringify(event.data.message.source) - index = this.queued.findIndex(entry => entry.steering && entry.sourceJson === source) - } else { - return - } + const id = event.type === 'user/message' + ? event.data.id + : event.type === 'steering/message' + ? event.data.message.id + : undefined + if (id === undefined) return + const index = this.queued.findIndex(entry => entry.messageId === id) if (index < 0) return this.queued.splice(index, 1) this.queueRev++ diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index 53f80e0e69..74897468d3 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -12,7 +12,7 @@ const at = (seq: number, e: Record): SessionEvent => export const ev = { turnStart: (seq: number, turn: number): SessionEvent => - at(seq, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }), + at(seq, { type: 'turn/start', data: { turn } }), user: (seq: number, body: string): SessionEvent => at(seq, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ content: text(body), source: { kind: 'user' }, diff --git a/packages/client/runtime/tests/queue-store.spec.ts b/packages/client/runtime/tests/queue-store.spec.ts index 7a734ef393..5f221b7e87 100644 --- a/packages/client/runtime/tests/queue-store.spec.ts +++ b/packages/client/runtime/tests/queue-store.spec.ts @@ -1,6 +1,6 @@ /** * Queue mirror semantics (web input-triggers queue cut 1): session/queued - * intake, host-rule retirement (message turn/start claims oldest non-steering; + * intake, host-rule retirement (identified user/message claims its non-steering row; * steering/message drains by source), leave-running sweep, reconnect reset, * pre-instantiation buffering, and snapshot reference stability. */ @@ -18,7 +18,11 @@ const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }] const rid = (id: string): RpcId => id as RpcId /** session/queued frame with the wire-sourced rpcId key (the host prompt path). */ -function queuedFrame(body: string, rpcId: string, steering = false): MuxFrame { +function queuedFrame( + body: string, + rpcId: string, + steering = false, +): Extract { return { type: 'session/queued', sessionId: SID, @@ -74,22 +78,30 @@ describe('queue intake', () => { }) describe('queue retirement (host queuedMirror rules)', () => { - it('a message-triggered turn/start claims the oldest non-steering row', () => { + it('an admitted user/message claims its identified non-steering row', () => { const session = makeSession() - session.handleMuxEnvelope(rid('e1'), queuedFrame('先', 'p-1')) + const first = queuedFrame('先', 'p-1') + session.handleMuxEnvelope(rid('e1'), first) session.handleMuxEnvelope(rid('e2'), queuedFrame('后', 'p-2')) - session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: ev.turnStart(0, 0) }) + session.handleMuxEnvelope(rid('e3'), { + type: 'session/event', + sessionId: SID, + event: { + ...ev.user(0, '先'), + data: first.message, + }, + }) expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-2']) }) - it('an injection-triggered turn/start claims nothing', () => { + it('a turn/start alone claims nothing', () => { const session = makeSession() session.handleMuxEnvelope(rid('e1'), queuedFrame('留', 'p-1')) - const injection = { - ...ev.turnStart(0, 0), - data: { turn: 0, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'x' } } }, - } as never - session.handleMuxEnvelope(rid('e2'), { type: 'session/event', sessionId: SID, event: injection }) + session.handleMuxEnvelope(rid('e2'), { + type: 'session/event', + sessionId: SID, + event: ev.turnStart(0, 0), + }) expect(session.getSnapshot().queue).toHaveLength(1) }) diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 4d417fb78e..ffdb1bbf33 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -155,8 +155,8 @@ export class BasicCompactService extends CompactService { } }) - ctx.on('agent/settled', (agent) => { - this.overflowRetries.delete(agent) + ctx.on('agent/status', (agent, status) => { + if (status === 'idle') this.overflowRetries.delete(agent) }) // A successful response starts a fresh overflow-recovery sequence even @@ -169,15 +169,11 @@ export class BasicCompactService extends CompactService { ctx.on('agent/request-error', async ( agent, - _turn, - _step, - _error, - failure, - _priorFailures, - _retryPolicy, + context, signal, next, ) => { + const { failure } = context if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next() this.overflowAgents.set(agent.session, agent) const target = routedTarget(agent.session) diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index a368a3631d..332b76ef08 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -104,7 +104,7 @@ function promptInput(text: string): SummarizationInput { function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session { const session = new Session(SessionId(`conversation-${turns}`)) for (let turn = 1; turn <= turns; turn += 1) { - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `${text} user ${turn}` }], source: { kind: 'user' }, @@ -133,7 +133,6 @@ function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session { } session.append('turn/start', { turn: turns + 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) return session } @@ -142,7 +141,7 @@ function toolConversation(): Session { const session = new Session(SessionId('tools')) for (let turn = 1; turn <= 3; turn += 1) { const callId = CallId(`call-${turn}`) - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `request ${turn} `.repeat(300) }], source: { kind: 'user' }, @@ -182,7 +181,7 @@ function toolConversation(): Session { session.append('step/end', { turn, step: 1 }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } - session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 4 }) return session } @@ -190,7 +189,7 @@ function toolConversation(): Session { function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Session { const session = new Session(SessionId(`oversized-tool-${chars}`)) const callId = CallId('oversized') - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) if (withCompactablePrompt) { session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'older history '.repeat(200) }], @@ -227,7 +226,7 @@ function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Sess }, { surfaceOp: 'append' }) session.append('step/end', { turn: 1, step: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 2 }) return session } @@ -474,7 +473,7 @@ describe('pressure measurement and retention', () => { it('skips when no durable routed model exists instead of using AgentOptions fallback', async () => { const compact = service(compactConfig) const session = new Session(SessionId('headerless')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) await expect(compact.compactIfNeeded(agent(session, MODEL), 'pressure', SIGNAL)) .resolves.toBeNull() expect(compact.calls).toHaveLength(0) @@ -557,7 +556,7 @@ describe('pressure measurement and retention', () => { const compact = service(compactConfig) const session = new Session(SessionId('single-tool-pair')) const callId = CallId('single-call') - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('request/header', { header: { config: { provider: MODEL, model: MODEL } }, @@ -647,7 +646,7 @@ describe('pressure measurement and retention', () => { it('declines when envelope pressure is high but the surface has no compactable range', async () => { const compact = service(compactConfig) const empty = new Session(SessionId('empty')) - empty.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + empty.append('turn/start', { turn: 1 }) empty.append('request/header', { header: { config: { provider: MODEL, model: MODEL }, system: 'x'.repeat(100_000) }, reason: 'initial', @@ -723,7 +722,7 @@ describe('pressure measurement and retention', () => { const ctx = createContext() const session = new Session(SessionId('one-tool-pair')) const callId = CallId('only') - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('assistant/message', { turn: 1, @@ -1058,7 +1057,7 @@ describe('compaction region transaction', () => { it('lets a model-independent custom summarizer compact without a conversation model', async () => { const compact = service() const session = new Session(SessionId('model-less-region')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'history '.repeat(100) }], source: { kind: 'user' }, @@ -1665,7 +1664,6 @@ describe('automatic listener and loader composition', () => { const session = new Session(SessionId('headerless-overflow')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toBe(false) diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index 4e9c24f667..84f51375b3 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -190,7 +190,6 @@ function overflowHistorySeed(): SessionEvent[] { const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY' session.append('turn/start', { turn, - trigger: { kind: 'message', source: { kind: 'user' } }, }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `${sentinel} ${'old context '.repeat(200)}` }], diff --git a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts index aa8997179e..bd347e6d1e 100644 --- a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts +++ b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts @@ -38,7 +38,6 @@ function appendToolStep( const callId = CallId(call) session.append('turn/start', { turn, - trigger: { kind: 'message', source: { kind: 'user' } }, }) session.append('step/start', { turn, step: 1 }) session.append('assistant/message', { @@ -165,7 +164,6 @@ describe('ToolResultPruneService session transaction', () => { }) session.append('turn/start', { turn: 2, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const result = service().pruneSession(session) @@ -214,7 +212,6 @@ describe('ToolResultPruneService session transaction', () => { appendToolStep(session, 3, 'c', [{ type: 'text', text: 'C'.repeat(80) }]) session.append('turn/start', { turn: 4, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const prune = service() const first = prune.pruneSession(session) @@ -231,7 +228,6 @@ describe('ToolResultPruneService session transaction', () => { appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }]) session.append('turn/start', { turn: 2, - trigger: { kind: 'message', source: { kind: 'user' } }, }) service().pruneSession(session) const replay = new Session(session.id, [...session.events]) @@ -250,7 +246,6 @@ describe('ToolResultPruneService session transaction', () => { expect(() => prune.pruneSession(session)).toThrow(/outside any open turn/) session.append('turn/start', { turn: 2, - trigger: { kind: 'message', source: { kind: 'user' } }, }) expect(() => prune.pruneSession(session)).not.toThrow() }) diff --git a/packages/compact/compact/tests/invariant.spec.ts b/packages/compact/compact/tests/invariant.spec.ts index f5fc5c87c7..b4f6d258ba 100644 --- a/packages/compact/compact/tests/invariant.spec.ts +++ b/packages/compact/compact/tests/invariant.spec.ts @@ -23,7 +23,7 @@ const summary = (overrides: Record = {}) => ({ }) function startTurn(session: ReturnType, turn = 1): void { - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) } describe('compaction invariants', () => { @@ -45,7 +45,7 @@ describe('compaction invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('compact/start', { turn: 1 }) await ctx.plugin(InvariantService) await ctx.plugin(CompactInvariant) @@ -59,7 +59,7 @@ describe('compaction invariants', () => { expect(() => { ctx.emit('session/event', session, { type: 'turn/start', seq: 0, time: 0, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }) ctx.emit('session/event', session, { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 }, diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index 59daf23904..f117df5081 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -46,10 +46,10 @@ function reading( function preparing(turn: number, step: number): Session { const session = new Session(SessionId(`time-invariant-${turn}-${step}`)) for (let priorTurn = 1; priorTurn < turn; priorTurn += 1) { - session.append('turn/start', { turn: priorTurn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: priorTurn }) session.append('turn/end', { turn: priorTurn, reason: { kind: 'completed' } }) } - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `turn ${turn}` }], source: { kind: 'user' }, @@ -87,7 +87,7 @@ describe('time-context invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('time-invariant-late-valid')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'prepare' }], source: { kind: 'user' }, @@ -103,7 +103,7 @@ describe('time-context invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('time-invariant-late-invalid')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'prepare' }], source: { kind: 'user' }, @@ -125,7 +125,7 @@ describe('time-context invariants', () => { it('rejects a reading after cancellation closes the turn', async () => { const ctx = await setup() const session = preparing(1, 2) - session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } }) + session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } }) expect(() => { ctx.emit('session/event', session, event(reading('1', '2', 'step context'))) }) .toThrow(/inside an open turn/) }) @@ -180,7 +180,7 @@ describe('time-context invariants', () => { expect(() => { ctx.emit('session/event', preparing(1, 1), user) }).not.toThrow() expect(() => { ctx.emit('session/event', preparing(1, 1), { - type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + type: 'turn/start', seq: 0, time: 0, data: { turn: 1 }, }) ctx.emit('tools/change') }).not.toThrow() diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index ef2abf1a45..9e6efb032b 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -48,14 +48,13 @@ function sessionAgent(session: Session, id = 'agent'): Agent { inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, - send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), } } function openMessageTurn(session: Session, turn: number): void { - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `turn ${turn}` }], source: { kind: 'user' }, @@ -159,7 +158,7 @@ describe('durable step context', () => { it('reports an unavailable first-step baseline when no model-visible message precedes it', async () => { const { ctx } = await mount() const session = new Session(SessionId('unavailable')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) await fire(ctx, sessionAgent(session), 1, 1) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 3ff6b9b310..9b4089c146 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -183,7 +183,6 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, - send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 629653d459..18c15fc605 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -412,7 +412,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'stream(options: GenerateOptions): AsyncIterable', - jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection remains fixed through asynchronous exact-model resolution\n * and dispatch. Selection, dispatch, and iteration failures retain their\n * original Error identity and are tagged in a call-local scope for narrow\n * agent-loop request recovery; middleware and nested-call failures remain\n * untagged for the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */', + jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Replay state is\n * retained only when the same adapter instance owns its historical provider\n * and the target provider. Final adapter selection remains fixed through\n * asynchronous exact-model resolution and dispatch. Adapter selection,\n * dispatch, and iteration failures become terminal `error` or `aborted`\n * finish chunks; middleware, nested-call, cleanup, and consumer failures\n * remain thrown.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */', }, ], }, @@ -1073,13 +1073,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ 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 */', summary: 'A declarative agent entry failed before it could publish a live agent.', }, - { - name: 'agent/cancel-requested', - mode: 'emit', - signature: '\'agent/cancel-requested\'(this: Scoped, agent: Agent, cause: AgentCancelCause): void', - jsDoc: '/**\n * Effective broad cancellation was requested, before queued/outbox work\n * is cleared or the active turn is aborted. This observe-only notification\n * cannot veto cancellation; listener failures are contained.\n * @param agent - the agent whose current work is being cancelled.\n * @param cause - the explicit typed cancellation cause.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'Effective broad cancellation was requested, before queued/outbox work is cleared or the active turn is aborted.', - }, { name: 'agent/created', mode: 'emit', @@ -1102,32 +1095,25 @@ export const EVENT_API: readonly EventApiEntry[] = [ summary: 'A step or turn errored.', }, { - name: 'agent/inbox/dequeue', + name: 'agent/inbox/admitted', mode: 'emit', - signature: '\'agent/inbox/dequeue\'( this: Scoped, agent: Agent, message: UserMessage, placement: InboxPlacement, ): void', - jsDoc: '/**\n * The driver claimed one item out of the inbox: a queued item at a turn\n * boundary, or steering drained between steps. Fires after the item leaves\n * its FIFO and before it becomes a durable message.\n * @param agent - the agent whose inbox item was claimed.\n * @param message - the claimed message.\n * @param placement - the FIFO that claimed this occurrence; together with\n * `message.id`, it matches the earliest outstanding enqueue in that FIFO.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'The driver claimed one item out of the inbox: a queued item at a turn boundary, or steering drained between steps.', + signature: '\'agent/inbox/admitted\'(this: Scoped, agent: Agent, message: UserMessage): void', + jsDoc: '/**\n * The driver admitted one inbox item for model-visible history.\n * @param agent - the agent whose inbox item was claimed.\n * @param message - the admitted message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'The driver admitted one inbox item for model-visible history.', }, { - name: 'agent/inbox/discard', + name: 'agent/inbox/canceled', mode: 'emit', - signature: '\'agent/inbox/discard\'(this: Scoped, agent: Agent, messages: UserMessage[]): void', - jsDoc: '/**\n * Pending inbox items were dropped without delivering them, so every\n * enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR\n * `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal,\n * emits this after `agent/cancel-requested` when applicable and before\n * aborting the active work. Fires once per drop with every dropped item.\n * @param agent - the agent whose inbox items were dropped.\n * @param messages - the discarded messages in FIFO order (queued then steering); never empty.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'Pending inbox items were dropped without delivering them, so every enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`.', - }, - { - name: 'agent/inbox/enqueue', - mode: 'emit', - signature: '\'agent/inbox/enqueue\'(this: Scoped, agent: Agent, message: UserMessage, placement: InboxPlacement): void', - jsDoc: '/**\n * An item entered the queued or steering inbox. `placement` is the\n * acceptance-time routing result; listeners must not reconstruct it from\n * later agent or session state.\n * @param agent - the owning agent.\n * @param message - accepted content, source, and correlation identity.\n * @param placement - resolved queued or steering placement.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'An item entered the queued or steering inbox.', + signature: '\'agent/inbox/canceled\'(this: Scoped, agent: Agent, message: UserMessage): void', + jsDoc: '/**\n * One pending inbox item was dropped without entering model-visible\n * history. `cancel()` without `keepInbox`, including disposal, emits this\n * once for each dropped item before aborting active work.\n * @param agent - the agent whose inbox items were dropped.\n * @param message - the dropped message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'One pending inbox item was dropped without entering model-visible history.', }, { name: 'agent/prompt-submit', mode: 'waterfall', - signature: '\'agent/prompt-submit\'(this: Scoped, agent: Agent, message: UserMessage, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message or opens a turn. Call `next()` for the unchanged default. The\n * signal controls only this admission attempt; listeners may cooperate with\n * it but must not retain it for a later attempt or turn.\n * @param agent - the agent whose turn claimed the message.\n * @param message - the frozen claimed message, including identity and source.\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 */', - summary: 'Allow, rewrite, or block one claimed prompt before it becomes a user message or opens a turn.', + signature: '\'agent/prompt-submit\'(this: Scoped, agent: Agent, messages: UserMessage[], signal: AbortSignal, next: () => Promise): Promise', + jsDoc: '/**\n * Allow, rewrite, or block one claimed inbox batch before it becomes\n * model-visible or opens a turn. Call `next()` for the unchanged default. The\n * signal controls only this admission attempt; listeners may cooperate with\n * it but must not retain it for a later attempt or turn.\n * @param agent - the agent whose driver claimed the batch.\n * @param messages - the claimed messages.\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 */', + summary: 'Allow, rewrite, or block one claimed inbox batch before it becomes model-visible or opens a turn.', }, { name: 'agent/request', @@ -1139,9 +1125,9 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent/request-error', mode: 'waterfall', - signature: '\'agent/request-error\'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], retryPolicy: ResolvedRetryPolicy | undefined, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Handle a model-request failure after its failed step has closed but\n * before the failed turn closes. A listener returns `{ kind: \'retry\' }`\n * without calling `next()` when it owns the error, or calls `next()` to\n * delegate. The default `undefined` leaves the failure terminal.\n * @param agent - the agent whose request failed.\n * @param turn - the open turn number.\n * @param step - the failed step number.\n * @param error - the original model-request failure.\n * @param failure - serializable facts normalized at the final adapter boundary.\n * @param priorFailures - immutable failures that already authorized another\n * retry turn in this consecutive sequence.\n * @param retryPolicy - immutable policy of the adapter registration that served\n * the failed request, or `undefined` if no final adapter served it.\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 */', - summary: 'Handle a model-request failure after its failed step has closed but before the failed turn closes.', + signature: '\'agent/request-error\'(this: Scoped, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise): Promise', + jsDoc: '/**\n * Handle one failed model-request attempt before the loop retries or closes\n * its step. A listener returns `{ kind: \'retry\' }` without calling `next()`\n * when it owns recovery, or calls `next()` to delegate. The default\n * `undefined` leaves the failure terminal.\n * @param agent - the agent whose request failed.\n * @param context - request coordinates, provider, normalized failure, and serving policy.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + summary: 'Handle one failed model-request attempt before the loop retries or closes its step.', }, { name: 'agent/session-start', @@ -1150,18 +1136,11 @@ export const EVENT_API: readonly EventApiEntry[] = [ 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 */', summary: 'The session lifecycle began, once before the first turn.', }, - { - name: 'agent/settled', - mode: 'emit', - signature: '\'agent/settled\'(this: Scoped, agent: Agent, turn: number, reason: SettleReason): void', - jsDoc: '/**\n * One drain chain reached its terminal turn: that turn\'s `turn/end` is\n * already committed. Automatically recovered failed turns do not emit this\n * notification, and neither does a run that aborts or fails before its\n * `turn/start` commits — there is no durable turn to settle against.\n * `reason` says why; model-request recovery is exhausted when an error\n * reaches it.\n * @param agent - the agent whose turn closed.\n * @param turn - the terminal turn number.\n * @param reason - why the terminal turn ended, with live error facts when it failed.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'One drain chain reached its terminal turn: that turn\'s `turn/end` is already committed.', - }, { name: 'agent/status', mode: 'emit', signature: '\'agent/status\'(this: Scoped, agent: Agent, status: AgentStatus): void', - jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`). `send()` does not enter\n * `running` synchronously; drive lifecycle from this event.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`). A waking delivery enters\n * `running` synchronously after reserving cancellation; `idle` means no\n * driver remains scheduled or active.\n * @param 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 */', summary: 'Agent status changed (`idle` ⇄ `running`).', }, { @@ -1429,11 +1408,11 @@ export const EVENT_API: readonly EventApiEntry[] = [ export const TYPE_API: readonly TypeApiEntry[] = [ { name: 'Agent', - declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}', }, { name: 'AgentCancelCause', - declaration: 'export type AgentCancelCause = {\n readonly kind: \'user\';\n} | {\n readonly kind: \'parent\';\n};', + declaration: 'export type AgentCancelCause = {\n readonly kind: \'user\';\n} | {\n readonly kind: \'parent\';\n} | {\n readonly kind: \'hook\';\n readonly reason: string;\n} | {\n readonly kind: \'disposed\';\n};', }, { name: 'AgentFactory', @@ -1549,7 +1528,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CancelOptions', - declaration: 'export interface CancelOptions {\n keepInbox?: boolean;\n}', + declaration: 'export interface CancelOptions {\n keepInbox?: boolean | undefined;\n}', }, { name: 'CodeBindingErrorClass', @@ -1917,7 +1896,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PreparedLlmCall', - declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n stream(options: GenerateOptions): AsyncIterable;\n}', + declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n readonly retryPolicy: ResolvedRetryPolicy;\n stream(options: GenerateOptions): AsyncIterable;\n}', }, { name: 'PreparedReferencedMessage', @@ -2103,14 +2082,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ScopeKey', declaration: 'export type ScopeKey = object;', }, - { - name: 'SendOptions', - declaration: 'export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n}', - }, - { - name: 'SendTarget', - declaration: 'export type SendTarget = \'next-turn\' | \'next-step\';', - }, { name: 'Session', declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', @@ -2125,7 +2096,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventMap', - declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n message: UserMessage;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n}', + declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n message: UserMessage;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n}', }, { name: 'SessionEventMetadataFilter', @@ -2645,15 +2616,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TurnEndReasonMap', - declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n };\n error: {\n kind: \'error\';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: \'disposed\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}', - }, - { - name: 'TurnTrigger', - declaration: 'export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];', - }, - { - name: 'TurnTriggerMap', - declaration: 'export interface TurnTriggerMap {\n message: {\n kind: \'message\';\n source: MessageSource;\n };\n retry: {\n kind: \'retry\';\n };\n injection: {\n kind: \'injection\';\n source: MessageSource;\n };\n}', + declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n reason: AgentCancelCause;\n };\n error: {\n kind: \'error\';\n error: unknown;\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}', }, { name: 'UserInteractionProvider', diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 75edc1357f..c11b692347 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: 6bb8b12af69f54c2a75cd672e4d3802887808c76 -README.zh.md: f9eb8aa3cdead427a88492e35c00eab80ba12f91 +README.md: 33e349f8945b45bf322171d4c02b9a940a68f2c2 +README.zh.md: 65a81ea82a02ea81bc3e0a8892fd23b281477df2 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 6bb8b12af6..33e349f894 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -55,7 +55,7 @@ Configured agents start automatically. A model call requires both `provider` and The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. -The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. Every inbox enqueue publishes `agent/inbox/enqueue` with the resolved queued-or-steering placement; taking it publishes `agent/inbox/dequeue` with the same placement, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. +The concrete driver routes `followup()`/`steer()`/`inject()` through one private `send()` primitive. A follow-up joins the queued FIFO and wakes the driver; admission happens before any turn opens. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. Every inbox enqueue publishes `agent/inbox/enqueue` with the resolved queued-or-steering placement; taking it publishes `agent/inbox/dequeue` with the same placement, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. ### Loop lifecycle (`agent.ts`) @@ -65,7 +65,7 @@ Every provider call that reaches a successful finish appends exactly one `assist After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort and materialize its configured default under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently. -Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and other extension failures close directly. Recovery receives the exact live error, immutable provider facts, immutable prior failures, the immutable retry policy of the adapter registration that served the request, and the turn signal after the failed step closes; the policy is absent if no final adapter served it. A handling listener returns `{ kind: 'retry' }`; the loop closes the failed turn with its error and opens one numbered retry turn without an intervening idle notification. Success clears the consecutive history, and an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. +Plugin failure ends the current turn, not the loop. Final adapter selection, dispatch, and iteration failures arrive from `ctx.llm` as terminal error or aborted finishes and enter `agent/request-error`; middleware, result processing, tools, and other extension failures remain thrown and close directly. Recovery receives request coordinates, immutable provider facts, the immutable retry policy captured by the prepared adapter registration, and the turn signal; the policy is absent when middleware owns an unprepared route. A handling listener returns `{ kind: 'retry' }`; an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, and retains their finalized result context without distinguishing the cancellation cause. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index f9eb8aa3cd..65a81ea82a 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -55,7 +55,7 @@ interface Config { 实体 `ReactLoopAgent`、其排队输入、outbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent,而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。 -统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO,除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering(中途引导)及与其一同暂存的上下文则继续待处理,以供重试或之后获准的提示词使用。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。每次 inbox 入队都会发布 `agent/inbox/enqueue`,并携带解析出的 queued 或 steering 路由归类;取走它会发布 `agent/inbox/dequeue`,并携带相同的路由归类;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。 +具体驱动器通过一个私有 `send()` 原语路由 `followup()`/`steer()`/`inject()`。后续消息加入排队 FIFO 并唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering(中途引导)及与其一同暂存的上下文则继续待处理,以供重试或之后获准的提示词使用。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。每次 inbox 入队都会发布 `agent/inbox/enqueue`,并携带解析出的 queued 或 steering 路由归类;取走它会发布 `agent/inbox/dequeue`,并携带相同的路由归类;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。 ### 循环生命周期(`agent.ts`) @@ -65,7 +65,7 @@ interface Config { 在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度,并填入其配置默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。 -插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败以及带内的终止错误或中止结束才进入 `agent/request-error`;中间件、结果处理、工具及其他扩展失败会直接关闭轮次。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实、不可变的先前失败、为请求提供服务的适配器注册所对应的不可变重试策略,以及轮次信号;如果没有最终适配器为其提供服务,则该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;循环用其错误关闭失败轮次,并在不插入空闲通知的情况下开启一个编号重试轮次。成功会清除连续失败历史;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。 +插件失败会结束当前轮次,而不是结束循环。最终适配器选择、分发与迭代失败会由 `ctx.llm` 作为终止 error 或 aborted finish 返回,并进入 `agent/request-error`;middleware、结果处理、工具及其他扩展失败仍会抛出并直接关闭轮次。恢复逻辑会接收请求坐标、不可变的提供方事实、准备完成的适配器注册所捕获的不可变重试策略以及轮次信号;middleware 接管未准备路由时,该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。 在步骤内,独占调用形成屏障;并行安全调用使用有界滚动池,并在启动前重新分类。只有分发/主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用,drain 已启动的结果,并保留其已定案的结果上下文,不区分取消原因。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 949e9801c4..d42c2790d3 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -7,47 +7,41 @@ * @module dsh-agent-loop/agent */ -import type { Context } from 'cordis' -import { agentCarrier, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent' -import { createScope } from '@deepseek-ai/dsh-scope' -import type { Scope } from '@deepseek-ai/dsh-scope' import type { Agent, - CancelOptions, - AgentInterruptReason, - InboxPlacement, + AgentCancelCause, AgentOptions, AgentStatus, - SettleReason, - PromptDecision, - RequestError, + CancelOptions, RequestErrorAction, - SendOptions, } from '@deepseek-ai/dsh-agent' +import { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent' +import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm' import { BlockAssembler, LlmError, - assertNever, createAssistantMessage, deepFreeze, errorChain, - freezeMessage, - isHarnessError, - llmFailureOf, - llmRetryPolicyOf, markAgentLoopRequest, } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' +import type { Scope } from '@deepseek-ai/dsh-scope' +import { createScope } from '@deepseek-ai/dsh-scope' +import type { AssistantMessage, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session' import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session' -import type { AssistantMessage, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import type {} from '@deepseek-ai/dsh-tools' +import type { Context } from 'cordis' import { executeToolCalls } from './tool-calls.ts' -/** One completed step or a final-adapter failure eligible for recovery. */ -type StepOutcome = - | { kind: 'completed'; continueTurn: boolean; concluded: boolean; maxTokens: boolean } - | { kind: 'request-failed'; error: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined } +type Phase = + | { kind: 'idle'; lastTurn: number } + | { kind: 'collecting'; abort: AbortController; lastTurn: number } + | { kind: 'running'; abort: AbortController; turn: number; step: number } + +type Admission = + | { kind: 'empty' } + | { kind: 'admitted'; claimed: UserMessage[]; messages: UserMessage[] } + | { kind: 'blocked' } /** * The concrete {@link Agent}: each `run()` owns one turn and repeats model @@ -55,31 +49,18 @@ type StepOutcome = */ export class ReactLoopAgent implements Agent { /** Prompts awaiting individual turns. */ - private queued: { message: UserMessage; wakeup: boolean }[] = [] + private queued: UserMessage[] = [] /** Input taken into the session log at step boundaries. */ - private outbox: { message: UserMessage; steering: boolean }[] = [] + private outbox: UserMessage[] = [] - /** Whether observers see a running interval; consecutive turns share it. */ - private busy = false - /** Whether an idle waking send has deferred driver admission. */ - private wakeScheduled = false - /** Whether next-step input belongs to the current admission or open turn. */ - acceptsNextStep = false - /** Abort owner for the current admission or turn. */ - private abort: AbortController | undefined - /** Resolves when the current admission and turn exit. */ - done: Promise = Promise.resolve() + private phase: Phase + private driverDone: Promise = Promise.resolve() - /** The agent-scoped registration boundary; the lifecycle owner unwinds it after {@link done}. */ + /** The agent-scoped registration boundary; the lifecycle owner unwinds it after the driver exits. */ readonly scope: Scope /** The agent's scoped composition context ({@link Agent.ctx}). */ readonly ctx: Context - /** Last turn number opened by this loop or present in its seeded log. */ - private lastTurn: number - /** Whether the session log is owed a matching turn end event. */ - private turnOpen = false - private stepOpen = false /** Whether this loop instance has appended its initial/resume request anchor. */ private requestHeaderLogged = false @@ -89,474 +70,282 @@ export class ReactLoopAgent implements Agent { public readonly options: AgentOptions, public readonly session: Session, ) { - this.lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0 + const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0 + this.phase = { kind: 'idle', lastTurn } this.scope = createScope(loopCtx, this) this.ctx = this.scope.ctx.extend({ agent: this }) } /** Last activity state published to observers. */ get status(): AgentStatus { - return this.busy ? 'running' : 'idle' + return this.phase.kind === 'idle' ? 'idle' : 'running' + } + + /** Commit a phase and publish its externally visible status transition. */ + private setPhase(next: Phase): void { + const previousStatus = this.status + this.phase = next + const status = this.status + if (status !== previousStatus) { + emitAgentEvent(this.loopCtx, this, 'agent/status', status) + } } /** Accept and route one unified send item. */ - send( - message: UserMessage, - options: SendOptions, - ): void { - const { target, wakeup } = options - if (target === 'next-step' && !wakeup) { - if (this.acceptsNextStep) { - this.outbox.push({ message, steering: false }) - return - } - this.session.append('user/message', message, { surfaceOp: 'append' }) - return + private send(message: UserMessage, target: 'next-turn' | 'next-step', wakeup: boolean): void { + this.session.append('agent/inbox/added', message) + // Waking input cannot join an aborted admission or turn, so it starts the next turn. + const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted + const inbox = target === 'next-turn' || wakingAfterAbort ? this.queued : this.outbox + inbox.push(message) + if (wakeup) { + this.scheduleKick() } - - const placement: InboxPlacement = target === 'next-step' && this.acceptsNextStep ? 'steering' : 'queued' - if (placement === 'steering') { - this.outbox.push({ message, steering: true }) - } else { - this.queued.push({ message, wakeup }) - } - // Preserve the routing decision for every send in this synchronous caller - // stack, while installing quiescence ownership before enqueue observers - // can cancel or dispose. - if (placement === 'queued' && wakeup) this.scheduleKick() - emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', message, placement) } /** Queue one ordinary prompt turn and wake the driver. */ followup(input: UserMessage): void { - this.send(input, { - target: 'next-turn', - wakeup: true, - }) + this.send(input, 'next-turn', true) } /** Steer the open turn, falling back to a waking prompt while idle. */ steer(input: UserMessage): void { - this.send(input, { - target: 'next-step', - wakeup: true, - }) + this.send(input, 'next-step', true) } /** Append model-facing context without waking the driver. */ inject(input: UserMessage): void { - this.send(input, { - target: 'next-step', - wakeup: false, - }) + this.send(input, 'next-step', false) } /** * Clear all pending work and abort the active turn; the first cause wins. * The cause is signal payload for observers and the durable turn/end * classification — it selects no machine behavior. Teardown is just - * `cancel({kind:'disposed'})` + await {@link done} + {@link scope} dispose, - * all owned by the factory. + * `cancel({kind:'disposed'})` + driver join + {@link scope} dispose, all + * owned by the factory. */ - cancel(cause: AgentInterruptReason, options: CancelOptions = {}): void { - // Effective only when it aborts the active turn or actually discards - // pending work: a keepInbox call with no active turn is a documented - // no-op, so it must not emit cancel-requested for consumers to misread. - const discards = !options.keepInbox && (this.queued.length > 0 || this.outbox.length > 0) - if (this.abort !== undefined || discards) { - // Observe-only: coordination consumers update their state before the - // inboxes clear; listener failures are contained by the dispatcher. - if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause) - } + cancel(cause: AgentCancelCause, options: CancelOptions = {}): void { if (!options.keepInbox) { - const discarded = this.queued.map(item => item.message) - for (const item of this.outbox) { - if (item.steering) discarded.push(item.message) + for (const message of [...this.outbox.splice(0), ...this.queued.splice(0)]) { + emitAgentEvent(this.loopCtx, this, 'agent/inbox/canceled', message) } - // Clear before abort observers run: replacement work belongs to the next turn. - this.queued.length = 0 - this.outbox.length = 0 - if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded) } - const reason = Object.freeze({ kind: cause.kind }) - this.abort?.abort(reason) - } - - /** Resolve at idle quiescence: no run driving and no waking prompt waiting. */ - async whenIdle(): Promise { - // `done` is replaced per activity, so re-reading it follows chained turns. - // Every driver failure today is contained before it can reject `done`, - // but the waiter must not gamble quiescence on that: a future escape - // still counts as settled activity. - /* v8 ignore next 3 -- the catch arm backstops rejection paths that are all currently contained */ - while (this.busy || this.wakeScheduled || this.abort !== undefined || this.queued.some(item => item.wakeup)) { - await this.done.catch(() => undefined) + if (this.phase.kind !== 'idle') { + this.phase.abort.abort(cause) } } - /** Defer idle admission while keeping {@link done} as its quiescence owner. */ + /** Reserve a driver before deferring idle admission. */ private scheduleKick(): void { - if (this.abort !== undefined || this.wakeScheduled) return - this.wakeScheduled = true - const pending = Promise.withResolvers() - const scheduled = pending.promise + if (this.phase.kind !== 'idle') return + const driver = Promise.withResolvers() + this.driverDone = driver.promise + this.setPhase({ kind: 'collecting', abort: new AbortController(), lastTurn: this.phase.lastTurn }) queueMicrotask(() => { - this.wakeScheduled = false - this.kick() - const activity = this.done - if (activity === scheduled) { - pending.resolve() - } else { - void activity.then( - () => { pending.resolve() }, - () => { pending.resolve() }, - ) - } + this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject) }) - this.done = scheduled + } + + /** Resolve after the current driver and synchronous replacement chain exits. */ + async whenIdle(): Promise { + let driver: Promise + do { + await (driver = this.driverDone) + } while (driver !== this.driverDone) + } + + private async kick(): Promise { + try { + while (await this.turn()) {} + } catch (error: unknown) { + if (this.phase.kind !== 'idle') { + const turn = this.phase.kind === 'collecting' ? this.phase.lastTurn : this.phase.turn + this.setPhase({ kind: 'idle', lastTurn: turn }) + emitAgentEvent(this.loopCtx, this, 'agent/error', turn, 0, error) + } + } finally { + if (this.phase.kind === 'running') { + this.setPhase({ kind: 'idle', lastTurn: this.phase.turn }) + } + } } /** Claim and admit the next queued prompt, then start its turn. */ - private kick(): void { - if (this.abort !== undefined || !this.queued.some(item => item.wakeup)) return - // The some() guard above proves the queue is non-empty; the non-null - // assertion expresses that invariant. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const { message } = this.queued.shift()! - const inheritedOutboxLength = this.outbox.length - - const admission = new AbortController() - this.abort = admission - this.acceptsNextStep = true - // Claimed admission is part of the running interval: it is cancellable - // activity, so observers (and their cancel routing) must see it. - if (!this.busy) { - this.busy = true - emitAgentEvent(this.loopCtx, this, 'agent/status', 'running') + private async admit(onTurnBoundary: boolean): Promise { + if (this.phase.kind !== 'running') throw new Error() + const signal = this.phase.abort.signal + const claimed = this.outbox.slice() + const outboxLength = this.outbox.length + const queued = onTurnBoundary ? this.queued[0] : undefined + if (queued !== undefined) claimed.push(queued) + if (claimed.length === 0) return { kind: 'empty' } + const decision = await agentEvents(this.loopCtx, this).waterfall( + 'agent/prompt-submit', claimed, signal, + () => Promise.resolve({ kind: 'allow', messages: claimed }), + ) + signal.throwIfAborted() + if (decision.kind === 'allow') { + this.outbox.splice(0, outboxLength) + if (queued !== undefined) this.queued.shift() + return { kind: 'admitted', claimed, messages: decision.messages } + } else { + this.cancel({ kind: 'hook', reason: decision.reason }, { keepInbox: decision.keepInbox }) + return { kind: 'blocked' } } - // The admission body runs synchronously up to the prompt-submit - // waterfall's first await, so the waterfall snapshots its listeners - // before a disposal initiated by the running-status emit above can - // unregister a vetoing plugin. - this.done = this.loopCtx.agents.withInitiator(this, async () => { - const signal = admission.signal - const trigger: TurnTrigger = { kind: 'message', source: message.source } - // Admitted input stays on the stack until its turn/start commits: the - // turn owns it only once the turn exists in the log. - let admitted: UserMessage[] | undefined - try { - signal.throwIfAborted() - const decision = await this.loopCtx.waterfall( - agentCarrier(this), 'agent/prompt-submit', this, message, signal, - () => Promise.resolve({ kind: 'allow' }), - ) - signal.throwIfAborted() - - if (decision.kind === 'allow') { - admitted = [decision.content === undefined - ? message - : freezeMessage({ ...message, content: decision.content })] - for (const context of decision.additionalContexts ?? []) { - admitted.push(freezeMessage(context)) - } - } - } catch (error: unknown) { - if (!signal.aborted) { - this.loopCtx.logger.warn(`agent "${this.id}": prompt admission failed: ${errorChain(error)}`) - } - } - - // cancel() aborts but never clears the slot, and kick()/run() - // all refuse to install a new owner while one exists, so the admission - // still owns the slot here and releasing it unconditionally is exact. - this.abort = undefined - if (admitted === undefined) { - this.acceptsNextStep = false - try { - this.flushRejectedAdmissionContexts() - } catch (error: unknown) { - // No turn exists for agent/error coordinates. Preserve the - // uncommitted suffix for a later boundary and report locally. - this.loopCtx.logger.warn( - `agent "${this.id}": committing rejected-admission context failed: ${errorChain(error)}`, - ) - } - // A synchronously aborted admission would otherwise publish idle - // inside send()'s own synchronous extent, before any post-send - // subscriber could observe the transition. - await Promise.resolve() - this.continueOrIdle() - return - } - await this.run(trigger, admitted, inheritedOutboxLength) - }) - // Published only after the abort owner and pending done are installed: a - // dequeue listener that cancels or disposes must find live cancellation - // and quiescence ownership, not the previous activity's settled state. - emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', message, 'queued') } /** * Run one turn and any request-error retry. `admitted` input enters the log * only after `turn/start` commits; until then it has no owner state to unwind. */ - private async run( - trigger: TurnTrigger, - admitted: UserMessage[] = [], - inheritedOutboxLength = 0, - priorFailures: readonly LlmFailure[] = Object.freeze([]), - ): Promise { - // Both entries hold the invariant: kick() clears the admission slot before - // awaiting run(), and a retry is entered only after the prior run clears it. - /* v8 ignore next -- unreachable guard: every caller clears or checks the abort slot first */ - if (this.abort !== undefined) throw new Error(`agent "${this.id}" is already running`) - const controller = new AbortController() - this.abort = controller - this.acceptsNextStep = true - const signal = controller.signal - const turn = this.lastTurn + 1 - let step = 0 - let opened = false - let reason: TurnEndReason = { kind: 'completed' } - let settleReason: SettleReason = { kind: 'completed' } - let requestFailureHistory = priorFailures - let retryFailures: readonly LlmFailure[] | undefined - const cancelRetry = (): void => { retryFailures = undefined } - signal.addEventListener('abort', cancelRetry, { once: true }) - + private async turn(): Promise { + if (this.phase.kind === 'idle') throw new Error() + const abort = this.phase.kind === 'collecting' ? this.phase.abort : new AbortController() + const lastTurn = this.phase.kind === 'collecting' ? this.phase.lastTurn : this.phase.turn + const phase = { kind: 'running' as const, abort, turn: lastTurn, step: 0 } + this.setPhase(phase) + if (abort.signal.aborted) return this.outbox.length > 0 || this.queued.length > 0 + let admission: Admission try { - signal.throwIfAborted() - this.session.append('turn/start', { turn, trigger }) - // Committed: publish the turn to the machine's own bookkeeping and let - // the admitted input enter the log it now belongs to. - this.turnOpen = true - opened = true - this.lastTurn = turn - // Context or steering retained by an earlier rejected admission happened - // before this prompt and must occupy the same order in durable history. - this.drainOutbox(turn, inheritedOutboxLength) - for (const input of admitted) { - this.session.append('user/message', input, { surfaceOp: 'append' }) - } - signal.throwIfAborted() - - this.drainOutbox(turn) - - steps: while (true) { - step += 1 - const outcome = await this.step(turn, step, signal) - switch (outcome.kind) { - case 'completed': - requestFailureHistory = Object.freeze([]) - if (outcome.maxTokens) reason = { kind: 'max-tokens' } - // A concluding tool result is terminal: steering already in the - // log waits for the next turn's request instead of reopening this - // one, and the agent/turn-stopping drain below is skipped for the same - // reason. - if (outcome.concluded) break steps - if (outcome.continueTurn || this.outbox.some(item => item.steering)) continue - break - case 'request-failed': { - // step() reports request failures only after step/start commits - // and before its own step/end, so the step is always open here. - this.stepOpen = false - this.session.append('step/end', { turn, step }) - if (!signal.aborted) { - try { - const action = await this.loopCtx.waterfall( - agentCarrier(this), 'agent/request-error', this, turn, step, outcome.error, - outcome.failure, requestFailureHistory, outcome.retryPolicy, signal, - () => Promise.resolve(undefined), - ) - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited. - if (action?.kind === 'retry' && !signal.aborted) { - retryFailures = Object.freeze([...requestFailureHistory, outcome.failure]) - } - } catch (recoveryError: unknown) { - this.loopCtx.logger.warn( - `agent "${this.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`, - ) - } - } - const settlement = this.settle(turn, step, outcome.error, signal, outcome.failure) - reason = settlement.reason - settleReason = settlement.settleReason - break steps + admission = await this.admit(true) + if (admission.kind !== 'admitted') return false + abort.signal.throwIfAborted() + } catch (error: unknown) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- cancel may abort while admission awaits + if (abort.signal.aborted) return this.outbox.length > 0 || this.queued.length > 0 + throw error + } + const turn = ++phase.turn + this.session.append('turn/start', { turn }) + let turnEnds: TurnEndReason | null = null + try { + while (true) { + if (admission.kind === 'admitted') { + for (const message of admission.claimed) { + emitAgentEvent(this.loopCtx, this, 'agent/inbox/admitted', message) + } + for (const message of admission.messages) { + this.session.append('user/message', message, { surfaceOp: 'append' }) } - /* v8 ignore next 2 -- closed-union exhaustiveness guard */ - default: - assertNever(outcome) } - await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal) - signal.throwIfAborted() - if (!this.drainOutbox(turn)) break - } - } catch (caught: unknown) { - try { - if (this.stepOpen) { - this.stepOpen = false + abort.signal.throwIfAborted() + const step = ++phase.step + this.session.append('step/start', { turn, step }) + try { + turnEnds = await this.step() + } finally { this.session.append('step/end', { turn, step }) } - } catch (closeError: unknown) { - // Contained like the finally's turn close: a persistently rejecting - // step boundary must not escape run(), or the post-finally tail would - // never publish the terminal status and observers would see a - // permanently running agent whose whenIdle() already resolved. - this.loopCtx.logger.warn(`agent "${this.id}": closing step ${turn}/${step} failed: ${errorChain(closeError)}`) - emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, closeError) - } - ({ reason, settleReason } = this.settle(turn, step, caught, signal)) - } finally { - // Every step-close happens before this point on both success and - // failure paths (step(), the request-failed branch, the catch), so the - // finally owes only the turn boundary. - this.acceptsNextStep = false - try { - if (this.turnOpen) { - // Re-entrant turn/end listeners must route new input to a later turn. - this.turnOpen = false - this.session.append('turn/end', { turn, reason }) + abort.signal.throwIfAborted() + if (turnEnds && this.outbox.length === 0) { + await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, abort.signal) + abort.signal.throwIfAborted() } - } catch (error: unknown) { - retryFailures = undefined - this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(error)}`) - emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error) + admission = await this.admit(false) + if (admission.kind === 'blocked') { + turnEnds = { kind: 'aborted', reason: abort.signal.reason as AgentCancelCause } + return false + } + abort.signal.throwIfAborted() + if (admission.kind === 'empty' && turnEnds) break } - // cancel() aborts but never clears the slot, and no second run can - // install a controller while this one is still unwinding, so the slot - // is still this run's controller here. - this.abort = undefined - signal.removeEventListener('abort', cancelRetry) - } - - if (opened) { - try { - await this.loopCtx.sessions.flush(this.session) - } catch (error: unknown) { - this.loopCtx.logger.warn(`agent "${this.id}": session/flush failed at turn ${turn}: ${errorChain(error)}`) - emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error) - } - } - - if (retryFailures !== undefined) { - await this.run({ kind: 'retry' }, [], 0, retryFailures) - } else { - // agent/settled names only committed turns: a run aborted or rejected - // before turn/start has no durable turn/end for consumers to settle - // against, so it exits without the notification. - if (opened) emitAgentEvent(this.loopCtx, this, 'agent/settled', turn, settleReason) - this.continueOrIdle() + } catch (error: unknown) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- cancel may abort during any awaited turn operation + if (abort.signal.aborted) turnEnds = { kind: 'aborted', reason: abort.signal.reason as AgentCancelCause } + else turnEnds = { kind: 'error', error: errorChain(error) } + } finally { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- the turn is always ended in this block + this.session.append('turn/end', { turn, reason: turnEnds! }) } + return this.outbox.length > 0 || this.queued.length > 0 } /** * Run the `agent/step` extension point, commit pending input, derive one * request, and execute its tool calls inside one durable step boundary. */ - private async step( - turn: number, - step: number, - signal: AbortSignal, - ): Promise { - const { session } = this - - // The single between-steps extension point: listeners inject, steer, or - // edit the log here; the request derives from the log after this settles. + private async step(): Promise { + if (this.phase.kind !== 'running') throw new Error() + const { turn, step, abort: { signal } } = this.phase + signal.throwIfAborted() await this.loopCtx.serial(agentCarrier(this), 'agent/step', this, turn, step, signal) signal.throwIfAborted() - - // Take the outbox whole — same-boundary steering and context leave in - // this request together. - this.drainOutbox(turn) - - // Assemble the system prompt fresh each step (it may depend on log state). const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal)) signal.throwIfAborted() const system = renderPrompt(assembly) - // Snapshot the exact log prefix: the reconstruction boundary. Appends - // after this synchronous snapshot join the next request. - const boundaryMessages = session.deriveMessages() - - session.append('step/start', { turn, step }) - this.stepOpen = true - signal.throwIfAborted() - - const { request, preparedCall } = await this.buildRequest( - turn, step, assembly.tools, system, boundaryMessages, signal, - ) - - const assembler = new BlockAssembler() - const chunkSeqs: number[] = [] - const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request) - try { + let message: AssistantMessage + while (true) { + const boundaryMessages = this.session.deriveMessages() + const { request, preparedCall } = await this.buildRequest( + turn, step, assembly.tools, system, boundaryMessages, signal, + ) + const assembler = new BlockAssembler() + const chunkSeqs: number[] = [] + const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request) + signal.throwIfAborted() for await (const chunk of stream) { signal.throwIfAborted() - const chunkEvent = session.append('assistant/chunk', { turn, step, chunk }) + const chunkEvent = this.session.append('assistant/chunk', { turn, step, chunk }) chunkSeqs.push(chunkEvent.seq) assembler.push(chunk) } - } catch (error: unknown) { - const facts = llmFailureOf(stream, error) - if (facts !== undefined && error instanceof Error) { - return { kind: 'request-failed', error, failure: facts, retryPolicy: llmRetryPolicyOf(stream) } + 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, { + turn, + step, + provider: request.provider, + failure: finish.failure, + retryPolicy: preparedCall?.retryPolicy, + }, signal, + () => Promise.resolve(undefined), + ) + signal.throwIfAborted() + if (action?.kind !== 'retry') { + return { kind: 'error', error: finish.failure } + } + } else { + message = createAssistantMessage({ + content: assembler.blocks(), + source: { + provider: request.provider, + model: request.model, + ...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {}, + }, + }) + this.session.append( + 'assistant/message', + { + turn, + step, + message, + ...assembler.usage === undefined ? {} : { usage: assembler.usage }, + }, + { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, + ) + if (finish.kind === 'max-tokens') { + return { kind: 'max-tokens' } + } + break } - throw error - } - signal.throwIfAborted() - - // Failure finish chunks take the same path as thrown stream errors. - const finish = assembler.finish - if (finish.kind === 'error' || finish.kind === 'aborted') { - const error = new LlmError(finish.failure.message, finish.failure.code, finish.failure) - return { kind: 'request-failed', error, failure: finish.failure, retryPolicy: llmRetryPolicyOf(stream) } } - // Truncated (max-tokens) output cannot owe tool calls. - const assembled = assembler.blocks() - const content = finish.kind === 'max-tokens' - ? assembled.filter(block => block.type !== 'tool-call') - : assembled - const message: AssistantMessage = createAssistantMessage({ - content, - source: { - provider: request.provider, - model: request.model, - ...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {}, - }, - }) - - session.append( - 'assistant/message', - { - turn, - step, - message, - ...assembler.usage === undefined ? {} : { usage: assembler.usage }, - }, - { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, - ) - - const toolCalls = content.filter(block => block.type === 'tool-call') - let concluded = false + const toolCalls = message.content.filter(block => block.type === 'tool-call') + let result: TurnEndReason | null if (toolCalls.length > 0) { - ({ concluded } = await executeToolCalls( + const { concluded } = await executeToolCalls( this.loopCtx, turn, step, toolCalls, signal, - context => this.outbox.push({ message: freezeMessage(context), steering: false }), - )) - } - - // Tool results stay adjacent to their calls; input accepted during the - // request enters the log only after the complete result batch. - const steered = this.drainOutbox(turn) - session.append('step/end', { turn, step }) - this.stepOpen = false - return { - kind: 'completed', - continueTurn: (toolCalls.length > 0 && !concluded) || steered, - concluded, - maxTokens: finish.kind === 'max-tokens', + context => this.outbox.push(context), + ) + result = concluded ? { kind: 'completed' } : null + } else { + result = { kind: 'completed' } } + return result } /** @@ -571,11 +360,9 @@ export class ReactLoopAgent implements Agent { boundaryMessages: Message[], signal: AbortSignal, ): Promise<{ request: GenerateOptions; preparedCall?: PreparedLlmCall }> { - const { session } = this - // A loop instance starts from its declared route, restoring only an opaque // effort owned by that exact model. Later steps fold the config it logged. - const persistedConfig = session.requestHeader()?.config + const persistedConfig = this.session.requestHeader()?.config const route = { provider: this.options.provider ?? '', model: this.options.model ?? '' } const reasoningEffort = persistedConfig?.provider === route.provider && persistedConfig.model === route.model @@ -618,113 +405,23 @@ export class ReactLoopAgent implements Agent { ...system ? { system } : {}, ...tools.length > 0 ? { tools } : {}, }) - const baseline = session.requestHeader() + const baseline = this.session.requestHeader() if (!this.requestHeaderLogged) { - session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' }) + this.session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' }) this.requestHeaderLogged = true } else if (baseline === undefined || !headerEquals(baseline, header)) { - session.append('request/header', { header, reason: 'change' }) + this.session.append('request/header', { header, reason: 'change' }) } + signal.throwIfAborted() const request = markAgentLoopRequest(deepFreeze({ ...header.config, messages: boundaryMessages, ...header.system !== undefined ? { system: header.system } : {}, ...header.tools !== undefined ? { tools: header.tools } : {}, - sessionId: session.id, + sessionId: this.session.id, signal, })) return { request, ...preparedCall === undefined ? {} : { preparedCall } } } - - /** Commit the outbox and report whether it contained steering. */ - private drainOutbox(turn: number, limit = this.outbox.length): boolean { - let steered = false - for (const item of this.outbox.splice(0, limit)) { - if (item.steering) { - steered = true - emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.message, 'steering') - this.session.append( - 'steering/message', - { turn, message: item.message }, - { surfaceOp: 'append' }, - ) - } else { - this.session.append('user/message', item.message, { surfaceOp: 'append' }) - } - } - return steered - } - - /** - * Give context-only input its ordinary idle placement when admission - * produces no turn. Steering keeps the whole boundary staged so context - * accepted beside it cannot split from the request it accompanies. - */ - private flushRejectedAdmissionContexts(): void { - if (this.outbox.some(item => item.steering)) return - const contexts = this.outbox.splice(0) - for (let index = 0; index < contexts.length; index += 1) { - const item = contexts[index] - /* v8 ignore next 2 -- the steering precheck proves this batch is context-only */ - if (item === undefined || item.steering) throw new Error('rejected-admission context batch changed') - try { - this.session.append('user/message', item.message, { surfaceOp: 'append' }) - } catch (error: unknown) { - this.outbox.unshift(...contexts.slice(index)) - throw error - } - } - } - - /** - * The single settlement funnel: classify one turn failure (interruption - * beats error) into the durable turn/end reason and live settlement report. - */ - private settle( - turn: number, - step: number, - error: unknown, - signal: AbortSignal, - failure?: LlmFailure, - ): { reason: TurnEndReason; settleReason: SettleReason } { - if (signal.aborted) { - // Slot invariant, stated rather than re-validated: the turn controller - // is machine-private and cancel() is its only aborter, always with one - // frozen canonical cause as the reason. - const interrupt = signal.reason as AgentInterruptReason - return { - reason: { kind: interrupt.kind === 'disposed' ? 'disposed' : 'aborted' }, - settleReason: { kind: 'aborted' }, - } - } - if (failure !== undefined) { - emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error) - // The durable record renders the full cause chain: turn/end is the one - // durable trace of the failure, so a wrapper message alone would lose - // the transport detail the log exists to keep. - const rendered = errorChain(error) - return { - reason: { kind: 'error', step, failure: { ...failure, ...rendered === '' ? {} : { message: rendered } } }, - settleReason: { kind: 'error', error, failure }, - } - } - emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error) - return { - reason: { kind: 'error', step, message: errorChain(error), ...isHarnessError(error) ? { code: error.code } : {} }, - settleReason: { kind: 'error', error }, - } - } - - /** Continue with a waking prompt, or publish the idle status. */ - private continueOrIdle(): void { - if (this.queued.some(item => item.wakeup)) { - this.kick() - } else { - // Every caller sits inside an admission or run whose install marked the - // interval busy, so the flag is still set here. - this.busy = false - emitAgentEvent(this.loopCtx, this, 'agent/status', 'idle') - } - } } diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index ca6b83dadb..b2b025965a 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -384,18 +384,7 @@ export class AgentLoop extends Service implements AgentFactory { if (machine === undefined) await machineReady.promise if (machine !== undefined) { machine.cancel({ kind: 'disposed' }) - // Drain to TRUE quiescence: cancel's own synchronous event chain - // (running→idle) can legitimately re-enter through an automation - // listener (goal-session's idle drive) and replace `done` with a - // fresh admission before this await captures it. The replacement - // work is cancelled and drained in turn until the slot stabilizes. - let done = machine.done - while (true) { - await Promise.allSettled([done]) - if (machine.done === done) break - done = machine.done - machine.cancel({ kind: 'disposed' }) - } + await machine.whenIdle() await machine.scope.dispose() } } finally { @@ -452,7 +441,7 @@ export class AgentLoop extends Service implements AgentFactory { loopCtx.agents.announce(agent) assertLive() // A synchronous announce/session-start listener may have started - // teardown; the machine is already live (send() works from the + // 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) assertLive() diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 1636998094..1bc9d58406 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -7,7 +7,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' * @module dsh-agent-loop/tests/cancel */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' @@ -55,33 +55,6 @@ function userTexts(agent: Agent): string[] { } describe('Agent.cancel()', () => { - it('notifies every observer before clearing work and contains listener failures', async () => { - const adapter = new MockAdapter([textResponse('must remain unused')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('cancel-event'), { provider: 'mock', model: 'mock' }) - const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const seen: string[] = [] - ctx.on('agent/cancel-requested', (subject, cause) => { - if (subject !== agent) return - seen.push(`first:${cause.kind}`) - subject.followup(createUserMessage({ content: [{ type: 'text', text: 'queued by cancel observer' }], source: { kind: 'user' } })) - throw new Error('observer failed') - }) - ctx.on('agent/cancel-requested', (subject, cause) => { - if (subject === agent) seen.push(`second:${cause.kind}`) - }) - - send(agent, 'drop me') - agent.cancel({ kind: 'user' }) - await new Promise(resolve => setTimeout(resolve, 30)) - agent.cancel({ kind: 'parent' }) - - expect(seen).toEqual(['first:user', 'second:user']) - expect(userTexts(agent)).toEqual([]) - expect(adapter.requests).toHaveLength(0) - expect(warned).toHaveBeenCalledWith(expect.stringContaining('agent/cancel-requested')) - }) - it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => { const adapter = new MockAdapter([textResponse('reply')]) const ctx = await harness(adapter) @@ -103,62 +76,29 @@ describe('Agent.cancel()', () => { const adapter = new MockAdapter([textResponse('reply')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - const discards: unknown[] = [] - ctx.on('agent/inbox/discard', (subject, items) => { if (subject === agent) discards.push(items) }) - const cancelRequests: unknown[] = [] - ctx.on('agent/cancel-requested', (subject, cause) => { if (subject === agent) cancelRequests.push(cause) }) + const canceled: unknown[] = [] + ctx.on('agent/inbox/canceled', (subject, message) => { if (subject === agent) canceled.push(message) }) - // Queue a turn WITHOUT waking the driver, so it sits in the inbox. - agent.send(createUserMessage({ content: [{ type: 'text', text: 'preserved' }], source: { kind: 'user' } }), { target: 'next-turn', wakeup: false }) - // keepInbox cancel: no active turn, work preserved, no discard event. With - // nothing to abort and nothing discarded, the call is a documented no-op, - // so it emits no cancel-requested either. + agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'preserved' }], + source: { kind: 'user' }, + })) + // Abort the collecting activity while preserving its queued item. agent.cancel({ kind: 'user' }, { keepInbox: true }) - expect(discards).toEqual([]) - expect(cancelRequests).toEqual([]) + expect(canceled).toEqual([]) - // The preserved item still runs once the driver is woken by a later send. + // The preserved item still runs once a later follow-up wakes the driver. send(agent, 'wake it') await waitForIdle(ctx, agent) expect(userTexts(agent)).toEqual(['preserved', 'wake it']) }) - it('a lone quiet (wakeup:false) send leaves the agent parked at idle', async () => { - const adapter = new MockAdapter([textResponse('reply')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - // A quiet item alone must NOT wake the driver: no turn runs and whenIdle - // resolves (the agent is quiescent), leaving the item queued. - agent.send(createUserMessage({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }), { target: 'next-turn', wakeup: false }) - await agent.whenIdle() - expect(agent.status).toBe('idle') - expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) - - // A later waking send drives the loop, and the quiet item rides along first. - send(agent, 'wake') - await waitForIdle(ctx, agent) - expect(userTexts(agent)).toEqual(['quiet', 'wake']) - }) - - it('cancelling a parked quiet item settles a pending whenIdle() without a later send', async () => { - const adapter = new MockAdapter([textResponse('reply')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - agent.send(createUserMessage({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }), { target: 'next-turn', wakeup: false }) - const idle = agent.whenIdle() - agent.cancel({ kind: 'user' }) - await idle - expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) - }) - it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - // send() queues synchronously (status still idle, loop microtask not yet + // followup() queues synchronously (status still idle, loop microtask not yet // resumed). Cancel in that pre-step window: the queued turn must not run. send(agent, 'drop me first') send(agent, 'drop me second') diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 8c31c4121e..5a2ef01b7e 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -506,24 +506,6 @@ describe('driver bookkeeping edges', () => { expect(agent.session.events).toEqual([]) }) - it('a whenIdle waiter survives a rejected driver promise', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('waiter-chain'), { provider: 'mock', model: 'mock' }) - // A throwing terminal-notification listener rejects the driver promise - // (the run's containment covers only session appends); the waiter's - // catch arm must treat that rejection as quiescence instead of - // propagating it. - ctx.on('agent/settled', (subject) => { - if (subject === agent) throw new Error('settled listener exploded') - }) - - send(agent, 'one') - // Entered while the run owns the abort slot, the waiter awaits the - // driver promise; its rejection must count as quiescence and resolve. - await expect(agent.whenIdle()).resolves.toBeUndefined() - }) - it('a request failure that concludes recovery after step/end closed keeps the boundary balanced', async () => { const { LlmError } = await import('@deepseek-ai/dsh-llm') // The failure finish-chunk path returns request-failed AFTER step() has diff --git a/packages/core/agent-loop/tests/invariant.spec.ts b/packages/core/agent-loop/tests/invariant.spec.ts index d3381cd524..3abdf04aa0 100644 --- a/packages/core/agent-loop/tests/invariant.spec.ts +++ b/packages/core/agent-loop/tests/invariant.spec.ts @@ -25,7 +25,7 @@ function loopRequest(options: T): Readonly { async function requestSetup() { const ctx = await setup() const session = ctx.sessions.create(SessionId('req-check')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -74,7 +74,7 @@ describe('request-reconstruction invariant', () => { it('rejects loop requests with no boundary or header', async () => { const ctx = await setup() const session = ctx.sessions.create(SessionId('req-bare')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const bare = loopRequest({ model: 'm', messages: Object.freeze([]), sessionId: session.id }) expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/) session.append('step/start', { turn: 1, step: 1 }) @@ -122,7 +122,7 @@ describe('request-reconstruction invariant', () => { await ctx.plugin(InvariantService) await ctx.plugin(AgentLoopInvariant) const session = ctx.sessions.create(SessionId('prepend-check')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index e051642dfd..ee2159c0d0 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -433,8 +433,6 @@ describe('agent loop', () => { // split the assistant tool call from the provider's tool-result message. const turnStarts = agent.session.events.filter(e => e.type === 'turn/start') expect(turnStarts).toHaveLength(1) - const ts0 = turnStarts[0]! - expect(ts0.type === 'turn/start' && ts0.data.trigger.kind).toBe('message') const result = agent.session.events.find(e => e.type === 'tool/result')! const contexts = agent.session.events.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin') expect(contexts).toHaveLength(2) @@ -1031,16 +1029,11 @@ describe('agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plugin message' }], source: { kind: 'plugin', plugin: 'test' } })) await idle - const triggers = agent.session.events - .filter(event => event.type === 'turn/start') - .map(event => event.data.trigger) + const turns = agent.session.events.filter(event => event.type === 'turn/start') const sources = agent.session.events .filter(event => event.type === 'user/message') .map(event => event.data.source) - expect(triggers).toEqual([ - { kind: 'message', source: { kind: 'user' } }, - { kind: 'message', source: { kind: 'plugin', plugin: 'test' } }, - ]) + expect(turns).toHaveLength(2) expect(sources).toEqual([ { kind: 'user' }, { kind: 'plugin', plugin: 'test' }, diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index c0e151016d..afd62f7d20 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -63,13 +63,9 @@ describe('agent/request-error', () => { retryPolicy: ResolvedRetryPolicy | undefined }[] = [] const statuses: string[] = [] - const settledTurns: number[] = [] ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) }) - ctx.on('agent/settled', (subject, turn) => { - if (subject === agent) settledTurns.push(turn) - }) ctx.on('agent/request-error', async ( subject, turn, step, _error, failure, priorFailures, retryPolicy, ) => { @@ -101,12 +97,7 @@ describe('agent/request-error', () => { code: 'SERVICE_UNAVAILABLE', }, ]) - expect(agent.session.events.filter(event => event.type === 'turn/start').map(event => event.data.trigger)) - .toEqual([ - { kind: 'message', source: { kind: 'user' } }, - { kind: 'retry' }, - { kind: 'retry' }, - ]) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) expect(seen.map(item => item.priorFailures.map(failure => failure.code))) .toEqual([[], ['RATE_LIMIT']]) expect(seen.map(item => item.retryPolicy)).toEqual([ @@ -114,7 +105,6 @@ describe('agent/request-error', () => { expect.objectContaining({ mode: 'normal' }), ]) expect(statuses).toEqual(['running', 'idle']) - expect(settledTurns).toEqual([3]) }) it('lets cancellation win over a retry action', async () => { diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index f133f1979c..197688031f 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -43,7 +43,7 @@ async function persistSession(sessionId: SessionId): Promise { // balanced completed turn is the smallest resumable log and avoids running // the model merely to construct this lifecycle fixture. const seed: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, ] const session = ctx.sessions.create(sessionId, { seed }) @@ -86,7 +86,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', createdAt: 1, }) await first.ctx.sessionPersistence.append(sessionId, [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'user/message', seq: 1, @@ -175,7 +175,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const { ctx } = await persistentHarness(new MockAdapter([textResponse('unused')])) const sessionId = SessionId('live-resume-race') const first = (await ctx.agents.create({ sessionId })).agent - first.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + first.session.append('turn/start', { turn: 1 }) await ctx.sessions.flush(first.session) await expect(ctx.agents.resume({ resumeSessionId: sessionId })) @@ -494,7 +494,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', // in its header) by creating it with a complete-turn seed — the write path // materializes the fork (header + seed) on disk. const seed: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, ] const adapter1 = new MockAdapter([textResponse('a')]) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 5f4e51784a..315d7f5b6f 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -147,7 +147,7 @@ describe('agent scope lifecycle', () => { expect(agent.ctx.agent).toBe(agent) // The root accessor default: a plain context answers undefined, not a throw. expect(ctx.agent).toBeUndefined() - await ctx.agents.get(SessionId('a1'))?.whenIdle() + await agent.whenIdle() }) it('records agents created through an agent context as non-root runtime children', async () => { diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 59dcdac249..a0facf83d0 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent/README.md -README.md: 9ca79f28506b133a555bd7d1e984386c715fd9d6 -README.zh.md: 165f71f1b395bdf0c229e2c4b1a30e89347b6be1 +README.md: 2d373487b7ae17a68edfaa4c45d8479f869276a5 +README.zh.md: 9e3b043baa4832721c66d6d0752c8601ea9d817c diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 9ca79f2850..2d373487b7 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -50,7 +50,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. -Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. 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. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while `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; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. 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. `PromptDecision.additionalContexts` is an array of identified, frozen `UserMessage` values so every context keeps its own identity and source. The admitted prompt and every additional context become separate model-facing `user/message` events before the turn runs. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; replacing admitted content preserves the prompt's identity. @@ -60,11 +60,10 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(message, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `message` is an already identified, frozen `UserMessage`; callers normally create it with `createUserMessage()` before routing begins. `SendOptions` owns only the `target` and `wakeup` policy. The agent publishes or queues the complete value as-is without minting or replacing its identity. The message's `agent/inbox/enqueue`/`dequeue`/`discard` events carry it so callers can correlate a queued item with its lifecycle; enqueue and dequeue also carry the resolved `queued | steering` placement so repeated message identities retire from the correct FIFO. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. -- `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. +- `agent.followup(input)` — queue an ordinary follow-up turn and wake the driver. Each admitted item becomes the sole ordinary prompt in its turn; the [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. - `agent.steer(input)` — the `next-step`/wakeup preset: during prompt admission or an open turn, stage steering for the next safe boundary without dispatching `agent/prompt-submit`; outside that acceptance window, delegate to a woken follow-up. Admission failure leaves staged steering for retry or a later admitted prompt, while cancellation or disposal may discard it. - `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event. -- `agent.acceptsNextStep` — whether a `next-step` send would currently join prompt admission or the open turn. Use this narrower routing predicate when a caller must choose between steering and a fresh admitted prompt; `status === 'running'` also covers admission exit and turn settlement. +- `agent.acceptsNextStep` — whether steering would currently join prompt admission or the open turn. Use this narrower routing predicate when a caller must choose between steering and a fresh admitted prompt; `status === 'running'` also covers admission exit and turn settlement. - `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 165f71f1b3..9e3b043baa 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -50,7 +50,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, 生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器静默后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。 -大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点,而 `agent/request-error` 是失败模型请求的恢复 waterfall:失败步骤关闭后,它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。 +大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点,而 `agent/request-error` 是失败模型请求的恢复 waterfall:它接收请求坐标、规范化失败事实、可用时的服务注册重试策略以及信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。 `PromptDecision.additionalContexts` 是由带标识且冻结的 `UserMessage` 值组成的数组,因此每个上下文都保留自己的标识和来源。获准的提示词与每个附加上下文都会在轮次运行前成为各自独立、面向模型的 `user/message` 事件。包装下游允许决策的监听器会保留其 `content` 与 `additionalContexts`,除非有意替换任一字段;替换获准内容时仍会保留提示词的标识。 @@ -60,11 +60,10 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, 每个插件面向的 handle: -- `agent.send(message, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`message` 是已有标识且已冻结的 `UserMessage`;调用方通常会在开始路由前使用 `createUserMessage()` 创建它。`SendOptions` 只持有 `target` 与 `wakeup` 策略。agent 会原样发布或排队完整值,不会生成或替换其标识。该消息的 `agent/inbox/enqueue`/`dequeue`/`discard` 事件会携带完整消息,调用方可据此把排队项与其生命周期关联;入队与出队事件还会携带解析出的 `queued | steering` 路由归类,使重复出现的消息标识能在正确的 FIFO 中完成结算。`target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'` 且 `wakeup: true` 提交 steering(中途引导),而 `target: 'next-step'` 且 `wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。 -- `agent.followup(input)`:`send()` 的 `next-turn`/wakeup 预设:排队一个普通后续轮次并唤醒驱动器。 +- `agent.followup(input)`:排队一个普通后续轮次并唤醒驱动器。每个获准项都会成为其轮次中唯一的普通提示词;轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。 - `agent.steer(input)`:`next-step`/wakeup 预设:提示词接纳期间或轮次打开时,为下一个安全边界暂存 steering,且不分发 `agent/prompt-submit`;该接收窗口之外则委托给会唤醒的后续轮次。接纳失败会保留暂存的 steering,以供重试或之后获准的提示词使用,而取消或 dispose 可能丢弃它。 - `agent.inject(input)`:`next-step`/不唤醒预设:追加面向模型的上下文而不运行模型;下一次请求会看到一条逐字的 user role 消息,其来源由必填的 `input.source` 携带。提示词接纳期间或轮次打开时,注入会在 outbox 中等待下一个安全边界。该接收窗口之外,它会立即追加而不开启轮次;如果接纳结束却未开启轮次,仅含上下文的接纳批次会采用这一回退,而与 steering 一同暂存的上下文则会随其继续待处理。持久化独立地响应 `session/event`。注入不发出 `agent/inbox/*` 事件。 -- `agent.acceptsNextStep`:当前发送 `next-step` 时,是否会加入提示词接纳或已打开的轮次。当调用方必须在 steering 与新接纳的提示词之间选择时,应使用这一更窄的路由判定;`status === 'running'` 还涵盖接纳收尾与轮次结算阶段。 +- `agent.acceptsNextStep`:steering 当前是否会加入提示词接纳或已打开的轮次。当调用方必须在 steering 与新接纳的提示词之间选择时,应使用这一更窄的路由判定;`status === 'running'` 还涵盖接纳收尾与轮次结算阶段。 - `agent.cancel(cause, options?)`:取消活动轮次,并在未设置 `options.keepInbox` 时取消全部待处理工作。调用方必须显式选择 `user | parent` 原因;活动持有者会在中止前把其判别字段复制为已分离、冻结的信号原因。有效调用会在清除排队与 steering 工作前,随原因发出 `agent/cancel-requested`;丢弃项在 `agent/inbox/discard` 上报告,观察方可以同步状态,但不能 veto 取消。`keepInbox: true` 会中止轮次,但保留排队与 steering 项(不丢弃,且不删除尚未开始的工作)。同进程类型化 seam 不会为无类型调用方添加运行时校验或兼容回退。重复取消活动轮次时,首个信号生效;空闲取消是安全空操作,不发通知。ACP 映射到 `user`,进程内父传播映射到 `parent`。原因只存在于运行时;持久 `turn/end` 保持粗粒度的 `aborted`。 - `agent.whenIdle()`:agent 从 `running` 结算后达到静默时解析(idle ⇒ 立即;disposed ⇒ 等待循环退出)。这是非拥有者的静默观测钩子:观察工作结算,但不 teardown agent。Teardown 独立存在;生命周期拥有者通过 `AgentHandle.dispose()` 停止并注销,并直接等待循环退出。 - `agent.session`、`agent.status`、`agent.options`、`agent.id` diff --git a/packages/core/agent/src/invariant.ts b/packages/core/agent/src/invariant.ts index 5051ac4e31..f2d9a69539 100644 --- a/packages/core/agent/src/invariant.ts +++ b/packages/core/agent/src/invariant.ts @@ -21,27 +21,6 @@ const install: InvariantInstaller = (ctx, fail) => { } lastStatus.set(agent, status) }, { global: true }) - - // Inbox FIFO conservation: an item leaves the inbox (dequeue) or is dropped - // (discard) only after it entered (enqueue), so the live outstanding count - // per agent can never go negative. Injection bypasses the FIFOs entirely and - // never appears on these events. - const outstanding = new WeakMap() - ctx.on('agent/inbox/enqueue', (agent) => { - outstanding.set(agent, (outstanding.get(agent) ?? 0) + 1) - }, { global: true }) - ctx.on('agent/inbox/dequeue', (agent) => { - const count = outstanding.get(agent) ?? 0 - if (count <= 0) fail('agent/inbox/dequeue without a matching prior enqueue') - outstanding.set(agent, count - 1) - }, { global: true }) - ctx.on('agent/inbox/discard', (agent, items) => { - const count = outstanding.get(agent) ?? 0 - if (items.length > count) { - fail(`agent/inbox/discard dropped ${items.length} items but only ${count} were outstanding`) - } - outstanding.set(agent, count - items.length) - }, { global: true }) } /** diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 28b4ca4c71..a7512037c4 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -7,8 +7,9 @@ import type { Context } from 'cordis' import type { Scoped } from '@deepseek-ai/dsh-scope' -import type { ContentBlock, LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' -import type { Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session' +import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' +import type { AgentCancelCause, Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session' +export type { AgentCancelCause } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { @@ -27,95 +28,55 @@ export interface AgentOptions { maxTokens?: number } -/** - * Which inbox queue a {@link Agent.send} item joins: - * - `next-turn` — the item becomes its own turn, claimed at a turn boundary. - * - `next-step` — during prompt admission or an open turn, the item stages for - * the next safe step boundary; otherwise it is promoted per its `wakeup` - * flag. - */ -export type SendTarget = 'next-turn' | 'next-step' - -/** Resolved inbox placement reported when an accepted message is enqueued. */ -export type InboxPlacement = 'queued' | 'steering' - -/** - * Options for the unified {@link Agent.send} primitive over the - * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} - * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and - * {@link Agent.inject} (`next-step`/no-wakeup). - * - * The object is complete so routing policy is explicit. - */ -export interface SendOptions { - /** Queue the item joins. */ - target: SendTarget - /** - * Whether this item makes the model run: wake a parked driver (`next-turn`) - * or force a continuation step (`next-step` while running). A `false` - * `next-turn` item queues without waking; a `false` - * `next-step` item attaches durable context without forcing another step - * (the injection preset). - */ - wakeup: boolean -} - /** Options for {@link Agent.cancel}. */ export interface CancelOptions { /** * Preserve queued and steering inbox items instead of discarding them. The * active turn is still aborted, but un-started and pending work survives for a - * later turn and no `agent/inbox/discard` fires. + * later turn and no `agent/inbox/canceled` fires. */ - keepInbox?: boolean + keepInbox?: boolean | undefined } /** * An agent's lifecycle state, emitted on every transition as `agent/status`: - * `idle` (parked, waiting for queued work), `running` (the driver is draining - * work and may be closing or checkpointing a turn). Disposal removes the - * agent from its registry; it is not a third observable status. + * `idle` means no driver is scheduled or active; `running` begins when a + * cancellable admission is scheduled and lasts while the driver drains, + * closes, or checkpoints turns. Disposal removes the agent from its registry; + * it is not a third observable status. */ export type AgentStatus = 'idle' | 'running' /** - * Prompt interception result. `allow.content` replaces the prompt, while - * `additionalContexts` appends model-facing context before the turn starts. - * An `allow` returned by a listener is authoritative: a listener wrapping - * `next()` preserves both fields unless it intentionally replaces them. + * Prompt interception result. An allowed batch replaces the submitted + * messages. A listener wrapping `next()` preserves the returned batch unless + * it intentionally replaces it. */ export type PromptDecision = - | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: UserMessage[] } - | { kind: 'block'; reason: string } + | { kind: 'allow'; messages: UserMessage[] } + | { kind: 'block'; reason: string; keepInbox?: boolean } -/** Model-request failure with an optional machine-routable provider code. */ -export type RequestError = Error & { code?: string } +/** 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 -/** - * Why a turn ended, reported live on `agent/settled` right after the turn's - * durable `turn/end`. `error` carries the thrown value verbatim for observers; - * model-request recovery runs earlier through `agent/request-error`. - */ -export type SettleReason = - | { kind: 'completed' } - | { kind: 'aborted' } - | { kind: 'error'; error: unknown; failure?: LlmFailure } - /** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */ export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' -/** Stable runtime cause accepted by {@link Agent.cancel}. */ -export type AgentCancelCause = - | { readonly kind: 'user' } - | { readonly kind: 'parent' } - -/** Runtime reason carried by the signal that controls one live turn. */ -export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' } - -/** Public live-agent handle with aliases over the unified delivery primitive. */ +/** Public live-agent handle. */ export interface Agent { /** The single identity shared with {@link session}. */ readonly id: SessionId @@ -125,77 +86,46 @@ export interface Agent { readonly session: Session /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus - /** - * Whether a `next-step` send currently stages for prompt admission or the - * open turn. Unlike {@link status}, this excludes admission exit and turn - * settlement, when a waking `next-step` send becomes a queued follow-up. - */ - readonly acceptsNextStep: boolean /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context - /** - * The unified delivery primitive over the (`target` × `wakeup`) matrix. - * It routes the caller's typed content and source as follows: - * - * - `next-turn` queues an item that becomes the sole ordinary message of its - * own FIFO-ordered turn; `wakeup:true` wakes a - * parked driver, while `wakeup:false` queues without waking. - * - `next-step` with `wakeup:true` stages steering during prompt admission - * or an open turn; outside that window it falls back to a woken - * `next-turn`. - * - `next-step` with `wakeup:false` injects durable model-facing context - * without running the model: admission or an open turn stages it for the - * next safe log position, while an injection outside that window appends - * immediately without opening a turn. If admission closes without a turn, - * a context-only boundary appends immediately; context staged beside - * steering remains pending with it. - * The agent publishes or queues the identified frozen message as-is. - * @param message - identified model-facing content and its producer provenance. - * @param options - target queue and wakeup decision. - */ - send(message: UserMessage, options: SendOptions): void - /** * Clear queued and steering work — unless `keepInbox` — and abort the active - * turn. An effective call first emits `agent/cancel-requested` with the - * resolved typed cause. The first cause wins for the active turn, and - * `whenIdle()` resolves after cancellation reaches quiescence. Idle - * cancellation is a no-op and does not arm later work. + * turn. The first cause wins for the active turn. Idle cancellation is a + * no-op and does not arm later work. * @param cause - the stable caller intent carried by the current turn signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ cancel(cause: AgentCancelCause, options?: CancelOptions): void - /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ + /** + * Resolve after the current whole-agent activity reaches quiescence. This + * follows replacement work scheduled before the observed driver retires, + * but does not identify the settlement of any particular message. + * @returns fulfillment after no scheduled or active driver remains. + */ whenIdle(): Promise /** - * Queue an ordinary follow-up turn and wake the driver — the - * `next-turn`/wakeup preset of {@link send}. The item becomes the sole - * ordinary message of its own turn. + * Queue an ordinary follow-up turn and wake the driver. The item becomes the + * sole ordinary message of its own turn. * @param message - identified prompt content and its producer provenance. */ followup(message: UserMessage): void /** - * Submit steering during prompt admission or an open turn — the - * `next-step`/wakeup preset of {@link send}. It stages for the next steering - * checkpoint before a request or stop decision. If the activity fails before - * that boundary, the remainder stays staged without waking the agent; retry - * or a later prompt takes it. Outside that window steering falls back to a - * woken follow-up turn, while cancellation or disposal may discard pending - * steering. + * Submit steering for the nearest step. An idle driver schedules a turn; + * collecting and running drivers consume it at their next step boundary. + * Cancellation or disposal may discard pending steering. * @param message - identified steering content and its producer provenance. */ steer(message: UserMessage): void /** - * Append model-facing context without running the model — the - * `next-step`/no-wakeup preset of {@link send}. Admission or an open turn - * stages it at the next safe log position; outside that window it appends - * immediately without opening a turn. If admission closes without a turn, - * a context-only boundary appends immediately; context staged beside + * Append model-facing context without running the model. Admission or an + * open turn stages it at the next safe log position; outside that window it + * appends immediately without opening a turn. If admission closes without a + * turn, a context-only boundary appends immediately; context staged beside * steering remains pending with it. * @param message - identified injected context and its producer provenance. */ @@ -226,8 +156,9 @@ declare module 'cordis' { */ 'agent/disposed'(this: Scoped, agent: Agent): void /** - * Agent status changed (`idle` ⇄ `running`). `send()` does not enter - * `running` synchronously; drive lifecycle from this event. + * 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). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. @@ -235,56 +166,23 @@ declare module 'cordis' { */ 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void /** - * An item entered the queued or steering inbox. `placement` is the - * acceptance-time routing result; listeners must not reconstruct it from - * later agent or session state. - * @param agent - the owning agent. - * @param message - accepted content, source, and correlation identity. - * @param placement - resolved queued or steering placement. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode emit - */ - 'agent/inbox/enqueue'(this: Scoped, agent: Agent, message: UserMessage, placement: InboxPlacement): void - /** - * The driver claimed one item out of the inbox: a queued item at a turn - * boundary, or steering drained between steps. Fires after the item leaves - * its FIFO and before it becomes a durable message. + * The driver admitted one inbox item for model-visible history. * @param agent - the agent whose inbox item was claimed. - * @param message - the claimed message. - * @param placement - the FIFO that claimed this occurrence; together with - * `message.id`, it matches the earliest outstanding enqueue in that FIFO. + * @param message - the admitted message. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/dequeue'( - this: Scoped, - agent: Agent, - message: UserMessage, - placement: InboxPlacement, - ): void + 'agent/inbox/admitted'(this: Scoped, agent: Agent, message: UserMessage): void /** - * Pending inbox items were dropped without delivering them, so every - * enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR - * `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal, - * emits this after `agent/cancel-requested` when applicable and before - * aborting the active work. Fires once per drop with every dropped item. + * One pending inbox item was dropped without entering model-visible + * history. `cancel()` without `keepInbox`, including disposal, emits this + * once for each dropped item before aborting active work. * @param agent - the agent whose inbox items were dropped. - * @param messages - the discarded messages in FIFO order (queued then steering); never empty. + * @param message - the dropped message. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/discard'(this: Scoped, agent: Agent, messages: UserMessage[]): void - /** - * Effective broad cancellation was requested, before queued/outbox work - * is cleared or the active turn is aborted. This observe-only notification - * cannot veto cancellation; listener failures are contained. - * @param agent - the agent whose current work is being cancelled. - * @param cause - the explicit typed cancellation cause. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode emit - */ - 'agent/cancel-requested'(this: Scoped, agent: Agent, cause: AgentCancelCause): void - + 'agent/inbox/canceled'(this: Scoped, agent: Agent, message: UserMessage): void // ---- session lifecycle (emit) ---- /** * The session lifecycle began, once before the first turn. Use @@ -300,17 +198,17 @@ declare module 'cordis' { // ---- the machine's extension seams ---- /** - * Allow, rewrite, or block one claimed prompt before it becomes a user - * message or opens a turn. Call `next()` for the unchanged default. The + * Allow, rewrite, or block one claimed inbox batch before it becomes + * model-visible or opens a turn. Call `next()` for the unchanged default. The * signal controls only this admission attempt; listeners may cooperate with * it but must not retain it for a later attempt or turn. - * @param agent - the agent whose turn claimed the message. - * @param message - the frozen claimed message, including identity and source. + * @param agent - the agent whose driver claimed the batch. + * @param messages - the claimed messages. * @param 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/prompt-submit'(this: Scoped, agent: Agent, message: UserMessage, signal: AbortSignal, next: () => Promise): Promise + 'agent/prompt-submit'(this: Scoped, agent: Agent, messages: UserMessage[], signal: AbortSignal, next: () => Promise): Promise /** * Awaited serial checkpoint before EVERY request of a turn is built (the * first as well as each post-tools continuation). The single "between @@ -338,24 +236,17 @@ declare module 'cordis' { */ 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise): Promise /** - * Handle a model-request failure after its failed step has closed but - * before the failed turn closes. A listener returns `{ kind: 'retry' }` - * without calling `next()` when it owns the error, or calls `next()` to - * delegate. The default `undefined` leaves the failure terminal. + * 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 turn - the open turn number. - * @param step - the failed step number. - * @param error - the original model-request failure. - * @param failure - serializable facts normalized at the final adapter boundary. - * @param priorFailures - immutable failures that already authorized another - * retry turn in this consecutive sequence. - * @param retryPolicy - immutable policy of the adapter registration that served - * the failed request, or `undefined` if no final adapter served it. + * @param context - request coordinates, provider, normalized failure, and serving policy. * @param 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, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], retryPolicy: ResolvedRetryPolicy | undefined, signal: AbortSignal, next: () => Promise): Promise + 'agent/request-error'(this: Scoped, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise): Promise /** * The turn is about to close: the model owes no response (no live tool * calls, no fresh steering). Awaited before the boundary commits — a @@ -371,21 +262,6 @@ declare module 'cordis' { * @mode serial */ 'agent/turn-stopping'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | void - /** - * One drain chain reached its terminal turn: that turn's `turn/end` is - * already committed. Automatically recovered failed turns do not emit this - * notification, and neither does a run that aborts or fails before its - * `turn/start` commits — there is no durable turn to settle against. - * `reason` says why; model-request recovery is exhausted when an error - * reaches it. - * @param agent - the agent whose turn closed. - * @param turn - the terminal turn number. - * @param reason - why the terminal turn ended, with live error facts when it failed. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode emit - */ - 'agent/settled'(this: Scoped, agent: Agent, turn: number, reason: SettleReason): void - // ---- error notifications (emit) ---- /** * A step or turn errored. The machine reports a failure here (plus the @@ -400,3 +276,10 @@ declare module 'cordis' { 'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: unknown): void } } + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** One message was accepted into the agent inbox. */ + 'agent/inbox/added': UserMessage + } +} diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index bd560c7c99..b2178aea7e 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -1,6 +1,5 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context, Service, symbols } from 'cordis' -import type { Events } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, @@ -21,14 +20,11 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { options: {}, session: new Session(id), status: 'idle', - acceptsNextStep: false, ctx: new Context(), - send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, - whenIdle() { return Promise.resolve() }, } return Object.assign(agent, overrides) } @@ -188,7 +184,6 @@ describe('agentEvents()', () => { describe('explicit cancellation contract', () => { it('exposes the closed typed cancellation cause at the Agent seam', () => { expectTypeOf[0]>().toEqualTypeOf() - expectTypeOf[1]>().toEqualTypeOf() }) }) diff --git a/packages/core/agent/tsdown.config.ts b/packages/core/agent/tsdown.config.ts index e92275a7f5..3a0934ccf8 100644 --- a/packages/core/agent/tsdown.config.ts +++ b/packages/core/agent/tsdown.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from 'tsdown' -/** Build the package root and optional invariant companion as independent bundles. */ +/** Build the package root and companions as independent bundles. */ export default defineConfig([ { entry: ['lib/types/index.js'], diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts index 89515bf3c2..cdcf706fd2 100644 --- a/packages/core/scope/src/scoped-events.generated.ts +++ b/packages/core/scope/src/scoped-events.generated.ts @@ -8,18 +8,15 @@ type ScopedSubjectResolver = (args: readonly unknown[]) => unknown const scopedSubjectResolvers: Readonly> = Object.freeze({ - 'agent/cancel-requested': args => args[0], 'agent/created': args => args[0], 'agent/disposed': args => args[0], 'agent/error': args => args[0], - 'agent/inbox/dequeue': args => args[0], - 'agent/inbox/discard': args => args[0], - 'agent/inbox/enqueue': args => args[0], + 'agent/inbox/admitted': args => args[0], + 'agent/inbox/canceled': args => args[0], 'agent/prompt-submit': args => args[0], 'agent/request': args => args[0], 'agent/request-error': args => args[0], 'agent/session-start': args => args[0], - 'agent/settled': args => args[0], 'agent/status': args => args[0], 'agent/step': args => args[0], 'agent/turn-stopping': args => args[0], diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index 54f2e1e17b..da30844c2a 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -51,7 +51,6 @@ describe('scoped-dispatch invariants', () => { 'agent/inbox/enqueue': [agent, message, 'queued'], 'agent/inbox/dequeue': [agent, message, 'queued'], 'agent/inbox/discard': [agent, []], - 'agent/cancel-requested': [agent, { kind: 'user' }], 'agent/session-start': [agent, 'startup'], 'agent/step': [agent, 1, 1, signal], 'agent/prompt-submit': [agent, message, signal, () => Promise.resolve({ kind: 'allow' })], @@ -68,7 +67,6 @@ describe('scoped-dispatch invariants', () => { () => Promise.resolve(undefined), ], 'agent/turn-stopping': [agent, 1, signal], - 'agent/settled': [agent, 1, { kind: 'completed' }], 'agent/error': [agent, 1, 0, new Error('x')], } satisfies { [K in AgentEventName]: EventArgs } const rows: Array<[string, unknown[]]> = [ diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 9fb097d9fb..56895397fc 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: a9b6905dcf2b8ef1f75595e567273f7a3150a412 -README.zh.md: f1a5e97e32d1ad1abcd6ad96e6c621af9972e989 +README.md: af93791dfc17f66b79b376ba32ec657761ec63bc +README.zh.md: ed9bba76d307a764a20c7cc4a3d2716c55a1acc0 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index a9b6905dcf..af93791dfc 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -14,7 +14,6 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall - `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`. - `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject. -- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest log event because between-turn records and non-message turns have no prompt outcome. - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index f1a5e97e32..ed9bba76d3 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -14,7 +14,6 @@ - `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。 - `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。 -- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果。 - `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。 - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index e42414503b..5ff000f7fa 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -30,27 +30,6 @@ export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from ' export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts' -/** - * Find the latest closed message-triggered turn, ignoring other triggers and - * between-turn events. - * @param events - session events, or an owned suffix, to inspect. - * @returns the latest matching turn end, or `undefined`. - */ -export function findLastMessageTurnEnd( - events: readonly SessionEvent[], -): SessionEvent<'turn/end'> | undefined { - const messageTurns = new Set() - let latest: SessionEvent<'turn/end'> | undefined - for (const event of events) { - if (event.type === 'turn/start') { - if (event.data.trigger.kind === 'message') messageTurns.add(event.data.turn) - continue - } - if (event.type === 'turn/end' && messageTurns.delete(event.data.turn)) latest = event - } - return latest -} - declare module 'cordis' { interface Context { sessions: SessionStore diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 7f3a12b54e..495791f410 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -3,8 +3,6 @@ import type { AssistantMessage, CallId, LlmCallConfig, - LlmFailure, - MessageSource, StreamChunk, TokenUsage, ToolResultMessage, @@ -87,24 +85,12 @@ export interface CreateSessionOptions { } } -/** - * What started a turn. - * Merge-extensible sum type (same pattern as MessageSourceMap). - */ -export interface TurnTriggerMap { - message: { kind: 'message'; source: MessageSource } - /** Recovery turn reopened over the repaired current session log. */ - retry: { kind: 'retry' } - /** - * An out-of-band producer explicitly enclosed injected context in a one-shot - * turn. `Agent.inject()` appends idle context directly and does not use this - * trigger; the source mirrors the producer of the enclosed `user/message`. - */ - injection: { kind: 'injection'; source: MessageSource } -} - -/** The union over {@link TurnTriggerMap} — what started a turn; plugins extend it by merging variants into the map. */ -export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap] +/** Why an active agent driver was cancelled. */ +export type AgentCancelCause = + | { readonly kind: 'user' } + | { readonly kind: 'parent' } + | { readonly kind: 'hook'; readonly reason: string } + | { readonly kind: 'disposed' } /** * Why a turn ended. Merge-extensible sum type. @@ -112,20 +98,11 @@ export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap] export interface TurnEndReasonMap { completed: { kind: 'completed' } /** A cancellation request interrupted the live turn. */ - aborted: { kind: 'aborted' } + aborted: { kind: 'aborted'; reason: AgentCancelCause } /** - * The turn failed: a step threw or the model reported a failure. `step` is the - * step number the failure occurred on (the operational error's location — the - * single durable record of an in-turn failure; live diagnostics also fire via - * `agent/error`). Final model-request failures retain their normalized facts - * as one `failure`; other thrown values retain their rendered message and a - * real `HarnessError` code when present. + * The turn failed. */ - error: { kind: 'error'; step: number } & ( - | { failure: LlmFailure; message?: never; code?: never } - | { message: string; code?: string; failure?: never } - ) - disposed: { kind: 'disposed' } + error: { kind: 'error'; error: unknown } /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } /** @@ -185,9 +162,11 @@ export type RequestHeaderReason = 'initial' | 'resume' | 'change' */ export interface SessionEventMap { /** - * Opens turn `turn`. `trigger` records what started the model loop. + * Opens turn `turn`. Every turn begins when the loop admits queued input; + * the following identified `user/message` event or batch records the + * admitted input. */ - 'turn/start': { turn: number; trigger: TurnTrigger } + 'turn/start': { turn: number } /** * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop * awaits `session/flush` after an ordinary turn ends before claiming the next diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index 2c87c0d98e..f44ca3be02 100644 --- a/packages/core/session/tests/derived-cache.spec.ts +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -22,7 +22,7 @@ function scratch(session: Session): unknown { describe('derived-message cache', () => { it('stays deep-equal to a from-scratch replay derivation as the log grows', () => { const session = new Session(SessionId('cache-grow')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) userText(session, 'one') expect(session.deriveMessages()).toEqual(scratch(session)) userText(session, 'two') @@ -55,7 +55,7 @@ describe('derived-message cache', () => { it('rebuilds on a surface replace and still matches scratch', () => { const session = new Session(SessionId('cache-replace')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) userText(session, 'one') userText(session, 'two') const beforeReplace = session.deriveMessages() @@ -73,7 +73,7 @@ describe('derived-message cache', () => { it('returns a fresh array per call: later appends never grow a held snapshot', () => { const session = new Session(SessionId('cache-snapshot')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) userText(session, 'one') const first = session.deriveMessages() userText(session, 'two') @@ -90,7 +90,7 @@ describe('derived-message cache', () => { describe('Session.deriveEventMessage — the per-event projection', () => { it('projects one appended event exactly as the full derivation projects its node', () => { const session = new Session(SessionId('per-event')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const event = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -100,7 +100,7 @@ describe('Session.deriveEventMessage — the per-event projection', () => { it('reuses the logged event\'s already frozen content', () => { const session = new Session(SessionId('per-event-clone')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const event = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -114,7 +114,7 @@ describe('Session.deriveEventMessage — the per-event projection', () => { it('projects null for events that produce no message (boundaries, empty assistant)', () => { const session = new Session(SessionId('per-event-null')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const boundary = session.append('step/start', { turn: 1, step: 1 }) expect(session.deriveEventMessage(boundary)).toBeNull() const empty = session.append('assistant/message', { diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index a317ec565b..dc22ec6512 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -22,7 +22,7 @@ function appendClosedTurn( text = `hello ${turn}`, reason: TurnEndReason = { kind: 'completed' }, ): void { - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' }, @@ -31,7 +31,7 @@ function appendClosedTurn( } function appendOpenTurn(session: Session, turn: number): void { - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `open ${turn}` }], source: { kind: 'user' }, @@ -205,23 +205,23 @@ describe('SessionStore.fork', () => { const { ctx, sessions } = await setup() const cases: [string, (session: Session) => number][] = [ ['turn/start', (session) => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) return lastSeq(session) }], ['step/start', (session) => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) return lastSeq(session) }], ['user/message', (session) => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'open' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) return lastSeq(session) }], ['assistant/message', (session) => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('assistant/message', { turn: 1, step: 1, @@ -238,7 +238,7 @@ describe('SessionStore.fork', () => { }], ['tool/call', (session) => { const callId = CallId('call-open') - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('assistant/message', { turn: 1, @@ -279,7 +279,7 @@ describe('SessionStore.fork', () => { it('rejects a duplicate child session id before validating the boundary', async () => { const { ctx, sessions } = await setup() const source = ctx.sessions.create(SessionId('open-parent')) - source.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + source.append('turn/start', { turn: 1 }) ctx.sessions.create(SessionId('child')) expect(() => sessions.fork(source, undefined, SessionId('child'))) diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts index aa0cd5bd64..d4b71e0da2 100644 --- a/packages/core/session/tests/invariant.spec.ts +++ b/packages/core/session/tests/invariant.spec.ts @@ -26,7 +26,7 @@ describe('session-log invariants', () => { await scopedCtx.plugin(SessionInvariant) const session = ctx.sessions.create(SessionId('global-under-scoped-invariants')) expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }).not.toThrow() }) @@ -35,7 +35,7 @@ describe('session-log invariants', () => { const { ctx } = await setup() const session = ctx.sessions.create() expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -78,11 +78,10 @@ describe('session-log invariants', () => { }) expect(() => session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, })).toThrow('later dispatch veto') expect(session.events).toEqual([]) expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }).not.toThrow() }) @@ -94,7 +93,7 @@ describe('session-log invariants', () => { const session = ctx.sessions.create(SessionId('postcommit-peer')) ctx.on('session/event', () => { throw new Error('hostile observer') }, { prepend: true }) expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }).not.toThrow() expect(warnings).toHaveLength(2) @@ -107,7 +106,7 @@ describe('session-log invariants', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, } as never) expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, { type: 'turn/end', @@ -120,16 +119,16 @@ describe('session-log invariants', () => { it('enforces turn numbering and core execution enclosure', async () => { const first = await setup() const open = first.ctx.sessions.create() - open.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) + open.append('turn/start', { turn: 1 }) + expect(() => open.append('turn/start', { turn: 2 })) .toThrow(/turn 1 is still open/) expect(() => open.append('turn/end', { turn: 2, reason: { kind: 'completed' } })) .toThrow(/does not match open turn 1/) const second = (await setup()).ctx.sessions.create() - second.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + second.append('turn/start', { turn: 1 }) second.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - expect(() => second.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })) + expect(() => second.append('turn/start', { turn: 3 })) .toThrow(/expected turn 2, got 3/) const outside = (await setup()).ctx.sessions.create() @@ -149,17 +148,16 @@ describe('session-log invariants', () => { expect(() => { appendUnknown('plugin/marker', {}) }).not.toThrow() expect(() => outside.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, })).not.toThrow() }) it('enforces open-step identity and numbering', async () => { const wrongTurn = (await setup()).ctx.sessions.create() - wrongTurn.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + wrongTurn.append('turn/start', { turn: 1 }) expect(() => wrongTurn.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/) const nested = (await setup()).ctx.sessions.create() - nested.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + nested.append('turn/start', { turn: 1 }) nested.append('step/start', { turn: 1, step: 1 }) expect(() => nested.append('step/start', { turn: 1, step: 2 })).toThrow(/while step 1 is still open/) expect(() => nested.append('turn/end', { turn: 1, reason: { kind: 'completed' } })) @@ -179,7 +177,7 @@ describe('session-log invariants', () => { }, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step 1/) const skipped = (await setup()).ctx.sessions.create() - skipped.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + skipped.append('turn/start', { turn: 1 }) skipped.append('step/start', { turn: 1, step: 1 }) skipped.append('step/end', { turn: 1, step: 1 }) expect(() => skipped.append('step/start', { turn: 1, step: 3 })) @@ -188,7 +186,7 @@ describe('session-log invariants', () => { it('requires step-scoped stream and tool events to name the open step', async () => { const chunk = (await setup()).ctx.sessions.create() - chunk.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + chunk.append('turn/start', { turn: 1 }) expect(() => chunk.append('assistant/chunk', { turn: 1, step: 1, @@ -196,7 +194,7 @@ describe('session-log invariants', () => { })).toThrow(/open is turn 1\/step null/) const tool = (await setup()).ctx.sessions.create() - tool.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + tool.append('turn/start', { turn: 1 }) tool.append('step/start', { turn: 1, step: 1 }) expect(() => tool.append('tool/result', { turn: 1, @@ -212,7 +210,7 @@ describe('session-log invariants', () => { it('keeps fresh tool-result appends open-step checked', async () => { const { ctx } = await setup() const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) expect(() => session.append('tool/result', { turn: 1, step: 1, @@ -227,7 +225,7 @@ describe('session-log invariants', () => { it('treats a validated tool-result replacement as a turn-enclosed rewrite', async () => { const { ctx } = await setup() const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('tool/call', { turn: 1, @@ -248,7 +246,7 @@ describe('session-log invariants', () => { session.append('step/end', { turn: 1, step: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 2 }) expect(() => session.append('tool/result', { ...original.data, message: freezeMessage({ @@ -267,7 +265,7 @@ describe('session-log invariants', () => { it('rejects a tool-result replacement outside a turn', async () => { const { ctx } = await setup() const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('tool/call', { turn: 1, @@ -306,7 +304,7 @@ describe('session-log invariants', () => { it('allows not-started repair results and unresolved calls at step end', async () => { const repaired = (await setup()).ctx.sessions.create() expect(() => { - repaired.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + repaired.append('turn/start', { turn: 1 }) repaired.append('step/start', { turn: 1, step: 1 }) repaired.append('tool/result', { turn: 1, @@ -324,7 +322,7 @@ describe('session-log invariants', () => { const unresolved = (await setup()).ctx.sessions.create() expect(() => { - unresolved.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + unresolved.append('turn/start', { turn: 1 }) unresolved.append('step/start', { turn: 1, step: 1 }) unresolved.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' }) unresolved.append('step/end', { turn: 1, step: 1 }) @@ -335,7 +333,7 @@ describe('session-log invariants', () => { it('does not let a result in a later step satisfy an earlier call', async () => { const { ctx } = await setup() const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' }) session.append('step/end', { turn: 1, step: 1 }) @@ -354,22 +352,22 @@ describe('session-log invariants', () => { it('replays seeded sessions and tracks each session independently', async () => { const { ctx } = await setup() const badSeed = [ - { type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, - { type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, + { type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1 } }, + { type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2 } }, ] expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(InvariantError) const a = ctx.sessions.create(SessionId('a')) const b = ctx.sessions.create(SessionId('b')) - a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - expect(() => b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })) + a.append('turn/start', { turn: 1 }) + expect(() => b.append('turn/start', { turn: 1 })) .not.toThrow() }) it('rebuilds trace state for sessions that exist when the companion reloads', async () => { const { ctx, fiber } = await setup() const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) await fiber.dispose() await ctx.plugin(SessionInvariant) @@ -378,18 +376,17 @@ describe('session-log invariants', () => { step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' }, })).not.toThrow() - expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) + expect(() => session.append('turn/start', { turn: 2 })) .toThrow(/turn 1 is still open/) }) it('removes all listeners when the companion is disposed', async () => { const { ctx, fiber } = await setup() const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) await fiber.dispose() expect(() => session.append('turn/start', { turn: 2, - trigger: { kind: 'message', source: { kind: 'user' } }, })).not.toThrow() }) }) diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts index 1449f3684e..365408861d 100644 --- a/packages/core/session/tests/properties.spec.ts +++ b/packages/core/session/tests/properties.spec.ts @@ -70,7 +70,7 @@ const messageEventArb: fc.Arbitrary = fc.oneof( // A non-message event (trace/replay data — must NOT affect derived history). const nonMessageEventArb: fc.Arbitrary = fc.oneof( - fc.constant({ type: 'turn/start', data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), + fc.constant({ type: 'turn/start', data: { turn: 1 } }), fc.constant({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }), fc.constant({ type: 'step/start', data: { turn: 1, step: 1 } }), fc.constant({ type: 'step/end', data: { turn: 1, step: 1 } }), diff --git a/packages/core/session/tests/repair.spec.ts b/packages/core/session/tests/repair.spec.ts index 645d7d6921..941819c0af 100644 --- a/packages/core/session/tests/repair.spec.ts +++ b/packages/core/session/tests/repair.spec.ts @@ -13,7 +13,7 @@ import type { SessionEvent, SurfaceEvent } from '../src/index.ts' */ const userTurnStart = (turn: number, seq: number): SessionEvent => - ({ type: 'turn/start', seq, time: seq, data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) + ({ type: 'turn/start', seq, time: seq, data: { turn } }) describe('interruptedTurnClosers', () => { it('returns nothing for a balanced log (ends on turn/end)', () => { diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index 53c76a5298..f795a8a79c 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -45,7 +45,7 @@ describe('foldRequestHeader', () => { it('returns the supplied baseline when no snapshot follows', () => { const from: EpochHeader = { config: CONFIG, system: 'baseline' } const unrelated: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, ] expect(foldRequestHeader(unrelated)).toBeUndefined() expect(foldRequestHeader(unrelated, from)).toBe(from) @@ -53,7 +53,7 @@ describe('foldRequestHeader', () => { it('takes the latest full snapshot and skips unrelated events', () => { const session = new Session(SessionId('fold')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('request/header', { header: { config: CONFIG, system: 'first' }, reason: 'initial' }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, diff --git a/packages/core/session/tests/scoped.spec.ts b/packages/core/session/tests/scoped.spec.ts index 7a5e617254..ad58f07ddd 100644 --- a/packages/core/session/tests/scoped.spec.ts +++ b/packages/core/session/tests/scoped.spec.ts @@ -40,7 +40,7 @@ describe('session dispatch carriers', () => { otherScope.ctx.on('session/created', session => void heard.push(`other-created:${session.id}`)) const session = scope.ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) expect(heard).toEqual([ `owner-created:${session.id}`, @@ -57,7 +57,7 @@ describe('session dispatch carriers', () => { scope.ctx.on('session/event', (_s, event) => void heard.push(`owner:${event.type}`)) const bare = ctx.sessions.create() - bare.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + bare.append('turn/start', { turn: 1 }) expect(heard).toEqual(['global:turn/start']) }) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 7152dd5d41..ab4c84b1e7 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -2,7 +2,6 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import { createUserMessage, CallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import SessionStore, { - findLastMessageTurnEnd, SESSION_FORMAT_VERSION, Session, SessionEvent, @@ -22,7 +21,7 @@ describe('Session', () => { it('derives message history from the event log', () => { const session = new Session(SessionId('s1')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -62,7 +61,7 @@ describe('Session', () => { // The max-tokens TurnEndReason variant carries no extra data, so it must // append and persist like any other reason (JSON-serializable, no fields). const session = new Session(SessionId('s1')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } }) const turnEnd = session.events.findLast(e => e.type === 'turn/end')! @@ -71,45 +70,9 @@ describe('Session', () => { expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' }) }) - it('finds the latest message-turn outcome past later non-message turns', () => { - const session = new Session(SessionId('message-turn-outcome')) - expect(findLastMessageTurnEnd(session.events)).toBeUndefined() - session.append('turn/start', { - turn: 1, - trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } }, - }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'before' }], - source: { kind: 'plugin', plugin: 'before' }, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - expect(findLastMessageTurnEnd(session.events)).toBeUndefined() - - session.append('turn/start', { - turn: 2, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'bounded prompt' }], - source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - const messageEnd = session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } }) - session.append('turn/start', { - turn: 3, - trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'after' } }, - }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'after' }], - source: { kind: 'plugin', plugin: 'after' }, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn: 3, reason: { kind: 'completed' } }) - - expect(findLastMessageTurnEnd(session.events)).toBe(messageEnd) - }) - it('round-trips the coarse aborted turn outcome', () => { const session = new Session(SessionId('aborted')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } }) const replayed = new Session(SessionId('aborted-replay'), structuredClone(session.events)) expect(replayed.events).toEqual(session.events) @@ -121,7 +84,7 @@ describe('Session', () => { const legacy = [ { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }, { type: 'turn/end', seq: 1, time: 2, @@ -169,7 +132,7 @@ describe('Session', () => { it('replays identically from a seeded event log', () => { const original = new Session(SessionId('s3')) - original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + original.append('turn/start', { turn: 1 }) original.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -346,13 +309,13 @@ describe('Session', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }) expect(boundary).toEqual({ type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }) const extended = snapshotSessionEvent({ @@ -463,7 +426,7 @@ describe('Session', () => { it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => { const session = new Session(SessionId('s5b')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) // A widened SessionEventType bypasses the overload's conditional requirement, // so the runtime guard must still reject the missing surface marker. const widenedType = 'user/message' as SessionEventType @@ -492,7 +455,7 @@ describe('Session', () => { it('validates seed events: rejects a non-contiguous seq', () => { const gapSeed = [ - { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, + { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } }, { type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, reason: { kind: 'completed' as const } } }, // gap: expected seq 1 ] as SessionEvent[] expect(() => new Session(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/) @@ -504,7 +467,7 @@ describe('Session', () => { // so a resume/fork would silently lose history. append() forbids this at // compile time; a raw seed must be rejected at runtime to match. const markerlessSeed = [ - { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, + { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } }, { type: 'user/message' as const, seq: 1, time: 2, data: createUserMessage({ content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const }, }) }, @@ -515,7 +478,7 @@ describe('Session', () => { it('accepts a well-formed contiguous serializable seed', () => { const goodSeed = [ - { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, + { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } }, { type: 'user/message' as const, seq: 1, time: 2, data: createUserMessage({ content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const }, }), surfaceOp: 'append' as const }, @@ -530,7 +493,7 @@ describe('Session', () => { type: 'turn/start' as const, seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } }, + data: { turn: 1 }, } const drifted = { ...accepted, seq: 99, data: { invalid: 1n } } let reads = 0 @@ -606,7 +569,7 @@ describe('Session', () => { readonly type = 'turn/start' as const readonly seq = 0 readonly time = 1 - readonly data = { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } + readonly data = { turn: 1 } } const seed: SessionEvent[] = [new SeedEvent()] @@ -619,7 +582,7 @@ describe('Session', () => { type: 'turn/start' as const, seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } }, + data: { turn: 1 }, }) as unknown as SessionEvent const session = new Session(SessionId('seed-null-prototype'), [event]) @@ -701,7 +664,7 @@ describe('Session', () => { it('snapshots the seed: mutating the original after construction does not affect session.events', () => { const seed = [ - { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, + { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } }, { type: 'user/message' as const, seq: 1, time: 2, data: { id: MessageId('seed-input'), role: 'user' as const, @@ -852,14 +815,14 @@ describe('Session', () => { expect(() => appendRaw( 'turn/start', - { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + { turn: 1 }, { surfaceOp: 'append' }, )).toThrow(/not surface-eligible and cannot carry surfaceOp/) expect(() => new Session(SessionId('non-surface-metadata-seed'), [{ type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, surfaceOp: 'append', } as unknown as SessionEvent])).toThrow(/invalid seed event.*not surface-eligible/) expect(session.events).toEqual([]) @@ -870,13 +833,12 @@ describe('Session', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }]) const seededEvent = seeded.events[0]! if (seededEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start') expect(Object.isFrozen(seededEvent)).toBe(true) expect(Object.isFrozen(seededEvent.data)).toBe(true) - expect(Object.isFrozen(seededEvent.data.trigger)).toBe(true) expect(() => { seededEvent.data.turn = 99 }).toThrow(TypeError) const appended = new Session(SessionId('append-frozen')) @@ -892,7 +854,7 @@ describe('Session', () => { it('returns cached frozen event-array snapshots that do not grow after append', () => { const session = new Session(SessionId('events-snapshot')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const before = session.events const beforeEvent = before[0]! if (beforeEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start') @@ -989,7 +951,7 @@ describe('Session', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, } const cases: unknown[] = [ { ...base, extra: true }, @@ -1028,7 +990,7 @@ describe('SessionStore', () => { // may create an unrelated property with the old implementation's name, // but cannot suppress the durable event feed. expect(Reflect.set(session, 'onAppend', undefined)).toBe(true) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -1046,7 +1008,7 @@ describe('SessionStore', () => { const a = ctx.sessions.create(SessionId('fixed')) expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists') - a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + a.append('turn/start', { turn: 1 }) a.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -1295,7 +1257,7 @@ describe('SessionStore', () => { ctx.on('session/event', (_session, event) => void events.push(event)) const session = ctx.sessions.create(SessionId('fixed')) expect(ctx.sessions.get(SessionId('fixed'))).toBe(session) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -1321,7 +1283,6 @@ describe('SessionStore', () => { expect(() => { appended = session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) }).not.toThrow() expect(committedBeforeNotify).toBe(true) @@ -1360,14 +1321,12 @@ describe('SessionStore', () => { expect(() => session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, })).toThrow('reject first candidate') expect(session.events).toEqual([]) expect(observed).toEqual([]) const appended = session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) expect(validations.map(({ logLength, frozen }) => ({ logLength, frozen }))).toEqual([ { logLength: 0, frozen: true }, @@ -1383,7 +1342,7 @@ describe('SessionStore', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('surface-dispatch-veto')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'source' }], @@ -1438,7 +1397,6 @@ describe('SessionStore', () => { expect(() => session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, })).toThrow('dispatch instrumentation rejected the carrier') expect(session.events).toEqual([]) expect(observed).toEqual([]) @@ -1458,7 +1416,6 @@ describe('SessionStore', () => { const appended = session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) expect(session.events).toEqual([appended]) expect(heard).toEqual([appended]) @@ -1489,7 +1446,6 @@ describe('SessionStore', () => { const appended = session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) expect(session.events).toEqual([appended]) @@ -1640,7 +1596,7 @@ describe('todo/write event', () => { it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => { const original = new Session(SessionId('t4')) - original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + original.append('turn/start', { turn: 1 }) original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] }) original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) // Seeding a non-surface event with no surfaceOp must not throw. diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 4078f526e6..c4901f113e 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -19,7 +19,7 @@ import { /** Build a minimal session with turn boundaries and a single user message. */ function surfaceSession(): Session { const s = new Session(SessionId('ss')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('turn/start', { turn: 1 }) s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -93,7 +93,7 @@ describe('foldSurface provenance', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, sourceEventSeqs: [0], } as unknown as SessionEvent expect(() => foldSurface([event])).toThrow(/cannot carry sourceEventSeqs/) @@ -378,7 +378,7 @@ describe('SurfaceManager', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, surfaceOp: 'append', } as unknown as SessionEvent @@ -396,7 +396,7 @@ describe('SurfaceManager', () => { it('empty surface yields empty nodes', () => { const s = new Session(SessionId('empty')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('turn/start', { turn: 1 }) s.append('step/start', { turn: 1, step: 1 }) s.append('step/end', { turn: 1, step: 1 }) s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -665,7 +665,7 @@ describe('deriveMessages with surface', () => { it('surface path skips non-surface events (chunks, boundaries)', () => { const s = new Session(SessionId('filter')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('turn/start', { turn: 1 }) s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } }) s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'i' } }) s.append('user/message', createUserMessage({ @@ -731,7 +731,7 @@ describe('deriveMessages with surface', () => { describe('Session.append surface opts', () => { it('records sourceEventSeqs and surfaceOp on the event', () => { const s = new Session(SessionId('opts')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('turn/start', { turn: 1 }) s.append('step/start', { turn: 1, step: 1 }) const event = s.append('assistant/message', { @@ -759,7 +759,7 @@ describe('Session.append surface opts', () => { // but _deriveOneMessage returns null for it, so the surface derivation path's // null-check is exercised — the node is on the surface yet produces no message. const seed: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, { type: 'assistant/message', seq: 2, time: 3, data: { turn: 1, step: 1, @@ -782,7 +782,7 @@ describe('Session.append surface opts', () => { it('a non-surface event carries no surface fields', () => { const s = new Session(SessionId('noopts')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('turn/start', { turn: 1 }) expect((s.events[0] as SessionEvent).sourceEventSeqs).toBeUndefined() expect((s.events[0] as SessionEvent).surfaceOp).toBeUndefined() }) @@ -816,7 +816,7 @@ describe('Session.append surface opts', () => { } expect(isSurfaceEvent(noMarker)).toBe(false) // A non-surface type is rejected too (the type gate). - const boundary: SessionEvent = { type: 'turn/start', seq: 1, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } + const boundary: SessionEvent = { type: 'turn/start', seq: 1, time: 1, data: { turn: 1 } } expect(isSurfaceEvent(boundary)).toBe(false) // A properly-marked surface event narrows. const marked = { ...noMarker, surfaceOp: 'append' } as SurfaceEvent @@ -866,7 +866,7 @@ describe('surface type guards', () => { describe('SurfaceManager.replaceGeneration', () => { it('folds the pending log delta on access and counts replaces', () => { const s = new Session(SessionId('gen')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('turn/start', { turn: 1 }) s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'one' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) diff --git a/packages/core/tools/tests/invariant.spec.ts b/packages/core/tools/tests/invariant.spec.ts index f703934a5d..80ae299da7 100644 --- a/packages/core/tools/tests/invariant.spec.ts +++ b/packages/core/tools/tests/invariant.spec.ts @@ -98,7 +98,7 @@ describe('tool-pipeline invariants', () => { arguments: {}, } expect(() => session.append('tool/code-dispatch-start', data)).toThrow(/outside any open turn/) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) expect(() => session.append('tool/code-dispatch-start', data)).not.toThrow() session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }) @@ -107,7 +107,7 @@ describe('tool-pipeline invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('tool/code-dispatch', { parentCallId: CallId('parent'), subCallId: CallId('child'), diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 5a6f89525c..8f555c0238 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -163,7 +163,6 @@ describe('dsh-agent-spine-demo bundle', () => { const session = ctx.sessions.create(SessionId('configured-title-limits')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'One two three four' }], @@ -206,8 +205,8 @@ describe('dsh-agent-spine-demo bundle', () => { it('mounts package companions and forwards invariant selection config', async () => { const nestedTurn = (ctx: Context): void => { const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) + session.append('turn/start', { turn: 2 }) } const enabled = await mount({ workspaceContext: false }) diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index 370379fb55..fc77b11331 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -388,14 +388,14 @@ describe('runOneShot and executeCli', () => { if (subject !== agent || injected) return injected = true agent.inject(createUserMessage({ content: [{ type: 'text', text: 'startup injection' }], source: { kind: 'plugin', plugin: 'test' } })) - other.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } } }) + other.append('turn/start', { turn: 1 }) other.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }) const output = await invoke(ctx, ['--output-format', 'stream-json', 'task']) const lines = output.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record) const events = lines.slice(0, -1).map(line => line['event'] as SessionEvent) expect(lines.at(-1)).toMatchObject({ type: 'result', success: true, turn: 1, result: 'streamed' }) - expect(events[0]).toMatchObject({ type: 'turn/start', data: { turn: 1, trigger: { kind: 'message' } } }) + expect(events[0]).toMatchObject({ type: 'turn/start', data: { turn: 1 } }) expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 1 } }) expect(lines.slice(0, -1).every(line => line['sessionId'] === agent.session.id)).toBe(true) expect(events.some(event => event.type === 'user/message' @@ -510,11 +510,9 @@ describe('formatTurnFailure', () => { it('diagnoses every durable reason and preserves merge-extensible unknowns', () => { const cases: [TurnEndReason, string][] = [ [{ kind: 'completed' }, 'completed'], - [{ kind: 'aborted' }, 'was aborted'], - [{ kind: 'aborted' }, 'was aborted'], - [{ kind: 'error', step: 2, message: 'bad' }, 'failed at step 2: bad'], - [{ kind: 'error', step: 3, failure: { message: 'provider bad', code: 'SERVER' } }, 'failed at step 3: provider bad'], - [{ kind: 'disposed' }, 'was disposed'], + [{ kind: 'aborted', reason: { kind: 'user' } }, 'was aborted'], + [{ kind: 'error', error: new Error('bad') }, 'failed: bad'], + [{ kind: 'error', error: { message: 'provider bad', code: 'SERVER' } }, 'provider bad'], [{ kind: 'max-tokens' }, 'output-token limit'], [{ kind: 'interrupted' }, 'persistence recovery'], ] diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 61734ada01..ed3a1f6b2d 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -20,7 +20,7 @@ interface Harness { function appendInjection(session: Session, input: UserMessage): void { const lastStart = session.events.findLast(event => event.type === 'turn/start') const turn = (lastStart?.data.turn ?? 0) + 1 - session.append('turn/start', { turn, trigger: { kind: 'injection', source: input.source } }) + session.append('turn/start', { turn }) session.append('user/message', input, { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } @@ -37,7 +37,6 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } ctx: new Context(), get status() { return status }, get acceptsNextStep() { return status === 'running' }, - send: () => {}, followup: () => {}, steer: () => {}, inject(input) { appendInjection(session, input) }, diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index 31dbe9c4e6..fe2f01b0e4 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -9,7 +9,7 @@ import type { Context } from 'cordis' import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent' import type { GoalMessageSource, GoalRef, GoalView } from '@deepseek-ai/dsh-goal' import { createUserMessage, assertNever } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' import { classifyGoalRound } from './outcome.ts' import type { GoalRoundOutcome } from './outcome.ts' @@ -33,6 +33,7 @@ interface RoundIdentity { /** One queued or admitted attempt, retained until its physical turn settles. */ interface RoundAttempt extends RoundIdentity { + readonly messageId: MessageId readonly content: ContentBlock[] phase: 'queued' | 'admitted' turn: number | undefined @@ -214,10 +215,15 @@ export function apply(ctx: Context): void { const round = goal.roundsStarted + 1 const content = renderGoalRoundPrompt(goal, round) + const message = createUserMessage({ + content, + source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round }, + }) const reservation: RoundAttempt = { goalId: goal.id, revision: goal.revision, round, + messageId: message.id, content, phase: 'queued', turn: undefined, @@ -226,7 +232,7 @@ export function apply(ctx: Context): void { } state.attempt = reservation try { - agent.followup(createUserMessage({ content, source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round } })) + agent.followup(message) } catch (error: unknown) { state.attempt = undefined ctx.logger.warn(`goal-session: could not queue round ${round} for agent "${agent.id}": ${renderThrown(error)}`) @@ -277,9 +283,8 @@ export function apply(ctx: Context): void { }) } - // One composite effect owns every listener and the quiescent close. Cordis - // unloads sibling effects concurrently; nesting makes the close run first - // and keeps the admission fence installed until its drain settles. + // One composite effect keeps the admission fence installed until this + // plugin's own scheduling tasks settle. ctx.effect(function* () { /** Mark a post-turn persistence failure before idle scheduling can run. */ ctx.on('agent/error', (agent, turn) => { @@ -304,40 +309,21 @@ export function apply(ctx: Context): void { const state = stateFor(agent) if (status === 'idle') { state.competingQueued = false + const attempt = state.attempt + const goal = currentGoal(state) + if (attempt !== undefined && attempt.turn === undefined && attempt.reason === undefined + && goal?.phase === 'active' && goal.activation === 'armed') { + state.attempt = undefined + try { + applyOutcome(state, goal, { kind: 'pause', reason: 'cancelled' }) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`) + disarm(state) + } + } requestDrive(state) } }) - ctx.on('agent/inbox/enqueue', (agent, info) => { - const state = stateFor(agent) - const attempt = state.attempt - if (attempt !== undefined && sameQueued(info.content, info.source, attempt)) return - state.competingQueued = true - if (attempt?.phase === 'queued') attempt.stale = true - }) - ctx.on('agent/cancel-requested', (agent, cause) => { - const state = stateFor(agent) - const attempt = state.attempt - state.competingQueued = false - const goal = currentGoal(state) - if (goal?.phase === 'active' && goal.activation === 'armed') { - if (attempt === undefined) { - disarm(state) - return - } - // An admitted round closes durably as aborted; retain it so the normal - // turn outcome path appends pause after cancellation reaches idle. - // Pausing here would stage context into the active outbox only for this - // same cancel() call to discard it. - if (attempt.turn !== undefined || attempt.phase === 'admitted') return - state.attempt = undefined - try { - applyOutcome(state, goal, { kind: 'pause', reason: cause.kind }) - } catch (error: unknown) { - ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`) - disarm(state) - } - } - }) ctx.on('goal/changed', (agent) => { const state = stateFor(agent) state.needsCheckpoint = true @@ -349,35 +335,22 @@ export function apply(ctx: Context): void { if (agent === undefined || agent.session !== session) return const state = stateFor(agent) switch (event.type) { - case 'turn/start': + case 'agent/inbox/added': { + const attempt = state.attempt + const { content, source } = event.data + if (attempt !== undefined && sameQueued(content, source, attempt)) return + state.competingQueued = true + if (attempt?.phase === 'queued') attempt.stale = true + return + } + case 'turn/start': { state.openTurn = event.data.turn - switch (event.data.trigger.kind) { - case 'message': - if (state.attempt !== undefined && isGoalRoundSource(event.data.trigger.source) - && sameRound(event.data.trigger.source, state.attempt)) { - state.attempt.turn = event.data.turn - } - return - case 'retry': - // A recovery policy (llm-retry) closed the round's failed turn - // and reopened its history: the attempt rides the retry turn, - // and the failed turn's provisional reason no longer settles - // the round — the retry's own outcome does. - if (state.attempt !== undefined && state.attempt.reason !== undefined - && state.attempt.reason.kind === 'error') { - state.attempt.turn = event.data.turn - state.attempt.reason = undefined - } - return - default: - // Injection and merge-extensible plugin triggers cannot admit a queued goal message. - return - } + return + } case 'user/message': - if (state.attempt !== undefined && isGoalRoundSource(event.data.source) - && sameRound(event.data.source, state.attempt)) { + if (state.attempt !== undefined && event.data.id === state.attempt.messageId) { state.attempt.phase = 'admitted' - /* v8 ignore next -- this driver's admitted message always follows its observed turn/start */ + /* v8 ignore next -- the loop logs admitted input inside an open turn */ if (state.openTurn !== undefined) state.attempt.turn = state.openTurn } return @@ -407,8 +380,10 @@ export function apply(ctx: Context): void { && source.round === goal.roundsStarted + 1 } - ctx.on('agent/prompt-submit', async (agent, message, _signal, next): Promise => { - const { content, source } = message + ctx.on('agent/prompt-submit', async (agent, messages, _signal, next): Promise => { + const submitted = messages.find(message => isGoalRoundSource(message.source)) + if (submitted === undefined) return next() + const { content, source } = submitted if (!isGoalRoundSource(source)) return next() const state = stateFor(agent) let valid = false @@ -494,7 +469,6 @@ export function apply(ctx: Context): void { if (attempt.phase === 'admitted' && state.agent.status === 'running') { state.agent.cancel({ kind: 'parent' }) } - waits.push(state.agent.whenIdle()) } if (state.run !== undefined) waits.push(state.run) } diff --git a/packages/goal/goal-session/src/outcome.ts b/packages/goal/goal-session/src/outcome.ts index e138bf2030..29615231a0 100644 --- a/packages/goal/goal-session/src/outcome.ts +++ b/packages/goal/goal-session/src/outcome.ts @@ -28,15 +28,17 @@ export function classifyGoalRound(reason: TurnEndReason, durable: boolean): Goal case 'aborted': return { kind: 'pause', reason: 'cancelled' } case 'error': { - const { code, message } = reason.failure ?? reason + const error = reason.error + const code = typeof error === 'object' && error !== null && 'code' in error + ? error.code + : undefined + const message = error instanceof Error ? error.message : String(error) return code === 'RATE_LIMIT' || code === 'QUOTA' ? { kind: 'blocked', code: 'usage-limited', message } : { kind: 'blocked', code: 'turn-error', message } } case 'max-tokens': return { kind: 'blocked', code: 'max-tokens', message: 'model output reached max tokens' } - case 'disposed': - return { kind: 'disarm', reason: 'disposed' } case 'interrupted': return { kind: 'disarm', reason: 'interrupted' } // TurnEndReason is merge-extensible. An unknown producer cannot opt into diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index 6a11633384..6766f25e95 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -12,13 +12,6 @@ import { SessionId } from '@deepseek-ai/dsh-session' import type { TurnEndReason } from '@deepseek-ai/dsh-session' import * as goalSession from '../src/index.ts' -declare module '@deepseek-ai/dsh-session' { - interface TurnTriggerMap { - /** Test-only plugin turn with no message source. */ - 'test-metadata': { kind: 'test-metadata' } - } -} - type ScriptEntry = StreamChunk[] | Error | 'hang' | ((options: GenerateOptions) => StreamChunk[]) /** Small request-recording adapter with controllable failure and cancellation. */ @@ -334,31 +327,6 @@ describe('same-session goal driving', () => { expect(requestText(test.adapter.requests[1]!)).toContain('') }) - it('ignores plugin-owned turn triggers while a goal round is queued', async () => { - const test = await harness([textResponse('goal answer')]) - const warnings: string[] = [] - test.ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof test.ctx.logger.warn - let inserted = false - test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return - inserted = true - const lastStart = agent.session.events.findLast(event => event.type === 'turn/start') - const turn = (lastStart?.data.turn ?? 0) + 1 - agent.session.append('turn/start', { - turn, - trigger: { kind: 'test-metadata' }, - }) - agent.session.append('turn/end', { turn, reason: { kind: 'completed' } }) - }) - test.ctx.goals.create(test.agent, { objective: 'ignore metadata', maxGoalRounds: 1 }) - - await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked') - - expect(inserted).toBe(true) - expect(test.adapter.requests).toHaveLength(1) - expect(warnings.some(warning => warning.includes('session/event listener threw'))).toBe(false) - }) - it('makes a reserved round stale when a listener queues human work behind it', async () => { const test = await harness([textResponse('human batch'), textResponse('later goal')]) let inserted = false @@ -908,8 +876,7 @@ describe('same-session goal driving', () => { let queued = false test.ctx.on('session/event', (session, event) => { if (session !== test.agent.session || queued) return - if (event.type === 'turn/start' && event.data.trigger.kind === 'message' - && event.data.trigger.source.kind === 'goal') { + if (event.type === 'user/message' && event.data.source.kind === 'goal') { queued = true test.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human interleaved' }], source: { kind: 'user' } })) } @@ -997,7 +964,6 @@ describe('same-session goal driving', () => { const orphan = test.ctx.sessions.create(SessionId('goal-session-orphan')) orphan.append('turn/start', { turn: 1, - trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } }, }) orphan.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) diff --git a/packages/goal/goal-session/tests/invariant.spec.ts b/packages/goal/goal-session/tests/invariant.spec.ts index 4303f79c20..e35f87dfc8 100644 --- a/packages/goal/goal-session/tests/invariant.spec.ts +++ b/packages/goal/goal-session/tests/invariant.spec.ts @@ -41,7 +41,7 @@ function view(roundsStarted: number): GoalView { } function appendChange(session: Session): void { - session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: renderGoalChange(change), source: changeSource, @@ -51,7 +51,7 @@ function appendChange(session: Session): void { function appendRound(session: Session, turn: number, content = renderGoalRoundPrompt(view(turn - 2), turn - 1)): void { const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: turn - 1 } as const - session.append('turn/start', { turn, trigger: { kind: 'message', source } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content, source, }), { surfaceOp: 'append' }) @@ -82,7 +82,7 @@ describe('goal-session prompt invariants', () => { ctx.sessions.create(SessionId('goal-session-invariant-dispatch')) const userSource = { kind: 'user' } as const - session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: userSource } }) + session.append('turn/start', { turn: 4 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'ordinary human message' }], source: userSource, @@ -90,7 +90,7 @@ describe('goal-session prompt invariants', () => { session.append('turn/end', { turn: 4, reason: { kind: 'completed' } }) const stateSource = { ...changeSource, round: 0 } as const - session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: stateSource } }) + session.append('turn/start', { turn: 5 }) expect(() => { session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'round zero is not a driver continuation' }], @@ -114,7 +114,7 @@ describe('goal-session prompt invariants', () => { it('rejects a goal round without a reconstructable active goal', async () => { const { session } = await mount() const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 } as const - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source } }) + session.append('turn/start', { turn: 1 }) expect(() => { session.append('user/message', createUserMessage({ @@ -128,7 +128,7 @@ describe('goal-session prompt invariants', () => { it('attributes an invalid durable prefix during late loading', async () => { const { ctx, session } = await mount(true) - session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'counterfeit goal state' }], source: changeSource, diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index c681c307e5..806f546a83 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -47,7 +47,6 @@ function stubAgentForSession(session: Session): StubAgent { ctx: new Context(), get status() { return status }, get acceptsNextStep() { return status === 'running' }, - send: () => {}, followup: () => {}, steer: () => {}, inject(input) { @@ -88,7 +87,7 @@ async function harness(config: { defaultMaxGoalRounds?: number } = {}) { function appendRound(session: Session, ref: GoalRef, round: number): void { const source = { kind: 'goal', goalId: ref.id, revision: ref.revision, round } as const const turn = nextTurn(session) - session.append('turn/start', { turn, trigger: { kind: 'message', source } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `round ${round}` }], source, }), { surfaceOp: 'append' }) @@ -503,7 +502,7 @@ describe('GoalService mutations', () => { } const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change } as const const turn = nextTurn(session) - session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: renderGoalChange(change), source, }), { surfaceOp: 'append' }) @@ -584,7 +583,7 @@ describe('goal replay validation', () => { change, } const turn = nextTurn(session) - session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: overrides.content ?? renderGoalChange(change), source, @@ -640,7 +639,7 @@ describe('goal replay validation', () => { expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 }) const source = { kind: 'plugin', plugin: 'ordinary-user-message' } as const const turn = nextTurn(session) - session.append('turn/start', { turn, trigger: { kind: 'message', source } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'ordinary' }], source, }), { surfaceOp: 'append' }) @@ -782,7 +781,7 @@ describe('goal replay validation', () => { const session = new Session(SessionId('goal-source-without-meta')) const source = { kind: 'goal', goalId: GoalId('goal-missing-meta'), revision: 1, round: 0 } as const const turn = nextTurn(session) - session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'missing' }], source, }), { surfaceOp: 'append' }) @@ -850,7 +849,7 @@ describe('goal replay validation', () => { } const source = { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0, change: clear } as const const turn = nextTurn(session) - session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) + session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: renderGoalChange(clear), source, }), { surfaceOp: 'append' }) diff --git a/packages/goal/goal/tests/invariant.spec.ts b/packages/goal/goal/tests/invariant.spec.ts index 2e953ce1e6..805c98fdcf 100644 --- a/packages/goal/goal/tests/invariant.spec.ts +++ b/packages/goal/goal/tests/invariant.spec.ts @@ -46,7 +46,7 @@ describe('goal stream invariants', () => { it('accepts canonical goal snapshots and sequential admitted rounds', async () => { const ctx = await setup() const session = ctx.sessions.create(SessionId('goal-invariant-valid')) - session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: renderGoalChange(change), source: changeSource, @@ -70,7 +70,7 @@ describe('goal stream invariants', () => { it('rejects model-visible drift before committing it and keeps the fold reusable', async () => { const ctx = await setup() const session = ctx.sessions.create(SessionId('goal-invariant-invalid')) - session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) + session.append('turn/start', { turn: 1 }) expect(() => { session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'counterfeit' }], @@ -93,7 +93,7 @@ describe('goal stream invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('goal-invariant-late-load')) - session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: renderGoalChange(change), source: changeSource, diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 75c01295ce..27488a313e 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -38,7 +38,6 @@ function liveAgent(ctx: Context, session: Session): Agent { ctx, get status() { return status }, get acceptsNextStep() { return false }, - send: () => {}, followup: () => {}, steer: () => {}, inject(input: UserMessage) { @@ -145,7 +144,7 @@ describe('goal projection unit', () => { // A non-message event (the registry drives EVERY committed event through // apply): early same-reference return. - const turnStart = { type: 'turn/start', seq: 3, time: 4, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never + const turnStart = { type: 'turn/start', seq: 3, time: 4, data: { turn: 1 } } as never expect(applyGoalProjection(state, turnStart)).toBe(state) // A round-zero goal source whose change carries a foreign kind: same posture. diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 1293b8642b..4bf4ef3c2c 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -32,7 +32,6 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { get status() { return status }, get acceptsNextStep() { return status === 'running' }, ctx: new Context(), - send: () => {}, followup: () => {}, steer: () => {}, inject(input) { @@ -49,7 +48,7 @@ function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): numb const turn = stub.session.events .filter(event => event.type === 'turn/start') .reduce((max, event) => Math.max(max, event.data.turn), 0) + 1 - stub.session.append('turn/start', { turn, trigger: { kind: 'message', source } }) + stub.session.append('turn/start', { turn }) stub.session.append('user/message', createUserMessage({ content: [{ type: 'text', text }], source, diff --git a/packages/hooks/hook-protocol/tests/invariant.spec.ts b/packages/hooks/hook-protocol/tests/invariant.spec.ts index 5092758e74..4efaae36c6 100644 --- a/packages/hooks/hook-protocol/tests/invariant.spec.ts +++ b/packages/hooks/hook-protocol/tests/invariant.spec.ts @@ -30,7 +30,7 @@ const result = (overrides: Record = {}) => ({ }) function startTurn(session: Session, turn = 1): void { - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) } describe('hook-protocol invariants', () => { @@ -49,7 +49,7 @@ describe('hook-protocol invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('hook/invoked', invoked()) await ctx.plugin(InvariantService) await ctx.plugin(HookInvariant) @@ -63,7 +63,7 @@ describe('hook-protocol invariants', () => { expect(() => { ctx.emit('session/event', session, { type: 'turn/start', seq: 0, time: 0, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }) ctx.emit('session/event', session, { type: 'hook/invoked', seq: 1, time: 1, data: invoked(), diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 8552598818..bd3d979a14 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -197,6 +197,11 @@ export function apply(ctx: Context, config: Config): void { return [ours, ...theirs ?? []] } + /** Append hook context to an admitted inbox batch. */ + function appendPromptContext(theirs: UserMessage[], ours: UserMessage): UserMessage[] { + return [...theirs, ours] + } + // 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. @@ -213,8 +218,9 @@ export function apply(ctx: Context, config: Config): void { // --- UserPromptSubmit → PromptDecision. The prompt text is the payload; no // matcher subject (CC ignores matchers for this event). --- - ctx.on('agent/prompt-submit', async (agent, message, signal, next): Promise => { - const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, message.content), { agent, signal }) + ctx.on('agent/prompt-submit', async (agent, messages, signal, next): Promise => { + const content = messages.flatMap(message => message.content) + const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, signal }) if (merged.decision === 'deny') { return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' } } @@ -225,8 +231,7 @@ export function apply(ctx: Context, config: Config): void { if (!ours || downstream.kind !== 'allow') return downstream return { kind: 'allow', - ...downstream.content !== undefined ? { content: downstream.content } : {}, - additionalContexts: prependContext(ours, downstream.additionalContexts), + messages: appendPromptContext(downstream.messages, ours), } }) diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index d68e2b9d0a..19d1c43f7d 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -182,6 +182,11 @@ export function apply(ctx: Context, config: Config): void { return [ours, ...theirs ?? []] } + /** Append hook context to an admitted inbox batch. */ + function appendPromptContext(theirs: UserMessage[], ours: UserMessage): UserMessage[] { + return [...theirs, ours] + } + // SessionStart injects plain stdout when its detached hook resolves; a slow // hook may miss the first request. // TODO(session-start-gating): add a startup gate before promising first-turn delivery. @@ -196,11 +201,11 @@ export function apply(ctx: Context, config: Config): void { }) // UserPromptSubmit → PromptDecision. Codex supports block, not allow or ask. - ctx.on('agent/prompt-submit', async (agent, message, signal, next): Promise => { + ctx.on('agent/prompt-submit', async (agent, messages, signal, next): Promise => { const payload = { ...base(ctx, agent, 'UserPromptSubmit', model), turn_id: String(lastTurn(agent) + 1), - prompt: blocksToText(message.content), + prompt: blocksToText(messages.flatMap(message => message.content)), } const merged = await runPoint('UserPromptSubmit', '', payload, { agent, plainStdoutAsContext: true, signal }) /* jscpd:ignore-start */ @@ -212,8 +217,7 @@ export function apply(ctx: Context, config: Config): void { if (!ours || downstream.kind !== 'allow') return downstream return { kind: 'allow', - ...downstream.content !== undefined ? { content: downstream.content } : {}, - additionalContexts: prependContext(ours, downstream.additionalContexts), + messages: appendPromptContext(downstream.messages, ours), } }) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 765982862e..226abb1706 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -9,7 +9,7 @@ import { join } from 'node:path' import type { Context } from 'cordis' import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' import type { - Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxPlacement, + Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, } from '@deepseek-ai/dsh-agent' import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { errorChain } from '@deepseek-ai/dsh-llm' @@ -474,38 +474,38 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro * inbox event retires one matching occurrence, so repeated sends of the same * identified message remain visible until every occurrence is claimed. */ - const queuedMirror = new Map() + const queuedMirror = new Map() ctx.effect(() => { - const retire = (agent: Agent, id: MessageId, placement?: InboxPlacement): void => { + const retire = (agent: Agent, id: MessageId): void => { const entries = queuedMirror.get(agent.id) if (entries === undefined) return - const index = entries.findIndex(entry => - entry.message.id === id - && (placement === undefined || entry.steering === (placement === 'steering'))) + const index = entries.findIndex(message => message.id === id) if (index !== -1) entries.splice(index, 1) if (entries.length === 0) queuedMirror.delete(agent.id) } const disposers = [ - ctx.on('agent/inbox/enqueue', (agent: Agent, message: UserMessage, placement) => { + ctx.on('session/event', (session: Session, event: SessionEvent) => { + if (event.type !== 'agent/inbox/added') return + const agent = ctx.agents.get(session.id) + if (agent === undefined || agent.session !== session) return + const message = event.data let entries = queuedMirror.get(agent.id) if (entries === undefined) { entries = [] queuedMirror.set(agent.id, entries) } - const steering = placement === 'steering' - entries.push({ message, steering }) + entries.push(message) broadcast({ type: 'session/queued', sessionId: agent.id, message, - steering, }) }), - ctx.on('agent/inbox/dequeue', (agent: Agent, message: UserMessage, placement) => { - retire(agent, message.id, placement) + ctx.on('agent/inbox/admitted', (agent: Agent, message: UserMessage) => { + retire(agent, message.id) }), - ctx.on('agent/inbox/discard', (agent: Agent, messages: UserMessage[]) => { - for (const message of messages) retire(agent, message.id) + ctx.on('agent/inbox/canceled', (agent: Agent, message: UserMessage) => { + retire(agent, message.id) }), ctx.on('session/disposed', (session: Session) => { queuedMirror.delete(session.id) @@ -1338,12 +1338,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // in arrival order per session; a reconnecting client rebuilds its // queue view from these alone. for (const [sessionId, entries] of queuedMirror) { - for (const entry of entries) { + for (const message of entries) { queue.push(frame({ type: 'session/queued', sessionId, - message: entry.message, - steering: entry.steering, + message, })) } } diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 4e729b4c41..ff7c4c2b67 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -42,7 +42,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [ // and must fail loud here, not reach the composer. z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema).min(1) }), z.object({ type: z.literal('question/resolved'), sessionId: sessionIdSchema, questionRpcId: rpcIdSchema, outcome: z.union([z.literal('answered'), z.literal('cancelled')]) }), - z.object({ type: z.literal('session/queued'), sessionId: sessionIdSchema, message: messageSchema, steering: z.boolean() }), + z.object({ type: z.literal('session/queued'), sessionId: sessionIdSchema, message: messageSchema }), // value stays wide: it already passed its unit's own schema on the host, // and deep-validating here would import every domain's schema into the carrier. z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 9f56fc1dd5..464e4376a9 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -68,12 +68,10 @@ export type MuxFrame = * host replays the current queue snapshot for every attached session (same * refresh-recovery baseline as pending questions); queue clearing on cancel * has no dedicated frame — clients fold it from the status flip. - * `steering` is the host's acceptance-time queue classification and remains - * authoritative in reconnect snapshots. `message.source` carries the prompt's rpcId - * when the message came over this wire (the client's provisional-echo - * reconciliation key). + * `message.source` carries the prompt's rpcId when the message came over + * this wire (the client's provisional-echo reconciliation key). */ - | { type: 'session/queued'; sessionId: SessionId; message: Message; steering: boolean } + | { type: 'session/queued'; sessionId: SessionId; message: Message } /** * One projection unit's finished value changed (session-projection RFC). * Live push state, never logged — replay recomputes on the host (the diff --git a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts index e5bd8bfbee..e9236f3fb9 100644 --- a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts @@ -71,7 +71,7 @@ describe('summary blank = conversation not started', () => { const session = ctx.sessions.create() attach(session) appendStandalone(session) - session.append('turn/start', { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 0 }) expect(await listBlank(api, session.id)).toBe(false) }) }) diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index bcfc067ff5..4916f93e32 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -225,7 +225,7 @@ describe('session/projection push frame', () => { seedMessages(session, 1) // Same-reference apply: turn/start does not concern the unit — no frame. - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) seedMessages(session, 1) const frames = await collected diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts index b970755d6b..b0cc61b3fa 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -83,7 +83,7 @@ describe('mux live view computation', () => { const rawResult = `RAW_RESULT:${'x'.repeat(64 * 1024)}` const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' }) @@ -146,7 +146,7 @@ describe('mux live view computation', () => { // history resolves the agent first; a live structural stub is enough (only // .session is read on this path). ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-term'), name: 'term', arguments: '{"cmd":"ls"}' }) // meta rides through to presentResult's ToolResult (the spread arm). session.append('tool/result', { @@ -217,7 +217,7 @@ describe('mux live view computation', () => { const fiber = await ctx.plugin(Object.assign((inner: Context) => { session = inner.sessions.create('session-doomed' as SessionId) }, { inject: ['sessions'] })) - session?.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session?.append('turn/start', { turn: 1 }) session?.append('tool/call', { turn: 1, step: 1, callId: CallId('c-doomed'), name: 'term', arguments: '{"cmd":"x"}' }) // Disposing the owning fiber detaches the session mid-stream; the // session/disposed listener must clear its open-call table entry. @@ -236,7 +236,7 @@ describe('mux live view computation', () => { const collected = collect(stream, 4, abort) const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) // The turn/end above cleared the live table; pairing must fall back to diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 3968cba5c7..7b9b149943 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -50,7 +50,6 @@ function stubAgent(session: Session): Agent { followup: () => {}, steer: () => {}, inject: () => {}, - send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 7871fa8a93..0af1bbf385 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -1,5 +1,5 @@ /** - * Provider-routed model-request retry policy on the agent loop's closed-step + * Provider-routed model-request retry policy on the agent loop's request * recovery seam. Each scheduled retry is durable before its cancellable wait. * * @module @deepseek-ai/dsh-llm-retry @@ -7,14 +7,13 @@ import type { Context } from 'cordis' import z from 'schemastery' -import type { Agent, RequestError, RequestErrorAction } from '@deepseek-ai/dsh-agent' +import type { Agent, RequestErrorAction, RequestFailureContext } from '@deepseek-ai/dsh-agent' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { providerForClosedStep } from './history.ts' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { - /** Durable, non-surface record of one provider-routed retry scheduled after a closed failed step. */ + /** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */ 'llm/retry': { turn: number step: number @@ -172,24 +171,12 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna async function recover( agent: Agent, - turn: number, - step: number, - _error: RequestError, - failure: LlmFailure, - priorFailures: readonly LlmFailure[], - policy: ResolvedRetryPolicy | undefined, + context: RequestFailureContext, signal: AbortSignal, next: () => Promise, ): Promise { + const { turn, step, provider, failure, retryPolicy: policy } = context if (policy === undefined) return next() - // The call-local policy belongs to the registration that served this - // failure. Recover only the durable provider identity from the header; - // downstream recovery may append later state before an always fallback. - const provider = providerForClosedStep(agent.session.events, turn, step) - /* v8 ignore next 3 -- agent-loop closes only steps whose request header was recorded */ - if (provider === undefined) { - throw new Error(`llm-retry: no request provider for closed turn ${turn}/step ${step}`) - } if (policy.mode === 'always') { if (signal.aborted || lifetime.signal.aborted) return const fusedSignal = AbortSignal.any([signal, lifetime.signal]) @@ -211,11 +198,10 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna } const policyKey = retryPolicyKey(policy) - const firstPriorTurn = turn - priorFailures.length const priorPolicyRetry = agent.session.events.findLast((event): event is SessionEvent<'llm/retry'> => event.type === 'llm/retry' - && event.data.turn >= firstPriorTurn - && event.data.turn < turn + && event.data.turn === turn + && event.data.step === step && event.data.provider === provider && event.data.policyKey === policyKey, ) @@ -241,12 +227,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna const disposeListener = ctx.on('agent/request-error', ( agent: Agent, - turn: number, - step: number, - error: RequestError, - failure: LlmFailure, - priorFailures: readonly LlmFailure[], - policy: ResolvedRetryPolicy | undefined, + context: RequestFailureContext, signal: AbortSignal, next: () => Promise, ) => { @@ -254,7 +235,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna // removed. Lifetime cancellation must prevent that stale callback from // entering a downstream policy after disposal. if (lifetime.signal.aborted) return Promise.resolve(undefined) - return track(recover(agent, turn, step, error, failure, priorFailures, policy, signal, next)) + return track(recover(agent, context, signal, next)) }) ctx.effect(() => async () => { diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts index 03379c82d0..3b154f046c 100644 --- a/packages/llm/llm-retry/src/invariant.ts +++ b/packages/llm/llm-retry/src/invariant.ts @@ -41,34 +41,6 @@ function validateFailure(value: unknown, fail: InvariantFailure): asserts value } } -/** Find the first turn in the structured-failure retry chain containing `turn`. */ -function retryChainStart(history: readonly SessionEvent[], turn: number): number { - let startIndex = history.findLastIndex( - event => event.type === 'turn/start' && event.data.turn === turn, - ) - while (startIndex >= 0) { - const start = history[startIndex] - if (start?.type !== 'turn/start' || start.data.trigger.kind !== 'retry') break - - let endIndex = startIndex - 1 - while (endIndex >= 0 && history[endIndex]?.type !== 'turn/end') endIndex -= 1 - const end = history[endIndex] - if (end?.type !== 'turn/end' - || end.data.reason.kind !== 'error' - || end.data.reason.failure === undefined) break - - const previousStart = history.findLastIndex( - (event, index) => - index < endIndex - && event.type === 'turn/start' - && event.data.turn === end.data.turn, - ) - if (previousStart < 0) break - startIndex = previousStart - } - return startIndex -} - /** Validate one retry record against the open turn and most recently closed step. */ function validateRetry( history: readonly SessionEvent[], @@ -139,7 +111,9 @@ function validateRetry( fail(`llm/retry provider ${provider} does not match the failed request provider ${String(routedProvider)}`) } - const chainStart = retryChainStart(history, turn) + const chainStart = history.findLastIndex( + prior => prior.type === 'turn/start' && prior.data.turn === turn, + ) const chain = history.slice(Math.max(chainStart, 0)) const lastSuccess = chain.findLastIndex(prior => prior.type === 'assistant/message') const chainRetries = chain.slice(lastSuccess + 1) diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index fd35af7e24..5724b631f9 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -17,7 +17,7 @@ async function setup(): Promise { function closeStep(ctx: Context, id: string, turn = 1, step = 1) { const session = ctx.sessions.create(SessionId(id)) - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) session.append('step/start', { turn, step }) session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' } }, @@ -28,7 +28,7 @@ function closeStep(ctx: Context, id: string, turn = 1, step = 1) { } function appendRetryTurn(session: Session, turn: number) { - session.append('turn/start', { turn, trigger: { kind: 'retry' } }) + session.append('turn/start', { turn }) session.append('step/start', { turn, step: 1 }) session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' } }, @@ -73,7 +73,7 @@ describe('llm-retry invariants', () => { expect(() => { session.append('llm/retry', { turn: 1, step: 1, ...normal }) session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } }) - session.append('turn/start', { turn: 2, trigger: { kind: 'retry' } }) + session.append('turn/start', { turn: 2 }) session.append('step/start', { turn: 2, step: 1 }) session.append('step/end', { turn: 2, step: 1 }) session.append('llm/retry', { @@ -169,14 +169,14 @@ describe('llm-retry invariants', () => { }).toThrow(/open turn is 1/) const openStep = ctx.sessions.create(SessionId('retry-invariant-open-step')) - openStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + openStep.append('turn/start', { turn: 1 }) openStep.append('step/start', { turn: 1, step: 1 }) expect(() => { openStep.append('llm/retry', { turn: 1, step: 1, ...normal }) }).toThrow(/step 1 is still open/) const noStep = ctx.sessions.create(SessionId('retry-invariant-no-step')) - noStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + noStep.append('turn/start', { turn: 1 }) expect(() => { noStep.append('llm/retry', { turn: 1, step: 1, ...normal }) }).toThrow(/latest closed step is undefined/) @@ -208,7 +208,7 @@ describe('llm-retry invariants', () => { const mismatch = closeStep(ctx, 'retry-invariant-numbering') mismatch.append('llm/retry', { turn: 1, step: 1, ...normal }) mismatch.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } }) - mismatch.append('turn/start', { turn: 2, trigger: { kind: 'retry' } }) + mismatch.append('turn/start', { turn: 2 }) mismatch.append('step/start', { turn: 2, step: 1 }) mismatch.append('step/end', { turn: 2, step: 1 }) expect(() => { @@ -218,7 +218,7 @@ describe('llm-retry invariants', () => { const reset = closeStep(ctx, 'retry-invariant-reset') reset.append('llm/retry', { turn: 1, step: 1, ...normal }) reset.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } }) - reset.append('turn/start', { turn: 2, trigger: { kind: 'retry' } }) + reset.append('turn/start', { turn: 2 }) reset.append('step/start', { turn: 2, step: 1 }) reset.append('assistant/message', { turn: 2, @@ -234,7 +234,7 @@ describe('llm-retry invariants', () => { }, { surfaceOp: 'append' }) reset.append('step/end', { turn: 2, step: 1 }) reset.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - reset.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) + reset.append('turn/start', { turn: 3 }) reset.append('step/start', { turn: 3, step: 1 }) reset.append('step/end', { turn: 3, step: 1 }) expect(() => { diff --git a/packages/llm/llm-retry/tests/persistence.spec.ts b/packages/llm/llm-retry/tests/persistence.spec.ts index 2b5d0234bd..60fedbc70d 100644 --- a/packages/llm/llm-retry/tests/persistence.spec.ts +++ b/packages/llm/llm-retry/tests/persistence.spec.ts @@ -32,7 +32,7 @@ describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind) const ctx = await backend(kind) try { const session = ctx.sessions.create(SessionId(`retry-${kind}`)) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' } }, diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index a4716026cd..9e6d551144 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: d343449d1530bf70a3a8c57f883894e29c42d18f -README.zh.md: 6ac57b1e6010b58c45b516f13ec6361d47ca8d12 +README.md: dc7499a6854fe9a45c1297aa2a1a67aea92eaf6f +README.zh.md: 1f5850b6e73c067aa554636d33bfd9194a4410f3 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index d343449d15..dc7499a685 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -16,10 +16,10 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. - `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` Resolve validated exact-model identity plus available context and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters. - `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise` Validate an explicit effort and materialize an adapter-configured default without clamping. -- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` Resolve a config and capture its current adapter registration as one cancellable, one-shot call. +- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` Resolve a config and capture its current adapter registration plus immutable retry policy as one cancellable, one-shot call. - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`. -`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`; `llmRetryPolicyOf(stream)` returns the immutable policy of the exact registration selected at that boundary, even if the route is later disposed or replaced. A call that never reaches a final adapter has no serving policy. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`. +`LlmService` normalizes failures from final adapter selection, synchronous dispatch, iterator construction, and iteration into the stream protocol's single terminal form: `finish { kind: 'error' | 'aborted', failure }`. A failure after partial deltas may leave content blocks open; consumers discard that incomplete output. Errors from `llm/stream` middleware, nested calls, adapter cleanup, and downstream consumers remain thrown because they are plugin or consumer failures rather than model-request outcomes. A prepared call exposes the immutable retry policy captured with its exact adapter registration; a route handled entirely by middleware has no serving policy. Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. @@ -44,7 +44,7 @@ Reasoning identifiers are opaque adapter-owned strings rather than a core enum. Message content is an array of typed blocks: `text`, `reasoning`, `tool-call`, `tool-result`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. Assistant messages use a model source carrying provider/model provenance and optional adapter-private replay state. Before dispatch, `LlmService` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers. The core block set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map together with the adapter/UI/compaction support that honors it. -Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. +Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. ### Call configuration (`call-config.ts`) @@ -67,7 +67,7 @@ Every product adapter sends application identity on provider HTTP requests. `att ### Real adapters -Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses direct fetch with `eventsource-parser` SSE framing for the `deepseek` route, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves configured provider/model pairs through `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish, tool arguments remain raw strings, and errors take one of two sanctioned paths. See [the twin LLM adapters](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the design rationale. +Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses direct fetch with `eventsource-parser` SSE framing for the `deepseek` route, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves configured provider/model pairs through `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish and tool arguments remain raw strings. Adapter implementations may throw or emit a failure finish internally; `LlmService` exposes both as a terminal failure finish. See [the twin LLM adapters](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the adapter rationale and [the terminal-failure decision](../../../.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.md) for the service boundary. ## Model Experience diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 6ac57b1e60..1f5850b6e7 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -16,10 +16,10 @@ - `ctx.llm.listModels(provider: string): Promise` 发现某个已注册提供方当前公布的模型。 - `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` 从拥有精确路由的适配器解析经校验的确切模型身份、可用上下文和推理(reasoning)元数据;异步适配器可选地支持取消。 - `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise` 校验显式推理强度,并填入适配器配置的默认值,但不自动调整。 -- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` 解析配置并将其当前适配器注册捕获为一次可取消、一次性调用。 +- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` 解析配置,并将其当前适配器注册与不可变重试策略捕获为一次可取消、一次性调用。 - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` 将一次模型调用流式输出为原始 chunk(token 级 delta)。消费方使用 `BlockAssembler` 将 chunk 组装为块/消息。 -`LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回相邻的不可变 `LlmFailure`;`llmRetryPolicyOf(stream)` 返回在该边界选中的确切注册所对应的不可变策略,即使之后释放或替换路由也不变。未到达最终适配器的调用没有服务策略。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器的原始编码 `Error`。 +`LlmService` 会把最终适配器选择、同步 dispatch、iterator 构造与迭代产生的失败规范化为流协议的单一终止形式:`finish { kind: 'error' | 'aborted', failure }`。部分 delta 之后的失败可能留下未关闭内容块;消费方会丢弃这部分不完整输出。`llm/stream` middleware、嵌套调用、适配器清理和下游消费方的错误仍会抛出,因为它们属于插件或消费方失败,而非模型请求结果。准备完成的调用会公开随其确切适配器注册捕获的不可变重试策略;完全由 middleware 处理的路由没有服务策略。 提供方与模型元数据是发现表层,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。 @@ -44,7 +44,7 @@ 消息内容是类型化内容块数组:`text`、`reasoning`、`tool-call`、`tool-result`。联合从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加块类型。assistant 消息使用模型来源,其中携带提供方/模型溯源与可选适配器私有回放状态。dispatch 前,`LlmService` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型/提供方间恢复或转换该状态。核心块集只包含每条已发布路径都支持的块。多模态内容(图像、音频等)没有核心块类型;需要它的功能会通过 map 添加,并一并添加支持它的适配器/UI/压缩实现。 -流式输出是原始 chunk 协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。`BlockAssembler` 是将 chunk 组装为块/消息的唯一共享实现。 +流式输出是原始 chunk 协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。每个适配器结果都以一个终止 `finish` 抵达消费方;运行失败使用其中的 `error` 或 `aborted` reason,不再跨 stream API 抛出。`BlockAssembler` 是将 chunk 组装为块/消息的唯一共享实现。 ### 调用配置(`call-config.ts`) @@ -67,7 +67,7 @@ ### 真实适配器 -两个适配器使用不同内部机制实现 `LlmAdapter`:[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek` 路由使用直接 fetch 加 `eventsource-parser` SSE 分帧,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `StreamChunk` 约定,定义见 `types.ts`:usage 先于 finish,工具参数保持原始字符串,错误使用两种已批准路径之一。设计理由见 [双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)。 +两个适配器使用不同内部机制实现 `LlmAdapter`:[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek` 路由使用直接 fetch 加 `eventsource-parser` SSE 分帧,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `types.ts` 中的 `StreamChunk` 约定:usage 先于 finish,工具参数保持原始字符串。适配器实现内部可以抛出或发出失败 finish;`LlmService` 会将两者都作为终止失败 finish 暴露。适配器设计理由见[双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md),服务边界见[终止失败决策](../../../.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.md)。 ## 模型体验 diff --git a/packages/llm/llm/src/adapter-failure.ts b/packages/llm/llm/src/adapter-failure.ts index e888d216a8..d11ee2e52b 100644 --- a/packages/llm/llm/src/adapter-failure.ts +++ b/packages/llm/llm/src/adapter-failure.ts @@ -1,67 +1,40 @@ /** - * Private provider-failure tagging shared by `LlmService` and its consumers. + * Normalization for values thrown by a final LLM adapter boundary. * * @module @deepseek-ai/dsh-llm/adapter-failure */ import { HarnessError } from './error.ts' -import type { LlmFailure, StreamChunk } from './types.ts' -import type { ResolvedRetryPolicy } from './retry-policy.ts' - -/** Call-local facts captured when one model call enters its final adapter boundary. */ -export interface AdapterFailureScope { - /** Errors and normalized facts proven to originate in this call's final adapter boundary. */ - readonly failures: WeakMap - /** Immutable policy of the exact adapter registration selected for this call. */ - retryPolicy?: ResolvedRetryPolicy -} - -/** Call-local failure scopes keyed by the exact stream handle returned to a consumer. */ -const adapterFailureScopes = new WeakMap, AdapterFailureScope>() +import type { LlmFailure } from './types.ts' /** - * Bind one call's adapter-failure scope to a unique returned stream handle. - * @param stream - the waterfall-selected stream for this call. - * @param failures - errors tagged by this call's final adapter boundary. - * @returns a unique stream handle that delegates iteration to `stream`. + * Detach serializable provider facts from a value thrown by an adapter. + * @param value - arbitrary value thrown during adapter dispatch or iteration. + * @returns immutable provider-neutral facts suitable for a terminal finish chunk. * @internal */ -export function bindAdapterFailureScope( - stream: AsyncIterable, - failures: AdapterFailureScope, -): AsyncIterable { - const call = { - [Symbol.asyncIterator](): AsyncIterator { - return stream[Symbol.asyncIterator]() - }, - } - adapterFailureScopes.set(call, failures) - return call -} - -/** - * Preserve an adapter's Error identity while tagging its provider origin. - * @param failures - the call-local final-adapter failure scope. - * @param value - arbitrary value thrown by adapter dispatch or iteration. - * @returns the original Error, or a coded Error wrapping a non-Error throw. - * @internal - */ -export function markLlmAdapterFailure( - failures: AdapterFailureScope, - value: unknown, -): Error & { code?: string } { +export function normalizeLlmFailure(value: unknown): LlmFailure { const error = value instanceof Error - ? value as Error & { code?: string } - : new HarnessError(String(value), 'UNKNOWN', { cause: value }) + ? value + : new HarnessError(thrownMessage(value), 'UNKNOWN', { cause: value }) // Cross-package copies preserve own data but not class identity. Trust the // carried facts only when both own properties agree after validation. const carried = ownFailureSnapshot(error) - const failure = carried !== undefined && carried.code === ownErrorCode(error) ? carried : Object.freeze({ + if (carried !== undefined && carried.code === ownErrorCode(error)) return carried + return Object.freeze({ message: errorMessage(error), code: harnessErrorCode(error), }) - failures.failures.set(error, failure) - return error +} + +/** Render a non-Error throw without letting hostile coercion escape normalization. */ +function thrownMessage(value: unknown): string { + try { + const message = String(value) + return message.length > 0 ? message : 'LLM adapter failed' + } catch (_hostileThrownValue) { + return 'LLM adapter failed' + } } /** Read a foreign error's own data-backed `code` without invoking accessors. */ @@ -129,46 +102,3 @@ function errorMessage(error: Error): string { function harnessErrorCode(error: Error): string { return error instanceof HarnessError ? error.code : 'UNKNOWN' } - -/** - * Whether a failure came from final adapter dispatch, iterator construction, - * or iteration for the call represented by the exact returned stream handle. - * @param stream - the exact stream returned by the model call being classified. - * @param value - arbitrary failure caught by a model-call consumer. - * @returns true only for errors tagged at that call's final adapter boundary. - */ -export function isLlmAdapterFailure( - stream: AsyncIterable, - value: unknown, -): value is Error & { code?: string } { - const failures = adapterFailureScopes.get(stream) - return value instanceof Error && failures !== undefined && failures.failures.has(value) -} - -/** - * Retrieve normalized provider facts only for an Error tagged by this exact - * model call's final adapter boundary. - * @param stream - the exact stream returned to the consumer. - * @param value - the caught failure. - * @returns the immutable facts for that call, or `undefined` for middleware, nested, or consumer failures. - */ -export function llmFailureOf( - stream: AsyncIterable, - value: unknown, -): LlmFailure | undefined { - const failures = adapterFailureScopes.get(stream) - return value instanceof Error ? failures?.failures.get(value) : undefined -} - -/** - * Read the retry policy of the exact registration selected at this call's - * final adapter boundary. The policy remains available after that registration - * is disposed or replaced; absence means no final adapter served the call. - * @param stream - the exact stream returned by the model call. - * @returns the immutable serving-registration policy, or `undefined`. - */ -export function llmRetryPolicyOf( - stream: AsyncIterable, -): ResolvedRetryPolicy | undefined { - return adapterFailureScopes.get(stream)?.retryPolicy -} diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts index 252d6b89ac..a0e1332417 100644 --- a/packages/llm/llm/src/assembler.ts +++ b/packages/llm/llm/src/assembler.ts @@ -127,11 +127,15 @@ export class BlockAssembler { /** * Assemble all blocks seen so far, in stream order. - * @returns one block per seen index; an open block assembles from its - * accumulated deltas (an unknown block type never closed by `block-end` throws). + * @returns one block per seen index, except that max-token truncation drops + * tool calls that cannot be executed safely; an open block assembles from + * its accumulated deltas (an unknown block type never closed by `block-end` throws). */ blocks(): ContentBlock[] { - return this.order.map(index => this.assemble(this.mustGet(index), index)) + const blocks = this.order.map(index => this.assemble(this.mustGet(index), index)) + return this.finish.kind === 'max-tokens' + ? blocks.filter(block => block.type !== 'tool-call') + : blocks } /** Usage from the `usage` chunk; undefined until one arrives. */ diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index c8f5a8b0fc..1e267dd86d 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -22,8 +22,7 @@ import type { ProviderRequestId } from './brand.ts' import { callConfigEquals, deepFreeze } from './call-config.ts' import type { LlmCallConfig } from './call-config.ts' import { HarnessError } from './error.ts' -import { bindAdapterFailureScope, markLlmAdapterFailure } from './adapter-failure.ts' -import type { AdapterFailureScope } from './adapter-failure.ts' +import { normalizeLlmFailure } from './adapter-failure.ts' export * from './attribution.ts' export * from './brand.ts' @@ -35,7 +34,6 @@ export * from './retry-policy.ts' export { BlockAssembler } from './assembler.ts' export { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from './call-config.ts' export type { LlmCallConfig } from './call-config.ts' -export { isLlmAdapterFailure, llmFailureOf, llmRetryPolicyOf } from './adapter-failure.ts' declare module 'cordis' { interface Context { @@ -113,6 +111,8 @@ export class LlmError extends HarnessError { export interface PreparedLlmCall { /** Detached, deep-frozen config with any adapter-owned default materialized. */ readonly config: LlmCallConfig + /** Immutable retry policy captured with the adapter registration. */ + readonly retryPolicy: ResolvedRetryPolicy /** * Dispatch this call once through the registration captured during * preparation. The request's call-config fields must match {@link config}; @@ -440,10 +440,17 @@ export class LlmService extends Service { let dispatched = false return Object.freeze({ config: resolvedConfig, + retryPolicy: registration.retryPolicy, stream: (options: GenerateOptions): AsyncIterable => { if (dispatched) { throw new LlmError('a prepared LLM call can only be dispatched once', 'INVALID_PREPARED_CALL') } + if (!callConfigEquals(options, resolvedConfig)) { + throw new LlmError( + 'prepared LLM call config changed before adapter dispatch', + 'INVALID_PREPARED_CALL', + ) + } dispatched = true return this.streamWithRegistration(options, { registration, config: resolvedConfig }) }, @@ -473,31 +480,20 @@ export class LlmService extends Service { } /** - * Final adapter boundary. It tags only failures from adapter selection, - * synchronous dispatch, iterator construction, or iteration while preserving - * the original Error object. Middleware outside this generator remains - * distinguishable as plugin work. An iteration failure skips adapter cleanup - * so it cannot suppress the primary provider error. A downstream close awaits - * adapter cleanup, whose failures remain ordinary untagged work. + * Final adapter boundary. Adapter selection, dispatch, iterator construction, + * and iteration failures become one terminal failure chunk. Middleware and + * downstream consumer failures remain thrown plugin or consumer errors. */ private async * adapterStream( options: GenerateOptions, - failures: AdapterFailureScope, prepared?: { registration: AdapterRegistration; config: LlmCallConfig }, ): AsyncGenerator { let iterator: AsyncIterator try { const registration = prepared?.registration ?? this.registration(options.provider) - failures.retryPolicy = registration.retryPolicy const resolvedConfig = prepared === undefined ? await this.resolveCallConfigFor(registration, options, options.signal) : prepared.config - if (prepared !== undefined && !callConfigEquals(options, resolvedConfig)) { - throw new LlmError( - 'prepared LLM call config changed before adapter dispatch', - 'INVALID_PREPARED_CALL', - ) - } const resolvedOptions = prepared !== undefined || callConfigEquals(options, resolvedConfig) ? options : Object.isFrozen(options) @@ -507,32 +503,31 @@ export class LlmService extends Service { const stream = adapter.stream(this.forAdapter(resolvedOptions, adapter)) iterator = stream[Symbol.asyncIterator]() } catch (error: unknown) { - throw markLlmAdapterFailure(failures, error) + yield adapterFailureChunk(error, options.signal) + return } let completed = false - let iterationFailed = false try { while (true) { - let value: StreamChunk + let item: IteratorResult try { - const item = await iterator.next() - if (item.done) { - completed = true - return - } - value = item.value + item = await iterator.next() } catch (error: unknown) { - iterationFailed = true - throw markLlmAdapterFailure(failures, error) + completed = true + yield adapterFailureChunk(error, options.signal) + return + } + if (item.done) { + completed = true + return } // End the adapter-owned try before yielding: consumer/middleware - // failures resumed into this generator must remain untagged. - yield value + // failures resumed into this generator must remain thrown. + yield item.value } } finally { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the iteration catch sets its latch before entering finally. - if (!completed && !iterationFailed) { + if (!completed) { const close = iterator.return?.bind(iterator) if (close) await close() } @@ -540,15 +535,13 @@ export class LlmService extends Service { } /** - * Stream one model call as raw chunks (token-level deltas). Throws - * `LlmError` with code `NO_ADAPTER` if no adapter is registered for - * `options.provider`. Replay state is retained only when the same adapter - * instance owns its historical provider and the target provider. Final - * adapter selection remains fixed through asynchronous exact-model resolution - * and dispatch. Selection, dispatch, and iteration failures retain their - * original Error identity and are tagged in a call-local scope for narrow - * agent-loop request recovery; middleware and nested-call failures remain - * untagged for the outer call. + * Stream one model call as raw chunks (token-level deltas). Replay state is + * retained only when the same adapter instance owns its historical provider + * and the target provider. Final adapter selection remains fixed through + * asynchronous exact-model resolution and dispatch. Adapter selection, + * dispatch, and iteration failures become terminal `error` or `aborted` + * finish chunks; middleware, nested-call, cleanup, and consumer failures + * remain thrown. * @param options - the full request; `options.provider` selects the adapter. * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. */ @@ -560,14 +553,23 @@ export class LlmService extends Service { options: GenerateOptions, prepared?: { registration: AdapterRegistration; config: LlmCallConfig }, ): AsyncIterable { - const failures: AdapterFailureScope = { failures: new WeakMap() } - const stream = this.ctx.waterfall( + return this.ctx.waterfall( this, 'llm/stream', options, - () => this.adapterStream(options, failures, prepared), + () => this.adapterStream(options, prepared), ) - return bindAdapterFailureScope(stream, failures) + } +} + +/** Convert one adapter throw into the stream protocol's terminal outcome. */ +function adapterFailureChunk(error: unknown, signal?: AbortSignal): StreamChunk { + const failure = normalizeLlmFailure(error) + return { + type: 'finish', + reason: signal?.aborted || failure.code === 'ABORTED' + ? { kind: 'aborted', failure } + : { kind: 'error', failure }, } } diff --git a/packages/llm/llm/src/invariant.ts b/packages/llm/llm/src/invariant.ts index 76d55509cb..8af9c42f46 100644 --- a/packages/llm/llm/src/invariant.ts +++ b/packages/llm/llm/src/invariant.ts @@ -72,7 +72,9 @@ async function* validateStream( usageSeen = true break case 'finish': - if (open.size > 0) fail(`LLM stream finished with ${open.size} open block(s)`) + if (open.size > 0 && chunk.reason.kind !== 'error' && chunk.reason.kind !== 'aborted') { + fail(`LLM stream finished with ${open.size} open block(s)`) + } finished = true break } diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 4e6e0eabe2..66e988084c 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -170,8 +170,9 @@ export interface LlmResolvedModelInfo extends LlmModelInfo { * Raw streaming protocol emitted by adapters. * Block indexes correlate interleaved deltas, and `block-end` carries the * assembled block. Adapters emit usage before the terminal finish and nothing - * afterward; tool arguments remain raw JSON strings. Failures either throw or - * end with `error`/`aborted`, and consumers must handle both paths. + * afterward; tool arguments remain raw JSON strings. An adapter implementation + * may throw, but `LlmService.stream()` normalizes that failure to a terminal + * `error` or `aborted` finish before exposing it to consumers. */ export type StreamChunk = | { type: 'block-start'; index: number; blockType: ContentBlockType } diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index 30342e81ec..c23ba0b38e 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -670,7 +670,7 @@ describe('malformed replay and listener lifecycle', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }] }) activeMeter.measure(session) session.append('user/message', createUserMessage({ diff --git a/packages/plan/plan-mode/tests/invariant.spec.ts b/packages/plan/plan-mode/tests/invariant.spec.ts index fe31510be7..4826036bf4 100644 --- a/packages/plan/plan-mode/tests/invariant.spec.ts +++ b/packages/plan/plan-mode/tests/invariant.spec.ts @@ -19,7 +19,7 @@ function event(active: unknown): SessionEvent { function emitTurnStart(ctx: Context, session: Session): void { ctx.emit('session/event', session, { type: 'turn/start', seq: 0, time: 0, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }) } @@ -56,7 +56,7 @@ describe('plan-mode stream invariants', () => { expect(() => { ctx.emit('tools/change') ctx.emit('session/event', session, { - type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + type: 'turn/start', seq: 0, time: 0, data: { turn: 1 }, }) }).not.toThrow() }) @@ -65,7 +65,7 @@ describe('plan-mode stream invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('plan/mode', { active: 'plan' as unknown as boolean }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.plugin(InvariantService, { enabled: true }) @@ -77,7 +77,7 @@ describe('plan-mode stream invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('plan/mode', { active: true }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.plugin(InvariantService, { enabled: true }) diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 72d273d20f..d988fc3d1c 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -75,7 +75,7 @@ async function boundary(ctx: Context, agent: Agent & { session: Session }, type: /** Open a turn so a selection queues for the boundary flush (the mid-turn shape). */ function openTurn(session: Session, turn = 0): void { - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) } /** Close the open turn (the between-turns shape: selections commit immediately). */ diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index 7c69417e58..c26112c8fd 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -58,7 +58,7 @@ function runPlanCommand(session: Session, args: string, index: number): void { /** Commit one plan/mode flip inside an open turn (the invariant's turn-enclosure rule). */ function commitPlanMode(session: Session, active: boolean, turn: number): void { - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn }) session.append('plan/mode', { active }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index 153e29842d..5fa6ada775 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -42,7 +42,7 @@ function agent(ctx: Context): Agent { const id = SessionId('agent') return { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), } } @@ -232,7 +232,7 @@ describe('pty-local plugin shape', () => { const session = ctx.sessions.create(SessionId('unowned-mode')) expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) }).not.toThrow() expect(() => { setSandboxMode(session, 'read-only') }).not.toThrow() }) @@ -249,7 +249,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) @@ -258,7 +258,7 @@ describe('pty-local plugin shape', () => { const unrelated = ctx.sessions.create(SessionId('unrelated-mode')) expect(() => { setSandboxMode(unrelated, 'read-only') }).not.toThrow() expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) }).not.toThrow() expect(() => { setSandboxMode(session, 'danger-full-access') }).not.toThrow() @@ -292,7 +292,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const gate = Promise.withResolvers() diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index d045dc4d26..6c217d518a 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) return { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 905708fbb1..734848782c 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -31,7 +31,6 @@ function stubAgent(ctx: Context, rawId: string): Agent { followup: () => {}, steer: () => {}, inject: () => {}, - send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index 85d0deefb8..024253d3e9 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -40,7 +40,7 @@ function agent(ctx: Context): Agent { const id = SessionId('pty-loader-agent') const value: Agent = { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(value) return value diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index ec754aa96c..43b50d92ee 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const agent: Agent = { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(agent) return agent diff --git a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts index 4d128c0012..0b10ae591f 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -61,7 +61,7 @@ describe('session-checkpoint-policy request boundary', () => { it('awaits the live session checkpoint before constructing the downstream model stream', async () => { const ctx = await setup() const session = ctx.sessions.create(SessionId('request-checkpoint')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const gate = Promise.withResolvers() const order: string[] = [] ctx.on('session/flush', async () => { diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 5171bb8585..87d630a56e 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -59,7 +59,7 @@ afterEach(async () => { }) function appendClosedTurn(session: Session): void { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' }, @@ -208,7 +208,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { it('round-trip is byte-identical (incl. assistant/chunk verbatim)', async () => { const m = meta('chunks') const log: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, { type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'he' } } }, { type: 'assistant/chunk', seq: 3, time: 4, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'llo' } } }, @@ -342,7 +342,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true }) await writeFile(path, [ JSON.stringify(toHeaderLine(m)), - JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), + JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }), JSON.stringify({ type: 'request/header-delta', seq: 1, time: 2, data: { config: { model: 'legacy' } } }), JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }), '', @@ -397,7 +397,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { // partial line with no newline (a torn fragment never fully flushed). const path = rawLogPath(root, '/proj', m.id) await writeFile(path, [ - JSON.stringify({ type: 'turn/start', seq: 6, time: 8, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }), + JSON.stringify({ type: 'turn/start', seq: 6, time: 8, data: { turn: 2 } }), JSON.stringify({ type: 'step/start', seq: 7, time: 9, data: { turn: 2, step: 1 } }), '{"type":"assistant/chunk","seq":8,"ti', // truncated partial line (no newline) ].join('\n'), { flag: 'a' }) @@ -416,7 +416,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { // The next append continues at seq 10 (the balanced length). const turn3 = [ - { type: 'turn/start', seq: 10, time: 11, data: { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 10, time: 11, data: { turn: 3 } }, { type: 'turn/end', seq: 11, time: 12, data: { turn: 3, reason: { kind: 'completed' } } }, ] as SessionEvent[] await ctx.sessionPersistence.append(m.id, turn3) @@ -435,7 +435,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { await writeFile(rawLogPath(root, undefined, m.id), '\n{"partial', { flag: 'a' }) await ctx.sessionPersistence.load(m.id) await ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, ] as SessionEvent[]) const after = await readFile(rawLogPath(root, undefined, m.id), 'utf8') @@ -463,7 +463,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { }) const turn2 = [ - { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, ] as SessionEvent[] // The append rejects, but the partial bytes are truncated back: the file is @@ -501,7 +501,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { try { await ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } }, ] as SessionEvent[]) throw new Error('expected append to reject') } catch (error) { @@ -526,7 +526,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { // metadata must be unaffected, so a later append still finds the right log. mutableHeader(loaded.meta).cwd = '/evil' await ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, ] as SessionEvent[]) // The append landed in the ORIGINAL /proj log, not beside an /evil path. @@ -543,7 +543,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }]) await ctx.sessionPersistence.create(b) await ctx.sessionPersistence.append(b.id, oneTurnLog()) @@ -596,8 +596,8 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () => const a = ctx.sessions.create(SessionId('sa')) const b = ctx.sessions.create(SessionId('sb')) - a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + a.append('turn/start', { turn: 1 }) + b.append('turn/start', { turn: 1 }) a.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'A' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -678,7 +678,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('a seq gap after the last turn/end bounds the preserved tail (torn fragment tolerated)', () => { const log = [ JSON.stringify({ type: 'session', version: 0, id: 'g', createdAt: 1, delegationDepth: 0 }), - JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), + JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }), JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1 ].join('\n') + '\n' // No committed turn/end, so the gap is a tolerated crash boundary: scanLog PRESERVES the @@ -690,7 +690,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('rejects a seq gap BEFORE a later committed turn/end (committed data damaged)', () => { const log = [ JSON.stringify({ type: 'session', version: 0, id: 'g2', createdAt: 1, delegationDepth: 0 }), - JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), + JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }), JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1 JSON.stringify({ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }), ].join('\n') + '\n' @@ -719,7 +719,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('a corrupt line after the last turn/end bounds the preserved tail', () => { const log = [ JSON.stringify({ type: 'session', version: 0, id: 'c2', createdAt: 1, delegationDepth: 0 }), - JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), + JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }), '{not json', // corrupt crash fragment, no turn/end committed ].join('\n') + '\n' // The contiguous prefix (turn/start seq 0) is preserved; the corrupt @@ -730,7 +730,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('tolerates a seq gap AFTER a turn/end (uncommitted tail)', () => { const log = [ JSON.stringify({ type: 'session', version: 0, id: 't', createdAt: 1, delegationDepth: 0 }), - JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), + JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }), JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }), JSON.stringify({ type: 'step/start', seq: 9, time: 3, data: { turn: 2, step: 1 } }), // gap in uncommitted tail ].join('\n') + '\n' @@ -760,7 +760,7 @@ describe('SessionPersistenceJsonl: default packed chunk rows', () => { data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: `t${k}` } }, })) return [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, ...deltas, { type: 'assistant/message', seq: 7, time: 8, data: { @@ -851,7 +851,7 @@ describe('SessionPersistenceJsonl: default packed chunk rows', () => { it('scanLog: a packed row advances the seq cursor by its whole run', () => { const logText = [ JSON.stringify({ type: 'session', version: 0, id: 'rows', createdAt: 1, delegationDepth: 0 }), - JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), + JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }), JSON.stringify({ type: 'text-chunks', seq0: 1, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }), JSON.stringify({ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }), ].join('\n') + '\n' @@ -873,7 +873,7 @@ describe('SessionPersistenceJsonl: default packed chunk rows', () => { it('scanLog: a packed row with a mid-run seq gap after the last turn/end drops the whole row', () => { const logText = [ JSON.stringify({ type: 'session', version: 0, id: 'row-gap', createdAt: 1, delegationDepth: 0 }), - JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), + JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }), // seq0 skips 1 — the run's first member is already a gap; no turn/end follows. JSON.stringify({ type: 'text-chunks', seq0: 2, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }), ].join('\n') + '\n' @@ -1151,7 +1151,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { // A live session materializes and owns the id. const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { const a = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } }) - a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + a.append('turn/start', { turn: 1 }) a.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }, { inject: ['sessions'] })) for (const s of ctx.sessions.list()) await ctx.sessions.flush(s) @@ -1229,7 +1229,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx2.plugin(SessionStore) await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) await ctx2.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, ] as SessionEvent[]) const loaded = await ctx2.sessionPersistence.load(m.id) @@ -1244,7 +1244,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { const m = meta('open-turn', '/h') await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, ] as SessionEvent[]) const { events } = await ctx.sessionPersistence.load(m.id) expect(events.map(e => e.type)).toEqual(['turn/start', 'turn/end']) @@ -1276,7 +1276,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) const session = ctx2.sessions.create(SessionId('flush-fail')) // A full turn lands in the write-behind buffer. - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts index cef1ff71e5..566d649a79 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts @@ -285,7 +285,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { const path = logPath(root, header.cwd, header.id, 'zstd') const before = await readFile(path) const secondTurn = [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, ] as SessionEvent[] await ctx.sessionPersistence.append(header.id, secondTurn) @@ -380,7 +380,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { const path = logPath(root, header.cwd, header.id, 'zstd') const committed = await readFile(path) const openTurn = [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, { type: 'assistant/chunk', seq: 8, time: 9, data: { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: deterministicNoise(300_000) } } }, ] as SessionEvent[] @@ -427,7 +427,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { await ctx.sessionPersistence.append(header.id, oneTurnLog()) const path = logPath(root, header.cwd, header.id, 'zstd') const secondTurn = [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, ] as SessionEvent[] const frame = await compressZstdFrame(secondTurn.map(e => JSON.stringify(e)).join('\n') + '\n') @@ -475,7 +475,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { return realSync.call(this) }) const secondTurn = [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, ] as SessionEvent[] await expect(ctx.sessionPersistence.append(header.id, secondTurn)).rejects.toThrow(/simulated Zstandard fsync failure/) diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index ddc46880cf..da22c9bc00 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -107,7 +107,7 @@ describe('scanRows', () => { // is no torn fragment to delete. (load() then synthesizes the closers.) const withOpenTurn: SessionEvent[] = [ ...oneTurnLog(), - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, ] const { preserved, tornFrom } = scanRows(rows(withOpenTurn)) @@ -119,7 +119,7 @@ describe('scanRows', () => { // A gap after seq 0 (no committed turn/end): seq 0 is the preserved // interrupted-turn event; the gap bounds it and marks the torn fragment. const gapped: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // seq 1 missing ] const { preserved, tornFrom } = scanRows(rows(gapped)) @@ -133,7 +133,7 @@ describe('scanRows', () => { it('throws on a seq gap inside the committed region (before the last turn/end)', () => { const gapped: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // seq 1 missing { type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }, ] @@ -183,7 +183,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { db.prepare('INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision) VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, 1)') .run(m.id, m.version, m.createdAt, m.cwd ?? null, 'legacy-header-delta') const insert = db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)') - insert.run(m.id, 0, 'turn/start', 1, JSON.stringify({ turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })) + insert.run(m.id, 0, 'turn/start', 1, JSON.stringify({ turn: 1 })) insert.run(m.id, 1, 'request/header-delta', 2, JSON.stringify({ config: { model: 'legacy' } })) insert.run(m.id, 2, 'turn/end', 3, JSON.stringify({ turn: 1, reason: { kind: 'completed' } })) db.close() @@ -228,7 +228,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { await ctx1.sessionPersistence.create(m) await ctx1.sessionPersistence.append(m.id, oneTurnLog()) await ctx1.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, ]) await fiber1.dispose() @@ -251,7 +251,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { // load durably closed the turn, so the next append continues at the balanced // length (seq 10) and a reload round-trips identically. await ctx2.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 10, time: 9, data: { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } }, { type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } }, ]) const reloaded = await ctx2.sessionPersistence.load(m.id) @@ -269,7 +269,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { // Hand-write an interrupted turn (turn/start seq 6, no turn/end). const db = openDatabase(path, 'wal') db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)') - .run(m.id, 'turn/start', JSON.stringify({ turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) + .run(m.id, 'turn/start', JSON.stringify({ turn: 2 })) db.close() const b2 = await backend(path) @@ -294,7 +294,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { await b1.ctx.sessionPersistence.create(m) // A first turn that NEVER completed: turn/start + user/message, no turn/end. await b1.ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'user/message', seq: 1, time: 2, data: createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }) }, @@ -477,7 +477,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { expect(loaded.events).toEqual(oneTurnLog()) // torn tail discarded, committed intact (turn 1 already balanced → no closers) // load physically deleted the corrupt tail row, so a fresh append continues. await b2.ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 8, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 8, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 9, data: { turn: 2, reason: { kind: 'completed' } } }, ]) const reloaded = await b2.ctx.sessionPersistence.load(m.id) @@ -703,7 +703,7 @@ describe('SessionPersistenceSqlite: edge cases', () => { const b2 = await backend(path) await b2.ctx.sessionPersistence.load(m.id) // cursor 6 in b2 const turn2: SessionEvent[] = [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, ] // b1 commits seq 6..7 first. @@ -761,7 +761,7 @@ describe('SessionPersistenceSqlite: edge cases', () => { await ctx.plugin(Object.assign((inner: Context) => { session = inner.sessions.create(SessionId('hmr-collide')) }, { inject: ['sessions'] })) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) await ctx.plugin(SessionPersistenceSqlite, { path }) await expectFlushError(ctx.sessions.flush(session), /id collision/) await ctx.fiber.dispose() @@ -814,7 +814,7 @@ describe('surface field round-trip', () => { await ctx.plugin(SessionStore) const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' }) const session = ctx.sessions.create(SessionId('roundtrip-surface')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, @@ -849,7 +849,7 @@ describe('surface field round-trip', () => { await ctx.plugin(SessionStore) const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' }) const session = ctx.sessions.create(SessionId('surface-noseq')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('steering/message', { turn: 1, message: createUserMessage({ diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 9ff82101d7..ca9384b616 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -33,7 +33,7 @@ export function meta(id: string, cwd?: string): SessionHeader { /** A well-formed one-turn event log (contiguous seqs from 0). */ export function oneTurnLog(): SessionEvent[] { return [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'user/message', seq: 1, time: 2, data: freezeMessage({ id: MessageId('one-turn-user'), role: 'user', @@ -124,7 +124,7 @@ export function runPersistenceContract(name: string, make: () => Promise Promise Promise Promise Promise event.type)).toEqual(['turn/start']) @@ -347,7 +347,7 @@ export function runPersistenceContract(name: string, make: () => Promise snapshot.header.id === m.id) expect(changed?.revision).not.toBe(first?.revision) @@ -376,7 +376,7 @@ export function runPersistenceContract(name: string, make: () => Promise Promise< session = inner.sessions.create(SessionId('live-load'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) try { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) await ctx.sessions.flush(session) await expect(ctx.sessionPersistence.load(session.id)) @@ -206,7 +206,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, } await ctx.sessionPersistence.create(header) await ctx.sessionPersistence.append(id, [start]) @@ -294,7 +294,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const { ctx, fiber } = await freshCtx(fix) try { const session = ctx.sessions.create(SessionId('mutate'), { meta: { cwd: WORK } }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const ev = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'original' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -450,7 +450,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const p = ctx.sessionPersistence.append(m.id, events) // Mutate the caller's array AND an event object after the call but before // the queued op runs: the snapshot taken at call time must shield the copy. - events.push({ type: 'turn/start', seq: 6, time: 99, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }) + events.push({ type: 'turn/start', seq: 6, time: 99, data: { turn: 2 } }) if (userMsg?.type === 'user/message') { (userMsg.data as { content: unknown[] }).content = [{ type: 'text', text: 'MUTATED' }] } @@ -505,7 +505,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const loaded = await second.ctx.sessionPersistence.load(SessionId('resumed')) const s2 = second.ctx.sessions.create(SessionId('resumed'), { seed: loaded.events, meta: { cwd: WORK } }) await second.ctx.sessions.flush(s2) // let onCreated adopt - s2.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + s2.append('turn/start', { turn: 2 }) s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) await second.ctx.sessions.flush(s2) @@ -525,7 +525,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.plugin(SessionStore) // A session exists BEFORE the persistence plugin is applied. const session = ctx.sessions.create(SessionId('pre-existing'), { meta: { cwd: WORK } }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -549,7 +549,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.plugin(SessionStore) const fiber = await fix.mount(ctx) const session = await liveSessionInFiber(ctx, 'drain', WORK) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'buffered' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -577,7 +577,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< try { // Backend instance 1 materializes the session. const backend1 = await fix.mount(ctx) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -589,7 +589,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // materialized prefix, then persist another turn rather than rejecting it as a collision. await backend1.dispose() await fix.mount(ctx) - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 2 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'again' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -612,7 +612,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< try { // Instance 1 flushes turn 1. const backend1 = await fix.mount(ctx) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.sessions.flush(session) @@ -620,7 +620,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // flushing turn 2: it is now ONLY in the live session's events; the new // backend never buffered it via session/event. await backend1.dispose() - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 2 }) session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) // Instance 2 adopts the stored prefix (turn 1) and MUST also persist the @@ -643,7 +643,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const session = await liveSessionInFiber(ctx, 'hmr-open', WORK) try { const first = await fix.mount(ctx) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) await ctx.sessions.flush(session) @@ -686,7 +686,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const second = await freshCtx(fix) try { const s2 = second.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } }) - s2.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s2.append('turn/start', { turn: 1 }) await expect(second.ctx.sessions.flush(s2)) .rejects.toThrow(/already has a persisted log|id collision/) } finally { @@ -713,7 +713,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< reuse = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) await expect(ctx.sessions.flush(reuse)).resolves.toBeUndefined() - reuse.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + reuse.append('turn/start', { turn: 1 }) reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.sessions.flush(reuse) const loaded = await ctx.sessionPersistence.load(SessionId('abandoned')) @@ -734,7 +734,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< }, { inject: ['sessions'] })) await ctx.sessions.flush(first) // Append a turn but do NOT flush — events sit in the write-behind buffer. - first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + first.append('turn/start', { turn: 1 }) first.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await firstFiber.dispose() @@ -761,7 +761,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const { ctx, fiber } = await freshCtx(fix) try { const session = ctx.sessions.create(SessionId('idem'), { meta: { cwd: WORK } }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -838,7 +838,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const contFiber = await ctx.plugin(Object.assign((inner: Context) => { cont = inner.sessions.create(SessionId('claim'), { seed: [ ...events, - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, ], meta: { cwd: WORK, createdAt: 2000 } }) }, { inject: ['sessions'] })) @@ -928,7 +928,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const second = await freshCtx(fix) try { await second.ctx.sessionPersistence.append(SessionId('adopt-append'), [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, ]) const loaded = await second.ctx.sessionPersistence.load(SessionId('adopt-append')) @@ -1028,7 +1028,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // async onCreated init has necessarily set state (exercises the // state-undefined cursor path). const session = ctx.sessions.create(SessionId('flush-nostate'), { meta: { cwd: WORK } }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -1061,7 +1061,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await first.ctx.sessionPersistence.append(m.id, oneTurnLog()) // committed 0..5 (balanced) // A second turn whose real events are durable but never closed (open turn). await first.ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, ]) } finally { @@ -1088,7 +1088,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // The repair is durable: the next append continues at the balanced length // (seq 10) and a reload round-trips identically. await second.ctx.sessionPersistence.append(SessionId('torn'), [ - { type: 'turn/start', seq: 10, time: 9, data: { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } }, { type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } }, ]) const reloaded = await second.ctx.sessionPersistence.load(SessionId('torn')) diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index bca32438f4..b9f7ebd361 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -237,7 +237,7 @@ describe('PersistenceCoordinator eager writes', () => { try { const session = ctx.sessions.create(SessionId('eager-follow-up')) await ctx.sessions.flush(session) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -272,7 +272,7 @@ describe('PersistenceCoordinator eager writes', () => { try { const session = ctx.sessions.create(SessionId('eager-flush-retry')) await ctx.sessions.flush(session) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) @@ -302,7 +302,7 @@ describe('PersistenceCoordinator stored identity', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }], }) let coordinator!: PersistenceCoordinator @@ -329,7 +329,7 @@ describe('PersistenceCoordinator stored identity', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, } backend.store.set(id, { meta: header, events: [start] }) const loadGate = Promise.withResolvers() @@ -534,7 +534,7 @@ describe('PersistenceCoordinator observation cancellation', () => { const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => { session = inner.sessions.create(id) }, { inject: ['sessions'] })) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) // Dispose the session so retirement starts; its append is gated, so the // retirement promise stays pending in the coordinator. @@ -677,7 +677,7 @@ describe('PersistenceCoordinator retirement', () => { }, { inject: ['sessions'] })) await ctx.sessions.flush(first) backend.beforeAppend = async () => { await appendGate.promise } - first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + first.append('turn/start', { turn: 1 }) first.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) await firstFiber.dispose() @@ -717,7 +717,7 @@ describe('PersistenceCoordinator retirement', () => { }, { inject: ['sessions'] })) await ctx.sessions.flush(first) backend.beforeAppend = async () => { await appendGate.promise } - first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + first.append('turn/start', { turn: 1 }) first.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) await firstFiber.dispose() @@ -776,7 +776,7 @@ describe('PersistenceCoordinator retirement', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }]) const secondAppend = coordinator.append(id, [{ type: 'turn/end', @@ -824,7 +824,7 @@ describe('PersistenceCoordinator retirement', () => { const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => { session = inner.sessions.create(SessionId('retry-retirement')) }, { inject: ['sessions'] })) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await sessionFiber.dispose() @@ -868,7 +868,7 @@ describe('PersistenceCoordinator retirement', () => { const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => { session = inner.sessions.create(SessionId('inflight-retirement')) }, { inject: ['sessions'] })) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await sessionFiber.dispose() await vi.waitFor(() => { @@ -916,7 +916,7 @@ describe('PersistenceCoordinator retirement', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }]) await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) @@ -1053,7 +1053,7 @@ describe('SessionPersistence service registration', () => { const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => { session = inner.sessions.create(SessionId(`disposed-${index}`)) }, { inject: ['sessions'] })) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.sessions.flush(session) await sessionFiber.dispose() diff --git a/packages/session-projection/session-projection-cache/tests/cache.spec.ts b/packages/session-projection/session-projection-cache/tests/cache.spec.ts index 8cf9345772..8474e21594 100644 --- a/packages/session-projection/session-projection-cache/tests/cache.spec.ts +++ b/packages/session-projection/session-projection-cache/tests/cache.spec.ts @@ -221,7 +221,7 @@ describe('SessionProjectionCache write policy', () => { describe('SessionProjectionCache cold read', () => { const storedLog = (marks: string[][]): SessionEvent[] => { const events: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }, ] for (const m of marks) { events.push({ type: 'cache-test/mark', seq: events.length, time: events.length, data: { marks: m } }) diff --git a/packages/session-projection/session-projection/tests/registry.spec.ts b/packages/session-projection/session-projection/tests/registry.spec.ts index 90b8de4a40..bd33914b2d 100644 --- a/packages/session-projection/session-projection/tests/registry.spec.ts +++ b/packages/session-projection/session-projection/tests/registry.spec.ts @@ -97,7 +97,7 @@ describe('SessionProjectionRegistry drive', () => { }) const event = mark(session, ['a']) // Non-matching event: apply returns the same reference — no notification. - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) expect(seen).toEqual([{ key: 'test/marks', value: { marks: ['a'] }, seq: event.seq, sessionId: String(session.id) }]) }) @@ -119,7 +119,7 @@ describe('SessionProjectionRegistry drive', () => { ctx.sessionProjections.onChanged((_session, key) => { changedKeys.push(key) }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) // count applied (+1 change), marks returned the same reference. expect(changedKeys).toEqual(['test/count']) const snapshot = ctx.sessionProjections.snapshot(session) @@ -235,7 +235,7 @@ describe('SessionProjectionRegistry drive', () => { }, tail, 3)).toThrow(/re-read from seq 0/) // The full-log re-read (baseSeq 0) refolds the mismatched key from init. const full: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }, { type: 'test/mark', seq: 1, time: 1, data: { marks: ['old'] } }, { type: 'test/mark', seq: 2, time: 2, data: { marks: ['old', '2'] } }, ...tail, @@ -261,7 +261,7 @@ describe('SessionProjectionRegistry drive', () => { 'test/count': { ver: 1, seq: 2, val: 3 }, } const tail: SessionEvent[] = [ - { type: 'turn/start', seq: 3, time: 3, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 3, time: 3, data: { turn: 2 } }, { type: 'turn/end', seq: 4, time: 4, data: { turn: 2, reason: { kind: 'completed' } } }, ] const { snapshot } = ctx.sessionProjections.restore(rows, tail, 3) @@ -309,7 +309,7 @@ describe('SessionProjectionRegistry drive', () => { expect(() => ctx.sessionProjections.restore(rows, [], 9)).toThrow(/re-read from seq 0/) // The full re-read discards the overreaching row and refolds from init. const events: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }, { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } }, ] const { snapshot } = ctx.sessionProjections.restore(rows, events, 0) diff --git a/packages/session-query/session-query/src/extraction.ts b/packages/session-query/session-query/src/extraction.ts index ffdde75e04..be52d469dd 100644 --- a/packages/session-query/session-query/src/extraction.ts +++ b/packages/session-query/session-query/src/extraction.ts @@ -45,12 +45,9 @@ export function extractSessionEventText(event: SessionEvent): string { function turnEndText(reason: SessionEvent<'turn/end'>['data']['reason']): string { switch (reason.kind) { case 'error': - return 'failure' in reason - ? joinText(['error', reason.failure.message, reason.failure.code]) - : joinText(['error', reason.message, reason.code ?? '']) + return joinText(['error', reason.error instanceof Error ? reason.error.message : String(reason.error)]) case 'aborted': return 'aborted' - case 'disposed': case 'max-tokens': case 'interrupted': return reason.kind diff --git a/packages/session-query/session-query/tests/search-helpers.spec.ts b/packages/session-query/session-query/tests/search-helpers.spec.ts index 5117de7ef7..891b95892c 100644 --- a/packages/session-query/session-query/tests/search-helpers.spec.ts +++ b/packages/session-query/session-query/tests/search-helpers.spec.ts @@ -125,7 +125,7 @@ describe('session-query semantic extraction', () => { expect(extractSessionEventText({ type: 'turn/end', seq: 0, time: 1, data: { turn: 1, reason } })).toBe(text) } const structural: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, { type: 'step/end', seq: 2, time: 1, data: { turn: 1, step: 1 } }, { type: 'assistant/chunk', seq: 3, time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } }, diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index c22bbcd98a..c0ffdd2c7f 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -893,7 +893,7 @@ describe('session-query exact reads', () => { it('classifies current, shadowed, and raw-log-only events through foldSurface', async () => { const ctx = await liveContext() const session = ctx.sessions.create(SessionId('surface')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) const first = session.append( 'user/message', @@ -1008,7 +1008,7 @@ describe('session-query exact reads', () => { it('returns a bounded detached raw-event window and validates the request', async () => { const ctx = await liveContext({ readWindowMax: 1 }) const session = ctx.sessions.create(SessionId('window'), { meta: { cwd: '/work' } }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) for (const text of ['one', 'two', 'three']) { session.append( 'user/message', @@ -1050,7 +1050,7 @@ describe('session-query exact reads', () => { ]) const ctx = await liveContext() const live = ctx.sessions.create(shared.id, { meta: { createdAt: 3, cwd: '/same' } }) - live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + live.append('turn/start', { turn: 1 }) live.append( 'user/message', createUserMessage({ @@ -1090,7 +1090,7 @@ describe('session-query exact reads', () => { TestPersistence.reset() const ctx = await liveContext() const live = ctx.sessions.create(SessionId('live')) - live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + live.append('turn/start', { turn: 1 }) live.append( 'user/message', createUserMessage({ diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index 21e1a9089b..a6b5d73eb9 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -106,7 +106,7 @@ function expectCode(code: SessionQueryErrorCode): Error { } function appendTraceEvents(session: Session): void { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) session.append('assistant/chunk', { turn: 1, @@ -348,7 +348,7 @@ describe('session event tracing', () => { expect([TracePersistence.listCalls, TracePersistence.inspectCalls]).toEqual([1, 1]) const live = ctx.sessions.create(durable.id, { meta: { createdAt: 1, cwd: '/same' } }) - live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + live.append('turn/start', { turn: 1 }) live.append( 'user/message', createUserMessage({ @@ -412,7 +412,7 @@ describe('session event tracing', () => { it.each([ ['non-surface sources', [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, sourceEventSeqs: [0] }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 }, sourceEventSeqs: [0] }, ]], ['invalid source array', [ { ...appendEvent(0), sourceEventSeqs: 'invalid' }, @@ -461,7 +461,7 @@ describe('session event tracing', () => { type: 'turn/start', seq: 0, time: 1, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, surfaceOp: 'append', }] as unknown as SessionEvent[] TracePersistence.reset([{ meta: durable, events }]) diff --git a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts index 414bebc867..09c9c2e812 100644 --- a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts +++ b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts @@ -64,7 +64,7 @@ describe('tool-session-query with the real SQLite provider', () => { const caller = ctx.sessions.create(SessionId('caller'), { meta: { createdAt: 10, cwd: '/work' }, }) - caller.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + caller.append('turn/start', { turn: 1 }) caller.append( 'user/message', createUserMessage({ diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index b6fab0e68e..dc513e890b 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -64,7 +64,7 @@ function createSession( } function openStep(session: Session, text = 'prior needle'): void { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append( 'user/message', createUserMessage({ diff --git a/packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts b/packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts index f8d6117d10..5390607d23 100644 --- a/packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts +++ b/packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts @@ -32,7 +32,7 @@ async function settle(): Promise { describe('all-messages LLM title provider', () => { it('includes seeded history and the latest prompt while inheriting the logged request route', async () => { const seeded = new Session(SessionId('seed-source')) - seeded.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + seeded.append('turn/start', { turn: 1 }) const inherited = seeded.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'inherited prompt' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -52,7 +52,7 @@ describe('all-messages LLM title provider', () => { seed: seeded.events, meta: { parentSession: seeded.id, seedLength: seeded.seq }, }) - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 2 }) const latest = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'latest prompt' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) diff --git a/packages/session-title/session-title-first-message-llm/tests/loader-composition.spec.ts b/packages/session-title/session-title-first-message-llm/tests/loader-composition.spec.ts index 25fded0c38..eb0125efd9 100644 --- a/packages/session-title/session-title-first-message-llm/tests/loader-composition.spec.ts +++ b/packages/session-title/session-title-first-message-llm/tests/loader-composition.spec.ts @@ -93,7 +93,6 @@ describe('session-title Loader composition', () => { const session = ctx.sessions.create(SessionId('loader-title')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const message = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Compose a title through Loader' }], diff --git a/packages/session-title/session-title-first-message-llm/tests/provider.e2e.ts b/packages/session-title/session-title-first-message-llm/tests/provider.e2e.ts index 1268656551..3154a3577c 100644 --- a/packages/session-title/session-title-first-message-llm/tests/provider.e2e.ts +++ b/packages/session-title/session-title-first-message-llm/tests/provider.e2e.ts @@ -37,7 +37,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('first-message title provider wit const session = ctx.sessions.create(SessionId('real-title-provider')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const message = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Explain why append-only logs make session titles durable.' }], diff --git a/packages/session-title/session-title-first-message-llm/tests/provider.spec.ts b/packages/session-title/session-title-first-message-llm/tests/provider.spec.ts index 5b9f7240ec..554cfab8ca 100644 --- a/packages/session-title/session-title-first-message-llm/tests/provider.spec.ts +++ b/packages/session-title/session-title-first-message-llm/tests/provider.spec.ts @@ -60,7 +60,7 @@ describe('first-message LLM title provider', () => { ctx.llm.registerAdapter(['title-route'], adapter) await ctx.plugin(providerPlugin, LLM_CONFIG) const session = ctx.sessions.create(SessionId('first-plugin')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const first = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'first input' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) diff --git a/packages/session-title/session-title-llm/tests/llm.spec.ts b/packages/session-title/session-title-llm/tests/llm.spec.ts index beed98ec45..17d1a2b2b9 100644 --- a/packages/session-title/session-title-llm/tests/llm.spec.ts +++ b/packages/session-title/session-title-llm/tests/llm.spec.ts @@ -80,7 +80,6 @@ function request(ctx: Context, signal = new AbortController().signal): SessionTi const session = ctx.sessions.create(SessionId(`title-call-${++nextSession}`)) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const first = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'first prompt' }], diff --git a/packages/session-title/session-title/tests/persistence.spec.ts b/packages/session-title/session-title/tests/persistence.spec.ts index c4023ccce7..7d5428983f 100644 --- a/packages/session-title/session-title/tests/persistence.spec.ts +++ b/packages/session-title/session-title/tests/persistence.spec.ts @@ -25,7 +25,6 @@ async function appendPersistedTitle(ctx: Context, id: ReturnType { const firstSeq = appendTitle(session, 'First title') const secondSeq = appendTitle(session, 'Second title') // Unrelated event: same-reference apply, no notification. - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) expect(changes).toEqual([ { key: 'title', value: 'First title', seq: firstSeq }, { key: 'title', value: 'Second title', seq: secondSeq }, diff --git a/packages/session-title/session-title/tests/provider.spec.ts b/packages/session-title/session-title/tests/provider.spec.ts index c47b6c32e8..5bfe30ac14 100644 --- a/packages/session-title/session-title/tests/provider.spec.ts +++ b/packages/session-title/session-title/tests/provider.spec.ts @@ -55,7 +55,6 @@ describe('SessionTitleService provider lifecycle', () => { const parent = ctx.sessions.create(SessionId('title-parent')) parent.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const inheritedMessage = appendHumanPrompt(parent, 'Inherited title prompt') await settle() @@ -77,7 +76,6 @@ describe('SessionTitleService provider lifecycle', () => { }) child.append('turn/start', { turn: 2, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const childMessage = appendHumanPrompt(child, 'Child follow-up prompt') await settle() @@ -98,7 +96,6 @@ describe('SessionTitleService provider lifecycle', () => { }) child.append('turn/start', { turn: 3, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const latestMessage = appendHumanPrompt(child, 'Retitle the fork now') await settle() @@ -136,7 +133,6 @@ describe('SessionTitleService provider lifecycle', () => { const session = ctx.sessions.create(SessionId('first-provider')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const first = appendHumanPrompt(session, 'Explain asynchronous title generation') await settle() @@ -195,7 +191,6 @@ describe('SessionTitleService provider lifecycle', () => { const session = ctx.sessions.create(SessionId('dispose-provider')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const message = appendHumanPrompt(session, 'Generate this title') await settle() @@ -245,7 +240,6 @@ describe('SessionTitleService provider lifecycle', () => { const session = ctx.sessions.create(SessionId('supersede')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const first = appendHumanPrompt(session, 'First prompt') await settle() @@ -286,7 +280,6 @@ describe('SessionTitleService provider lifecycle', () => { const session = ctx.sessions.create(SessionId('unchanged-route')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const first = appendHumanPrompt(session, 'First routed prompt') await settle() @@ -298,7 +291,6 @@ describe('SessionTitleService provider lifecycle', () => { session.append('turn/start', { turn: 2, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const second = appendHumanPrompt(session, 'Second prompt on the same route') await settle() @@ -345,7 +337,6 @@ describe('SessionTitleService provider lifecycle', () => { const pending = ctx.sessions.create(SessionId('unmatched-boundary')) pending.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) appendHumanPrompt(pending, 'Wait for a matching request boundary') await settle() @@ -369,7 +360,6 @@ describe('SessionTitleService provider lifecycle', () => { const session = ctx.sessions.create(SessionId('failure')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) appendHumanPrompt(session, 'Keep a fallback') await settle() diff --git a/packages/session-title/session-title/tests/service-contracts.spec.ts b/packages/session-title/session-title/tests/service-contracts.spec.ts index f481d8b02e..609e9fb3b9 100644 --- a/packages/session-title/session-title/tests/service-contracts.spec.ts +++ b/packages/session-title/session-title/tests/service-contracts.spec.ts @@ -37,7 +37,6 @@ function startSession(ctx: Context, id: string): ReturnType { disposeCtx.sessions.announce(disposed) disposed.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const disposedMessage = appendPrompt(disposed, 'Dispose this session') await settle() @@ -168,7 +166,6 @@ describe('SessionTitleService configuration and refresh boundaries', () => { const seed = new Session(SessionId('fallback-concurrency-seed')) seed.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const source = appendPrompt(seed, 'Create exactly one fallback title') seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -337,7 +334,6 @@ describe('SessionTitleService configuration and refresh boundaries', () => { }) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) appendPrompt(session, 'Detach before the fallback microtask') await settle() diff --git a/packages/session-title/session-title/tests/session-title.spec.ts b/packages/session-title/session-title/tests/session-title.spec.ts index b43d5a1876..8b2a24a314 100644 --- a/packages/session-title/session-title/tests/session-title.spec.ts +++ b/packages/session-title/session-title/tests/session-title.spec.ts @@ -43,7 +43,6 @@ describe('SessionTitleService', () => { const session = ctx.sessions.create(SessionId('fresh')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) const message = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: ' Build\nlog-backed session titles please ' }], @@ -80,7 +79,6 @@ describe('SessionTitleService', () => { const session = ctx.sessions.create(SessionId('prefixed-title')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Explain this referenced session' }], @@ -99,7 +97,6 @@ describe('SessionTitleService', () => { const session = ctx.sessions.create(SessionId('eligibility')) session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'plugin text' }], diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 5348af69b6..9937417ec3 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -4,7 +4,6 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { Context } from 'cordis' import { createUserMessage, CallId, type Message } from '@deepseek-ai/dsh-llm' -import {} from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' import { createScope, type Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' @@ -46,7 +45,6 @@ function agentForCwd(cwd: string): Agent { session, status: 'idle', acceptsNextStep: false, - send: () => {}, followup: () => {}, steer: () => {}, inject(input) { diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index c6f9b4059b..e165440f0d 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -10,7 +10,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' -import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' @@ -47,7 +47,6 @@ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason { case 'aborted': return 'aborted' case 'error': - case 'disposed': case 'interrupted': default: return 'error' @@ -160,7 +159,8 @@ export async function startInProcessRun( const result: Promise = (async () => { try { - child.followup(createUserMessage({ content: request.prompt, source: { kind: 'user' } })) + const message = createUserMessage({ content: request.prompt, source: { kind: 'user' } }) + child.followup(message) await child.whenIdle() return readResult( child, @@ -194,12 +194,11 @@ function readResult( ): SubagentResult { const own = child.session.events.slice(seedLength) const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message') - const lastEnd = findLastMessageTurnEnd(own) + const lastEnd = own.findLast((event): event is SessionEvent<'turn/end'> => event.type === 'turn/end') const output: ContentBlock[] = lastMessage?.data.message.content ?? [] const recorded = toStopReason(lastEnd?.data.reason) - // Disposal can tear the owner down before the loop records its ordinary - // `aborted` end, yielding `disposed` instead. A requested cancellation owns - // every non-completed in-flight outcome; a turn already completed stays so. + // A requested cancellation owns every non-completed in-flight outcome; a + // turn already completed stays so. const stopReason: SubagentStopReason = cancelled && recorded !== 'completed' ? 'aborted' : recorded diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index fb631ee10e..0697af4b72 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -56,34 +56,27 @@ describe('startInProcessRun', () => { expect(ctx.agents.get(run.id)).toBeUndefined() }) - it('reports the message-turn outcome when a later non-message turn completes during flush', async () => { - const { ctx, parent } = await setup([maxTokensResponse('partial answer')]) - let injected = false - ctx.on('session/flush', (session) => { - if (injected || session.header.parentSession === undefined) return - const lastEnd = session.events.findLast(event => event.type === 'turn/end') - if (lastEnd?.type !== 'turn/end' || lastEnd.data.reason.kind !== 'max-tokens') return - injected = true - const turn = lastEnd.data.turn + 1 - session.append('turn/start', { - turn, - trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } }, - }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'late metadata' }], - source: { kind: 'plugin', plugin: 'late-metadata' }, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn, reason: { kind: 'completed' } }) + it('reports the final whole-agent outcome after idle replacement work', async () => { + const { ctx, parent } = await setup([maxTokensResponse('partial answer'), textResponse('replacement answer')]) + let replaced = false + ctx.on('agent/status', (agent, status) => { + if (replaced || status !== 'idle' || agent.session.header.parentSession === undefined) return + replaced = true + agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'replacement work' }], + source: { kind: 'plugin', plugin: 'replacement' }, + })) }) const run = await startInProcessRun(request(parent), {}) const result = await run.result const child = ctx.agents.get(run.id)! - expect(injected).toBe(true) + expect(replaced).toBe(true) expect(child.session.events.findLast(event => event.type === 'turn/end')) .toMatchObject({ data: { reason: { kind: 'completed' } } }) - expect(result.stopReason).toBe('max-tokens') + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('replacement answer') await run.dispose() }) diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index d693c0571b..467cc26a0b 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -141,7 +141,7 @@ describe('deriveReplayScript', () => { it('ignores non-assistant/chunk events', () => { let seq = 1 const events: SessionEvent[] = [ - { type: 'turn/start', seq: seq++, time: 0, data: { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: seq++, time: 0, data: { turn: 1 } }, ...TEXT_CHUNKS.map(c => chunkEvent(seq++, 1, 1, c)), { type: 'turn/end', seq: seq++, time: 0, data: { turn: 1, reason: { kind: 'completed' } } }, ] diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 590f752956..da595c602a 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -28,7 +28,6 @@ function stubAgent(ctx: Context, rawId: string): Agent { followup: () => {}, steer: () => {}, inject: () => {}, - send: () => {}, cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index cccb90ed43..d6f8bf0664 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -93,7 +93,7 @@ describe('TelemetryOtel wire', () => { const { url, captures } = await mockCollector() const { ctx, fiber } = await boot(url) const session = ctx.sessions.create(SessionId('wire'), { meta: { cwd: '/tmp/w' } }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } }) await fiber.dispose() @@ -146,7 +146,7 @@ describe('TelemetryOtel wire', () => { processor: { scheduledDelayMillis: 10 }, }) const session = ctx.sessions.create(SessionId('drain'), { meta: {} }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) await arrived.promise const disposal = fiber.dispose() @@ -171,7 +171,7 @@ describe('TelemetryOtel wire', () => { exporter: { url, compression: 'gzip' }, } as Config) const session = ctx.sessions.create(SessionId('gzip'), { meta: {} }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) await fiber.dispose() expect(captures.length).toBeGreaterThan(0) @@ -186,7 +186,7 @@ describe('TelemetryOtel wire', () => { const { ctx, fiber } = await boot(url) ctx.on('telemetry/record', (_record, next) => ({ ...next(), severity: 'warn' })) const session = ctx.sessions.create(SessionId('warn'), { meta: {} }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) // No flush(): the coordinator's optional-call forwarding no-ops, and the // batch processor owns export cadence end to end (see the backend note). expect('flush' in ctx.telemetry && ctx.telemetry.flush !== undefined).toBe(false) diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index a449a4053d..c5d3fb650a 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -70,7 +70,7 @@ function liveSession(ctx: Context, id = `s-${Math.random().toString(36).slice(2) } function appendTurn(session: Session): void { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -108,7 +108,7 @@ describe('TelemetryCoordinator capture', () => { it('maps outcome flags to severity, unknown types falling through as info', async () => { const { ctx, backend } = await setup() const session = liveSession(ctx) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ @@ -194,7 +194,7 @@ describe('TelemetryCoordinator adoption', () => { const ctx = new Context() await ctx.plugin(SessionStore) const donor = ctx.sessions.create(SessionId('donor'), { meta: {} }) - donor.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + donor.append('turn/start', { turn: 1 }) donor.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } }) const resumed = ctx.sessions.create(SessionId('resumed'), { seed: [...donor.events], meta: {} }) await ctx.plugin({ @@ -261,7 +261,7 @@ describe('TelemetryCoordinator adoption', () => { const backend = new FakeBackend() const { ctx, fiber } = await setup(backend) const session = liveSession(ctx, 'hmr') - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } }) expect(backend.ledger()).toHaveLength(2) @@ -408,7 +408,7 @@ describe('TelemetryCoordinator lifecycle and containment', () => { const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const session = liveSession(ctx) backend.emitError = new Error('backend broke') - expect(() => session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })).not.toThrow() + expect(() => session.append('turn/start', { turn: 1 })).not.toThrow() expect(warn).toHaveBeenCalled() backend.emitError = undefined session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) diff --git a/packages/todo/tool-todo/tests/invariant.spec.ts b/packages/todo/tool-todo/tests/invariant.spec.ts index abfcd74b29..f186711845 100644 --- a/packages/todo/tool-todo/tests/invariant.spec.ts +++ b/packages/todo/tool-todo/tests/invariant.spec.ts @@ -47,7 +47,7 @@ describe('todo snapshot invariants', () => { expect(() => { ctx.emit('tools/change') ctx.emit('session/event', {} as Session, { - type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + type: 'turn/start', seq: 0, time: 0, data: { turn: 1 }, }) }).not.toThrow() }) diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index ede937801d..7090ddfca6 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -99,7 +99,7 @@ describe('todos projection provider', () => { session.append('todo/write', { todos: list }) session.append('turn/end', { turn: 0, reason: { kind: 'completed' } }) expect((await bench.tailProjections())?.values.todos).toEqual(list) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const cleared = await bench.tailProjections() expect(cleared?.values.todos).toBeNull() expect(cleared?.asOfSeq).toBe(session.seq - 1) diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts index f22e974d58..770c46ef61 100644 --- a/packages/ui/commands/tests/commands.spec.ts +++ b/packages/ui/commands/tests/commands.spec.ts @@ -396,7 +396,7 @@ describe('CommandService', () => { const ctx = await mount() const { agent } = await mintAgentScope(ctx, 'a') ctx.commands.register(command('mid')) - agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + agent.session.append('turn/start', { turn: 1 }) await ctx.commands.execute(agent, '/mid', new AbortController().signal) expect(agent.session.events.map(event => event.type)).toEqual([ 'turn/start', 'command/run', 'command/done', diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index aa4769aebc..7143e4c525 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -10,7 +10,7 @@ import { resolve } from 'node:path' import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope' -import { findLastMessageTurnEnd, SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' +import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' import type SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -28,7 +28,6 @@ import type { interface SessionRecord { handle: AgentHandle - lastTurnEnd: TurnEndReason | undefined activePrompt: boolean } @@ -72,12 +71,6 @@ export class HarnessSdkServer { ) { const serverOptions = this.options this.disposers.push(ctx.on('session/event', (session, event) => { - if (event.type === 'turn/end') { - const rec = this.sessions.get(String(session.id)) - if (rec && findLastMessageTurnEnd(session.events)?.seq === event.seq) { - rec.lastTurnEnd = event.data.reason - } - } const payload: SessionEventNotification = { sessionId: String(session.id), event } this.transport.notify('session.event', payload) })) @@ -146,13 +139,15 @@ export class HarnessSdkServer { } rec.activePrompt = true try { - rec.lastTurnEnd = undefined - rec.handle.agent.followup(createUserMessage({ content: params.contentBlocks, source: { kind: 'user' } })) + const message = createUserMessage({ content: params.contentBlocks, source: { kind: 'user' } }) + rec.handle.agent.followup(message) await rec.handle.agent.whenIdle() + const lastEnd = rec.handle.agent.session.events.findLast(event => event.type === 'turn/end') + const reason = lastEnd?.data.reason const payload: SessionFinishedNotification = { sessionId: params.sessionId, - status: this.finishedStatus(rec.lastTurnEnd), - reason: rec.lastTurnEnd, + status: this.finishedStatus(reason), + reason, } this.transport.notify('session.finished', payload) return { accepted: true } @@ -244,7 +239,7 @@ export class HarnessSdkServer { ...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens }, }, }) - const rec: SessionRecord = { handle, lastTurnEnd: undefined, activePrompt: false } + const rec: SessionRecord = { handle, activePrompt: false } this.sessions.set(sessionId, rec) return rec } diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 19ad56881c..7f634474da 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -255,7 +255,7 @@ describe('HarnessSdkServer', () => { await server.shutdown() }) - it('reports the message-turn outcome when a later non-message turn settles before idle', async () => { + it('reports the final whole-agent outcome after later activity settles', async () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) @@ -272,13 +272,11 @@ describe('HarnessSdkServer', () => { followup(input: UserMessage) { session.append('turn/start', { turn: 1, - trigger: { kind: 'message', source: input.source }, }) session.append('user/message', input, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } }) session.append('turn/start', { turn: 2, - trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } }, }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'late metadata' }], @@ -306,8 +304,8 @@ describe('HarnessSdkServer', () => { method: 'session.finished', params: { sessionId: 'message-outcome', - status: 'error', - reason: { kind: 'max-tokens' }, + status: 'ok', + reason: { kind: 'completed' }, }, }) await server.shutdown() diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index 5e9237574a..9a17408de9 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -460,7 +460,6 @@ function resumeTurnLabel(snapshot: SessionLogSnapshot): string { case 'completed': return `turn ${event.data.turn}: completed` case 'aborted': return `turn ${event.data.turn}: cancelled` case 'error': return `turn ${event.data.turn}: error` - case 'disposed': return `turn ${event.data.turn}: disposed` case 'max-tokens': return `turn ${event.data.turn}: max tokens` case 'interrupted': return `turn ${event.data.turn}: interrupted` default: return `turn ${event.data.turn}: unknown result` diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index d1ba37e61d..c04dc8582b 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -5,20 +5,7 @@ * @module @deepseek-ai/dsh-tui */ -import { - CombinedAutocompleteProvider, - Container, - Key, - Spacer, - Text, - TUI, - ProcessTerminal, - matchesKey, - visibleWidth, - type EditorTheme, - type SlashCommand, - type TerminalColorScheme, -} from '@earendil-works/pi-tui' +import { CombinedAutocompleteProvider, Container, Key, Spacer, Text, TUI, ProcessTerminal, matchesKey, visibleWidth, type EditorTheme, type SlashCommand, type TerminalColorScheme } from '@earendil-works/pi-tui' import { Service, type Context, type Fiber } from 'cordis' import { assembleContextFor, @@ -343,6 +330,7 @@ export function createTuiChat( const pendingSteering = new Set() let disposed = false let shuttingDown: Promise | undefined + let exitAfterIdle = false // Optional: skills mount conditionally, so read the global service store // rather than declaring an injection that would make the TUI require them. const skills = ctx.get('skills') @@ -786,9 +774,15 @@ export function createTuiChat( case 'completed': break case 'error': { - const key = `${event.data.turn}:${reason.step}` - const message = 'failure' in reason ? reason.failure.message : reason.message - if (!liveErrors.delete(key)) appendNotice(message, 'error') + const prefix = `${event.data.turn}:` + let alreadyReported = false + for (const key of liveErrors) { + if (!key.startsWith(prefix)) continue + liveErrors.delete(key) + alreadyReported = true + } + const message = reason.error instanceof Error ? reason.error.message : String(reason.error) + if (!alreadyReported) appendNotice(message, 'error') break } case 'aborted': @@ -797,9 +791,6 @@ export function createTuiChat( case 'max-tokens': appendNotice('The model reached its output-token limit.', 'warning') break - case 'disposed': - appendNotice('Turn stopped: the agent was disposed.', 'warning') - break case 'interrupted': appendNotice('The previous process ended during this turn.', 'warning') break @@ -892,9 +883,9 @@ export function createTuiChat( const requestExit = (): void => { if (agent.status === 'running') { + exitAfterIdle = true agent.cancel({ kind: 'user' }) appendNotice('Cancelling the active turn before exit…', 'warning') - void agent.whenIdle().then(() => shutdown(true)) return } void shutdown(true) @@ -1190,67 +1181,14 @@ export function createTuiChat( appendNotice(`Agent "${agent.id}" is disposed.`, 'error') return } - if (agent.acceptsNextStep) { - // Steering is never subject to prompt admission; an attached snapshot - // drains beside it at the same step boundary through the outbox. - if (attachedContext !== undefined) { - agent.inject(attachedContext) - } - const message = createUserMessage({ content, source: { kind: 'user' } }) - agent.steer(message) - pendingSteering.add(message.id) - refreshStatus() - return - } - if (attachedContext === undefined) { - agent.followup(createUserMessage({ content, source: { kind: 'user' } })) - return - } - // Idle: the snapshot rides the prompt's admission transaction so a - // blocking hook discards both together. - let cleanedUp = false - const message: UserMessage = createUserMessage({ content, source: { kind: 'user' } }) - const acceptedId = message.id - const discarded = new Set() - const cleanup = (): void => { - // Every completion path detaches both listeners. Keep this - // idempotent so later cleanup paths cannot double-release them. - /* v8 ignore next -- unreachable idempotence guard, see above */ - if (cleanedUp) return - cleanedUp = true - detachSubmit() - detachDiscard() - } - // Prepended so this wrapper is outermost: it observes the exact accepted - // message identity whether a downstream hook allows or blocks, then detaches. - const detachSubmit = ctx.on('agent/prompt-submit', async (subject, submitted, _signal, next) => { - if (subject !== agent || submitted.id !== message.id) return next() - cleanup() - const decision = await next() - if (decision.kind !== 'allow') return decision - return { ...decision, additionalContexts: [...decision.additionalContexts ?? [], attachedContext] } - }, { prepend: true }) - // Installed before followup(): an enqueue listener can synchronously - // cancel and discard before followup() returns its id. - const detachDiscard = ctx.on('agent/inbox/discard', (subject, messages) => { - if (subject !== agent) return - for (const message of messages) discarded.add(message.id) - if (discarded.has(acceptedId)) cleanup() - }) - // followup() accepts any typed input and contains listener failures; - // this guards a future synchronous throw so the wrapper cannot leak. - /* v8 ignore start -- future-proofing guard, see above */ - try { - agent.followup(message) - if (discarded.has(acceptedId)) cleanup() - } catch (error: unknown) { - cleanup() - throw error - } - /* v8 ignore stop */ + if (attachedContext !== undefined) agent.inject(attachedContext) + const message = createUserMessage({ content, source: { kind: 'user' } }) + agent.steer(message) + pendingSteering.add(message.id) + refreshStatus() } - /** Deliver a user turn to the agent: steer while running, send while idle, or report a disposed agent. */ + /** Deliver user input to the nearest step, or report a disposed agent. */ const deliver = (payload: string): void => { dispatchMessage([{ type: 'text', text: payload }]) } @@ -1448,14 +1386,11 @@ export function createTuiChat( const settlePendingSteering = (id: MessageId): void => { if (pendingSteering.delete(id)) refreshStatus() } - const disposeDequeued = ctx.on('agent/inbox/dequeue', (subject, message) => { + const disposeDequeued = ctx.on('agent/inbox/admitted', (subject, message) => { if (subject === agent) settlePendingSteering(message.id) }) - const disposeDiscarded = ctx.on('agent/inbox/discard', (subject, messages) => { - if (subject !== agent) return - let changed = false - for (const message of messages) changed = pendingSteering.delete(message.id) || changed - if (changed) refreshStatus() + const disposeDiscarded = ctx.on('agent/inbox/canceled', (subject, message) => { + if (subject === agent && pendingSteering.delete(message.id)) refreshStatus() }) const disposeStatus = ctx.on('agent/status', (subject, status) => { if (subject !== agent) return @@ -1464,6 +1399,10 @@ export function createTuiChat( // the queue without logging drains, cannot strand a stale count). if (status !== 'running') pendingSteering.clear() setStatus(status) + if (status === 'idle' && exitAfterIdle) { + exitAfterIdle = false + void shutdown(true) + } }) const disposeError = ctx.on('agent/error', (subject, turn, step, error) => { if (subject !== agent) return diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 87282276f8..3c120f0c5e 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -6,7 +6,6 @@ import AgentRegistry, { type AgentCancelCause, type AgentOptions, type AgentStatus, - type SendOptions, } from '@deepseek-ai/dsh-agent' import type { ContentBlock, @@ -27,7 +26,6 @@ interface FakeAgent extends Agent { status: AgentStatus sent: ContentBlock[][] sentMessages: UserMessage[] - sentOptions: (SendOptions | undefined)[] steered: ContentBlock[][] steeredIds: MessageId[] steeredOptions: UserMessage[] @@ -178,7 +176,6 @@ export async function createTuiTestHarness { session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) session.append('turn/start', { turn: 2, - trigger: { kind: 'message', source: { kind: 'user' } }, }) appendUser(session, 'Next question.') }, @@ -774,14 +773,14 @@ describe('TUI terminal-state snapshots', () => { turn: 1, reason: { kind: 'error', step: 1, message: 'provider stream failed after partial output' }, }) - harness.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + harness.session.append('turn/start', { turn: 2 }) harness.session.append('turn/end', { turn: 2, reason: { kind: 'interrupted' }, }) - harness.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) + harness.session.append('turn/start', { turn: 3 }) harness.session.append('turn/end', { turn: 3, reason: { kind: 'disposed' } }) - harness.session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }) + harness.session.append('turn/start', { turn: 4 }) // A merge-extensible turn-end kind unknown to the TUI still surfaces its // name so the agent never stops without a visible reason. harness.session.append('turn/end', { turn: 4, reason: { kind: 'plugin-policy' } as never }) @@ -822,7 +821,7 @@ describe('TUI terminal-state snapshots', () => { const log = (meta: typeof earlier, title: string, day: string): { meta: typeof earlier; events: SessionEvent[] } => ({ meta, events: [ - { type: 'turn/start', seq: 0, time: Date.parse(`${day}T00:00:01Z`), data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time: Date.parse(`${day}T00:00:01Z`), data: { turn: 1 } }, { type: 'user/message', seq: 1, time: Date.parse(`${day}T00:00:02Z`), data: createUserMessage({ content: [{ type: 'text', text: 'restore the selector' }], source: { kind: 'user' } }), surfaceOp: 'append' }, { type: 'step/start', seq: 2, time: Date.parse(`${day}T00:00:03Z`), data: { turn: 1, step: 1 } }, { type: 'request/header', seq: 3, time: Date.parse(`${day}T00:00:04Z`), data: { header: { config: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, reason: 'initial' } }, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index e918f9b299..2090a9824a 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -239,7 +239,7 @@ describe('goodbye message and /resume', () => { time = 100, reason: TurnEndReason = { kind: 'completed' }, ): SessionEvent[] => [ - { type: 'turn/start', seq: 0, time, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 0, time, data: { turn: 1 } }, { type: 'user/message', seq: 1, time: time + 1, data: createUserMessage({ content: [{ type: 'text', text: 'resume me' }], source: { kind: 'user' }, }), surfaceOp: 'append' }, @@ -1333,9 +1333,9 @@ describe('pi-tui chat lifecycle and transcript', () => { appendAssistant(result.session, []) result.session.append('step/end', { turn: 1, step: 1 }) result.session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } }) - result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + result.session.append('turn/start', { turn: 2 }) result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - result.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) + result.session.append('turn/start', { turn: 3 }) result.session.append('step/start', { turn: 3, step: 1 }) result.session.append('assistant/chunk', { turn: 3, @@ -1872,7 +1872,7 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).not.toContain('│') expect(result.terminal.output).not.toContain('Response 1s') - result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + result.session.append('turn/start', { turn: 2 }) result.session.append('step/start', { turn: 2, step: 1 }) clock += 1_000 result.terminal.output = '' @@ -3760,7 +3760,7 @@ describe('pi-tui chat lifecycle and transcript', () => { const events = await setup() const unrelatedSession = events.ctx.sessions.create(SessionId('unrelated-session')) const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession } - unrelatedSession.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + unrelatedSession.append('turn/start', { turn: 1 }) unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] }) agentEvents(events.ctx, unrelatedAgent).emit('agent/status', 'running') agentEvents(events.ctx, unrelatedAgent).emit('agent/error', 1, 1, new Error('hidden error')) @@ -3768,22 +3768,22 @@ describe('pi-tui chat lifecycle and transcript', () => { agentEvents(events.ctx, events.agent).emit('agent/error', 1, 1, new Error('live failure')) events.session.append('step/end', { turn: 1, step: 1 }) events.session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'live failure' } }) - events.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + events.session.append('turn/start', { turn: 2 }) events.session.append('turn/end', { turn: 2, reason: { kind: 'error', step: 1, message: 'durable failure' } }) - events.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) + events.session.append('turn/start', { turn: 3 }) events.session.append('turn/end', { turn: 3, reason: { kind: 'aborted' } }) - events.session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }) + events.session.append('turn/start', { turn: 4 }) events.session.append('turn/end', { turn: 4, reason: { kind: 'max-tokens' } }) - events.session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: { kind: 'user' } } }) + events.session.append('turn/start', { turn: 5 }) events.session.append('turn/end', { turn: 5, reason: { kind: 'interrupted' } }) - events.session.append('turn/start', { turn: 6, trigger: { kind: 'message', source: { kind: 'user' } } }) + events.session.append('turn/start', { turn: 6 }) events.session.append('turn/end', { turn: 6, reason: { kind: 'error', step: 1, failure: { message: 'structured provider failure', code: 'SERVER' } }, }) - events.session.append('turn/start', { turn: 8, trigger: { kind: 'message', source: { kind: 'user' } } }) + events.session.append('turn/start', { turn: 8 }) events.session.append('turn/end', { turn: 8, reason: { kind: 'disposed' } }) - events.session.append('turn/start', { turn: 9, trigger: { kind: 'message', source: { kind: 'user' } } }) + events.session.append('turn/start', { turn: 9 }) // Merge-extensible reason kind unknown to the TUI still names the stop. events.session.append('turn/end', { turn: 9, reason: { kind: 'plugin-policy' } as never }) agentEvents(events.ctx, events.agent).emit('agent/disposed') @@ -4715,7 +4715,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() }) @@ -4740,7 +4740,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). @@ -4775,14 +4775,14 @@ describe('terminal mounting', () => { const otherSession = ctx.sessions.create(SessionId('other-session')) ctx.agents.register({ id: otherSession.id, options: {}, session: otherSession, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), }) expect(terminal.started).toBe(0) const session = ctx.sessions.create(SessionId('late-session')) const agent = { id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), } as Agent ctx.agents.register(agent) await tick() @@ -4813,7 +4813,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main-session')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), }) await tick() expect(terminal.started).toBe(0) @@ -4853,11 +4853,11 @@ describe('terminal mounting', () => { await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const session = ctx.sessions.create(SessionId('failed-start-session')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, status: 'running', acceptsNextStep: true, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 0d586049fa..f779b1e250 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -118,7 +118,7 @@ describe('ApprovalService.request', () => { await ctx.plugin(SessionStore) await ctx.plugin(ApprovalService) const session = ctx.sessions.create(SessionId('asked-observer-throw')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const agent = { session } as unknown as Agent const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) ctx.on('session/event', (_session, event) => { @@ -141,7 +141,7 @@ describe('ApprovalService.request', () => { await ctx.plugin(SessionStore) await ctx.plugin(ApprovalService) const session = ctx.sessions.create(SessionId('decided-observer-throw')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const agent = { session } as unknown as Agent const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) ctx.on('session/event', (_session, event) => { @@ -360,7 +360,7 @@ describe('approval policy (the approval/policy fold)', () => { */ function sessionAgent(id: string): { agent: Agent; session: Session; injected: string[] } { const session = new Session(SessionId(id)) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const injected: string[] = [] const agent = { id, diff --git a/packages/ui/user-approval/tests/invariant.spec.ts b/packages/ui/user-approval/tests/invariant.spec.ts index 924272a360..6be086df4a 100644 --- a/packages/ui/user-approval/tests/invariant.spec.ts +++ b/packages/ui/user-approval/tests/invariant.spec.ts @@ -14,7 +14,7 @@ async function setup(): Promise { } function startTurn(session: Session): void { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) } describe('approval invariants', () => { @@ -32,7 +32,7 @@ describe('approval invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1 }) const id = ApprovalRequestId('ask-resume') session.append('approval/asked', { id, toolName: 'bash' }) await ctx.plugin(InvariantService) @@ -54,7 +54,7 @@ describe('approval invariants', () => { expect(() => { ctx.emit('session/event', session, { type: 'turn/start', seq: 0, time: 0, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }) ctx.emit('session/event', session, asked) ctx.emit('session/event', session, decided) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 6b9d698e2a..fc3b61f4af 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -35,10 +35,8 @@ export const LINK_MAP: Record = { ContinuationDecision: 'core.md', ContinuationStop: 'core.md', GenerateOptions: 'core.md', - InboxPlacement: 'core.md', MessageId: 'core.md', HookContext: 'core.md', - SettleReason: 'core.md', LlmCallConfig: 'core.md', LlmModelContext: 'core.md', LlmModelReasoningInfo: 'core.md', @@ -52,7 +50,7 @@ export const LINK_MAP: Record = { UserMessage: 'session.md', PromptDecision: 'core.md', RequestErrorAction: 'core.md', - RequestError: 'core.md', + RequestFailureContext: 'core.md', PreparedReferencedMessage: 'session-reference.md', SessionReferenceCandidate: 'session-reference.md', SessionReferenceInput: 'session-reference.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5821cd0a01..6d43db72ba 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -86,21 +86,11 @@ "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "SendTarget", - "source": "packages/core/agent/src/types.ts" - }, { "doc": "docs/core-data-structures/core.md", "symbol": "InboxPlacement", "source": "packages/core/agent/src/types.ts" }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "SendOptions", - "source": "packages/core/agent/src/types.ts" - }, { "doc": "docs/core-data-structures/core.md", "symbol": "CancelOptions",