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 1c6f4e06ac..6df51077c5 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 @@ -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-21-bounded-llm-request-recovery.md: 22a56dc6d69340ca1b5f7b77edb4731066c9b2f5 -2026-06-21-bounded-llm-request-recovery.zh.md: 09ebce376a206591ac766067cc41497b74ed1545 +# 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: 9c9d8a02595b988535158c9aa0ec43d6f1fa0c89 +2026-06-21-bounded-llm-request-recovery.zh.md: bb9e430eaf87789452fd4cc89085d7d635f54ed1 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 22a56dc6d6..9c9d8a0259 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 @@ -6,9 +6,9 @@ English | [中文](2026-06-21-bounded-llm-request-recovery.zh.md) ## 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. The default decision is `fail`; `dsh-compact-basic` is the only shipped recovery listener, and it retries a canonical context-window overflow only after compaction proves that the durable surface shrank. +`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. -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 step from the durable log. The harness therefore does not need a second response lifecycle or tentative-output protocol to keep two attempts separate. +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. The prior boundary left three narrower gaps. @@ -50,7 +50,7 @@ The shared transient-code set is intentionally small: adapter mappings for `RATE `@deepseek-ai/dsh-llm-retry` is a function plugin that listens to `agent/request-error`. It introduces no service or new loop branch; the agent-loop package changes only the data carried through its existing failed-step recovery control flow. -The `agent/request-error` seam carries the current `LlmFailure` and an immutable list of prior failures that led to another request attempt in this consecutive recovery sequence. `dsh-llm-retry` counts only prior failures whose codes are in its configured transient set, while `dsh-compact-basic` counts only prior context-overflow failures. A successful model request clears the history. Alternating transient and context-overflow failures therefore consume their owning policy budgets independently; the maximum request count is one plus the sum of the finite budgets of the loaded recovery policies. +The `agent/request-error` seam carries only the current `LlmFailure`; the loop owns no retry policy or attempt history. Each recovery plugin keeps a private per-agent counter for its own handled failures and clears it at terminal `agent/settled`. Alternating transient and context-overflow failures therefore consume the `dsh-llm-retry` and compact-basic budgets independently; the maximum request count is one plus the sum of the finite budgets of the loaded recovery policies. The plugin resolves and validates this deployment configuration at load: @@ -68,13 +68,13 @@ The defaults are two transient retries, a 500 millisecond initial delay, a 10 se For an eligible failure with budget remaining, the one-based transient retry count uses bounded exponential backoff. A valid `providerRetryAfterMs` replaces exponential backoff only when it does not exceed `maxDelayMs`; a longer provider delay causes delegation instead of an earlier retry that violates the provider instruction. Local backoff multiplies by an injected random factor in `[1 - jitterRatio, 1 + jitterRatio]` and clamps the final value to `maxDelayMs`; provider delay is not jittered. -The plugin owns a lifetime `AbortController` and tracks every active backoff callback. Each wait fuses the waterfall's turn signal with that lifetime signal. Effect cleanup first unregisters the listener, then aborts and awaits the active callbacks; a captured callback whose lifetime signal aborts returns `fail` and can neither retry nor enter the rest of its captured waterfall after disposal. This makes HMR disposal quiescent even though Cordis has already captured the listener. +The plugin owns a lifetime `AbortController` and tracks every active backoff callback. Each wait fuses the waterfall's turn signal with that lifetime signal. Effect cleanup first unregisters the listener, then aborts and awaits the active callbacks; a captured callback whose lifetime signal aborts returns without retrying or entering the rest of its captured waterfall. This makes HMR disposal quiescent even though Cordis has already captured the listener. Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, one-based transient retry number, configured maximum, scheduled delay, and `LlmFailure`. The plugin owns the `SessionEventMap` augmentation; `dsh-session` remains generic persistence and does not absorb the optional policy's vocabulary. The event says what was scheduled, not that the next request completed; cancellation during the delay is subsequently visible on `turn/end`. The event ships only with a production renderer and replay/snapshot coverage, because its purpose is operational state rather than trace collection. -The listener calls `next()` for a non-transient code, an exhausted policy budget, or an over-cap provider delay. This preserves composition with context-overflow recovery and later policy plugins. It returns `{ action: 'retry' }` only after the delay completes under both signals; turn cancellation and plugin disposal return `fail`, after which the loop's cancellation/disposal checks remain authoritative. +The listener calls `next()` for a non-transient code, an exhausted policy budget, or an over-cap provider delay. This preserves composition with context-overflow recovery and later policy plugins. For an owned failure it records and awaits the delay, then returns `{ kind: 'retry' }` without delegating. Turn cancellation and plugin disposal end the wait without returning a retry; the loop's cancellation/disposal checks remain authoritative. -The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, and ACP example compositions use the same bounded policy. Library consumers retain explicit plugin composition: omitting the plugin leaves `agent/request-error` at its current fail default. +The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, and ACP example compositions use the same bounded policy. Library consumers retain explicit plugin composition: omitting the plugin leaves request failures terminal. ### Make one layer own visible attempts @@ -92,7 +92,7 @@ Boundary tests prove termination at both actual transports. The hand-written ada ### Keep attempts separate in the existing log -A failed attempt may leave `assistant/chunk` events in its closed step, but it never appends `assistant/message` and never dispatches a tool. A retry opens the next numbered step, reconstructs the request from the durable surface, and produces its own chunks. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed step or `turn/end` records terminal failure; message derivation continues to ignore the failed chunks. +A failed attempt may leave `assistant/chunk` events in its closed step, but it never appends `assistant/message` and never dispatches a tool. A retry closes the failed turn, opens the next numbered turn, reconstructs the request from the durable surface, and produces its own chunks. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed step or `turn/end` records failure; message derivation continues to ignore the failed chunks. If recovery is exhausted, the final failure is stored once on `turn/end.reason` with the structured facts. If transient recovery continues, `llm/retry` is the durable home for that attempt's failure and delay. No standalone final-error event or response-id vocabulary is added. @@ -120,9 +120,9 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - 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. - 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 plus immutable prior-retried failure facts; a success clears that history, and alternating transient/context-overflow integration tests prove the two policies consume only their own finite budgets. +- `agent/request-error` carries only current failure facts; each plugin clears its private per-agent counter at terminal idle, and alternating transient/context-overflow integration tests prove the two policies consume only their own finite budgets. - `dsh-llm-retry` validates every config field at Loader startup, delegates all ineligible paths with `next()`, and makes at most `maxTransientRetries + 1` provider requests when no other policy applies. -- HMR-during-backoff tests prove disposal unregisters the listener, aborts and awaits its captured callbacks, emits no retry decision after disposal, and leaves no timer or promise alive. +- HMR-during-backoff tests prove disposal unregisters the listener, aborts and awaits its captured callbacks, makes no retry request after disposal, and leaves no timer or promise alive. - Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random seams, and abort during backoff. - Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new step, exhaustion to structured `turn/end.reason`, and composition with `dsh-compact-basic` context-overflow recovery. - The partial-chunk integration test proves failed chunks remain attributed to the failed step, no assistant message or tool side effect is committed for that step, and the successful retry has distinct provenance. @@ -132,7 +132,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` ## Consequences -- Every transient recovery attempt is visible as a closed step plus `llm/retry`, and the bounded policy prevents hidden SDK retries from multiplying cost. A retry can still duplicate provider billing even when no chunk arrived; the finite attempt budget limits but cannot remove that risk. +- Every transient recovery attempt is visible as a closed failed turn plus `llm/retry`, and the bounded policy prevents hidden SDK retries from multiplying cost. A retry can still duplicate provider billing even when no chunk arrived; the finite attempt budget limits but cannot remove that risk. - Provider SDKs may hide status or retry headers. Those adapters retain the stable facts they expose and otherwise use a coarse code rather than letting recovery policy parse fragile text. - Durable retry events expand the session protocol and UI state machine. Shipping the event and its consumer together prevents an unused telemetry vocabulary, but later schema changes still require persistence and replay work. - Clearing a failed step's live chunks can visibly retract output. That is preferable to presenting discarded text or partial tool JSON as committed history, and snapshots pin the transition. 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 09ebce376a..bb9e430eaf 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 @@ -6,9 +6,9 @@ Status: implemented ## 问题 -`dsh-llm` 可能在适配器分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束,以这两种形式报告提供方失败。最终适配器边界会标记抛出的失败,使 `dsh-agent-loop` 能将其与中间件和结果处理缺陷区分开。循环关闭失败步骤后,会把两种交付形式统一规范化为 `agent/request-error`。默认决策为 `fail`;`dsh-compact-basic` 是唯一已交付的恢复监听器,它仅在压缩(compaction)证明持久表层已缩减后,才会对规范化的上下文窗口溢出进行重试。 +`dsh-llm` 可能在适配器分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束,以这两种形式报告提供方失败。最终适配器边界会标记抛出的失败,使 `dsh-agent-loop` 能将其与中间件和结果处理缺陷区分开。循环关闭失败步骤后,会把两种交付形式统一规范化为 `agent/request-error`。未被处理的失败是终态;处理失败的监听器修复策略自有状态,返回 `{ kind: 'retry' }`,并停止 waterfall 委托。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.md)规定这一返回契约。 -该边界已能安全地再次发起请求。原始 `assistant/chunk` 事件携带失败的 `turn` 和 `step`;除非某条成功的 `assistant/message` 引用这些事件,否则消息派生会忽略它们。只有终止性 finish 成功且组装完成后,系统才会分发工具调用;重试则会从持久日志开启新的编号步骤。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试。 +该边界已能安全地再次发起请求。原始 `assistant/chunk` 事件携带失败的 `turn` 和 `step`;除非某条成功的 `assistant/message` 引用这些事件,否则消息派生会忽略它们。只有终止性 finish 成功且组装完成后,系统才会分发工具调用;重试则会从持久日志开启新的编号轮次。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试。 此前的边界还留有三个较窄的缺口。 @@ -50,7 +50,7 @@ agent loop(智能体循环)会保留 `RequestError` 作为该精确的错误 `@deepseek-ai/dsh-llm-retry` 是监听 `agent/request-error` 的函数插件。它不引入服务或新的循环分支;agent-loop 包仅会更改通过现有失败步骤恢复控制流携带的数据。 -`agent/request-error` seam 携带当前 `LlmFailure`,以及在这段连续恢复序列中导致再次请求的不可变先前失败列表。`dsh-llm-retry` 只计数 code 位于已配置暂时性集合中的先前失败,`dsh-compact-basic` 则只计数先前的上下文溢出失败。模型请求成功后会清空历史。因此,暂时性失败与上下文溢出交替出现时,两种策略会独立消耗各自预算;最大请求数等于 1 加上已加载恢复策略的有限预算总和。 +`agent/request-error` seam 只携带当前 `LlmFailure`;循环不拥有重试策略或尝试历史。每个恢复插件为自身处理的失败维护一个逐 agent 的私有计数器,并在终态 `agent/settled` 时清零。因此,暂时性失败与上下文溢出交替出现时,`dsh-llm-retry` 与 compact-basic 的预算独立消耗;最大请求数等于 1 加上已加载恢复策略的有限预算总和。 该插件在加载时解析并验证以下部署配置: @@ -68,13 +68,13 @@ interface Config { 对于预算未耗尽的合格失败,从 1 开始的暂时性重试计数使用有界指数退避。有效的 `providerRetryAfterMs` 只有在不超过 `maxDelayMs` 时才会取代指数退避;提供方延迟更长时,系统会委托给下一监听器,而不会违反提供方指令提前重试。本地退避乘以 `[1 - jitterRatio, 1 + jitterRatio]` 内的注入随机因子,并将最终值限制到 `maxDelayMs`;提供方延迟不加抖动。 -插件拥有一个全生命期 `AbortController`,并跟踪每个活跃的退避回调。每次等待都会融合 waterfall(瀑布式事件)的轮次信号与该生命期信号。effect 清理会先注销监听器,再中止并等待活跃回调;被捕获回调的生命期信号中止时,回调会返回 `fail`,既不能重试,也不能在插件释放后进入其捕获 waterfall 的剩余部分。尽管 Cordis 已捕获该监听器,此设计仍能使 HMR(热模块替换)释放达到完全停稳。 +插件拥有一个全生命期 `AbortController`,并跟踪每个活跃的退避回调。每次等待都会融合 waterfall(瀑布式事件)的轮次信号与该生命期信号。effect 清理会先注销监听器,再中止并等待活跃回调;被捕获回调的生命期信号中止时,回调会直接返回,不重试,也不进入其捕获 waterfall 的剩余部分。尽管 Cordis 已捕获该监听器,此设计仍能使 HMR(热模块替换)释放达到完全停稳。 休眠前,`dsh-llm-retry` 会追加一条不进入表层的 `llm/retry` 会话事件,其中包含轮次、失败步骤、从 1 开始的暂时性重试编号、已配置上限、计划延迟和 `LlmFailure`。该插件拥有 `SessionEventMap` 声明合并;`dsh-session` 继续负责通用持久化,不会吸收可选策略的词汇。事件记录已安排的内容,而不是下一个请求已完成;延迟期间取消随后会在 `turn/end` 中可见。因为该事件的目的是表示运行状态,而不是收集跟踪数据,所以它仅与生产渲染器及回放/快照覆盖一起交付。 -对非暂时性 code、耗尽的策略预算或超出上限的提供方延迟,监听器会调用 `next()`。这保留了与上下文溢出恢复及后续策略插件的组合能力。只有在两个信号下完成延迟后,它才会返回 `{ action: 'retry' }`;轮次取消和插件释放会返回 `fail`,此后仍以循环的取消/释放检查为准。 +对非暂时性 code、耗尽的策略预算或超出上限的提供方延迟,监听器会调用 `next()`。这保留了与上下文溢出恢复及后续策略插件的组合能力。对自身处理的失败,它会记录并等待延迟,然后在不委托的情况下返回 `{ kind: 'retry' }`。轮次取消和插件释放会结束等待且不返回重试动作,此后仍以循环的取消/释放检查为准。 -agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次性 CLI(命令行界面)和 ACP(Agent Client Protocol)示例组合使用同一有界策略。库消费方仍需显式组合插件:省略该插件时,`agent/request-error` 保持现有的 fail 默认值。 +agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次性 CLI(命令行界面)和 ACP(Agent Client Protocol)示例组合使用同一有界策略。库消费方仍需显式组合插件:省略该插件时,请求失败保持终态。 ### 由单一层负责可见的尝试 @@ -92,7 +92,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 ### 在现有日志中分隔尝试 -一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会开启下一个编号步骤,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录终止失败时,UI 再标记或清除这份暂时视图。消息派生仍会忽略失败分片。 +一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会关闭失败轮次,开启下一个编号轮次,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录失败时,UI 再标记或清除这份暂时视图。消息派生仍会忽略失败分片。 如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。如果暂时性恢复继续,`llm/retry` 就是该次尝试的失败与延迟的持久归属位置。本决策不增加独立的最终错误事件或响应 id 词汇。 @@ -120,9 +120,9 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 - 适配器抛出的 `Error` 会以完全相同的对象抵达 `agent/request-error`,其伴随的 `LlmFailure` 则抵达相邻参数;测试保留针对可扩展及冻结第三方错误的现有对象标识断言。 - DeepSeek 和 pi-ai 适配器测试覆盖具有代表性的 400、401/403、429、5xx、连接、格式错误/截断流、超时、中止、Retry-After 秒数/日期、请求 id 和未知 SDK 错误路径,恢复策略无需解析消息文本。 - Pi 将 SDK 选项固定为零次重试,并针对可重试的提供方响应执行一次可观测的实际网络请求;独立测试确保移除任一边界都会失败。 -- `agent/request-error` 携带当前失败事实以及不可变的先前已重试失败事实;成功会清除该历史,暂时性失败/上下文溢出交替发生的集成测试证明两种策略只消耗各自的有限预算。 +- `agent/request-error` 只携带当前失败事实;每个插件在终态空闲时清零其逐 agent 私有计数器,暂时性失败/上下文溢出交替发生的集成测试证明两种策略只消耗各自的有限预算。 - `dsh-llm-retry` 在 Loader 启动时验证每个配置字段,使用 `next()` 委托所有不合格路径,而且在没有其他策略时最多发起 `maxTransientRetries + 1` 次提供方请求。 -- 退避期间执行 HMR 的测试证明:释放过程会注销监听器、中止并等待其捕获的回调,释放后不发出重试决策,也不留下存活的定时器或 promise。 +- 退避期间执行 HMR 的测试证明:释放过程会注销监听器、中止并等待其捕获的回调,释放后不发起重试请求,也不留下存活的定时器或 promise。 - 纯单元测试覆盖暂时性 code 选择、指数退避和抖动边界、有效及超出上限的 `Retry-After`、耗尽的预算、确定性定时器/随机数 seam,以及退避期间中止。 - 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在新步骤中重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compact-basic` 上下文溢出恢复的组合。 - 部分分片集成测试证明:失败分片仍归属于失败步骤,该步骤不会提交 assistant 消息或工具副作用,成功的重试具有不同的来源信息。 @@ -132,7 +132,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 ## 后果 -- 每次暂时性恢复尝试都以一个已关闭步骤加 `llm/retry` 的形式可见,有界策略还会防止隐藏的 SDK 重试成倍增加成本。即使没有分片到达,重试仍可能造成提供方重复计费;有限的尝试预算只能限制而无法消除此风险。 +- 每次暂时性恢复尝试都以一个已关闭失败轮次加 `llm/retry` 的形式可见,有界策略还会防止隐藏的 SDK 重试成倍增加成本。即使没有分片到达,重试仍可能造成提供方重复计费;有限的尝试预算只能限制而无法消除此风险。 - 提供方 SDK 可能隐藏状态或重试标头。适配器会保留 SDK 公开的稳定事实,否则使用粗粒度 code,而不会让恢复策略解析脆弱的文本。 - 持久重试事件扩展了会话协议和 UI 状态机。事件与其消费方一同交付,可避免产生无人使用的遥测词汇;但以后更改 schema 仍需要同步完成持久化和回放工作。 - 清除失败步骤的实时分片可能会明显撤回输出。与把丢弃的文本或不完整工具 JSON 呈现为已提交历史相比,这是更好的选择;快照固定这一转换。 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 b25f335819..1904a25158 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 @@ -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-10-after-call-compaction-pressure-and-overflow-recovery.md: b934f7fd7087006be4f7eb3659e44e78b8ede367 -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 3b5b60a95bef0695a446cdd3d45d299550f449f6 +# 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 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 b934f7fd70..51d488db28 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,9 +22,9 @@ The loop fires awaited serial `agent/post-step(agent, turn, step, signal)` after ### Request recovery is limited to the final model boundary -`RequestError`, `RequestErrorDecision`, 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, post-step listeners, and cleanup remain ordinary failures. +`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. -The failed step closes before recovery runs. A retry opens the next numbered step and rebuilds the request from the durable log; consecutive recovery attempts reset only after a successful provider request. Both DeepSeek adapters normalize recognized provider context-limit failures to `CONTEXT_WINDOW_EXCEEDED`. +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. If cancellation lands after assistant tool calls are durable but before all calls dispatch, the loop records a synthetic `tool/call` and aborted `tool/result` pair for every undispatched call before following the normal abort path. The surface therefore never retains orphaned durable tool calls merely because cancellation won the race. @@ -34,7 +34,7 @@ If cancellation lands after assistant tool calls are durable but before all call For `pressure`, compact-basic resolves the durable provider/model target's adapter-owned capacity and exact-target policy, then applies the resulting threshold and retained-tail budgets to one unified `ctx.tokenMeter.measure()` result. Below pressure it returns without pruning. Once pressure qualifies, optional `ctx.toolResultPrune` rewrites oversized current results and compact-basic remeasures through the same meter; safe pressure skips the model call, while remaining pressure selects and summarizes from the pruned surface. The same singleton meter owns range pricing, provenance, shadowed token counts, and non-shrinking-summary rejection. Common defaults remain threshold ratio `0.8`, retained-history ratio `0.16`, summarization provider/model `''`, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`; optional `modelPolicies` entries override them for an exact provider/model pair. -For canonical overflow, compact-basic requires no capacity metadata and bypasses scalar pressure and the normal retained-token budget. It prunes first, then chooses the maximal tool-balanced head range while leaving the newest indivisible unit and attempts one shrinking summary compaction under the same signal when a range exists. The automatic listener snapshots `session.surface.replaceGeneration` and returns `{ action: 'retry' }` whenever pruning or summarization increases it. This remains true when pruning lands before later summary work throws; cancellation still wins. A backend returning a result without replacement cannot authorize retry, while pruning-only progress can authorize a retry without a `CompactionResult`. +For canonical overflow, compact-basic requires no capacity metadata and bypasses scalar pressure and the normal retained-token budget. It prunes first, then chooses the maximal tool-balanced head range while leaving the newest indivisible unit and attempts one shrinking summary compaction under the same signal when a range exists. The automatic listener snapshots `session.surface.replaceGeneration` and returns `{ kind: 'retry' }` whenever pruning or summarization increases it. This remains true when pruning lands before later summary work throws; cancellation still wins. A backend returning a result without replacement cannot authorize retry, while pruning-only progress can authorize a retry without a `CompactionResult`. `maxOverflowRetries` is optional and defaults to `1`; `0` disables overflow recovery without disabling pressure. `auto: false` registers neither automatic listener. Noncanonical errors, exhausted attempts, an already-aborted signal, a missing routed model, no safe range, no generation change, and recovery throws before any replacement all delegate to the next listener. With no later recovery, the loop reports the original provider error object and code. A recovery throw after generation advances authorizes retry from durable progress; cancellation or disposal remains authoritative even if recovery work completes concurrently. @@ -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-step retry numbering and reset, cancellation and disposal, post-step 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 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. ## 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 3b5b60a95b..ae33cf5c2e 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,9 +22,9 @@ Status: implemented ### 请求恢复只覆盖最终模型边界 -`RequestError`、`RequestErrorDecision` 与 `agent/request-error` waterfall 表示最终适配器已经选定之后的失败。每个返回的流句柄都绑定一个私有失败集合;该集合在分发、异步迭代器构造与迭代过程中保留原始抛出错误的身份,同时防止把嵌套调用的错误来源误归到外层调用。终止性的带内 `error` 或 `aborted` finish 进入同一路径。提示词装配、请求中间件、请求日志、结果处理、工具、post-step 监听器与清理仍属于普通失败。 +`RequestError` 与 `agent/request-error` waterfall 表示最终适配器已经选定之后的失败。每个返回的流句柄都绑定一个私有失败集合;该集合在分发、异步迭代器构造与迭代过程中保留原始抛出错误的身份,同时防止把嵌套调用的错误来源误归到外层调用。终止性的带内 `error` 或 `aborted` finish 进入同一路径。提示词装配、请求中间件、请求日志、结果处理、工具、step 监听器与清理仍属于普通失败。 -恢复运行前,失败 step 已经关闭。重试会打开下一个编号 step,并从持久日志重建请求;连续恢复尝试计数只在提供方请求成功后重置。两个 DeepSeek 适配器都把识别出的提供方上下文限制错误规范化为 `CONTEXT_WINDOW_EXCEEDED`。 +恢复运行前,失败 step 已经关闭。负责处理的监听器修复持久状态、返回 `{ kind: 'retry' }`,并停止 waterfall 委托。循环随后关闭失败 turn,并从持久日志开启一个重试 turn,中间不发布空闲通知。重试策略与尝试计数由插件自己拥有;compact-basic 在链路到达终态 `agent/settled` 时清除对应 agent 的溢出计数。两个 DeepSeek 适配器都把识别出的提供方上下文限制错误规范化为 `CONTEXT_WINDOW_EXCEEDED`。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.md)规定这一返回边界。 如果取消发生在 assistant 工具调用已经持久化之后、所有调用完成分发之前,循环会为每个尚未分发的调用记录一对合成的 `tool/call` 与 aborted `tool/result`,随后进入正常中止路径。因此,表层不会仅因取消赢得竞态而留下孤立的持久工具调用。 @@ -34,7 +34,7 @@ Status: implemented 对于 `pressure`,compact-basic 先解析持久提供方/模型目标的适配器所属容量与精确目标策略,再把得到的阈值与保留尾部预算应用到一次统一的 `ctx.tokenMeter.measure()` 结果。低于压力时直接返回,不执行剪枝。压力达到条件后,可选的 `ctx.toolResultPrune` 会改写当前表层中过大的工具结果,compact-basic 再通过同一个 meter 重新计量;若压力恢复安全则跳过模型调用,否则从已剪枝表层选择范围并生成摘要。范围定价、来源、被遮蔽 token 数与非缩小摘要拒绝也由同一个单例 meter 完成。通用默认值保持为阈值比例 `0.8`、保留历史比例 `0.16`、摘要提供方/模型 `''`、`maxTokens: 8192`、`compactionRetries: 1` 与 `auto: true`;可选 `modelPolicies` 项可以按精确提供方/模型组合覆盖这些值。 -对于规范化溢出,compact-basic 不要求容量元数据,并绕过标量压力与普通保留 token 预算。它先执行剪枝,再在保留最新不可分割单元的同时选择最大的工具配对平衡头部范围;存在范围时,才在同一 signal 下尝试一次缩小摘要压缩。自动监听器先记录 `session.surface.replaceGeneration`,剪枝或摘要让 generation 增加时就返回 `{ action: 'retry' }`。即使剪枝先落盘而后续摘要工作抛错,这条规则仍然成立;取消依然优先。后端若只返回结果但没有替换表层,不能授权重试;只有剪枝取得进展时,即使没有 `CompactionResult` 也可以授权重试。 +对于规范化溢出,compact-basic 不要求容量元数据,并绕过标量压力与普通保留 token 预算。它先执行剪枝,再在保留最新不可分割单元的同时选择最大的工具配对平衡头部范围;存在范围时,才在同一 signal 下尝试一次缩小摘要压缩。自动监听器先记录 `session.surface.replaceGeneration`,剪枝或摘要让 generation 增加时就返回 `{ kind: 'retry' }`。即使剪枝先落盘而后续摘要工作抛错,这条规则仍然成立;取消依然优先。后端若只返回结果但没有替换表层,不能授权重试;只有剪枝取得进展时,即使没有 `CompactionResult` 也可以授权重试。 `maxOverflowRetries` 可选且默认为 `1`;`0` 只禁用溢出恢复,不会禁用压力检查。`auto: false` 不注册任何自动监听器。非规范化错误、尝试耗尽、已经中止的 signal、缺失路由模型、没有安全范围、generation 未变化,以及在任何替换之前恢复抛错,都会委托给下一个监听器。若没有后续恢复,循环报告原始提供方错误对象与代码。generation 增加后的恢复抛错会基于持久进展授权重试;即使恢复工作并发完成,取消或销毁仍具有最终优先级。 @@ -42,7 +42,7 @@ Status: implemented ## 测试 -单元测试覆盖最终适配器失败的来源与身份、已关闭 step 的重试编号与重置、取消与销毁、post-step 顺序、已路由信封压力、压力门控剪枝、剪枝独立解除压力、从已剪枝输入生成摘要、平衡溢出缩减、后续失败前已落盘的剪枝进展、generation 证明、上限、委托与辅助调用路由。真实循环测试覆盖抛出式和带内溢出,并验证剪枝或摘要压缩后的重试请求从替换表层重建。 +单元测试覆盖最终适配器失败的来源与身份、已关闭 turn 的重试编号与重置、取消与销毁、step 边界顺序、已路由信封压力、压力门控剪枝、剪枝独立解除压力、从已剪枝输入生成摘要、平衡溢出缩减、后续失败前已落盘的剪枝进展、generation 证明、上限、委托与辅助调用路由。真实循环测试覆盖抛出式和带内溢出,并验证剪枝或摘要压缩后的重试请求从替换表层重建。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index 158a78acd8..11c3d9b5a2 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-16-explicit-turn-cancellation.md: 7ac743221084e663294954bfd048ba7ef1114f60 -2026-07-16-explicit-turn-cancellation.zh.md: 3dca6339787ebef749c0d6a15609376ede994a97 +2026-07-16-explicit-turn-cancellation.md: 15085a1da2cf183bace9957a4bedb3ea466aa472 +2026-07-16-explicit-turn-cancellation.zh.md: e945b0fea51bdbfee38048573c643b0fb8ecb685 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md index 7ac7432210..15085a1da2 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -22,7 +22,7 @@ The driver keeps only a cause-less pre-run marker for queued work cancelled befo The explicit event signatures keep their positional form and place `signal` immediately before a waterfall's final `next`. Prompt submission, request configuration, step-result processing, continuation, and terminal stop join the pre-existing explicit signal seams for pre-step, session prefix, model generation, tool execution, approval, and subagent or workflow requests. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. -`ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority, and `agentInterruptReasonOf(signal)` reads only its explicit argument. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam. +`ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a canonical frozen cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam. Agent disposal requests the runtime-only `{ kind: 'disposed' }` interruption on the active holder. If cancellation already won the controller reason, the reason cannot be rewritten, so terminal classification first checks lifecycle state: disposed wins, then a supported `user` or `parent` cause becomes the coarse aborted outcome, and unrelated exceptions retain the existing error path. ACP cancellation maps to `user`; in-process spawn and fork propagation map to `parent`. Remote ACP subagents retain their existing wire protocol. diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index 3dca633978..e945b0fea5 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -22,7 +22,7 @@ AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它 显式事件签名保留位置参数形式,并把 `signal` 放在 waterfall(瀑布式事件)的最后一个参数 `next` 之前。提示词提交、请求配置、步骤结果处理、继续决策和终止停止加入已有的步骤前处理、会话前缀、模型生成、工具执行、审批以及 subagent 或工作流请求的显式 signal seam。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 -`ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限,`agentInterruptReasonOf(signal)` 也只读取其显式参数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 +`ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时的 `{ kind: 'disposed' }` 中断。若取消已经先占用控制器的中断原因,该原因便无法改写,因此终态分类会先检查生命周期状态:资源释放结果优先,之后受支持的 `user` 或 `parent` 取消原因形成粗粒度的中止结果,其他异常保留现有错误路径。ACP(Agent Client Protocol)取消映射为 `user`;进程内 spawn 和 fork 的传播映射为 `parent`。远程 ACP subagent 保持现有协议。 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 0c5577e3bb..418637f08f 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 @@ -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-22-unified-send-and-coalesced-user-messages.md: 12128d9e57601d0b85d20d1cb4240bb08eadc3cb -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 177d90f7116f7451b8e3c4ccf7d1577ff12ae701 +# 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: 6936fbfa04c0fdaf1a8786c0465c193e9c285243 +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 3af14359fa01e92f63ae3b3e51dced9a97f6419f 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 12128d9e57..6936fbfa04 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 and coalesce injected context into user/message +# Agent Note: Unify agent delivery on send(target × wakeup) and coalesce injected context into user/message Status: implemented @@ -8,39 +8,45 @@ English | [中文](2026-07-22-unified-send-and-coalesced-user-messages.zh.md) The agent's public driving surface had grown three near-parallel verbs — `send`, `steer`, `inject` — each with its own options type, its own live event story, and its own durable event. `send` and `steer` both queued a frozen inbox record and emitted `agent/queued`; `inject` bypassed the inbox and wrote a separate `context/message` durable event. The three verbs actually vary along only two independent axes: which queue an item joins (a whole new turn versus the active turn) and whether the item makes the model run. Encoding that 2×2 as three hand-written methods hid the symmetry, made "queue a turn without waking the driver" unreachable, and left `cancel()` with no way to abort a turn while preserving queued work. -Separately, `context/message` and `user/message` had converged: the surface projected both as verbatim user-role content, and the only real difference was that injected context carried `source`/`meta` and was "not a prompt." Two event types for one projection meant every consumer branched on event type to answer "is this a human prompt?", and the goal system used the type split as a side channel (round-zero state changes were `context/message`, admitted rounds were `user/message`). +Separately, `context/message` and `user/message` had converged: the surface projected both as verbatim user-role content, and the only real difference was that injected context carried a non-user `source` and was "not a prompt." Two event types for one projection meant every consumer branched on event type to answer "is this a human prompt?", and the goal system used the type split as a side channel (round-zero state changes were `context/message`, admitted rounds were `user/message`). ## Decision -**One acceptance mechanism, four intent helpers.** The concrete loop resolves `followup`, `queue`, `steer`, and `inject` into one (`target` × `wakeup`) acceptance mechanism. `followup` is `next-turn`/wakeup, `queue` is `next-turn`/no-wakeup, `steer` is `next-step`/wakeup, and `inject` is `next-step`/no-wakeup. The public structural interface exposes that mechanism as `send(ResolvedAgentInput)` for callers that already have fully resolved routing; every field is mandatory, and the discriminated input type excludes attached contexts from injection. The [intent-named delivery decision](2026-07-24-intent-named-agent-delivery.md) owns that superseding interface choice. Internally, `wakeup` means “make the model run”: wake a parked driver for an ordinary item or force a continuation for running steering. +**One primitive, three preset aliases.** The `Agent` interface's `send(input, { target, wakeup })` covers the (`target` × `wakeup`) matrix. Its `UserMessageData` input owns the inseparable 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 input 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. -**inject keeps its mechanism.** `inject` appends durable model-facing context at the current log position (deferred behind an executing tool batch), or opens a one-shot `injection` turn when idle. It bypasses the FIFOs entirely, accepts no attached contexts, and defaults its source to `{ kind: 'plugin', plugin: '' }`, never `{ kind: 'user' }`. +**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 `UserMessageData.source` preserves the caller's explicit provenance. -**context/message is gone.** Injected context is now a `user/message` whose `source` is a non-`user` kind (plugin or goal). `PromptMessageData` gained the optional `meta` that `context/message` carried. The surface, derivation, and `SurfaceEventType` drop `context/message`; consumers that need "is this a human prompt?" read `source.kind === 'user'` instead of the event type. This keeps goal-authority's human-authority check exactly as strict as before — an injected message defaults to a plugin source and can never satisfy `source.kind === 'user'`. +**context/message is gone.** Injected context is now a `user/message`; context producers supply the appropriate non-user `source` explicitly, and typed source variants carry any domain-specific durable provenance. The surface, derivation, and `SurfaceEventType` drop `context/message`; consumers that need "is this a human prompt?" read `source.kind === 'user'` instead of the event type. -**Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` carrying `goal/change` metadata; a positive round is an admitted continuation prompt. `decodeGoalEvent` now takes a `user/message` and still fails loud on goal metadata under a non-goal source or a goal source lacking metadata. +**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. -**Delivery returns an id.** Each delivery method returns an opaque branded `AgentMessageId` for the accepted input. FIFO methods carry it through their inbox lifecycle events; injection bypasses those events. +**`send` returns an id.** `send` (and the aliases) return an opaque branded `AgentMessageId` for the accepted message; `send`'s previous return was `void`. -**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) each carry an `AgentMessage` — the accepted message including its returned `id`, steering/wakeup facts, source, and contexts — so a caller can correlate a queued item with its lifecycle. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including the loop-authored continuation-reason steer (`agent/turn-continuation` returning `{ action: 'continue', reason }`), 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. +**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) type their `AgentMessage` payload with only the accepted message's returned `id`, content, and source. Enqueue separately carries the resolved `queued | steering` placement captured by the producer at acceptance time, so observers and reconnect mirrors never reconstruct 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. -**cancel gains keepInbox.** `cancel(cause?, { keepInbox? })`; when true it aborts the active turn but preserves queued and steering items (no discard event, and un-started work is not dropped). +**Admission accepts next-step input without becoming a turn.** The loop opens a private next-step acceptance window before `agent/prompt-submit`, keeps it open through the turn, and closes it before `turn/end`. Steering and injection received during admission therefore remain together in the outbox and join an allowed turn. If admission blocks or fails, a context-only caller batch takes idle injection's immediate append, while steering and context staged beside it remain available to retry; neither path writes the rejected prompt. When a later prompt is admitted, retained outbox input enters its turn before that prompt, while input accepted during the current admission remains after the prompt. Closing the window before `turn/end` preserves the rule that reentrant late steering becomes an independent queued turn. `Agent.acceptsNextStep` exposes whether a `next-step` send would currently join this window; `status` remains the broader activity signal rather than a routing predicate. + +**One accepted message keeps one representation.** Durable user-role input and additional model-facing context both use `UserMessageData { content, source }` directly; public `AgentMessage` extends it with the correlation `id`, and the loop-private `PendingMessage` extends that with `wakeup`. The loop clones and freezes `UserMessageData` before publication, queueing, or immediate append, so later caller or observer mutation cannot change the accepted value. A queued message that becomes steering enters the outbox as the same `PendingMessage` object, while injected and tool-produced context enters as plain `UserMessageData`. The outbox therefore stores their union directly instead of wrapping steering beside a duplicate copy of its content and source. Provider-native assistant messages remain adapter-owned output types and do not participate in this input hierarchy. + +**Idle wakeup follows acceptance.** Before publishing enqueue, a waking queued send installs quiescence ownership and schedules driver admission for a microtask that runs after the id returns. Every send in one synchronous caller stack therefore resolves placement against the same pre-admission state, while reentrant cancellation or teardown cannot retire before the scheduled admission settles. Two idle `steer()` calls remain two FIFO turns instead of the first opening an admission window that captures the second. + +**cancel gains keepInbox.** `cancel(cause, { keepInbox? })`; callers choose the cause explicitly, and `keepInbox: true` aborts the active turn while preserving queued and steering items (no discard event, and un-started work is not dropped). ## Alternatives considered -- **A dedicated `MessageSource` kind `context`** for injected content. Rejected because `plugin` already means "not a human," so a fourth kind would add a parallel axis the authority checks would have to learn. Injected context defaults to a plugin source instead. -- **A typed discriminant field on `PromptMessageData`** (e.g. `origin: 'prompt' | 'context'`) to replace the event-type split. Rejected in favor of `source`, which every consumer already carries and which the goal system already keyed on; a second discriminant would duplicate that fact. -- **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/enqueue` is the same enqueue-time signal with the accepted routing facts, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe. +- **A dedicated `MessageSource` kind `context`** for injected content. Rejected because `plugin` already means "not a human," so a fourth kind would add a parallel axis the authority checks would have to learn. Plugin-produced injected context supplies its plugin source explicitly. +- **A typed discriminant field on `UserMessageData`** (e.g. `origin: 'prompt' | 'context'`) to replace the event-type split. Rejected in favor of `source`, which every consumer already carries and which the goal system already keyed on; a second discriminant would duplicate that fact. +- **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/enqueue` is the same enqueue-time signal with the resolved placement, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe. +- **Derive inbox placement from agent status or the session log.** Rejected because `running` includes admission and settlement, while reconnect baselines need the original acceptance result even when the earlier turn boundary is absent. The producer already owns the exact routing decision. ## Consequences -The concrete driver has one delivery mechanism. Four common helpers hide its (`target` × `wakeup`) matrix behind caller intent, while `send` exposes the fully resolved matrix for advanced callers. 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 goal fold's channel split moves from event type to `source.round`, and every consumer that filtered `context/message` filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged: an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`. +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. -Internally, `wakeup` is the “should the model run” signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `queue()` item stays parked at idle and rides along the next waking follow-up, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is left hanging). `SendOptions.meta` on a queued or steering message is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally absent from the live `AgentMessage`, which carries only routing facts. Every enqueued id gets exactly one terminal lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` both at the in-turn stop point and on the post-turn drain of late steering, and disposal discards any still-pending items before the loop exits. The `agent/inbox/*` payload is frozen so a listener cannot mutate the shared correlation object mid-dispatch, and a loop-authored continuation reason is snapshotted and frozen like public steering. Injection validates its payload before opening an idle one-shot turn; `InjectOptions` omits attached contexts, while the non-waking next-step variant of `ResolvedAgentInput` requires an empty context tuple. +`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. ## Related - [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md) — the one-claimed-message-per-turn rule this builds on. - [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. -- [intent-named-agent-delivery](2026-07-24-intent-named-agent-delivery.md) — the public helpers and fully resolved acceptance interface. 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 177d90f711..3af14359fa 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 投递并把注入的上下文合并进 user/message +# Agent Note: 将 agent 投递统一到 send(target × wakeup) 并把注入的上下文合并进 user/message Status: implemented @@ -8,39 +8,45 @@ Status: implemented agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`、`steer`、`inject`——各自带有独立的选项类型、独立的实时事件叙事,以及独立的持久事件。`send` 和 `steer` 都会把一条冻结的 inbox 记录入队并发出 `agent/queued`;`inject` 则绕过 inbox,写入一条独立的 `context/message` 持久事件。这三个动词实际上只沿两条独立的轴变化:一个队列项加入哪个队列(一个全新的轮次,还是当前活跃的轮次),以及这个队列项是否让模型运行。把这个 2×2 编码成三个手写方法,掩盖了其中的对称性,让“排入一个轮次但不唤醒驱动器”无法表达,也让 `cancel()` 无从在保留排队工作的前提下中止一个轮次。 -另外,`context/message` 与 `user/message` 已经趋同:对外接口把二者都投影为逐字的 user 角色内容,唯一真正的区别是注入的上下文携带 `source`/`meta` 且“不是提示词”。一个投影对应两种事件类型,意味着每个消费方都要根据事件类型分支来回答“这是不是一条人类提示词?”,而 goal 系统把这种类型区分当作侧信道使用(第 0 轮的状态变更是 `context/message`,已准入的轮次是 `user/message`)。 +另外,`context/message` 与 `user/message` 已经趋同:对外接口把二者都投影为逐字的 user 角色内容,唯一真正的区别是注入的上下文携带非 user `source` 且“不是提示词”。一个投影对应两种事件类型,意味着每个消费方都要根据事件类型分支来回答“这是不是一条人类提示词?”,而 goal 系统把这种类型区分当作侧信道使用(第 0 轮的状态变更是 `context/message`,已准入的轮次是 `user/message`)。 ## 决策 -**一种接受机制,四种意图辅助方法。** 具体循环把 `followup`、`queue`、`steer` 和 `inject` 解析到同一个(`target` × `wakeup`)接受机制中。`followup` 是 `next-turn`/wakeup,`queue` 是 `next-turn`/no-wakeup,`steer` 是 `next-step`/wakeup,`inject` 是 `next-step`/no-wakeup。公开的结构化接口将该机制暴露为 `send(ResolvedAgentInput)`;调用方若已持有完全解析的路由信息,即可使用该方法。使用时必须提供所有字段,可辨识输入类型也不允许注入携带附加上下文。取代旧接口的选择由[按意图命名的投递决策](2026-07-24-intent-named-agent-delivery.md)负责说明。内部的 `wakeup` 表示「让模型运行」:为一条普通消息唤醒处于停泊状态的驱动器,或强制运行中的 steering 继续执行。 +**一个原语,三个预设别名。** `Agent` 接口的 `send(input, { target, wakeup })` 覆盖 (`target` × `wakeup`) 矩阵。其 `UserMessageData` 输入持有不可分割的模型可见 `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(入队但不唤醒)可以表达,只是没有别名,也没有当前调用方。 -**inject 保留其机制。** `inject` 在当前日志位置追加持久、面向模型的上下文(在执行中的工具批处理之后延迟处理),或在空闲时开启一个一次性的 `injection` 轮次。它完全绕过 FIFO,不接受附加上下文,并把来源默认设为 `{ kind: 'plugin', plugin: '' }`,绝不是 `{ kind: 'user' }`。 +**inject 保留其机制。** `next-step`/no-wakeup 路径正是旧的 `inject`:持久的面向模型上下文会追加到当前日志位置;提示词准入或一个轮次占有下一个安全边界时,它会延迟处理,而在该窗口之外则直接追加。它完全绕过 FIFO 队列,而必填的 `UserMessageData.source` 会保留调用方显式提供的来源信息。 -**context/message 已移除。** 注入的上下文现在是一条 `user/message`,其 `source` 为非 `user` 类别(plugin 或 goal)。`PromptMessageData` 新增了 `context/message` 原本携带的可选 `meta`。对外接口、派生逻辑和 `SurfaceEventType` 都不再包含 `context/message`;需要判断“这是不是一条人类提示词?”的消费方改为读取 `source.kind === 'user'`,而不是事件类型。这让 goal-authority 的人类授权检查与此前一样严格——注入的消息默认使用 plugin 来源,永远无法满足 `source.kind === 'user'`。 +**context/message 已移除。** 注入的上下文现在是一条 `user/message`;上下文生产方显式提供合适的非 `user` 类别 `source`,类型化 source 变体携带所有特定于领域的持久来源信息。对外接口、派生逻辑和 `SurfaceEventType` 都不再包含 `context/message`;需要判断“这是不是一条人类提示词?”的消费方改为读取 `source.kind === 'user'`,而不是事件类型。 -**goal 回放靠轮次而非类型来区分。** 一次 goal 状态变更是一条第 0 轮、来源为 goal 的 `user/message`,携带 `goal/change` 元数据;正数轮次则是一条已准入的继续执行提示词。`decodeGoalEvent` 现在接收一条 `user/message`,并仍会在非 goal 来源携带 goal 元数据、或 goal 来源缺少元数据时立即报错。 +**goal 回放靠轮次而非类型来区分。** 一次 goal 状态变更是一条第 0 轮、来源为 goal 的 `user/message`,其 source 携带完整变更;正数轮次则是一条已准入的继续执行提示词。`decodeGoalEvent` 接收一条 `user/message`,并在 goal 状态内容与其类型化 source 不一致时立即报错。 -**投递返回一个 id。** 每种投递方法都为被接受的输入返回一个不透明的 branded `AgentMessageId`。FIFO 方法通过其 inbox 生命周期事件携带这个 id;注入绕过这些事件。 +**`send` 返回一个 id。** `send`(以及其别名)为被接受的消息返回一个不透明的 branded `AgentMessageId`;`send` 此前的返回值是 `void`。 -**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都携带一条 `AgentMessage`——即被接受的消息,包含其返回的 `id`、steering/wakeup 事实、来源和上下文——因此调用方可以把一个排队项与其生命周期关联起来。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 +**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都将各自的 `AgentMessage` 载荷类型限定为仅包含被接受消息所返回的 `id`、内容和来源。enqueue 还会单独携带生产方在接受消息时捕获的已解析 `queued | steering` 放置方式,因此观察方和重连镜像永远不必根据后续状态或会话历史重建路由。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括 `agent/turn-stopping` 监听器提交的 steering,因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 -**cancel 新增 keepInbox。** `cancel(cause?, { keepInbox? })`;当其为 true 时,它中止活跃轮次,但保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。 +**准入接受 next-step 输入,但不会因此成为一个轮次。** 循环会在 `agent/prompt-submit` 前打开一个私有的 next-step 接受窗口,使其贯穿整个轮次,并在 `turn/end` 前关闭。因此,在准入期间收到的 steering 和注入会一起留在 outbox 中并加入获准轮次。如果准入被阻止或失败,仅含调用方上下文的批次会采用空闲注入的立即追加行为,而 steering 及与其一同暂存的上下文仍可重试;两种路径都不会写入被拒绝的提示词。后续提示词获准时,保留在 outbox 中的输入会先于该提示词进入其轮次,而当前准入期间接受的输入则留在提示词之后。在 `turn/end` 前关闭窗口,可以保留这样的规则:可重入的晚到 steering 会成为一个独立的排队轮次。`Agent.acceptsNextStep` 会公开一次 `next-step` 发送当前是否会加入该窗口;`status` 仍是更宽泛的活动信号,而非路由判据。 + +**一条已接受消息只保留一种表示。** 持久的用户角色输入和附加的模型可见上下文都直接使用 `UserMessageData { content, source }`;公开的 `AgentMessage` 在此基础上增加用于关联的 `id`,循环私有的 `PendingMessage` 再增加 `wakeup`。循环会在发布、入队或立即追加前克隆并冻结 `UserMessageData`,因此调用方或观察方后续的修改无法改变已接受的值。一条成为 steering 的排队消息会以同一个 `PendingMessage` 对象进入 outbox,而注入和工具产生的上下文则以普通 `UserMessageData` 进入。因此,outbox 直接存储这两种类型的联合,而不再把 steering 与一份重复的内容和来源副本包装在一起。提供方原生的助手消息仍是适配器拥有的输出类型,不参与这套输入层级。 + +**空闲唤醒在接受之后发生。** 在发布 enqueue 前,一次会唤醒驱动器的排队发送会先取得完全停稳所有权,并把驱动器准入调度到一个会在该次发送返回 id 后运行的微任务中。因此,同一同步调用栈中的每次发送都会基于同一份准入前状态解析放置方式,而可重入的取消或拆除在已调度的准入结算前无法完成退役。空闲时的两次 `steer()` 调用会保留为两个 FIFO 轮次,而不会因第一次调用打开准入窗口而把第二次吸纳进去。 + +**cancel 新增 keepInbox。** `cancel(cause, { keepInbox? })`;调用方显式选择 cause,且 `keepInbox: true` 会中止活跃轮次,同时保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。 ## 考虑过的替代方案 -- **为注入内容设立专门的 `MessageSource` 类别 `context`。** 不予采纳,因为 `plugin` 已经表示“不是人类”,因此第四种类别会增加一条平行的轴,让授权检查不得不去学习它。注入的上下文改为默认使用 plugin 来源。 -- **在 `PromptMessageData` 上设一个类型化的判别字段**(例如 `origin: 'prompt' | 'context'`)来取代事件类型的区分。不予采纳,转而采用 `source`——每个消费方都已经携带它,goal 系统也已经以它为键;第二个判别字段会重复这一事实。 -- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/enqueue` 是同一个入队时刻的信号,只是多带了已接受的路由事实,而 dequeue/discard 事件补全了单个事件无法描述的 FIFO 生命周期。 +- **为注入内容设立专门的 `MessageSource` 类别 `context`。** 不予采纳,因为 `plugin` 已经表示“不是人类”,因此第四种类别会增加一条平行的轴,让授权检查不得不去学习它。由插件产生的注入上下文会显式提供其 plugin 来源。 +- **在 `UserMessageData` 上设一个类型化的判别字段**(例如 `origin: 'prompt' | 'context'`)来取代事件类型的区分。不予采纳,转而采用 `source`——每个消费方都已经携带它,goal 系统也已经以它为键;第二个判别字段会重复这一事实。 +- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/enqueue` 是同一个入队时刻的信号,只是带有已解析的放置方式,而 dequeue/discard 事件补全了单个事件无法描述的 FIFO 生命周期。 +- **根据 agent 状态或会话日志推导 inbox 放置方式。** 不予采纳,因为 `running` 同时涵盖准入与结算,而重连基线即使缺少此前的轮次边界,也需要最初的接受结果。生产方已经拥有精确的路由决策。 ## 后果 -具体驱动器只有一个投递机制。四种常用辅助方法以调用方意图封装其(`target` × `wakeup`)矩阵,而 `send` 则向高级调用方暴露完全解析后的矩阵。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处「是否人类提示词?」检查都简化为一次 `source` 判断。goal 折叠的通道区分从事件类型改到 `source.round`,此前过滤 `context/message` 的每个消费方都改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变:空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 +投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。`Agent` 契约仍是接口,因此其他实现和对象字面量形式的测试替身只需实现同一个最小结构接口。goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。空闲状态下的注入会在两个轮次之间追加 `user/message`,既不打开轮次,也不运行模型。 -在内部,`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `queue()` 项会停泊在空闲状态,并随下一条会唤醒驱动器的后续消息一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会让任何等待者悬而未决)。排队消息或 steering 消息上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 上,后者只携带路由事实。每个已入队的 id 都恰好得到一个终止性生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`,既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时;dispose(资源释放)会在 loop 退出前丢弃所有仍在等待的项。`agent/inbox/*` 的事件载荷已被冻结,因此监听器无法在分发中途修改共享的关联对象,而由 loop 生成的继续原因会像公开 steering 一样被快照并冻结。注入会在打开空闲状态的一次性轮次之前校验其载荷;`InjectOptions` 不包含附加上下文,而 `ResolvedAgentInput` 中不唤醒的下一步变体要求使用空上下文元组。 +`wakeup` 是“模型是否应当运行”的信号,因此 inbox 会区分能唤醒的排队工作与任何可 dequeue 的项:一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默。每一次 FIFO 退出都恰好发布一个生命周期事件,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。直接使用待处理项的表示方式,使公开生命周期事件保持可关联,既无需维护第二个 steering 包装层,也避免其持久数据发生分歧。 ## 相关 - [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md)——本决策所依托的“每轮次只认领一条消息”规则。 - [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` 所扩展的取消原因信号。 -- [intent-named-agent-delivery](2026-07-24-intent-named-agent-delivery.md)——公开辅助方法以及接受完全解析输入的接口。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml deleted file mode 100644 index ec8c9d105f..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-24-intent-named-agent-delivery.md: 32b0502350063610efff746cbef779e8225055eb -2026-07-24-intent-named-agent-delivery.zh.md: ce8860b397497f4de587a9373d1cd300cf7dab29 diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md deleted file mode 100644 index 32b0502350..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md +++ /dev/null @@ -1,52 +0,0 @@ -# Agent Note: Name public agent delivery by intent - -Status: implemented - -English | [中文](2026-07-24-intent-named-agent-delivery.zh.md) - -## Problem - -A configurable `send(content, { target?, wakeup?, ... })` makes every caller learn the loop's routing matrix, its defaults, and the interaction between active-turn targeting and model activation. Optional routing fields also let advanced-looking calls silently become ordinary sends. Most callers have one semantic intent, while some adapters already possess exact routing facts and should not have to reverse-map them into a helper name. - -Sharing helper implementations through an abstract `Agent` class also makes the public seam nominal in practice. Object-literal adapters and tests must inherit prototype methods even though the package promises a swappable structural handle. The shared base exists only to forward fixed arguments, while the concrete loop remains the sole production adapter. - -## Decision - -`Agent` is a structural interface with four intent-named delivery helpers: - -- `followup()` queues an ordinary turn and wakes the driver. -- `queue()` queues an ordinary turn without waking an idle driver. -- `steer()` targets the running turn and requests another step; while idle it becomes a waking ordinary turn. -- `inject()` appends model-facing context without running the model. - -`followup`, `queue`, and `steer` accept `SendOptions`; `inject` accepts `InjectOptions`, which omits attached contexts because injection has no inbox item to own them. `followup` names the waking next-turn operation used for both initial prompts and later independent prompts. - -`Agent` also exposes `send(ResolvedAgentInput)` for callers that already hold the complete route. Every field is mandatory: content, source, contexts, metadata (possibly `undefined`), target, and wakeup. The discriminated union requires the empty context tuple for non-waking next-step injection. `ReactLoopAgent` implements this method once, and all four helpers resolve their defaults before delegating to it. The method accepts the delivery facts as one resolved input; acceptance can still lead to later dequeue, discard, or durable injection rather than eventual delivery. - -The target/wakeup matrix is an explicit advanced part of the structural `Agent` interface, not the ordinary helper options and not a base-class implementation seam. With one concrete adapter, a protected subclass seam would be hypothetical; callers and tests use the same public interface. - -## Alternatives considered - -**Keep the resolved primitive private.** This minimizes the public method count, but forces adapters that already hold exact target/wakeup facts to reverse-map them into helper calls and removes the reusable type for that resolved state. - -**Use configurable `send(content, options)` as the primitive.** Optional routing fields would let advanced-looking calls silently become ordinary sends. One mandatory discriminated input keeps the resolved route explicit and rejects attached contexts on injection. - -**Name the primitive `acceptInput`, `sendInternal`, or `addMessageAdvanced`.** `acceptInput` describes the synchronous acceptance boundary but not the caller's delivery action. A public method must not describe itself as internal, and `addMessageAdvanced` is inaccurate because the input may later be discarded. - -**Use `send(content, options)` as the waking-turn helper.** This reserves the shortest delivery name for one preset and forces callers with complete target/wakeup facts through a less direct primitive name. `followup` distinguishes the next-turn/wakeup intent while leaving `send` for the resolved operation. - -**Bind source first through a public sender object.** A source-bound adapter can make attribution explicit for repeated producers, but it adds another public object and does not simplify one-off human input. The existing source default remains, with the standing requirement that non-human producers label their content. - -## Verification - -Focused agent-loop coverage exercises direct fully resolved acceptance, waking sends, quiet queues, active and idle steering, injection, source/context snapshots, cancellation, and inbox lifecycle correlation through the public methods. Type-level coverage uses structural `Agent` fakes, requires every `ResolvedAgentInput` field, requires empty contexts on its injection variant, and keeps routing fields out of `SendOptions`. The keyless Cordis inspection snapshot pins the structural interface without an abstract-class implementation. - -## Consequences - -Ordinary callers choose one verb instead of encoding two routing axes; advanced callers may submit the exact discriminated route. The concrete loop retains one acceptance path and one ownership boundary, while the structural interface preserves simple adapters and fakes. Adding a common delivery intent still requires an explicit public helper and mapping rather than another optional matrix combination. - -The advanced method adds interface surface and requires structural fakes to implement it. In return, resolved routing has one typed representation, while helper defaults and mappings stay beside the only implementation that owns them. - -## Related - -- [unified delivery and coalesced user messages](2026-07-22-unified-send-and-coalesced-user-messages.md) owns the shared acceptance mechanism, inbox lifecycle, and durable event convergence this decision narrows at the public seam. diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md deleted file mode 100644 index ce8860b397..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md +++ /dev/null @@ -1,52 +0,0 @@ -# Agent Note: 按意图命名公开的 agent 投递 - -Status: implemented - -[English](2026-07-24-intent-named-agent-delivery.md) | 中文 - -## 问题 - -可配置的 `send(content, { target?, wakeup?, ... })` 会迫使每个调用方理解循环的路由矩阵、默认值,以及活跃轮次目标与模型激活之间的相互作用。可选路由字段还会让看似高级的调用悄然变成普通投递。大多数调用方只有一种语义意图,而有些适配器已经持有确切的路由信息,不应再被迫将这些信息反向映射为某个辅助方法名称。 - -通过抽象 `Agent` 类共享辅助方法的实现,实际上也会让公开 seam 具有名义类型约束。对象字面量适配器和测试必须继承原型方法,尽管该包承诺提供一个可替换的结构化句柄。共享基类只负责转发固定参数,而具体循环仍是唯一的生产适配器。 - -## 决策 - -`Agent` 是一个结构化接口,提供四种按意图命名的投递辅助方法: - -- `followup()` 将一个普通轮次入队并唤醒驱动器。 -- `queue()` 将一个普通轮次入队,但不唤醒空闲驱动器。 -- `steer()` 以运行中的轮次为目标并请求另一个步骤;空闲时,它会变成一个唤醒式普通轮次。 -- `inject()` 追加面向模型的上下文,但不运行模型。 - -`followup`、`queue` 和 `steer` 接收 `SendOptions`;`inject` 接收 `InjectOptions`,后者不包含附加上下文,因为注入没有 inbox 项来拥有它们。`followup` 为唤醒式下一轮操作命名,这项操作既用于初始提示词,也用于后续的独立提示词。 - -`Agent` 还公开 `send(ResolvedAgentInput)`,供已经持有完整路由的调用方使用。每个字段都必须提供:内容、来源、上下文、元数据(可以是 `undefined`)、目标和唤醒标志。对于目标为下一步且不触发唤醒的注入,可辨识联合类型要求上下文为空元组。`ReactLoopAgent` 统一实现这个方法;四个辅助方法都会先解析各自的默认值,再委托给它。调用方以一个解析后的输入向该方法提交各项投递事实;接受之后,工作仍可能在稍后出队、被丢弃或持久注入,而不是最终必然送达。 - -结构化 `Agent` 接口显式包含面向高级用法的 target/wakeup 矩阵;该矩阵不属于普通辅助方法的选项,也不是基类实现 seam。只有一个具体适配器时,protected 子类 seam 只是假想的;调用方和测试使用同一个公开接口。 - -## 考虑过的替代方案 - -**让解析后的原语保持私有。** 这会把公开方法数量降到最低,但会迫使已经持有精确 target/wakeup 路由信息的适配器将其反向映射为辅助方法调用,也会移除表示该解析后状态的可复用类型。 - -**使用可配置的 `send(content, options)` 作为原语。** 可选路由字段会让看似高级的调用悄然变成普通投递。一个各字段均为必填项的可辨识输入既能让解析后的路由保持显式,也会拒绝为注入附加上下文。 - -**把原语命名为 `acceptInput`、`sendInternal` 或 `addMessageAdvanced`。** `acceptInput` 描述了同步接受边界,却没有描述调用方的投递操作。公开方法不应在名称中把自己称为内部方法,`addMessageAdvanced` 也不准确,因为输入可能在之后被丢弃。 - -**使用 `send(content, options)` 作为唤醒轮次的辅助方法。** 这会让最简短的投递名称只表示一种预设操作,并迫使持有完整 target/wakeup 信息的调用方改用一个不够直接的原语名称。`followup` 明确区分下一轮/唤醒意图,并把 `send` 留给解析后的操作。 - -**先通过公开的发送方对象绑定来源。** 对于重复产生消息的来源,来源绑定适配器可以明确标注归属,但它会增加一个公开对象,也不会简化一次性的人类输入。现有的来源默认值予以保留,同时继续要求非人类生产方标注其内容。 - -## 验证 - -聚焦的 agent-loop 覆盖率测试通过公开方法覆盖直接接受完全解析的输入、唤醒式投递、静默排队、活跃与空闲状态下的 steering(中途引导)、注入、来源与上下文快照、取消,以及 inbox 生命周期关联。类型级覆盖使用结构化 `Agent` 测试替身,要求提供 `ResolvedAgentInput` 的每个字段,要求其注入变体的上下文为空,并确保 `SendOptions` 不包含路由字段。无密钥的 Cordis 检查快照固定了不采用抽象类实现的结构化接口。 - -## 后果 - -普通调用方选择一个动词即可,无需编码两条路由轴;高级调用方则可提交经过判别的精确路由。具体循环保留一条接受路径和一个归属边界,而结构化接口保留了对简单适配器和测试替身的支持。新增一种常见投递意图时,仍需要显式提供公开辅助方法及其映射,而不是再增加一种可选的矩阵组合。 - -这个高级方法会扩大接口范围,并要求结构化测试替身实现它。作为回报,解析后的路由只有一种类型化表示,而辅助方法的默认值和映射仍留在拥有它们的唯一实现旁边。 - -## 相关 - -- [统一投递并合并 user 消息](2026-07-22-unified-send-and-coalesced-user-messages.md)负责定义共享的接受机制、inbox 生命周期和持久事件趋同;本决策只收窄它们的公开 seam。 diff --git a/.agents/notes/proposed/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 similarity index 51% rename from .agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml rename to .agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml index 233cc3901c..bd19eb7b18 100644 --- a/.agents/notes/proposed/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 @@ -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-24-separate-context-injection-from-turn-execution.md: 652c3d410ab625d91a828f854bce302adcb0c9e0 -2026-07-24-separate-context-injection-from-turn-execution.zh.md: 1064e7a869ab9ea46c0145eb010119894a03aacf +# 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: b74cd6bdc48e795e57d780ab31a907ffe94dd518 +2026-07-24-separate-context-injection-from-turn-execution.zh.md: f2421d2fc7b8c1329dd1349a6fb088407ac5fc75 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 new file mode 100644 index 0000000000..b74cd6bdc4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md @@ -0,0 +1,74 @@ +# Agent Note: Separate context injection from turn execution + +Status: implemented + +English | [中文](2026-07-24-separate-context-injection-from-turn-execution.zh.md) + +## Problem + +The agent API represented supplementary model-facing input in three overlapping ways: callers attached `HookContext[]` through `SendOptions.contexts`, interception and tool hooks returned `additionalContexts`, and plugins called `agent.inject()`. These paths eventually wrote context into the same model history, but carried different placement, metadata, admission, queue, and turn-lifecycle rules. + +Atomic attachment to an inbox message forced the loop to preserve context through prompt admission, steering conversion, cancellation, and terminal discard. `prompt-prefix` placement then combined context and the direct prompt into one event, requiring a model-hidden envelope so transcript consumers could recover what the user actually wrote. The result made outbox entries, session projection, and UI replay responsible for a distinction that belongs to the producer. + +Idle `inject()` exposed a second mismatch. Injection did not request model execution, yet the implementation opened and closed a zero-step `injection` turn solely to satisfy the turn-enclosure invariant and obtain a durability checkpoint. A turn therefore sometimes meant “run the agent loop” and sometimes meant “persist context without running it.” + +`HookContext` also named its producer rather than its role. The value could come from a native plugin, a hook bridge, prompt admission, or tool post-processing; its stable meaning was additional model-facing context with provenance. + +## Decision + +`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 `UserMessageData` through `inject()` and submits the direct message independently with `send()` 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. + +Every additional context is an independent `user/message` whose `source` records provenance. There is no `context/message`, prompt-prefix placement, stable request delimiter, or prompt envelope. Transcript and UI consumers distinguish direct user messages from injected context by `source`. + +## Injection lifecycle + +During prompt admission or an open turn, `inject()` stages context in the loop outbox. The private next-step acceptance window opens before `agent/prompt-submit` and closes before `turn/end`, so steering and context accepted for one boundary reach the same following request while a `turn/end` listener's late steering becomes a queued prompt. The loop drains the outbox at a safe step boundary, preserving tool protocol adjacency: context accepted during an assistant tool-call batch appears only after that batch's complete ordered results. + +Outside that window, `inject()` appends its `user/message` immediately. It does not increment turn numbering, emit `turn/start` or `turn/end`, change agent status, or run the model; persistence observes the append through `session/event`. + +If prompt admission blocks or fails, a caller-staged context-only batch appends immediately without a turn. Steering and context staged beside it remain in the outbox for a later admitted prompt; cancellation or disposal may discard them. Hook-produced `additionalContexts` never materialize because they belong to the rejected admission decision. + +The session invariant permits `user/message` between turns while continuing to require turn enclosure for execution events, steering, assistant output, tools, and package-added events by default. Persistence, recovery, resume, fork, and compaction treat a valid out-of-turn `user/message` as committed session history rather than an interrupted or discardable turn tail. + +## Extension and caller semantics + +`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. + +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. + +This decision preserves the caller-owned framing decision from [unwrapped injected content](../simplification/2026-07-20-unwrap-injected-content-envelopes.md), the one-item turn rule from [one send, one turn](../simplification/2026-07-17-one-send-one-turn.md), and narrows the [turn-enclosure decision](2026-06-15-turn-enclosure-invariant.md) so turns enclose execution rather than every session event. + +## Alternatives considered + +**Keep `SendOptions.contexts` as an atomic attachment.** This preserves all-or-nothing delivery when prompt admission blocks, but it keeps context inside inbox lifecycle state and requires every queue transition and observation event to carry it. The generic agent API should not encode a domain transaction that most callers can express as context injection followed by message delivery. + +**Keep a distinct `context/message` session event.** A separate event makes the out-of-turn exception narrower, but user-role model input would again have two event types with identical projection. `user/message.source` already carries the distinction needed by policy, transcript, and replay consumers. + +**Keep one-shot turns for idle injection.** This retains universal turn enclosure and a convenient flush boundary, but it makes turn counts and turn observers report work that never ran the model. Durability is an independent session concern and can be awaited without fabricating execution. + +**Keep `prompt-prefix` as an optional placement.** Prefix baking can make the context and request appear in one provider message, but it introduces a second representation of the direct prompt and spreads placement handling across admission, steering, logging, replay, and UI code. Producers that require textual framing may include it in their own context content. + +**Let hooks call `inject()` directly instead of returning additional contexts.** Direct injection would erase the extension point's admission ownership: a listener could append context before a downstream listener blocks the operation. Returning `additionalContexts` keeps the waterfall result authoritative while sharing the same post-admission outbox path. + +## Verification + +- `SendOptions` and steering inbox records contain no attached contexts; `agent/inbox/enqueue` reports only the message plus its resolved queued-or-steering placement. +- `UserMessageData` is the shared 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. +- Admission-time and active-turn injection drain at safe boundaries after complete tool-result batches and before the request that consumes them. +- Blocked prompt admission opens no turn and appends neither the prompt nor hook-produced additional contexts; caller context alone falls back to an idle append, while a steering boundary remains available to retry. +- Unit, persistence/resume, invariant, host/client queue, and TUI coverage pin event order, admission ownership, and reconnect classification. + +## Consequences + +- 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. +- 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 new file mode 100644 index 0000000000..f2421d2fc7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md @@ -0,0 +1,74 @@ +# Agent Note: 将上下文注入与轮次执行分离 + +Status: implemented + +[English](2026-07-24-separate-context-injection-from-turn-execution.md) | 中文 + +## 问题 + +agent API 曾用三种相互重叠的方式表示面向模型的补充输入:调用方通过 `SendOptions.contexts` 附加 `HookContext[]`,拦截钩子和工具钩子返回 `additionalContexts`,插件则调用 `agent.inject()`。这些路径最终都把上下文写入同一份模型历史,但各自携带不同的放置、元数据、准入、队列和轮次生命周期规则。 + +将上下文原子附加到收件箱消息后,agent loop(智能体循环)曾被迫让上下文跟随提示词准入、steering(中途引导)转换、取消和终止丢弃的完整生命周期。`prompt-prefix` 放置方式又曾把上下文与直接提示词合并为一个事件,因此 transcript(文本记录)消费方不得不依赖模型不可见的封套,才能还原用户实际输入。这样一来,outbox 条目、会话投影和 UI 回放都曾负责处理本应由生产方负责的区分。 + +空闲状态下的 `inject()` 还暴露了另一处语义错位。注入当时并不请求模型执行,但实现仅为了满足轮次封闭不变量并获得持久性检查点,就会打开并关闭一个零步骤的 `injection` 轮次。于是,当时的轮次有时表示「运行 agent loop」,有时却表示「不运行 agent,仅持久化上下文」。 + +`HookContext` 的名字也描述了生产方,而非该值的职责。它可能来自原生插件、hook bridge、提示词准入或工具后处理;其稳定含义是带来源信息的额外模型上下文。 + +## 决策 + +`inject()` 是调用方交付补充模型输入的唯一操作,而轮次表示一次模型循环执行。 + +`SendOptions` 只包含 `target` 和 `wakeup`。拥有上下文的调用方通过 `inject()` 交付 `UserMessageData`,再独立使用 `send()` 或 `steer()` 提交直接消息。 + +提示词和工具扩展点仍可返回 `additionalContexts`。这些值是扩展点的输出,而不是从调用方收件箱条目捕获的附件。提示词准入在 `run()` 打开轮次之前执行。获准的提示词及其返回的额外上下文会作为独立消息进入新轮次;提示词被阻止时,两者都不写入,也不打开轮次。工具产生的额外上下文则在对应工具结果之后进入 outbox。 + +每项额外上下文都是独立的 `user/message`,并由 `source` 记录来源。不再有 `context/message`、prompt-prefix 放置方式、稳定请求分隔符或提示词封套。transcript 与 UI 消费方通过 `source` 区分直接用户消息和注入上下文。 + +## 注入生命周期 + +提示词准入期间或轮次打开时,`inject()` 会将上下文暂存在 loop outbox 中。私有的 next-step 接受窗口在 `agent/prompt-submit` 前打开,并在 `turn/end` 前关闭,因此同一边界接受的 steering 和上下文会进入后续同一次请求,而 `turn/end` 监听器提交的晚到 steering 则成为排队提示词。agent loop 会在安全的步骤边界排空 outbox,同时保持工具协议要求的相邻关系:在助手工具调用批次期间接受的上下文,只能出现在该批次所有有序结果之后。 + +在该窗口之外,`inject()` 会立即追加对应的 `user/message`。它不会增加轮次编号、发出 `turn/start` 或 `turn/end`、改变 agent 状态,也不会运行模型;持久化通过 `session/event` 观察这次追加。 + +如果提示词准入被阻止或失败,调用方暂存的仅含上下文的批次会立即追加,且不产生轮次。steering 及与其一同暂存的上下文会留在 outbox 中,供后续获准提示词使用;取消或 dispose(资源释放)可能丢弃它们。钩子产生的 `additionalContexts` 属于被拒绝的准入决策,因此永远不会落入日志。 + +会话不变量允许 `user/message` 位于两个轮次之间,同时继续要求执行事件、steering、助手输出、工具事件以及默认的包扩展事件均受轮次边界约束。持久化、恢复、resume、fork 和压缩会把合法的轮次外 `user/message` 当作已提交会话历史,而不是中断轮次或可丢弃的日志尾部。 + +## 扩展点与调用方语义 + +`PromptDecision.content` 仍只替换直接提示词。`PromptDecision.additionalContexts` 和工具结果的 `additionalContexts` 保留 FIFO 顺序及各自来源,但不再选择放置方式。waterfall(瀑布式事件)监听器调用 `next()` 委托时,必须保留下游返回的提示词内容和额外上下文,除非它有意返回替代值。 + +调用方主动注入与钩子产生的额外上下文具有不同的准入归属。钩子的额外上下文只会在该钩子允许提示词或工具结果后落入日志。在 next-step 接受窗口之外,调用方执行 `inject(context)` 后再执行 `send(prompt)` 时,会独立提交上下文;需要全有或全无语义的调用方应使用领域专用的准入包装层。 + +跨会话引用采用这种领域组合方式:TUI 先准备快照,然后在接受窗口之外将其加入提示词准入决策,或在窗口期间将其注入到 steering 旁。目标日志包含两条简单消息,因此来源会话后续变化不会改变回放,transcript 消费方也不需要提示词封套。本决策取代[跨会话引用决策](../feature/2026-07-21-cross-session-references.md)中的附件机制,但保留其快照与信任边界规则。 + +本决策保留[移除注入内容封套](../simplification/2026-07-20-unwrap-injected-content-envelopes.md)确立的调用方自主管理框架原则,以及[一次 send、一个轮次](../simplification/2026-07-17-one-send-one-turn.md)确立的单条目轮次规则;同时收窄[轮次封闭决策](2026-06-15-turn-enclosure-invariant.md),使轮次约束执行过程,而不是约束所有会话事件。 + +## 曾考虑的替代方案 + +**保留 `SendOptions.contexts` 作为原子附件。** 提示词准入阻止消息时,这种方式能保留全有或全无交付,但也会让上下文继续成为收件箱生命周期状态的一部分,并迫使每次队列转换和观察事件携带它。大多数调用方都可以通过先注入上下文、再交付消息来表达需求,通用 agent API 不应内置领域事务。 + +**保留独立的 `context/message` 会话事件。** 独立事件可以缩小轮次外事件的例外范围,但面向模型的 user-role 输入会再次拥有两个投影完全相同的事件类型。`user/message.source` 已能为策略、transcript 和回放消费方提供所需区分。 + +**为空闲注入保留一次性轮次。** 这种方式能保留通用轮次封闭和方便的刷新边界,却会让轮次计数与轮次观察方报告从未运行模型的工作。持久性是独立的会话关注点,无需伪造执行即可等待。 + +**保留 `prompt-prefix` 可选放置方式。** 前缀烘焙可以让上下文和请求位于同一条提供方消息中,但它会引入直接提示词的第二种表示,并把放置处理扩散到准入、steering、日志、回放和 UI 代码。需要文本框架的生产方可以直接把它写入自身上下文内容。 + +**让钩子直接调用 `inject()`,而不是返回额外上下文。** 直接注入会破坏扩展点的准入归属:下游监听器阻止操作之前,上游监听器就可能已经追加上下文。返回 `additionalContexts` 能维持 waterfall 结果的最终权威性,同时复用准入后的 outbox 路径。 + +## 验证 + +- `SendOptions` 与 steering 收件箱记录不包含附加上下文;`agent/inbox/enqueue` 只报告消息及其已解析的 queued 或 steering 放置方式。 +- `UserMessageData` 是提示词拦截、工具执行、hook bridge、guard 和上下文生产方共享的形状。 +- 公共类型、持久事件、投影和 UI 回放中均不存在 prompt-prefix 放置方式、提示词封套与 `context/message`。 +- 空闲 `inject()` 在不产生轮次或模型调用的情况下,追加一条带来源的 `user/message`。 +- 准入期间和活跃轮次中的注入会在完整工具结果批次之后的安全边界排空,并在消费它们的请求之前进入日志。 +- 被阻止的提示词准入不会打开轮次,也不会追加提示词或钩子产生的额外上下文;仅有调用方上下文时会回退为空闲追加,而带 steering 的边界仍可重试。 +- 单元测试、持久化与 resume 测试、不变量测试、宿主/客户端队列测试和 TUI 覆盖会固定事件顺序、准入归属和重连分类。 + +## 后果 + +- 一个表层事件可以合法位于轮次之外,因此持久化扫描、崩溃恢复、fork、压缩和会话查询需要区分执行封闭与会话历史。 +- 两条连续的 user-role 消息会取代一条烘焙后的提示词消息;提供方适配器会保留这一顺序。 +- 在接受窗口之外,`inject()` 后跟一个被阻止的 `send()` 会留下缺少预期直接提示词的上下文,除非调用方提供领域专用的准入归属。 +- 公共投递契约和收件箱记录保持精简:没有上下文附件、上下文放置元数据、提示词封套或重复的持久事件类型。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 529e319ee0..f3a525fe70 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.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-25-web-client-session-scope-and-provide-channel.md: 063494b56461593015d6de4c2b55a2d1d6a3c676 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: cd5d29dfbcd9356a9ea15852d5d27a3660084abf +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +2026-07-25-web-client-session-scope-and-provide-channel.md: 09afe6d9e879ae7529d309c3b5e656be849fa543 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 4d45d74c2e7c34601a5229fc0fc0780a23ec6fd5 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 063494b564..09afe6d9e8 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -101,7 +101,7 @@ Slot scope is the closed set `root | session-maybe | session`: ### The read-only queue mirror -- The MuxFrame `session/queued`: the Session holds a read-only inbox mirror (previews truncated; steering retired by source match); queue frames never enter history — pure stream state, cleared on reconnect and refilled from the new baseline; the never-instantiated window is buffered and replayed through the manager pendingBuffers. +- The MuxFrame `session/queued`: the Session holds a read-only inbox mirror (previews truncated; steering retired by source match). The host stamps the agent-loop's acceptance-time steering classification on live and replayed frames, so a reconnect baseline does not depend on replaying an earlier `turn/start`. Queue frames never enter history — pure stream state, cleared on reconnect and refilled from the new baseline; the never-instantiated window is buffered and replayed through the manager pendingBuffers. - Queue semantics: running does not lock input; ordinary messages queue through `session.prompt {mode:'queue'}`, and commands never queue. ### Host wire smalls diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index cd5d29dfbc..4d45d74c2e 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -101,7 +101,7 @@ slot scope 是闭集 `root | session-maybe | session`: ### 队列只读镜像 -- MuxFrame `session/queued`:Session 持只读 inbox 镜像(预览截断、steering 按 source 匹配退休);queue 帧不进 history,纯 stream 态——重连清空、新基线重灌;未实例化窗口经 manager pendingBuffers 缓冲重放。 +- MuxFrame `session/queued`:Session 持只读 inbox 镜像(预览截断、steering 按 source 匹配退休)。宿主会在实时和回放帧中标记 agent loop 接受消息时的 steering 分类,因此重连基线不依赖回放更早的 `turn/start`。queue 帧不进 history,纯 stream 态——重连清空、新基线重灌;未实例化窗口经 manager pendingBuffers 缓冲重放。 - 队列语义:running 不锁输入;普通消息经 `session.prompt {mode:'queue'}` 排队,命令永不排队。 ### host wire 小件 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml index 4798f54960..a7a00fd548 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.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-20-error-cause-chain-diagnostics.md: 391e35997bb1bb050dd2ca620920961d77bb1c46 -2026-07-20-error-cause-chain-diagnostics.zh.md: 90d6559a9410e8a4e5475db9560a2a177ba7a1a7 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md +2026-07-20-error-cause-chain-diagnostics.md: 7de1f4f631cec90048ccc8eab7a6654560d84846 +2026-07-20-error-cause-chain-diagnostics.zh.md: 74820e80f729343a833c926adc6187b7cc9fc372 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md index 391e35997b..7de1f4f631 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md @@ -15,7 +15,7 @@ A TUI run against an unreachable DeepSeek endpoint failed with the single notice - `dsh-llm` exports `errorChain(value)`: renders a thrown value with its full `cause` chain (`outer: inner: …`) and AggregateError members (`msg [m1; m2]`), with circular-cause and hostile-coercion containment. It is a diagnostic-surface renderer only; routing stays on `HarnessError.code`. - The DeepSeek adapter wraps a pre-response transport failure in `LlmError('TRANSPORT')` naming the configured `baseURL` and chaining the original rejection as `cause`. An aborted request becomes `LlmError('ABORTED')`; because the turn signal is already aborted, the loop still classifies the turn as cancellation rather than recovery. -- Every diagnostic seam renders through `errorChain` instead of `error.message`/`String(error)`: the agent-loop's durable `turn/end` error message (`errorData`), its logger warnings, the TUI's `agent/error` notice and startup-failure line, and `dsh-stdio`'s startup-failure log lines. The per-package `renderThrown` copies in `dsh-agent-loop`, `dsh-stdio`, and `dsh-tui` are deleted in favor of the one shared renderer. +- Every diagnostic seam renders through `errorChain` instead of `error.message`/`String(error)`: the agent-loop's durable `turn/end` error message (`errorData`), its logger warnings, the TUI's `agent/error` notice and startup-failure line, and `dsh-stdio`'s startup-failure log lines. The live `agent/error` event and `SettleReason` preserve the thrown value as `unknown`; each diagnostic consumer renders it instead of the loop wrapping it into another error. The per-package `renderThrown` copies in `dsh-agent-loop`, `dsh-stdio`, and `dsh-tui` are deleted in favor of the one shared renderer. - `dsh-stdio` renders failure `turn/end` reasons: `[turn failed ] `, `[turn aborted] `, `[turn rejected] `, `[turn interrupted by a previous process exit]`, and the output-token-limit notice. Unknown merge-extended kinds fall through as ordinary turn ends. `errorChain` lives in `dsh-llm` beside `HarnessError` for the same reason the base class does: it is the leaf package every consumer already imports, so sharing costs no new dependency edge. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md index 90d6559a94..74820e80f7 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md @@ -15,7 +15,7 @@ TUI 连接不可达的 DeepSeek 端点时,失败只显示一条 `fetch failed` - `dsh-llm` 导出 `errorChain(value)`:渲染抛出值及其完整 `cause` 链(`outer: inner: …`)与 AggregateError 成员(`msg [m1; m2]`),并容错循环 cause 和恶意强制转换。它只是诊断表面的渲染器;路由仍然基于 `HarnessError.code`。 - DeepSeek 适配器把拿到响应之前的传输失败包装成 `LlmError('TRANSPORT')`,写明配置的 `baseURL` 并把原始拒绝值链为 `cause`。被中止的请求变为 `LlmError('ABORTED')`;由于轮次信号已处于中止状态,循环仍将该轮次归类为取消而非恢复。 -- 每个诊断接缝改用 `errorChain` 而非 `error.message`/`String(error)`:agent-loop 的持久化 `turn/end` 错误消息(`errorData`)、其日志警告、TUI 的 `agent/error` 通知与启动失败行、以及 `dsh-stdio` 的启动失败日志行。`dsh-agent-loop`、`dsh-stdio`、`dsh-tui` 里各自的 `renderThrown` 副本被删除,统一使用这一个共享渲染器。 +- 每个诊断接缝改用 `errorChain` 而非 `error.message`/`String(error)`:agent-loop 的持久化 `turn/end` 错误消息(`errorData`)、其日志警告、TUI 的 `agent/error` 通知与启动失败行、以及 `dsh-stdio` 的启动失败日志行。实时 `agent/error` 事件与 `SettleReason` 以 `unknown` 原样保留抛出值;各诊断消费者自行渲染,而不是由循环把它包装成另一个错误。`dsh-agent-loop`、`dsh-stdio`、`dsh-tui` 里各自的 `renderThrown` 副本被删除,统一使用这一个共享渲染器。 - `dsh-stdio` 渲染失败的 `turn/end` reason:`[turn failed ] `、`[turn aborted] `、`[turn rejected] `、`[turn interrupted by a previous process exit]` 以及输出 token 上限通知。未知的 merge 扩展 kind 按普通 turn 结束处理。 `errorChain` 与 `HarnessError` 一样放在 `dsh-llm` 里,理由相同:它是每个消费者都已导入的叶子包,共享不增加新的依赖边。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml index e6b0fa166a..663ad725d4 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.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 -2026-07-21-compaction-summary-prefix-cache-reuse.md: 490eb57a5891bf9cd0799c5d49d25d4e9838041f -2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: 02412ff07e87e12c7e7de00b5c69e1282433f735 +2026-07-21-compaction-summary-prefix-cache-reuse.md: d05d25cfa7c3984ce0ce75c38068a91a0e07dfe8 +2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: edf9de6fe5388d75612946bfb05c4383d1856102 diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md index 490eb57a58..d05d25cfa7 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md +++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md @@ -6,7 +6,7 @@ English | [中文](2026-07-21-compaction-summary-prefix-cache-reuse.zh.md) ## Problem -Automatic compaction fires mid-conversation, right after the loop has warmed the provider's KV cache with the last routed request (`system` + `tools` + `messagePrefix` + derived history). The default summarizer then issued a *separate* auxiliary request whose prefix shared nothing with that warm request: a bespoke summarizer `system` prompt followed by the older history flattened to a single rendered transcript string. A provider caches on the request's leading token sequence, so a first token that differs — a different system prompt — invalidates the entire cached prefix. Every compaction therefore paid full prompt-processing cost for the whole replayed history twice: once for the conversation request that tripped pressure, and again for the summarization call, defeating the cache exactly when the conversation is largest. +Automatic compaction fires mid-conversation, right after the loop has warmed the provider's KV cache with the last routed request (`system` + `tools` + derived history). The default summarizer then issued a *separate* auxiliary request whose prefix shared nothing with that warm request: a bespoke summarizer `system` prompt followed by the older history flattened to a single rendered transcript string. A provider caches on the request's leading token sequence, so a first token that differs — a different system prompt — invalidates the entire cached prefix. Every compaction therefore paid full prompt-processing cost for the whole replayed history twice: once for the conversation request that tripped pressure, and again for the summarization call, defeating the cache exactly when the conversation is largest. ## Decision @@ -14,7 +14,7 @@ The summarization directive moves from the **front** of the request (a fresh `sy ### `SummarizationInput` carries the replayed prefix, not a rendered string -`summarize()` (and the internal `summarizeWithLlm`) take a `SummarizationInput` — `{ system?, tools?, messages }` — instead of a flat transcript string. `region.ts` builds it from `session.requestHeader()` (the durable `system`, `tools`, and `messagePrefix`) plus the shadowed region mapped through `session.deriveEventMessage`, which yields byte-identical `Message` objects to what `deriveMessages()` folded into the routed request. `summarizeWithLlm` forwards `system` and `tools` onto `GenerateOptions` and sends `[...input.messages, { role: 'user', content: COMPACTION_INSTRUCTION }]`. `tools` ride along even though the summarizer never calls one: dropping them would shorten the token sequence and break alignment with the cached request. +`summarize()` (and the internal `summarizeWithLlm`) take a `SummarizationInput` — `{ system?, tools?, messages }` — instead of a flat transcript string. `region.ts` builds it from `session.requestHeader()` (the durable `system` and `tools`) plus the shadowed region mapped through `session.deriveEventMessage`, which yields byte-identical `Message` objects to what `deriveMessages()` folded into the routed request. `summarizeWithLlm` forwards `system` and `tools` onto `GenerateOptions` and sends `[...input.messages, { role: 'user', content: COMPACTION_INSTRUCTION }]`. `tools` ride along even though the summarizer never calls one: dropping them would shorten the token sequence and break alignment with the cached request. ### The instruction is a trailing user message @@ -27,7 +27,7 @@ Auto-compaction always anchors at the surface head, so the shadowed region is th ## Alternatives considered - **Keep the summarizer system prompt but reuse the rest** — rejected: the system slot is the very first token region a provider caches on, so a distinct summarizer system prompt invalidates the whole prefix regardless of what follows. Only moving the directive off the front recovers the cache. -- **Send only the shadowed region without the `system`/`tools`/`messagePrefix` head** — rejected: a shorter or differently-headed sequence still diverges from the cached request at the first token, so it caches no better while losing the framing the summary needs. +- **Send only the shadowed region without the `system`/`tools` head** — rejected: a differently-headed sequence still diverges from the cached request at the first token, so it caches no better while losing the framing the summary needs. - **Omit `tools` from the summarization request** (the model never calls one) — rejected: tool schemas are part of the cached token sequence; omitting them misaligns every following token and defeats reuse. - **A dedicated `assistant/chunk`-emitting summarization sub-session for snapshot replay** — out of scope here; the replay gap predates this change and is tracked in the [compaction-seam note](../feature/2026-06-18-compaction-capability-seam.md). diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md index 02412ff07e..edf9de6fe5 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -自动压缩(compaction)在对话中途触发,恰好在循环用最后一个已路由请求(`system` + `tools` + `messagePrefix` + 派生历史)预热了提供方的 KV 缓存之后。随后默认摘要器发出一个*独立的*辅助请求,其前缀与那个已预热请求没有任何共享部分:一个专门的摘要器 `system` 提示词,后接被拍平成单个渲染后 transcript(文本记录)字符串的较早历史。提供方基于请求起始的 token 序列做缓存,因此第一个 token 只要不同(即一个不同的系统提示词),整个已缓存前缀就会失效。于是每次压缩都要为整段回放的历史付出两次完整的提示词处理成本:一次用于触发压力的对话请求,另一次用于摘要调用,恰好在对话最大时让缓存失去作用。 +自动压缩(compaction)在对话中途触发,恰好在循环用最后一个已路由请求(`system` + `tools` + 派生历史)预热了提供方的 KV 缓存之后。随后默认摘要器发出一个*独立的*辅助请求,其前缀与那个已预热请求没有任何共享部分:一个专门的摘要器 `system` 提示词,后接被拍平成单个渲染后 transcript(文本记录)字符串的较早历史。提供方基于请求起始的 token 序列做缓存,因此第一个 token 只要不同(即一个不同的系统提示词),整个已缓存前缀就会失效。于是每次压缩都要为整段回放的历史付出两次完整的提示词处理成本:一次用于触发压力的对话请求,另一次用于摘要调用,恰好在对话最大时让缓存失去作用。 ## Decision @@ -14,7 +14,7 @@ Status: implemented ### `SummarizationInput` 携带回放的前缀,而非渲染后的字符串 -`summarize()`(以及内部的 `summarizeWithLlm`)接受一个 `SummarizationInput`(`{ system?, tools?, messages }`)而不是一个扁平的 transcript 字符串。`region.ts` 用 `session.requestHeader()`(持久的 `system`、`tools` 和 `messagePrefix`)加上经 `session.deriveEventMessage` 映射的被遮蔽区域来构建它,后者产出与 `deriveMessages()` 折叠进已路由请求的内容字节级一致的 `Message` 对象。`summarizeWithLlm` 把 `system` 和 `tools` 转发到 `GenerateOptions`,并发送 `[...input.messages, { role: 'user', content: COMPACTION_INSTRUCTION }]`。`tools` 会一同带上,即便摘要器从不调用任何工具:丢弃它们会缩短 token 序列,破坏与已缓存请求的对齐。 +`summarize()`(以及内部的 `summarizeWithLlm`)接受一个 `SummarizationInput`(`{ system?, tools?, messages }`)而不是一个扁平的 transcript 字符串。`region.ts` 用 `session.requestHeader()`(持久的 `system` 和 `tools`)加上经 `session.deriveEventMessage` 映射的被遮蔽区域来构建它,后者产出与 `deriveMessages()` 折叠进已路由请求的内容字节级一致的 `Message` 对象。`summarizeWithLlm` 把 `system` 和 `tools` 转发到 `GenerateOptions`,并发送 `[...input.messages, { role: 'user', content: COMPACTION_INSTRUCTION }]`。`tools` 会一同带上,即便摘要器从不调用任何工具:丢弃它们会缩短 token 序列,破坏与已缓存请求的对齐。 ### 指令是一条尾部 user 消息 @@ -27,7 +27,7 @@ Status: implemented ## Alternatives considered - **保留摘要器系统提示词但复用其余部分**——否决:system 槽位正是提供方最先做缓存的 token 区域,因此一个不同的摘要器系统提示词无论后面跟着什么都会使整个前缀失效。只有把指令移离前端才能恢复缓存。 -- **只发送被遮蔽区域而不带 `system`/`tools`/`messagePrefix` 头部**——否决:更短或头部不同的序列在第一个 token 处仍然与已缓存请求分叉,因此缓存效果并不更好,反而丢失了摘要所需的框架。 +- **只发送被遮蔽区域而不带 `system`/`tools` 头部**——否决:头部不同的序列在第一个 token 处仍然与已缓存请求分叉,因此缓存效果并不更好,反而丢失了摘要所需的框架。 - **从摘要请求中省略 `tools`**(模型从不调用任何工具)——否决:工具 schema 是已缓存 token 序列的一部分;省略它们会让后续每个 token 失去对齐,破坏复用。 - **为快照回放专门建立一个发出 `assistant/chunk` 的摘要子会话**——此处超出范围;该回放缺口早于本次改动,记录在 [compaction-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 中。 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml index 7998dc3257..414c63211a 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml @@ -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-compaction-capability-seam.md: a263b5e7d0245bd1279024a50e05b2f33edad521 -2026-06-18-compaction-capability-seam.zh.md: df0cf9d9131978e608d47124ba0f0db0343ee12a +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +2026-06-18-compaction-capability-seam.md: 3c219b734e148b963fb5857de89c16f28c2bd402 +2026-06-18-compaction-capability-seam.zh.md: b2c7e9720b596705b60a284e6ccf1448a782b7fc diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index a263b5e7d0..3c219b734e 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -39,7 +39,7 @@ An earlier draft put the full algorithm (the retention walk, token-summing, text Successful-call pressure cannot run at pre-step because final `agent/request` routing, provider output, tool results, buffered context, and steering do not exist there. Serial `agent/post-step(agent, turn, step, signal)` fires after those facts are durable and before `step/end`. `dsh-compact-basic` measures the canonical logged request through `ctx.tokenMeter`, so the next request sees any replacement without a speculative envelope override. Once pressure qualifies, optional `ctx.toolResultPrune` rewriting runs before summary selection; compact-basic remeasures the durable surface and skips summarization if pruning restores safe pressure. -Canonical provider context overflow takes a separate path. The failed step closes, `agent/request-error` receives the original request error and consecutive retry count, and compact-basic prunes before forcing one useful balanced reduction. It returns retry only if `session.surface.replaceGeneration` increases, including pruning-only progress when no summary range exists; the loop then opens a new numbered step and reconstructs its request from the durable log. No replacement, a recovery failure before any replacement, cancellation, an exhausted cap, or an unrelated error preserves the original provider failure. If pruning already advanced the generation before later summary work fails, recovery retries from that durable pruned surface unless cancellation or disposal wins. The complete lifecycle decision is in the [after-call recovery Agent Note](../architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md). +Canonical provider context overflow takes a separate path. The failed step closes and `agent/request-error` receives the original request error. Compact-basic owns its per-agent overflow count, prunes before forcing one useful balanced reduction, and returns `{ kind: 'retry' }` only if `session.surface.replaceGeneration` increases, including pruning-only progress when no summary range exists. The loop then closes the failed turn, opens a new numbered retry turn, and reconstructs its request from the durable log. No replacement, a recovery failure before any replacement, cancellation, an exhausted cap, or an unrelated error preserves the original provider failure. If pruning already advanced the generation before later summary work fails, recovery retries from that durable pruned surface unless cancellation or disposal wins. The complete lifecycle decision is in the [after-call recovery Agent Note](../architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md). ``` assistant/message → tool/result/context/steering diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md index df0cf9d913..b2c7e9720b 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -39,7 +39,7 @@ Status: implemented 成功调用的压力检查不能在步骤前运行,因为最终的 `agent/request` 路由、提供方输出、工具结果、缓冲上下文与 steering 当时尚不存在。串行的 `agent/post-step(agent, turn, step, signal)` 会在这些事实持久化后、`step/end` 之前触发。`dsh-compact-basic` 通过 `ctx.tokenMeter` 测量规范的已记录请求,因此下一个请求无需推测性覆盖信封即可看到任何替换。压力达到条件后,可选的 `ctx.toolResultPrune` 重写在摘要范围选择前运行;compact-basic 重新测量持久 surface,如果修剪恢复到安全压力便跳过摘要生成。 -规范的提供方上下文溢出走另一条路径。失败步骤先关闭,`agent/request-error` 接收原始请求错误与连续重试次数,compact-basic 在强制执行一次有效且平衡的缩减前先修剪。仅当 `session.surface.replaceGeneration` 增加时,它才返回 retry;这包括没有摘要范围时仅修剪取得的进展。随后循环开启新的编号步骤,并从持久日志重建请求。没有替换、任何替换前的恢复失败、取消、耗尽的上限或无关错误都会保留原始提供方失败。如果修剪已经推进 generation,而后续摘要工作失败,恢复会从该持久的已修剪 surface 重试,除非取消或资源释放胜出。完整生命周期决策见[调用后恢复 Agent Note](../architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)。 +规范的提供方上下文溢出走另一条路径。失败步骤先关闭,`agent/request-error` 接收原始请求错误。compact-basic 自行持有按 agent 计的溢出次数,在强制执行一次有效且平衡的缩减前先修剪,且仅当 `session.surface.replaceGeneration` 增加时才返回 `{ kind: 'retry' }`;这包括没有摘要范围时仅修剪取得的进展。随后循环关闭失败轮次,开启新的编号重试轮次,并从持久日志重建请求。没有替换、任何替换前的恢复失败、取消、耗尽的上限或无关错误都会保留原始提供方失败。如果修剪已经推进 generation,而后续摘要工作失败,恢复会从该持久的已修剪 surface 重试,除非取消或资源释放胜出。完整生命周期决策见[调用后恢复 Agent Note](../architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)。 ``` assistant/message → tool/result/context/steering diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index e8e4c59638..8e25425874 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.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-24-workspace-context.md: 6acdb6241bcc57250e217cfc8856e8b0598d4622 -2026-06-24-workspace-context.zh.md: f165d08108931df21697c7895523f10ef816297f +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-24-workspace-context.md +2026-06-24-workspace-context.md: f86e227be615c9b54e2a9013d3c7dca75d3975f0 +2026-06-24-workspace-context.zh.md: 154b5260955570e2de3c88d98286c5ea6afaa3b5 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index 6acdb6241b..f86e227be6 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -10,13 +10,13 @@ Repository guidance such as `AGENTS.md` belongs in a coding session's effective Neighboring products establish useful conventions but differ in details. Codex treats `AGENTS.md` as native, Claude Code uses `CLAUDE.md` and familiar system-reminder-style user context, and opencode supports both names with one winner per directory plus lazy nested discovery. The harness needs cross-tool compatibility without loading duplicate or contradictory files from the same scope. -The lifecycle has two distinct classes of content. The initial applicable chain is stable enough to live in the request prefix and benefit from provider prefix caching. Nested files, edits, candidate switches, and removals happen after the session starts and belong in durable append-only history rather than the frozen prefix. +The lifecycle has two distinct classes of content. The initial applicable chain is injected once before the first request. Nested files, edits, candidate switches, and removals happen later and join the same durable append-only history. ## Decision -The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. The shared demo spine and Host Runtime mount it from an explicit `{ maxBytes } | false` deployment choice; `dsh web` enables a 65,536-byte budget while the Host Runtime's headless consumer disables it. The plugin consumes `agent/session-prefix`, `tools/post-execute`, and the optional `ctx.fs` capability. +The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. The shared demo spine and Host Runtime mount it from an explicit `{ maxBytes } | false` deployment choice; `dsh web` enables a 65,536-byte budget while the Host Runtime's headless consumer disables it. The plugin consumes `agent/step`, `tools/post-execute`, and the optional `ctx.fs` capability. -The plugin does not statically inject `fs`. Providerless product trees therefore boot normally and the plugin no-ops until a filesystem provider exists. All production reads go through that provider. Candidate probes resolve each path and stat the result, so a final-component symlink is followed to its target: a link to a regular file loads, while a missing path or a non-file target is a confirmed absence. Following repository-owned links across the trust boundary is a deliberate reversal of the original no-follow probe; the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns that decision and its residual risk. The session-prefix signal and dynamic tool execution signal propagate through resolution, metadata probes, and streaming reads, so cancellation does not wait for an unrelated filesystem scan. A resolve or stat exception is classified as unavailable: it skips only that candidate and is never interpreted as the deletion of an already-loaded scope. +The plugin does not statically inject `fs`. Providerless product trees therefore boot normally and the plugin no-ops until a filesystem provider exists. All production reads go through that provider. Candidate probes resolve each path and stat the result, so a final-component symlink is followed to its target: a link to a regular file loads, while a missing path or a non-file target is a confirmed absence. Following repository-owned links across the trust boundary is a deliberate reversal of the original no-follow probe; the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns that decision and its residual risk. The step signal and dynamic tool execution signal propagate through resolution, metadata probes, and streaming reads, so cancellation does not wait for an unrelated filesystem scan. A resolve or stat exception is classified as unavailable: it skips only that candidate and is never interpreted as the deletion of an already-loaded scope. ### File Names And Precedence @@ -26,13 +26,13 @@ Candidate entries are same-directory file names. Empty entries, `.`/`..`, and en The user-global file is fixed at `$DSH_HOME/AGENTS.md`, is not affected by either candidate list, and has no local overlay. `$DSH_HOME` defaults to `~/.dsh`, matching the harness-level home role of `~/.codex` or `~/.claude` rather than introducing a plugin-specific home. Tilde expansion and the default live in `dsh-paths` so future harness features share the same convention. -### Baseline Prefix +### Baseline Injection -On the first request of an agent-loop instance, the plugin contributes one user-role message through `agent/session-prefix`. It loads the user-global file first, then finds the project root by walking upward from `agent.session.header.cwd` to a configured root marker (default `.git`), then loads one candidate from each directory from the root to the cwd. A `.git` file and a `.git` directory are both valid markers, covering linked worktrees and submodules. Without a marker, the cwd itself is the root. +At the first `agent/step` of an agent-loop instance, the plugin injects one sourced user-role message before the request is derived. It loads the user-global file first, then finds the project root by walking upward from `agent.session.header.cwd` to a configured root marker (default `.git`), then loads one candidate from each directory from the root to the cwd. A `.git` file and a `.git` directory are both valid markers, covering linked worktrees and submodules. Without a marker, the cwd itself is the root. -The plugin prepends its contribution before `await next()` returns, so session-prefix contributions appear in plugin registration order. In the product spine workspace instructions are registered before a skills catalog and therefore appear first. The loop deep-freezes the composed prefix, logs it in `EpochHeader.messagePrefix`, and reuses it verbatim for that instance. It is request state, not `Session.deriveMessages()` history. +The injection becomes a durable `user/message` with a typed `workspace-instructions` source. Its `baseline: true` marker distinguishes the complete startup or resume baseline from later deltas, and its change list persists the included scopes and content digests. In the product spine workspace instructions are registered before the skills catalog, so their `agent/step` listener injects first. The loop drains both messages before deriving the first request. -A resumed agent creates a new loop instance and recomposes the baseline from current files, with the new prefix anchored by the resume request header. This permits current baseline content on resume without mutating a prefix already used by an earlier instance. +A resumed agent creates a new loop instance and injects a baseline composed from current files before its first request. This permits current baseline content on resume without mutating an earlier history event. A resume and a hot plugin remount both face a log that may already hold a baseline; they are told apart by `agent/session-start`, which a startup or resume emits before the first step while a remount attaches to an already-live session and never sees it. A remount retains the existing baseline only when its typed event remains in the current visible surface, and still rebuilds scope and provider-version tracking from current files. If compaction has shadowed that event, the remount injects a current baseline. A resume always re-composes. The baseline is a user-role `` with `Instructions from: ` sections and explicit authority and precedence language. This familiar model-facing frame avoids a harness-specific XML vocabulary. Project paths are root-relative and the user-global path is `~/.dsh/AGENTS.md` for the default home or `$DSH_HOME/AGENTS.md` for a configured home. A literal `` inside file content is escaped. The package README owns the exact current [prompt shape](../../../../packages/context/workspace-context/README.md#prompt-shape). @@ -42,21 +42,21 @@ After a successful first-party `read`, `write`, or `edit` call, the `tools/post- A content edit appends `Updated instructions from: `, states that the new content replaces the previous content, and includes the complete current file. If precedence changes from one candidate to another, the message also names the previous path and says it no longer applies. If no candidate remains, the plugin appends `Instructions removed: ` and states that the previously loaded instructions no longer apply. -Dynamic messages carry their complete system-reminder framing in `content`, and every `context/message` reaches the model verbatim as a user-role message (there is no core wrapper to opt out of). `context/message.meta` carries opaque JSON state that is persisted but never rendered to the model. +Baseline and dynamic messages carry their complete system-reminder framing in `content`, and every sourced `user/message` reaches the model verbatim (there is no core wrapper to opt out of). The typed `workspace-instructions` source carries persisted state that is never rendered to the model. Shell commands are not discovery triggers. Local bash calls start fresh shells, and inferring reached paths from arbitrary command strings would require shell semantics the prompt plugin does not own. ### Duplicate Suppression And Change Detection -Every dynamic workspace context event stores versioned metadata with `{ action, scope, path, digest? }`, where `digest` is SHA-1 over the loaded content. The model-facing prompt has no HTML comments, hidden markers, or headings that are parsed back into state. +Every workspace context event stores versioned metadata with `{ action, scope, path, digest? }`, where `digest` is SHA-1 over the loaded content. Baselines additionally carry `baseline: true`. The model-facing prompt has no HTML comments, hidden markers, or headings that are parsed back into state. -At reconciliation time the plugin scans plugin-owned `context/message` events and derives the latest state for each visible scope. A short per-session pending map begins only after the immutable top-level `tools/result` proves an `additionalContexts` entry survived every post-execute listener, then covers the interval before the loop appends that context to the log. Each entry records the open `{ turn, step }`: an equal durable `context/message` at or after its sequence boundary confirms and removes it, while a matching `step/end` arriving first means the loop discarded its context buffer, so the plugin removes both the pending entry and its version-cache fast path. A nested Code Mode result stages its changes under the parent's opaque execution token so repeated sub-dispatches in one run do not duplicate them; the parent result rolls that provisional state back and commits only contexts retained by outer policy. +At reconciliation time the plugin scans workspace-sourced `user/message` events and derives the latest state for each visible scope. A short per-session pending map begins only after the immutable top-level `tools/result` proves an `additionalContexts` entry survived every post-execute listener, then covers the interval before the loop appends that context to the log. Each entry records the open `{ turn, step }`: an equal durable `user/message` at or after its sequence boundary confirms and removes it, while a matching `step/end` arriving first means the loop discarded its context buffer, so the plugin removes both the pending entry and its version-cache fast path. A nested Code Mode result stages its changes under the parent's opaque execution token so repeated sub-dispatches in one run do not duplicate them; the parent result rolls that provisional state back and commits only contexts retained by outer policy. An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata. If compaction removes an instruction event from the visible surface, that state no longer suppresses a later load, matching the fact that the model can no longer see it. Only changes actually included under the byte budget enter metadata or pending state, so an omitted file remains eligible on a later touch. -The frozen baseline keeps an in-memory path/digest map for comparison. A later successful filesystem touch appends baseline edits or removals as dynamic messages; it never rewrites the prefix. During resumed prefix composition the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the first resumed request. +The initial baseline's typed changes are comparison state only while its event remains in the visible session surface. A later successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends baseline edits or removals as dynamic messages; it never rewrites the original event. The in-memory scope marker and provider-version cache only select and accelerate probes, so neither can suppress context the model no longer sees. During resumed baseline preparation the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the first resumed request. -There is intentionally no watcher. Detection occurs at the next successful structured filesystem touch or resumed prefix composition. A provider failure produces no removal; absence is only accepted when all configured candidates in that scope were probed successfully. +There is intentionally no watcher. Detection occurs at the next successful structured filesystem touch or resumed baseline preparation. A provider failure produces no removal; absence is only accepted when all configured candidates in that scope were probed successfully. ### Byte Budget And Bounded Reads @@ -68,7 +68,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc **Use a global `ctx.systemPrompt.section()`.** Rejected because one Cordis context can host sessions with different cwd values, while repository-owned text is lower-authority context rather than top-authority provider system content. -**Inject the baseline on every `agent/pre-step`.** Rejected because repeated history injection wastes tokens, complicates duplicate state, and prevents a structurally stable provider prefix. Prefix composition gives a frozen, logged, per-instance baseline while dynamic append-only messages handle changes. +**Inject the baseline on every `agent/step`.** Rejected because repeated history injection wastes tokens and complicates duplicate state. A per-mount session guard gives one visible baseline event while it remains on the surface; dynamic append-only messages handle changes and compaction re-arming. **Load both `AGENTS.md` and `CLAUDE.md` in one directory.** Rejected because repositories in transition commonly duplicate guidance across both files. Ordered candidates make precedence explicit and configurable. @@ -78,7 +78,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc ## Consequences -Workspace guidance is isolated per session and shared by the demo front doors, Web Host, and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract carries JSON metadata propagated through prompt-submit and post-tool `additionalContexts` arrays without flattening entries. +Workspace guidance is isolated per session and shared by the demo front doors, Web Host, and every tool presentation mode. Initial, nested, and changed instructions are durable and replayable. The generic session/agent context contract carries typed source data through injected messages and post-tool `additionalContexts` arrays without flattening entries. Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, and delimiter escaping reduce risk but do not eliminate prompt injection. Following a candidate symlink to its target widens that surface to off-tree content, so the permission and sandbox layers that confine `ctx.fs` to trusted roots are the boundary that treats workspace files as data rather than authority (the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns the residual risk). diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index f165d08108..154b526095 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -10,13 +10,13 @@ Status: implemented 相邻产品形成了值得借鉴的约定,但具体做法各不相同。Codex 原生使用 `AGENTS.md`;Claude Code 使用 `CLAUDE.md`,并采用熟悉的 system-reminder 风格用户上下文;opencode 同时支持这两个名称,每个目录只选一个胜出者,并延迟发现嵌套文件。harness 需要跨工具兼容,同时避免从同一作用域加载重复或互相矛盾的文件。 -生命周期中有两类截然不同的内容。初始适用文件链足够稳定,可以放入请求前缀并受益于提供方前缀缓存。嵌套文件、编辑、候选项切换和移除都发生在会话启动后,应进入持久的仅追加历史,而不是被冻结的前缀。 +生命周期中有两类截然不同的内容。初始适用文件链在第一次请求前一次性注入。嵌套文件、编辑、候选项切换和移除发生在其后,进入同一份持久的仅追加历史。 ## 决策 -该实现在 `packages/context/workspace-context` 中,包(package)名为 `@deepseek-ai/dsh-workspace-context`。它是请求上下文扩展,不是核心服务或文件系统后端。共享 demo 主干与 Host Runtime 根据显式的 `{ maxBytes } | false` 部署选择挂载它;`dsh web` 启用 65,536 字节预算,Host Runtime 的 headless 消费方则禁用它。该插件使用 `agent/session-prefix`、`tools/post-execute` 和可选的 `ctx.fs` 功能。 +该实现在 `packages/context/workspace-context` 中,包(package)名为 `@deepseek-ai/dsh-workspace-context`。它是请求上下文扩展,不是核心服务或文件系统后端。共享 demo 主干与 Host Runtime 根据显式的 `{ maxBytes } | false` 部署选择挂载它;`dsh web` 启用 65,536 字节预算,Host Runtime 的 headless 消费方则禁用它。该插件使用 `agent/step`、`tools/post-execute` 和可选的 `ctx.fs` 功能。 -插件不会静态注入 `fs`。因此,不带提供方的产品树仍能正常启动;在文件系统提供方出现之前,插件保持无操作。所有生产读取都通过该提供方完成。候选项探测会解析每个路径并对结果执行 stat,因此会跟随最终路径组件的符号链接至其目标:指向普通文件的链接会被加载,缺失路径或非文件目标则确认为不存在。允许仓库拥有的链接跨越信任边界,是对最初不跟随探测方式的刻意反转;[跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明该决策及其残余风险。会话前缀信号与动态工具执行信号会贯穿解析、元数据探测和流式读取,因此取消不会等待无关的文件系统扫描。解析或 stat 异常归类为不可用:它只跳过该候选项,绝不被解释为已经加载的作用域被删除。 +插件不会静态注入 `fs`。因此,不带提供方的产品树仍能正常启动;在文件系统提供方出现之前,插件保持无操作。所有生产读取都通过该提供方完成。候选项探测会解析每个路径并对结果执行 stat,因此会跟随最终路径组件的符号链接至其目标:指向普通文件的链接会被加载,缺失路径或非文件目标则确认为不存在。允许仓库拥有的链接跨越信任边界,是对最初不跟随探测方式的刻意反转;[跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明该决策及其残余风险。步骤信号与动态工具执行信号会贯穿解析、元数据探测和流式读取,因此取消不会等待无关的文件系统扫描。解析或 stat 异常归类为不可用:它只跳过该候选项,绝不被解释为已经加载的作用域被删除。 ### 文件名与优先级 @@ -26,13 +26,13 @@ Status: implemented 用户全局文件固定为 `$DSH_HOME/AGENTS.md`,不受任一候选列表影响,也没有本地覆盖层。`$DSH_HOME` 默认为 `~/.dsh`,与 `~/.codex` 或 `~/.claude` 在 harness 层的 home 角色一致,而不会引入插件专用 home。波浪号展开与默认值位于 `dsh-paths` 中,以便未来的 harness 功能共享同一约定。 -### 基线前缀 +### 基线注入 -agent loop(智能体循环)实例的第一次请求会让插件通过 `agent/session-prefix` 提供一条 user 角色消息。它先加载用户全局文件,再从 `agent.session.header.cwd` 向上遍历至配置的根标记(默认为 `.git`)以确定项目根目录,随后从根目录至 cwd 的每级目录各加载一个候选项。`.git` 文件与 `.git` 目录都是有效标记,因而能覆盖链接 worktree 和 submodule。找不到标记时,cwd 本身就是根目录。 +在 agent loop(智能体循环)实例的第一个 `agent/step`,插件会在派生请求前注入一条带来源的 user 角色消息。它先加载用户全局文件,再从 `agent.session.header.cwd` 向上遍历至配置的根标记(默认为 `.git`)以确定项目根目录,随后从根目录至 cwd 的每级目录各加载一个候选项。`.git` 文件与 `.git` 目录都是有效标记,因而能覆盖链接 worktree 和 submodule。找不到标记时,cwd 本身就是根目录。 -插件会在 `await next()` 返回前前置其贡献,因此会话前缀贡献按插件注册顺序出现。在产品主干中,工作区指令的注册先于 skill 目录,所以它排在前面。循环会深度冻结组合后的前缀,将其记录在 `EpochHeader.messagePrefix` 中,并在该实例内逐字复用。它是请求状态,不是 `Session.deriveMessages()` 历史。 +该注入成为一条持久 `user/message`,并携带带类型的 `workspace-instructions` 来源。其 `baseline: true` 标记将完整的启动或恢复基线与后续增量区分开来,变更列表则持久保存已纳入的作用域和内容 digest。在产品主干中,工作区指令的注册先于 skill 目录,所以其 `agent/step` 监听器先注入。循环会在派生第一次请求前 drain 这两条消息。 -恢复 agent 会创建新的循环实例,并使用当前文件重新组合基线;新的前缀由恢复请求 header 锚定。这样,恢复时可以使用当前基线内容,而无需修改先前实例已经使用过的前缀。 +恢复 agent 会创建新的循环实例,并在其第一次请求前注入由当前文件组合的基线。这样,恢复时可以使用当前基线内容,而无需修改先前的历史事件。恢复与插件热重挂都会面对日志中可能已存在基线的情况;二者通过 `agent/session-start` 区分:启动或恢复会在第一步前发出该事件,而热重挂附着到一个已存活的会话、永远不会看到它。只有当基线的类型化事件仍在当前可见表层中时,热重挂才保留既有基线,同时仍会根据当前文件重建 scope 与提供方版本跟踪。如果压缩(compaction)已遮蔽该事件,热重挂会注入当前基线。恢复则始终重新组合。 基线是一条 user 角色的 ``,包含 `Instructions from: ` 章节,以及明确的权威性与优先级说明。这种熟悉的模型可见框架避免引入 harness 专用的 XML 词汇。项目路径相对于根目录;使用默认 home 时,用户全局路径为 `~/.dsh/AGENTS.md`,使用已配置 home 时则为 `$DSH_HOME/AGENTS.md`。文件内容中的字面量 `` 会被转义。包 README 负责规定当前准确的[提示词形态](../../../../packages/context/workspace-context/README.md#prompt-shape)。 @@ -42,21 +42,21 @@ agent loop(智能体循环)实例的第一次请求会让插件通过 `agent 内容编辑会追加 `Updated instructions from: `,说明新内容取代先前内容,并包含当前的完整文件。如果优先级从一个候选项变为另一个,消息还会指出先前路径并说明它不再适用。如果没有候选项保留,插件会追加 `Instructions removed: `,并说明先前加载的指令不再适用。 -动态消息在 `content` 中携带完整的 system-reminder 框架;每个 `context/message` 都作为 user 角色消息逐字抵达模型,核心层不会再添加可选择退出的包装。`context/message.meta` 携带不透明 JSON 状态,该状态会持久化,但绝不会渲染给模型。 +基线消息和动态消息都在 `content` 中携带完整的 system-reminder 框架;每条带来源的 `user/message` 都逐字抵达模型,核心层不会再添加可选择退出的包装。带类型的 `workspace-instructions` 来源携带持久化状态,该状态绝不会渲染给模型。 shell 命令不会触发发现。本地 bash 调用会启动全新的 shell,而从任意命令字符串推断已到达路径,需要实现提示词插件并不拥有的 shell 语义。 ### 重复抑制与变更检测 -每个动态工作区上下文事件都会存储带版本的元数据,其形态为 `{ action, scope, path, digest? }`;`digest` 是对已加载内容计算的 SHA-1。模型可见提示词中没有 HTML 注释、隐藏标记,也没有会被解析回状态的标题。 +每个工作区上下文事件都会存储带版本的元数据,其形态为 `{ action, scope, path, digest? }`;`digest` 是对已加载内容计算的 SHA-1。基线还会额外携带 `baseline: true`。模型可见提示词中没有 HTML 注释、隐藏标记,也没有会被解析回状态的标题。 -协调时,插件扫描自身拥有的 `context/message` 事件,并派生每个可见作用域的最新状态。一个简短的逐会话待处理映射只会在不可变的顶层 `tools/result` 证明某个 `additionalContexts` 条目经过所有 post-execute 监听器后仍然保留时开始记录;随后,它覆盖循环将该上下文追加到日志之前的间隔。每个条目记录开启状态的 `{ turn, step }`:如果相同的持久 `context/message` 出现在其序列边界或之后,该条目得到确认并被移除;如果匹配的 `step/end` 先到达,则说明循环丢弃了上下文缓冲区,插件会同时移除待处理条目及其版本缓存快速路径。嵌套的 Code Mode 结果会把变更暂存在父级的不透明执行 token 下,确保一次运行中的重复子分发不会产生重复项;父级结果会回滚这份临时状态,并且只提交外层策略保留的上下文。 +协调时,插件扫描带工作区来源的 `user/message` 事件,并派生每个可见作用域的最新状态。一个简短的逐会话待处理映射只会在不可变的顶层 `tools/result` 证明某个 `additionalContexts` 条目经过所有 post-execute 监听器后仍然保留时开始记录;随后,它覆盖循环将该上下文追加到日志之前的间隔。每个条目记录开启状态的 `{ turn, step }`:如果相同的持久 `user/message` 出现在其序列边界或之后,该条目得到确认并被移除;如果匹配的 `step/end` 先到达,则说明循环丢弃了上下文缓冲区,插件会同时移除待处理条目及其版本缓存快速路径。嵌套的 Code Mode 结果会把变更暂存在父级的不透明执行 token 下,确保一次运行中的重复子分发不会产生重复项;父级结果会回滚这份临时状态,并且只提交外层策略保留的上下文。 -路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩(compaction)从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。 +路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。 -被冻结的基线会保留一个内存中的 path/digest map 以供比较。后续成功的文件系统触碰会把基线编辑或移除操作追加为动态消息,绝不重写前缀。恢复时重新组合前缀的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在第一次恢复请求前追加更新。 +只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。后续成功的文件系统触碰会在压缩后重新添加未变化的基线 scope,或把基线编辑或移除操作追加为动态消息;它绝不重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。恢复时准备基线的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在第一次恢复请求前追加更新。 -系统刻意不使用文件监视器。检测发生在下一次成功的结构化文件系统触碰或恢复时的前缀组合。提供方失败不会产生移除;只有该作用域中的全部已配置候选项都成功完成探测后,系统才接受「不存在」这一结论。 +系统刻意不使用文件监视器。检测发生在下一次成功的结构化文件系统触碰或恢复时的基线准备。提供方失败不会产生移除;只有该作用域中的全部已配置候选项都成功完成探测后,系统才接受「不存在」这一结论。 ### 字节预算与有界读取 @@ -68,7 +68,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, **使用全局 `ctx.systemPrompt.section()`。** 不予采纳,因为同一个 Cordis 上下文可以承载 cwd 不同的多个会话,而仓库所有的文本属于低权威用户上下文,不是最高权威的提供方系统内容。 -**在每次 `agent/pre-step` 时注入基线。** 不予采纳,因为重复注入历史会浪费 token、使重复状态复杂化,并妨碍提供方前缀保持结构稳定。前缀组合提供冻结、已记录且逐实例的基线,动态仅追加消息则负责变更。 +**在每次 `agent/step` 时注入基线。** 不予采纳,因为重复注入历史会浪费 token,并使重复状态复杂化。逐挂载会话防护会在基线事件仍留在表面期间提供一条可见基线事件;动态仅追加消息负责处理变更和压缩后的重新启用。 **在一个目录中同时加载 `AGENTS.md` 和 `CLAUDE.md`。** 不予采纳,因为正在迁移的仓库通常会在两个文件中重复指引。按顺序排列的候选项让优先级显式且可配置。 @@ -78,7 +78,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, ## 影响 -工作区指引按会话隔离,并由 demo 前端、Web Host 与每一种工具展示模式共享。初始指令受益于稳定的前缀缓存,嵌套与变更内容则保持持久且可回放。通用的 session/agent 上下文契约通过 prompt-submit 与工具执行后的 `additionalContexts` 数组携带 JSON 元数据,而不会把条目展平。 +工作区指引按会话隔离,并由 demo 前端、Web Host 与每一种工具展示模式共享。初始、嵌套与变更指令都保持持久且可回放。通用的 session/agent 上下文契约通过注入消息与工具执行后的 `additionalContexts` 数组携带带类型的来源数据,而不会把条目展平。 仓库文本仍是不受信任的输入。低权威 user 角色框架、显式优先级说明和分隔符转义可以降低风险,但无法消除提示词注入。跟随候选符号链接到目标,会把该接口扩大至树外内容;因此,把 `ctx.fs` 限制在可信根目录内的权限与沙箱层才是真正的边界,它们让系统把工作区文件当作数据而不是权威([跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明残余风险)。 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml index 1ad47cd1f6..8d57b6fdcc 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.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-30-hook-bridges.md: c207b6901155925215548676364e903f5de2f29b -2026-06-30-hook-bridges.zh.md: d396279d7ed1991536da2cea39e2aec5e50960c2 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-hook-bridges.md +2026-06-30-hook-bridges.md: 99c6b1941a10e198ec3028f5fe505dabfff9abbe +2026-06-30-hook-bridges.zh.md: 11ed3a5d177661271b30f1a58d034caa577b5348 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md index c207b69011..99c6b1941a 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md @@ -6,7 +6,7 @@ English | [中文](2026-06-30-hook-bridges.zh.md) ## Problem -The harness's extension surface is its typed interception seams ([the interception-seams Agent Note](2026-06-30-interception-seams.md)): a "native hook" is just an ordinary cordis plugin subscribing to `agent/session-start`, `agent/prompt-submit`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation`, `subagent/start`, `subagent/end`. But users arrive with **existing** Claude Code (CC) and Codex hook configs — a `hooks.json` (or a settings file's `hooks` key) full of shell-command hooks — and want those to run unmodified. This Agent Note introduces the two **bridge plugins** that translate that external shell-hook protocol onto the typed seams, built on the shared wire-protocol library ([the hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md)). +The harness's extension surface is its typed interception seams ([the interception-seams Agent Note](2026-06-30-interception-seams.md)): a "native hook" is just an ordinary cordis plugin subscribing to `agent/session-start`, `agent/prompt-submit`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-stopping`, `subagent/start`, or `subagent/end`. But users arrive with **existing** Claude Code (CC) and Codex hook configs — a `hooks.json` (or a settings file's `hooks` key) full of shell-command hooks — and want those to run unmodified. This Agent Note introduces the two **bridge plugins** that translate that external shell-hook protocol onto the typed seams, built on the shared wire-protocol library ([the hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md)). The framing that shapes the whole design: **a bridge is a compatibility adapter, not a power tool.** Anything a bridge does (block a tool, inject context, force continuation, observe a subagent) a native cordis plugin does more powerfully — typed returns, full `ctx`, no serialization boundary. The bridge's reason to exist is to run the explicitly supported subset of external CC/Codex command hooks. That keeps each bridge thin: parse the config, pick a matcher mode, build the per-event payload, call `runHook` + `mergeHookOutputs` from the shared lib, and map the neutral outcome onto a seam Decision. The package READMEs own the exact current unsupported-event and partial-field inventory against the official protocols. @@ -27,7 +27,7 @@ Each bridge maps the neutral `MergedHookOutcome` from the shared lib onto the se | `agent/prompt-submit` | `deny`→`block`; context-only→delegate+fold | `block`→`block`; context-only→delegate+fold | | `tools/pre-execute` | `deny`→`deny`; `ask`→`ask` | `block`→`deny` (no allow/ask) | | `tools/post-execute` | `deny`→`block`+feedback; context-only→delegate+fold | same | -| `agent/turn-continuation` | blocking Stop → `continue` (reason = next-step steering) | same | +| `agent/turn-stopping` | blocking Stop → next-step steering | same | | `subagent/start` (emit) | additionalContext → inject into a live in-process child; a remote child has no local injection target | unsupported by this bridge | | `subagent/end` (emit) | observe-only | unsupported by this bridge | @@ -35,7 +35,9 @@ The CC bridge's `ask` result is a real permission path, not a terminal bridge de ### Context source is always the plugin (the mislabel guard) -`agent.inject()` defaults a missing `MessageSource` to `{ kind: 'user' }`, so every bridge `inject()` and `HookContext` passes `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`. Unit coverage pins the resulting `context/message.source` as the plugin rather than the user. +Every bridge `inject()` and additional-context input explicitly passes `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`. Unit coverage pins the resulting `user/message.source` as the plugin rather than the user. + +`UserPromptSubmit` runs during admission, before any turn opens. It therefore writes no turn-scoped `hook/invoked` / `hook/result` pair: a block leaves no transcript, while allowed additional context is durably represented by its sourced `user/message`. The Codex payload still receives the candidate next `turn_id`; rejection does not consume that number. ### Adding context is not a veto — delegate, then prepend @@ -57,13 +59,13 @@ Hooks run in the agent's session workspace, so relative paths target the user's - **Tool-input rewrite.** A CC/Codex `updatedInput` is logged + warned, not honored — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite Agent Note](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)), because the pre-execution args are read by `tool/call` audit + `assistant/message` history + tool presentation, so an honest rewrite is a design unit, not a field. - **Stop loop-guard** (`TODO(stop-loop-guard)`). Claude Code supplies `stop_hook_active` and overrides a hook after eight consecutive blocks; Codex supplies `stop_hook_active` but documents no equivalent cap. Both bridges always report `false`, so a Stop hook that unconditionally blocks force-continues every step — a hook author must self-limit until state tracking lands. -- **Hook `continue:false` (hard halt).** A hook can ask to halt the whole run (CC/Codex `continue:false`); the shared merge folds it into `MergedHookOutcome.stop`/`stopReason`, but no bridge acts on it (`TODO(hook-continue-false)`) — the interception seams have no "hard-halt the agent" primitive yet (a Decision blocks/steers a single point, not the run). Deferred with the loop-guard work; the halt request is recorded in the `hook/result` log, and the hook keeps its per-point effect (decision/context) meanwhile. +- **Hook `continue:false` (hard halt).** A hook can ask to halt the whole run (CC/Codex `continue:false`); the shared merge folds it into `MergedHookOutcome.stop`/`stopReason`, but no bridge acts on it (`TODO(hook-continue-false)`) — the interception seams have no "hard-halt the agent" primitive yet (a Decision blocks/steers a single point, not the run). Deferred with the loop-guard work; mid-turn requests record the halt in `hook/result`, and the hook keeps its per-point effect (decision/context) meanwhile. - **Config discovery.** The path is explicit in `cordis.yml` and process-level (see above); the full multi-layer CC/Codex precedence walk, per-session project-local discovery, and the trust/hash model are not reimplemented (`TODO(per-session-hook-config)`). - **Session-start / subagent-start context is best-effort (`TODO(session-start-gating)`).** Both hooks run detached from startup, so their context is injected when ready but may miss the first request or a short-lived child. Guaranteeing first-request delivery requires an awaited startup seam. ## Alternatives considered -**Concurrent per-point hook execution.** The reference engines run a point's matched hooks concurrently and fold the results. These bridges run them **serially** (`await` per hook inside the match loop) and fold with the same most-restrictive merge. Serial is deliberate: it keeps each hook's `hook/invoked`/`hook/result` pair adjacent and in a deterministic order in the session log, and the fold is order-independent for the decision (`deny > ask > allow`) so the outcome matches. The cost is latency (hook *N* waits for hook *N−1*) and that per-hook timeouts are not overlapped — acceptable for the hook counts real configs use; revisit if a config ever fans out enough for the wall-clock to matter. +**Concurrent per-point hook execution.** The reference engines run a point's matched hooks concurrently and fold the results. These bridges run them **serially** (`await` per hook inside the match loop) and fold with the same most-restrictive merge. Serial is deliberate: for turn-scoped points it keeps each hook's `hook/invoked`/`hook/result` pair adjacent and in deterministic order, and the fold is order-independent for the decision (`deny > ask > allow`) so the outcome matches. The cost is latency (hook *N* waits for hook *N−1*) and that per-hook timeouts are not overlapped — acceptable for the hook counts real configs use; revisit if a config ever fans out enough for the wall-clock to matter. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md index d396279d7e..11ed3a5d17 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note](2026-06-30-interception-seams.md)):所谓「原生钩子」不过是一个普通的 Cordis 插件,订阅 `agent/session-start`、`agent/prompt-submit`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-continuation`、`subagent/start`、`subagent/end`。但用户带着**既有的** Claude Code(CC)和 Codex 钩子配置到来,一个 `hooks.json`(或 settings 文件中的 `hooks` 键)里满是 shell 命令钩子,并希望它们原样运行。本 Agent Note 引入两个**桥接插件**,将外部 shell 钩子协议翻译到类型化 seam 上,构建于共享的协议格式(wire format)库之上(见 [hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md))。 +harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note](2026-06-30-interception-seams.md)):所谓「原生钩子」不过是一个普通的 Cordis 插件,订阅 `agent/session-start`、`agent/prompt-submit`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-stopping`、`subagent/start` 或 `subagent/end`。但用户带着**既有的** Claude Code(CC)和 Codex 钩子配置到来,一个 `hooks.json`(或 settings 文件中的 `hooks` 键)里满是 shell 命令钩子,并希望它们原样运行。本 Agent Note 引入两个**桥接插件**,将外部 shell 钩子协议翻译到类型化 seam 上,构建于共享的协议格式(wire format)库之上(见 [hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md))。 贯穿整个设计的定位:**桥接是兼容性适配器,不是高级工具。** 桥接能做的事(阻止工具、注入上下文、强制继续、观察 subagent),原生 Cordis 插件都能做得更强——类型化返回值、完整 `ctx`、无序列化边界。桥接存在的理由是运行外部 CC/Codex 命令钩子中被明确支持的子集。这使每个桥接保持精简:解析配置、选择匹配模式、构建每事件的 payload、调用共享库的 `runHook` + `mergeHookOutputs`,再将中性结果映射为 seam Decision。各包的 README 维护着当前不支持的事件和部分字段的完整清单,以官方协议为参照。 @@ -27,7 +27,7 @@ harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note]( | `agent/prompt-submit` | `deny`→`block`;仅上下文→delegate+fold | `block`→`block`;仅上下文→delegate+fold | | `tools/pre-execute` | `deny`→`deny`;`ask`→`ask` | `block`→`deny`(无 allow/ask) | | `tools/post-execute` | `deny`→`block`+feedback;仅上下文→delegate+fold | 同上 | -| `agent/turn-continuation` | 阻塞的 Stop → `continue`(reason = 下一步 steering(中途引导)) | 同上 | +| `agent/turn-stopping` | 阻塞的 Stop → 下一步 steering(中途引导) | 同上 | | `subagent/start`(emit) | additionalContext → 注入到存活的进程内 subagent;远程 subagent 无本地注入目标 | 本桥接不支持 | | `subagent/end`(emit) | 仅观察 | 本桥接不支持 | @@ -35,7 +35,9 @@ CC 桥接的 `ask` 结果是一条真正的权限路径,而非终态桥接决 ### 上下文来源始终是插件(误标签防护) -`agent.inject()` 在缺少 `MessageSource` 时默认为 `{ kind: 'user' }`,因此每个桥接的 `inject()` 和 `HookContext` 都传入 `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`。单元测试覆盖率固定验证结果中的 `context/message.source` 为插件而非用户。 +每个桥接的 `inject()` 和 additional-context 输入都显式传入 `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`。单元测试覆盖率固定验证结果中的 `user/message.source` 为插件而非用户。 + +`UserPromptSubmit` 在准入阶段运行,早于任何轮次开启。因此它不写入任何轮次范围的 `hook/invoked` / `hook/result` 对:阻止不会留下 transcript(文本记录),而被允许的额外上下文由其带来源的 `user/message` 持久呈现。Codex payload 仍会收到候选的下一个 `turn_id`;拒绝不会消耗该编号。 ### 添加上下文不是否决——先 delegate,再 prepend @@ -57,13 +59,13 @@ Claude Code 始终导出 `CLAUDE_PROJECT_DIR`,常见的未修改钩子引用 ` - **工具输入重写。** CC/Codex 的 `updatedInput` 被记录日志并发出警告,但不予执行——输入重写是一个推迟的一致性设计问题(见 [pre-tool-input-rewrite Agent Note](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)),因为 pre-execution 参数被 `tool/call` 审计、`assistant/message` 历史和工具展示共同读取,诚实的重写是一个设计单元,而非一个字段。 - **Stop 循环防护**(`TODO(stop-loop-guard)`)。Claude Code 提供 `stop_hook_active` 并在连续八次阻塞后覆盖钩子;Codex 提供 `stop_hook_active` 但未记录等效上限。两个桥接始终报告 `false`,因此一个无条件阻塞的 Stop 钩子会在每一步强制继续——在状态追踪落地之前,钩子作者必须自行限制。 -- **钩子 `continue:false`(硬停止)。** 钩子可以请求终止整个运行(CC/Codex `continue:false`);共享合并将其折叠为 `MergedHookOutcome.stop`/`stopReason`,但没有桥接对其采取行动(`TODO(hook-continue-false)`)——拦截 seam 尚无「硬停止 agent」原语(Decision 阻塞/引导的是单个点,而非整个运行)。与循环防护工作一同推迟;停止请求记录在 `hook/result` 日志中,钩子在此期间保留其逐点效果(决策/上下文)。 +- **钩子 `continue:false`(硬停止)。** 钩子可以请求终止整个运行(CC/Codex `continue:false`);共享合并将其折叠为 `MergedHookOutcome.stop`/`stopReason`,但没有桥接对其采取行动(`TODO(hook-continue-false)`)——拦截 seam 尚无「硬停止 agent」原语(Decision 阻塞/引导的是单个点,而非整个运行)。与循环防护工作一同推迟;轮中请求会将停止请求记录在 `hook/result` 中,钩子在此期间保留其逐点效果(决策/上下文)。 - **配置发现。** 路径在 `cordis.yml` 中显式指定且为进程级(见上文);完整的多层 CC/Codex 优先级遍历、按会话的项目本地发现以及信任/hash 模型未被重新实现(`TODO(per-session-hook-config)`)。 - **Session-start / subagent-start 上下文为尽力而为(`TODO(session-start-gating)`)。** 两个钩子以 detached 方式运行于启动过程之外,因此其上下文在就绪时注入,但可能错过首个请求或短命的 subagent。要保证首请求送达,需要一个 awaited 的启动 seam。 ## 曾考虑的替代方案 -**每点钩子并发执行。** 参考引擎对一个点匹配到的钩子并发运行并折叠结果。本桥接**串行**运行(匹配循环内每个钩子 `await`),并以相同的最严格合并策略折叠。串行是刻意的:它使每个钩子的 `hook/invoked`/`hook/result` 对在会话日志中相邻且顺序确定,而折叠对决策是顺序无关的(`deny > ask > allow`),因此结果一致。代价是延迟(钩子 *N* 等待钩子 *N−1*)以及每钩子超时不重叠——对真实配置中的钩子数量可以接受;如果某配置的扇出大到影响总耗时,再重新评估。 +**每点钩子并发执行。** 参考引擎对一个点匹配到的钩子并发运行并折叠结果。本桥接**串行**运行(匹配循环内每个钩子 `await`),并以相同的最严格合并策略折叠。串行是刻意的:对轮次范围的拦截点,它使每个钩子的 `hook/invoked`/`hook/result` 对相邻且顺序确定,而折叠对决策是顺序无关的(`deny > ask > allow`),因此结果一致。代价是延迟(钩子 *N* 等待钩子 *N−1*)以及每钩子超时不重叠——对真实配置中的钩子数量可以接受;如果某配置的扇出大到影响总耗时,再重新评估。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml index ff40078df0..5845297101 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml @@ -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-30-interception-seams.md: 81799f4c6e3e7a4c6b9605cd97f5728b99d11995 -2026-06-30-interception-seams.zh.md: 65ae16842c632641e7ac65908162f4784dc6e1e0 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-interception-seams.md +2026-06-30-interception-seams.md: 4658983e1f098ecd199eecec4408e7c2f134cbf7 +2026-06-30-interception-seams.zh.md: 13b7c56829412773111fcf6d75cc717c51d49c7b diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md index 81799f4c6e..4658983e1f 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md @@ -16,9 +16,9 @@ The canonical surface separates transformable policy, around-dispatch control, a **Agent events** (`dsh-agent`): - `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. -- `agent/prompt-submit(agent, content, source, signal, next) → PromptDecision` — waterfall, fired for the turn's single claimed queued message before the `user/message` append. The explicit turn signal is placed before the final `next`; `allow` optionally rewrites the prompt `content` or attaches separately sourced `additionalContexts[]`, while `block` appends a durable `prompt/blocked` and rejects that zero-step turn. +- `agent/prompt-submit(agent, content, source, signal, next) → PromptDecision` — waterfall, fired for one claimed queued message before the loop opens a turn or appends `user/message`. The explicit admission signal is placed before the final `next`; `allow` optionally rewrites the prompt `content` or attaches separately sourced `additionalContexts[]`, while `block` discards the candidate without creating session history. -**`agent/turn-continuation`** receives and returns a `ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing content and source recorded as next-step steering in the same turn — the typed twin of the `/goal` step-end-steer pattern. It is not a `context/message`, so its type does not offer durable context metadata. +**`agent/turn-stopping`** is an awaited notification at the natural stop boundary. A listener that needs another step calls `agent.steer()` with explicitly sourced model-facing content; the loop then re-reads the outbox and either continues or closes the turn. ### The tool pipeline gives each phase one kind of authority @@ -33,15 +33,13 @@ Every call follows `tools/pre-execute` → guards → `tools/execute` → dispat Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, invalid canonical value, renderer/projector, non-JSON presentation, and identity-shape failures resolve as JSON-safe `isError` results rather than escaping the turn. A post-execute listener can therefore inspect a thrown tool; definition-owned final content invariants also cover outer pipeline and candidate-materialization failures; and a final observer sees the execution-local canonical value beside exactly the presentation fields the session log can persist. The [canonical tool-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/projection and durability rules. -**`TurnEndReason.rejected`** (`dsh-session`): a zero-step turn whose claimed prompt was blocked by `prompt-submit`. - ### Three load-bearing loop decisions -1. **Open the turn before prompt policy.** A blocked prompt becomes a zero-step `rejected` turn, preserving enclosure and giving ACP a durable terminal event. The veto records `prompt/blocked` with the original prompt and reason, while every allowed `additionalContexts` entry is injected into the open turn. Each claimed ordinary-send item is the sole message in its turn under the [one-send-one-turn simplification](../simplification/2026-07-17-one-send-one-turn.md); a pre-start drop creates no turn. +1. **Run prompt policy before opening the turn.** A blocked prompt creates no turn or durable event. On allow, the loop stages the rewritten prompt followed by every returned `additionalContexts` entry, opens the turn, and drains that outbox before the first step. Each claimed ordinary-send item is the sole direct prompt in its turn under the [one-send-one-turn simplification](../simplification/2026-07-17-one-send-one-turn.md). -2. **Post-tool `additionalContexts` and asynchronous injections enter the active-batch FIFO and append when that batch settles.** `content`/`feedback` shape the result `execute()` returns, but each context is a separate `context/message`, and a single step or composite tool can produce many. Appending context immediately would interleave `result(c1) → context → result(c2)` or place nested context before its outer result, breaking tool-call/result adjacency. `ToolRunContext.deferContext()` therefore collects nested-dispatch context through failures, `execute()` surfaces the ordered array on `ToolExecutionResult`, and the loop accepts it into the same FIFO as `agent.inject()` calls made during execution. The FIFO appends after every recorded result when the batch settles, including before an interrupted turn closes. An accepted outer call preserves deferred contexts before decision contexts; an outer block discards deferred contexts and exposes only contexts explicitly supplied by the blocking decision. +2. **Post-tool `additionalContexts` and asynchronous injections enter the active-batch FIFO and append when that batch settles.** `content`/`feedback` shape the result `execute()` returns, but each context is a separate sourced `user/message`, and a single step or composite tool can produce many. Appending context immediately would interleave `result(c1) → context → result(c2)` or place nested context before its outer result, breaking tool-call/result adjacency. `ToolRunContext.deferContext()` therefore collects nested-dispatch context through failures, `execute()` surfaces the ordered array on `ToolExecutionResult`, and the loop accepts it into the same FIFO as `agent.inject()` calls made during execution. The FIFO appends after every recorded result when the batch settles, including before an interrupted turn closes. An accepted outer call preserves deferred contexts before decision contexts; an outer block discards deferred contexts and exposes only contexts explicitly supplied by the blocking decision. -3. **A forced `continue` `reason` is enqueued through the steering channel**, so the next step's top-of-loop drain records it as steering for the continued turn — next-*step* steering within the SAME turn, not a next-*turn* prompt (matching the existing `hasSteering` force-continue override). +3. **A stopping listener requests continuation through the steering channel**, so the next step's top-of-loop drain records it as steering for the continued turn — next-*step* steering within the SAME turn, not a next-*turn* prompt. ### Pre-tool input rewrite is a separate consistency decision @@ -49,7 +47,7 @@ Core dispatch and the tool body sit inside normalization boundaries, so tool, li ### Boundaries -The seam package does **not** declare `hook/*` session events (the durable hook-invocation log); those belong to `dsh-hook-protocol`, because a native plugin uses typed decisions without an external hook log. The native-plugin integration test (`packages/core/agent-loop/tests/interception.spec.ts`) composes the seams through the real loop with no `hook/*` protocol. Compaction (`PreCompact`/`PostCompact`), Notification, and Codex `PermissionRequest` remain outside this decision. The [approval seam](2026-07-06-approval-seam.md) resolves `ask` decisions through `ctx.approval`, while terminal monotonic stopping is owned separately by `agent/turn-stop`. +The seam package does **not** declare `hook/*` session events (the durable hook-invocation log); those belong to `dsh-hook-protocol`, because a native plugin uses typed decisions without an external hook log. The native-plugin integration test (`packages/core/agent-loop/tests/interception.spec.ts`) composes the seams through the real loop with no `hook/*` protocol. Compaction (`PreCompact`/`PostCompact`), Notification, and Codex `PermissionRequest` remain outside this decision. The [approval seam](2026-07-06-approval-seam.md) resolves `ask` decisions through `ctx.approval`; terminal monotonic stopping is expressed by tool-result data, while `agent/turn-stopping` is the last chance to steer another step. ## Alternatives considered @@ -58,4 +56,4 @@ The seam package does **not** declare `hook/*` session events (the durable hook- ## Consequences -The canonical interception surface is uniformly typed without giving every extension the same power: hooks return decisions, execution wrappers wrap, terminal guards only deny, and final observers only observe. The loop owns session-start, prompt-submit, post-tool context buffering, and continuation; `dsh-tools` owns identity sealing and the five-phase execution pipeline. Their contracts are documented in [architecture.md](../../../../docs/architecture.md), package READMEs, [core interception decisions](../../../../docs/core-data-structures/core.md#interception-decisions), and [tool structures](../../../../docs/core-data-structures/tools.md). The ACP bridge maps `rejected` turns to its `cancelled` codec value, while hook-driven snapshots verify the observable bridge behavior end to end. +The canonical interception surface is uniformly typed without giving every extension the same power: hooks return decisions, execution wrappers wrap, terminal guards only deny, and final observers only observe. The loop owns session-start, pre-turn prompt admission, post-tool context buffering, and stopping; `dsh-tools` owns identity sealing and the five-phase execution pipeline. Their contracts are documented in [architecture.md](../../../../docs/architecture.md), package READMEs, [core interception decisions](../../../../docs/core-data-structures/core.md#interception-decisions), and [tool structures](../../../../docs/core-data-structures/tools.md). The ACP bridge settles an admission rejection as `cancelled` after the agent becomes idle with no owned turn, while hook-driven snapshots verify the observable bridge behavior end to end. diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md index 65ae16842c..13b7c56829 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md @@ -16,9 +16,9 @@ harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那 **Agent 事件**(`dsh-agent`): - `agent/session-start(agent, source)` ——emit,在第 1 轮次之前触发一次,携带 `SessionStartSource`(`startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知,不能阻塞启动(这是有意的空白:桥接可以记录/注入,但不管控启动)。监听器通过 `agent.inject()` 注入上下文。 -- `agent/prompt-submit(agent, content, source, signal, next) → PromptDecision` ——waterfall,在轮次唯一取得所有权的排队消息追加为 `user/message` 之前触发。显式轮次 signal 位于最后的 `next` 之前;`allow` 可以重写提示词 `content` 或附加来源各自独立的 `additionalContexts[]`,而 `block` 会追加一条持久的 `prompt/blocked`,并拒绝这个零步骤轮次。 +- `agent/prompt-submit(agent, content, source, signal, next) → PromptDecision` ——waterfall,针对一条取得所有权的排队消息触发,早于循环开启轮次或追加 `user/message`。显式准入 signal 位于最后的 `next` 之前;`allow` 可以重写提示词 `content` 或附加来源各自独立的 `additionalContexts[]`,而 `block` 会丢弃该候选消息,不产生会话历史。 -**`agent/turn-continuation`** 接收并返回一个 `ContinuationDecision`。`{action:'continue', reason?}` 可携带面向模型的内容和来源,记录为同一轮次内的下一步 steering(中途引导)——与 `/goal` step-end-steer 模式互为类型化孪生。它不是 `context/message`,因此其类型不提供持久上下文元数据。 +**`agent/turn-stopping`** 是自然停止边界上的一次 awaited 通知。需要再执行一步的监听器调用 `agent.steer()`,传入来源显式的面向模型的内容(steering,中途引导);循环随后重新读取 outbox,继续执行或关闭轮次。 ### 工具流水线为每个阶段赋予一种权限 @@ -33,15 +33,13 @@ harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那 核心调度与工具体位于规范化边界内部,因此工具、监听器、无效规范值、渲染器/投影器、非 JSON 呈现和身份形状错误均解析为 JSON 安全的 `isError` 结果,而非逃逸出轮次。post-execute 监听器因此可以检查一个抛出异常的工具;由定义拥有的最终内容不变式也会覆盖外层流水线与候选结果实体化失败;最终观测者会同时看到执行期间的规范值,以及会话日志能够持久化的确切呈现字段。[规范工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md)定义值/投影与持久性规则。 -**`TurnEndReason.rejected`**(`dsh-session`):取得所有权的提示词被 `prompt-submit` 阻止的零步骤轮次。 - ### 三个承重的循环决策 -1. **在提示词策略之前开启轮次。** 被阻止的提示词成为零步骤的 `rejected` 轮次,保持封闭性并为 ACP(Agent Client Protocol)提供持久的终结事件。否决记录 `prompt/blocked`(含原始提示词和原因),而每个允许的 `additionalContexts` 条目都注入到已开启的轮次中。依照[一次 send 对应一个轮次的简化](../simplification/2026-07-17-one-send-one-turn.md),每个取得所有权的 ordinary-send 条目都是其轮次中的唯一消息;启动前丢弃不会创建轮次。 +1. **在开启轮次之前运行提示词策略。** 被阻止的提示词不会创建轮次,也不产生持久事件。允许时,循环先暂存重写后的提示词,再暂存每个返回的 `additionalContexts` 条目,然后开启轮次并在第一个步骤之前排空该 outbox。依照[一次 send 对应一个轮次的简化](../simplification/2026-07-17-one-send-one-turn.md),每个取得所有权的 ordinary-send 条目都是其轮次中唯一的直接提示词。 -2. **工具执行后的 `additionalContexts` 与异步注入进入活跃批次 FIFO,并在该批次结算时追加。** `content`/`feedback` 塑造 `execute()` 返回的结果,但每项上下文都是独立的 `context/message`,而单个步骤或组合工具可以产生许多上下文。立即追加上下文会产生 `result(c1) → context → result(c2)` 的交错,或把嵌套上下文放在外层结果之前,破坏工具调用/结果邻接性。因此 `ToolRunContext.deferContext()` 会在失败路径上也收集嵌套调度上下文,`execute()` 在 `ToolExecutionResult` 上暴露有序数组,循环再把它接纳到与执行期间 `agent.inject()` 调用相同的 FIFO 中。FIFO 在批次结算时,于每个已记录结果之后追加,其中也包括被中断轮次关闭之前。被接受的外层调用将 deferred contexts 保留在 decision contexts 之前;被外层阻止时则丢弃 deferred contexts,只暴露阻止 decision 显式提供的上下文。 +2. **工具执行后的 `additionalContexts` 与异步注入进入活跃批次 FIFO,并在该批次结算时追加。** `content`/`feedback` 塑造 `execute()` 返回的结果,但每项上下文都是一条独立的带来源 `user/message`,而单个步骤或组合工具可以产生许多上下文。立即追加上下文会产生 `result(c1) → context → result(c2)` 的交错,或把嵌套上下文放在外层结果之前,破坏工具调用/结果邻接性。因此 `ToolRunContext.deferContext()` 会在失败路径上也收集嵌套调度上下文,`execute()` 在 `ToolExecutionResult` 上暴露有序数组,循环再把它接纳到与执行期间 `agent.inject()` 调用相同的 FIFO 中。FIFO 在批次结算时,于每个已记录结果之后追加,其中也包括被中断轮次关闭之前。被接受的外层调用将 deferred contexts 保留在 decision contexts 之前;被外层阻止时则丢弃 deferred contexts,只暴露阻止 decision 显式提供的上下文。 -3. **强制 `continue` 的 `reason` 通过 steering 通道入队**,使得下一步骤在循环顶部排空时将其记录为当前轮次的 steering——同一轮次内的下一*步骤* steering,而非下一*轮次*的提示词(与现有的 `hasSteering` 强制继续覆盖一致)。 +3. **stopping 监听器通过 steering 通道请求继续执行**,使得下一步骤在循环顶部排空时将其记录为当前轮次的 steering——同一轮次内的下一*步骤* steering,而非下一*轮次*的提示词。 ### 工具执行前输入重写是一个独立的一致性决策 @@ -49,7 +47,7 @@ harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那 ### 边界 -seam 包**不**声明 `hook/*` 会话事件(持久的钩子调用日志);那些属于 `dsh-hook-protocol`,因为原生插件使用类型化 decision 而无需外部钩子日志。原生插件集成测试(`packages/core/agent-loop/tests/interception.spec.ts`)通过真实循环组合这些 seam,不涉及 `hook/*` 协议。压缩(compaction)(`PreCompact`/`PostCompact`)、Notification 和 Codex `PermissionRequest` 不在本决策范围内。[审批 seam](2026-07-06-approval-seam.md) 通过 `ctx.approval` 解析 `ask` decision,而终结性的单调停止由 `agent/turn-stop` 独立负责。 +seam 包**不**声明 `hook/*` 会话事件(持久的钩子调用日志);那些属于 `dsh-hook-protocol`,因为原生插件使用类型化 decision 而无需外部钩子日志。原生插件集成测试(`packages/core/agent-loop/tests/interception.spec.ts`)通过真实循环组合这些 seam,不涉及 `hook/*` 协议。压缩(compaction)(`PreCompact`/`PostCompact`)、Notification 和 Codex `PermissionRequest` 不在本决策范围内。[审批 seam](2026-07-06-approval-seam.md) 通过 `ctx.approval` 解析 `ask` decision;终结性的单调停止由工具结果数据表达,而 `agent/turn-stopping` 是引导再执行一步的最后机会。 ## 曾考虑的替代方案 @@ -58,4 +56,4 @@ seam 包**不**声明 `hook/*` 会话事件(持久的钩子调用日志); ## 后果 -规范拦截表面具有统一的类型化,同时不给每个扩展相同的权力:钩子返回 decision,执行包装层做包装,终结 guard 只能拒绝,最终观测者只能观测。循环负责 session-start、prompt-submit、工具执行后上下文缓冲和 continuation;`dsh-tools` 负责身份封存与五阶段执行流水线。它们的契约记录在 [architecture.md](../../../../docs/architecture.md)、各包 README、[核心拦截 decision](../../../../docs/core-data-structures/core.md#interception-decisions) 与[工具结构](../../../../docs/core-data-structures/tools.md)中。ACP 桥接将 `rejected` 轮次映射为其 `cancelled` 编解码值,而钩子驱动的快照端到端验证可观测的桥接行为。 +规范拦截表面具有统一的类型化,同时不给每个扩展相同的权力:钩子返回 decision,执行包装层做包装,终结 guard 只能拒绝,最终观测者只能观测。循环负责 session-start、轮次前的提示词准入、工具执行后上下文缓冲和 stopping;`dsh-tools` 负责身份封存与五阶段执行流水线。它们的契约记录在 [architecture.md](../../../../docs/architecture.md)、各包 README、[核心拦截 decision](../../../../docs/core-data-structures/core.md#interception-decisions) 与[工具结构](../../../../docs/core-data-structures/tools.md)中。ACP 桥接在 agent 空闲且不再拥有轮次后,将准入拒绝结算为 `cancelled`,而钩子驱动的快照端到端验证可观测的桥接行为。 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index ee6efc69f4..c00293eb69 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.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-06-sandbox.md: c6883873192f15ba2982436e156d8795396c0148 -2026-07-06-sandbox.zh.md: d84df9b06b15dd296801073d381603f34cfd2878 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md +2026-07-06-sandbox.md: b9c410e4498a565a7414085fddfb74856592da66 +2026-07-06-sandbox.zh.md: 876870a12f1205b4f2cc2c8c13b5e7ec815a1569 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index c688387319..b9c410e449 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -165,7 +165,7 @@ What shipped pins — the tiers in Testing hold each: - A resumed session's overrides apply with no catch-up state; a default changed while the process was down is narrated before the session's first new request, attributed to the operator. - Two concurrent sessions never see each other's state or notices. - Two concurrent project sessions in one Cordis context resolve independent workspace roots; bash and fs writes succeed inside the calling session's cwd and fail against its neighbor's cwd. -- `agent-loop` is untouched — everything rides `systemPrompt.section`, `SessionEventMap` merging, `agent.inject()`, `agent/pre-step`, `agent/prompt-submit`, and capability-owned policy resolution. +- `agent-loop` is untouched — everything rides `systemPrompt.section`, `SessionEventMap` merging, `agent.inject()`, `agent/step`, `agent/prompt-submit`, and capability-owned policy resolution. Costs and accepted limits: diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index d84df9b06b..876870a12f 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -128,7 +128,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。 - **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。 -- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——对安全不变式而言,它还远未经过实战检验。 +- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——它所经受的实战检验还不足以承载安全不变式。 ## 曾考虑的替代方案 @@ -165,7 +165,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - 恢复的会话的覆盖直接生效,无需追赶状态;进程停止期间变更的默认值在会话的首个新请求前被叙述,归因于运维人员。 - 两个并发会话永远看不到彼此的状态或通知。 - 同一个 Cordis 上下文中的两个并发项目会话解析各自独立的工作区根目录;bash 和 fs 写入在调用方会话的 cwd 内成功,对其相邻会话的 cwd 则失败。 -- `agent-loop` 未被触及——一切搭载 `systemPrompt.section`、`SessionEventMap` 合并、`agent.inject()`、`agent/pre-step`、`agent/prompt-submit` 和由能力拥有的策略解析。 +- `agent-loop` 未被触及——一切搭载 `systemPrompt.section`、`SessionEventMap` 合并、`agent.inject()`、`agent/step`、`agent/prompt-submit` 和由能力拥有的策略解析。 代价与已接受的限制: diff --git a/.agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml index e189661f67..7ea57fe142 100644 --- a/.agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-session-prefix.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 -2026-07-07-session-prefix.md: 75113952fc5f3df8da1580d42ed2a385b6135fe8 -2026-07-07-session-prefix.zh.md: e38bf09298296203b275d6d66a62ef17be7c045d +2026-07-07-session-prefix.md: df007012165da2da9b7de4bd9ae83534e45a439d +2026-07-07-session-prefix.zh.md: d0c574706352bfaad9d6b462a7866e509976f84e diff --git a/.agents/notes/implemented/feature/2026-07-07-session-prefix.md b/.agents/notes/implemented/feature/2026-07-07-session-prefix.md index 75113952fc..df00701216 100644 --- a/.agents/notes/implemented/feature/2026-07-07-session-prefix.md +++ b/.agents/notes/implemented/feature/2026-07-07-session-prefix.md @@ -2,6 +2,8 @@ Status: implemented +The request-only prefix seam described below was later removed by the [unified sourced-message decision](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md). Current producers inject durable sourced `user/message` context at `agent/step`; this record preserves the earlier design and its trade-offs. + English | [中文](2026-07-07-session-prefix.zh.md) ## Problem @@ -12,7 +14,7 @@ The obvious third option — let a plugin edit the request's `messages` on the w ## Decision -`agent/session-prefix` is a waterfall on the agent event map ([`packages/core/agent/src/types.ts`](../../../../packages/core/agent/src/types.ts)): listeners receive a frozen empty seed and return an extension (the canonical contribution is a prepend, `[mine, ...await next()]`, which yields registration order on the wire). The loop ([`packages/core/agent-loop/src/loop.ts`](../../../../packages/core/agent-loop/src/loop.ts)) fires it once per loop instance, lazily before the instance's first `agent/pre-step`; the composed list is deep-cloned, deep-frozen, cached on the instance, and placed in front of the ENTIRE derived history — directly after the provider's system slot — on every request the instance sends ([wire order](../../../../docs/core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header)). +`agent/session-prefix` is a waterfall on the agent event map ([`packages/core/agent/src/types.ts`](../../../../packages/core/agent/src/types.ts)): listeners receive a frozen empty seed and return an extension (the canonical contribution is a prepend, `[mine, ...await next()]`, which yields registration order on the wire). The loop ([agent-loop source](../../../../packages/core/agent-loop/src/)) fires it once per loop instance, lazily before the instance's first `agent/pre-step`; the composed list is deep-cloned, deep-frozen, cached on the instance, and placed in front of the ENTIRE derived history — directly after the provider's system slot — on every request the instance sends ([wire order](../../../../docs/core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header)). Three properties carry the design: diff --git a/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md b/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md index e38bf09298..d0c5747063 100644 --- a/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md @@ -2,6 +2,8 @@ Status: implemented +下文所述的仅请求前缀 seam 后来已被[统一带来源消息的决策](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md)移除。当前的生产方在 `agent/step` 时注入持久的带来源 `user/message` 上下文;本记录保留了早先的设计及其权衡。 + [English](2026-07-07-session-prefix.md) | 中文 ## 问题 @@ -12,7 +14,7 @@ Status: implemented ## 决策 -`agent/session-prefix` 是 agent 事件映射上的一个 waterfall(瀑布式事件)([`packages/core/agent/src/types.ts`](../../../../packages/core/agent/src/types.ts)):监听器接收一个冻结的空种子并返回扩展(规范的贡献方式是前置插入 `[mine, ...await next()]`,在协议格式上产生注册顺序)。agent loop(智能体循环)([`packages/core/agent-loop/src/loop.ts`](../../../../packages/core/agent-loop/src/loop.ts))在每个循环实例中触发一次,惰性地在实例首次 `agent/pre-step` 之前执行;组合后的列表被深拷贝、深冻结、缓存在实例上,并在该实例发出的每个请求中置于整个派生历史之前——紧接在提供方的 system 槽位之后([协议格式顺序](../../../../docs/core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header))。 +`agent/session-prefix` 是 agent 事件映射上的一个 waterfall(瀑布式事件)([`packages/core/agent/src/types.ts`](../../../../packages/core/agent/src/types.ts)):监听器接收一个冻结的空种子并返回扩展(规范的贡献方式是前置插入 `[mine, ...await next()]`,在协议格式上产生注册顺序)。agent loop(智能体循环)([agent-loop 源码](../../../../packages/core/agent-loop/src/))在每个循环实例中触发一次,惰性地在实例首次 `agent/pre-step` 之前执行;组合后的列表被深拷贝、深冻结、缓存在实例上,并在该实例发出的每个请求中置于整个派生历史之前——紧接在提供方的 system 槽位之后([协议格式顺序](../../../../docs/core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header))。 三个属性承载了这一设计: 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 9beb453252..6cf16c04ad 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 @@ -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 -2026-07-19-model-facing-goal-tools.md: 2c53a7658e213dee4fecc93709244f97b821aca0 -2026-07-19-model-facing-goal-tools.zh.md: aa7b5ea14b7afff88819f0efa35c5f2d5e2e933e +2026-07-19-model-facing-goal-tools.md: bc4305af80bb13ceeff1888d489dcd8a00132f94 +2026-07-19-model-facing-goal-tools.zh.md: b07f62aa526902c4b2e9c081777a76ca53783d31 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 2c53a7658e..bc4305af80 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 @@ -22,13 +22,13 @@ The prompt tells the model that it may infer goal intent from a direct human req All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. UI presentation is a pure function of arguments and uses generic read or mutation cards; mutation cards select meaningful action values before the goal id, so accepted fillers cannot blank their input. Activation is reported only as live observation and is never written into replay state. -An autonomous goal round that successfully reports completion or blocking contributes the existing terminal `agent/turn-stop` decision for that physical turn, preventing an unnecessary follow-up request. Direct-human mutations do not contribute a terminal stop: the assistant can acknowledge the change, and concurrent human steering remains available to ordinary continuation folding. +An autonomous goal round that successfully reports completion or blocking marks its tool result as concluding the physical turn, preventing an unnecessary follow-up request. Direct-human mutations do not conclude the turn: the assistant can acknowledge the change, and concurrent human steering remains available to ordinary stopping checks. ### Execution authority 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: `Agent.send()` and `steer()` default an omitted source to `{ kind: 'user' }`, so non-human producers must label their own content. 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.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. 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 aa7b5ea14b..b07f62aa52 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 @@ -22,13 +22,13 @@ Status: implemented 三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。UI 展示是参数的纯函数,使用通用读取或变更卡片;变更卡片选择输入时,先取有实际意义的操作值,再取目标 id,因此允许的占位值不会使卡片输入留空。激活态仅作为实时观察返回,绝不会写入回放状态。 -自主目标回合成功报告完成或阻塞后,插件会为该物理轮次贡献现有的终止型 `agent/turn-stop` 决策,避免再发起一次不必要的模型请求。直接人类发起的变更不会贡献终止决策:智能体可以确认该变更,并且并发的人类 steering(转向)仍可参与普通的继续执行折叠。 +自主目标回合成功报告完成或阻塞后,其工具结果会被标记为结束该物理轮次,避免再发起一次不必要的模型请求。直接人类发起的变更不会结束轮次:agent 可以确认该变更,并且并发的人类 steering(中途引导)仍可参与普通的停止检查。 ### 执行权限 每次调用都要求存在 `exec.agent`,且它必须是 `AgentRegistry` 中完全相同的运行中对象、当前继承的驱动发起者,并处于开放轮次内。这些检查在执行时进行,不能通过提示词注入或手写工具参数绕过。 -创建、编辑、暂停与恢复还要求运行时根智能体的当前轮次已经接纳一条用户消息或用户 steering(转向)事件。根所有权来自实时智能体图,而非持久的 fork 祖先关系:恢复后的派生会话可以接收新的直接人类权限,实时子智能体则仍然是子智能体,不能改变这些状态。用户来源是宿主的证明:`Agent.send()` 和 `steer()` 会把省略的来源默认为 `{ kind: 'user' }`,因此非人类生产者必须标注自己的内容。运行时证明来源,而不判断人类措辞在语义上是否足以创建或恢复目标;该解释仍由模型完成。 +创建、编辑、暂停与恢复还要求运行时根 agent 的当前轮次已经接纳一条用户消息或用户 steering 事件。根所有权来自实时 agent 图,而非持久的 fork 祖先关系:恢复后的派生会话可以接收新的直接人类权限,实时子级则仍是 subagent,不能改变这些状态。用户来源是宿主的证明:每个 `Agent.send()` 或 `steer()` 输入都必须显式提供来源,因此宿主把直接人类内容标为 `{ kind: 'user' }`,非人类生产者则标注自己的来源信息。运行时证明来源,而不判断人类措辞在语义上是否足以创建或恢复目标;该解释仍由模型完成。 完成与阻塞既接受直接人类权限,也接受准确的当前目标回合。目标回合权限要求存在一条来源为目标的 `user/message`,其中目标 id、修订号和回合都与折叠后的当前目标相等。它只授予这两种终止报告权限。直接人类权限可以立即停止目标。 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 8f948d3d45..b354999813 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 @@ -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 -2026-07-19-same-session-goal-round-driver.md: d23af9a9b05d60d2dccad095455524844f1185b9 -2026-07-19-same-session-goal-round-driver.zh.md: f4f0cd6fd575d14427025bdbd8d10bc90e25f780 +2026-07-19-same-session-goal-round-driver.md: 0e6be9fe3109336d47867ab52c585dc267309fb4 +2026-07-19-same-session-goal-round-driver.zh.md: cfd9d1aa8cbc3c17cd046df4f57a8f79a6877f5c 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 d23af9a9b0..0e6be9fe31 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 @@ -24,7 +24,7 @@ When an agent is idle, has no competing queued work, and its current goal is `ac 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. -Only the resulting `user/message` is an admitted round and advances the goal fold. A stale reservation becomes a durable `prompt/blocked` plus zero-step rejected turn, but the driver marks it stale and does not charge the round. A downstream policy rejection that is not caused by staleness blocks the goal rather than retrying around policy. +Only the resulting `user/message` is an admitted round and advances the goal fold. A stale reservation is discarded before a turn opens; the driver marks it stale and does not charge the round. A downstream policy rejection that is not caused by staleness blocks the goal rather than retrying around policy. ### Human work and revision races @@ -43,7 +43,6 @@ The driver classifies one closed goal-owned turn as follows: | `error` with code `RATE_LIMIT` or `QUOTA` | block with code `usage-limited` | | other `error` | block with code `turn-error` | | `max-tokens` | block with code `max-tokens` | -| non-stale `rejected` | block with code `prompt-rejected` | | failed durability checkpoint | disarm without changing durable phase | | `disposed` or `interrupted` | disarm | | plugin-added unknown result | block for inspection | 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 f4f0cd6fd5..cfd9d1aa8c 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 @@ -24,7 +24,7 @@ Status: implemented `agent/prompt-submit` 瀑布是接纳栅栏。正数目标来源只有在完全匹配驱动器待处理的身份和内容、实时目标仍具有相同 id 与修订号、激活态仍为 armed,并且该回合仍是下一个编号时才会获准。插件在委托下游监听器前检查一次,在下游返回后再检查一次。第二次检查防止异步钩子编辑或暂停目标后,旧提示词仍被接纳。 -只有最终产生的 `user/message` 才是已接纳目标回合,并推进目标折叠。过期预留会生成持久的 `prompt/blocked` 和零步骤 rejected 轮次,但驱动器会把它标记为过期,不消耗回合数。若下游策略拒绝并非由过期导致,目标会进入 blocked,而不会绕过该策略自动重试。 +只有最终产生的 `user/message` 才是已接纳目标回合,并推进目标折叠。陈旧预留会在轮次打开前被丢弃;驱动器会把它标记为陈旧,不消耗回合数。若下游策略拒绝并非由陈旧状态导致,目标会进入 blocked,而不会绕过该策略自动重试。 ### 人类工作与修订竞争 @@ -43,7 +43,6 @@ Status: implemented | 代码为 `RATE_LIMIT` 或 `QUOTA` 的 `error` | 以 `usage-limited` 代码阻塞 | | 其他 `error` | 以 `turn-error` 代码阻塞 | | `max-tokens` | 以 `max-tokens` 代码阻塞 | -| 非过期的 `rejected` | 以 `prompt-rejected` 代码阻塞 | | 持久检查点失败 | 解除激活,但不改变持久阶段 | | `disposed` 或 `interrupted` | 解除激活 | | 插件新增的未知结果 | 阻塞并等待检查 | 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 b1f73ed15a..bb2d360bac 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 @@ -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-cross-session-references.md: fc084b36e7920a72efff0f363278d24eaebc4c69 -2026-07-21-cross-session-references.zh.md: fe4a876b5265fa7ad298adf3b829bcec70e878e8 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-cross-session-references.md +2026-07-21-cross-session-references.md: 18dab5fb85f2201258e2f15c3c069d2668ae80d3 +2026-07-21-cross-session-references.zh.md: 44e33ab1ed762b08b6be30d66b82396a5b519c02 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 fc084b36e7..18dab5fb85 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 @@ -10,7 +10,7 @@ TUI users need to bring relevant work from another conversation into one new mes ## Decision -`@deepseek-ai/dsh-session-reference` is one context consumer service at `ctx.sessionReferences`. Hosts normalize their protocol into `SessionReferenceInput[]`, call `prepare()` before enqueue, and pass the returned contexts through the generic `SendOptions.contexts` boundary. Core agent packages know only that one queued message may carry frozen `HookContext[]`; they do not parse session URIs or read another log. +`@deepseek-ai/dsh-session-reference` is one context consumer service at `ctx.sessionReferences`. Hosts normalize their protocol into `SessionReferenceInput[]` and call `prepare()` before delivery. The service returns detached readable content plus an optional sourced `UserMessageData` snapshot; core agent packages do not parse session URIs or read another log. `dsh-session:` is the canonical host-independent identifier. JSON string encoding precedes base64url so quotes, slashes, backslashes, Unicode, newlines, and every other JavaScript string value round-trip without delimiter ambiguity. TUI renders that URI inside `@[label](uri)`; text-only clients may use the same inline mention. Explicit Markdown mentions reject malformed URIs. Bare text becomes a reference only for a non-empty base64url-shaped payload, whose decode must still be canonical; empty or punctuation-only uses remain ordinary discussion text. @@ -22,17 +22,17 @@ Preparation deduplicates in first-appearance order, rejects the target id, enfor 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. -One aggregated context is serialized as JSON beneath a fixed untrusted-background warning. The warning tells the model not to follow instructions, permission claims, or tool requests from referenced sessions unless the current user repeats them. Tag-safe serialization emits every data `<` as the lossless JSON escape `\u003c`; source strings therefore cannot spell the surrounding XML-like tags. The `## My request:` text is a routing cue rather than the trust boundary: referenced data may spell those words inside a JSON string, but it cannot forge the closing `` tag or escape the data region. The same serializer drives each source's independent byte accounting. The context declares `prompt-prefix` placement, so AgentLoop persists one `user/message` or `steering/message` containing the snapshot, `## My request:` delimiter, and effective direct prompt. Its model-hidden envelope retains the direct display content and source/retention metadata. Target replay therefore satisfies the model-visible/log-reconstructable invariant without a new event type or a separate user-role context message. +One aggregated context is serialized as JSON beneath a fixed untrusted-background warning. The warning tells the model not to follow instructions, permission claims, or tool requests from referenced sessions unless the current user repeats them. Tag-safe serialization emits every data `<` as the lossless JSON escape `\u003c`; source strings therefore cannot spell the surrounding XML-like tags or escape the data region. The same serializer drives each source's independent byte accounting. AgentLoop persists the snapshot as a sourced `user/message` immediately before the direct `user/message` or `steering/message`; target replay therefore satisfies the model-visible/log-reconstructable invariant without a new event type, placement mode, or prompt envelope. ## Message ownership -`send()` and `steer()` snapshot content, resolved source, and contexts together as one deeply frozen lossless-JSON inbox record. Synthetic `inject()` accepts source and model-hidden metadata but not attached contexts, which belong to inbox messages. A claimed ordinary message exposes its attached contexts as the default `agent/prompt-submit` additional contexts; a block writes neither user message nor contexts. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream content and contexts unless it intentionally replaces them. After admission, absent or `separate` placement writes an independent `context/message`, while `prompt-prefix` placement bakes context and the effective request into one prompt event. Drained steering bypasses `agent/prompt-submit` but applies the same placement split. Late steering retains the same record when converted to queued input, while cancellation, disposal, and terminal discard drop message and contexts together. `agent/queued` reports the frozen contexts so the observation event describes the complete retained item. +TUI owns the snapshot/direct-message transaction without extending the generic inbox record. Outside the next-step acceptance window, it installs a one-shot outer `agent/prompt-submit` listener before `followup()`; an allowed decision receives the snapshot as `additionalContexts`, while a blocked or discarded prompt releases the listener and writes neither message. During prompt admission or an open turn, TUI calls `inject(snapshot)` then `steer(prompt)`, and AgentLoop stages both for the same safe boundary. If admission fails before that boundary, both remain staged for retry or a later admitted prompt; cancellation or disposal may discard them. The [separate-context decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) owns this generic delivery boundary. -This preserves host driving semantics: TUI decides `send()` versus `steer()` from the agent state after preparation, so only its queued path dispatches UserPromptSubmit hooks. Reference preparation is not a new steering protocol and does not create a turn by itself. +Reference preparation is not a new steering protocol and does not create a turn by itself. A `followup()` outside the next-step acceptance window dispatches prompt admission; steering inside the window bypasses it while retaining snapshot order through the shared outbox. ## Host adapters -TUI combines session candidates with the existing `@` file provider. Each candidate displays the latest folded session title and falls back to the session id; lookup follows the editor's cancellation signal, and session id, cwd, and mention labels escape external terminal controls while the canonical URI retains the original id. TUI prepares only submissions containing structured mentions, disables duplicate submit while awaiting snapshots, restores failed input, renders the prompt envelope's display content as the user message, and renders its session-reference metadata as a compact source list instead of exposing the complete JSON in the terminal. +TUI combines session candidates with the existing `@` file provider. Each candidate displays the latest folded session title and falls back to the session id; lookup follows the editor's cancellation signal, and session id, cwd, and mention labels escape external terminal controls while the canonical URI retains the original id. TUI prepares only submissions containing structured mentions, disables duplicate submit while awaiting snapshots, restores failed input, renders the readable direct content as the user message, and renders session-reference source metadata as a compact source list instead of exposing the complete JSON in the terminal. The [automation-only ACP transport](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately does not mount session-query or session-reference services. @@ -45,15 +45,15 @@ Each of at most three references is independently capped at 65,536 UTF-8 bytes b - **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. - **Implement references separately in each host** — rejected because projection, security warning, retention, and persistence would drift across hosts. -- **Place a separate user-role context message beside the prompt** — rejected because two adjacent user messages weaken the prompt's deictic binding: in `@foo what does this session discuss?`, the model may resolve “this session” as the current conversation instead of the referenced snapshot. -- **Bake the prefix host-side before `send()`** — rejected because `agent/prompt-submit` must inspect and rewrite only the direct prompt. The effective prompt and attached contexts meet only after admission in AgentLoop, which can apply an `allow.content` rewrite consistently to both combined model content and `envelope.displayContent`; earlier host assembly would expose snapshot bytes to the hook or let those two views diverge. +- **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. - **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. ## Verification -Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, title-aware candidate ranking, terminal-control escaping, projection exclusions, non-recursive prompt-envelope projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, prompt blocking, send/steer placement, title isolation, missing capability, and compact TUI replay. A keyless TUI snapshot runs the real agent loop: the source surface replaces old user/assistant history with a compact checkpoint, the target submits a mention, and the captured model request contains one user message ordered as snapshot, request delimiter, and current prompt, without either shadowed string. +Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, title-aware candidate ranking, terminal-control escaping, projection exclusions, non-recursive snapshot projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, prompt blocking, admission-time staging, send/steer placement, title isolation, missing capability, and compact TUI replay. A keyless TUI snapshot runs the real agent loop: the source surface replaces old user/assistant history with a compact checkpoint, the target submits a mention, and the captured model request contains a sourced snapshot message followed by the readable current prompt, without either shadowed string. ## Consequences 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 fe4a876b52..44e33ab1ed 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 @@ -10,7 +10,7 @@ TUI 用户需要把另一场对话中的相关工作带入一条新消息,但 ## 决策 -`@deepseek-ai/dsh-session-reference` 是注册在 `ctx.sessionReferences` 上的单一上下文消费服务。宿主先把各自的协议规范化为 `SessionReferenceInput[]`,在入队前调用 `prepare()`,再通过通用的 `SendOptions.contexts` 边界传递返回的上下文。核心 agent 包只知道一条排队消息可以携带已冻结的 `HookContext[]`;它们既不解析会话 URI,也不读取其他日志。 +`@deepseek-ai/dsh-session-reference` 是注册在 `ctx.sessionReferences` 上的单一上下文消费服务。宿主先把各自的协议规范化为 `SessionReferenceInput[]`,并在交付前调用 `prepare()`。该服务返回分离的可读内容和一份可选的、带来源信息的 `UserMessageData` 快照;核心 agent 包既不解析会话 URI,也不读取其他日志。 `dsh-session:` 是与宿主无关的规范标识符。系统先执行 JSON 字符串编码,再执行 base64url 编码,因此引号、正斜杠、反斜杠、Unicode、换行符以及其他任意 JavaScript 字符串值都能无损往返,不会因分隔符产生歧义。TUI 把该 URI 渲染到 `@[label](uri)` 中;纯文本客户端可以使用同一种行内提及标记。显式 Markdown 提及标记会拒绝格式错误的 URI。裸文本只有在负载非空且形状符合 base64url 时才会成为引用,而且解码过程仍须通过规范性校验;空负载或只含标点符号的用法仍按普通讨论文本处理。 @@ -22,17 +22,17 @@ TUI 用户需要把另一场对话中的相关工作带入一条新消息,但 投影会保留直接用户消息与 steering(中途引导)、已完成的 assistant 文本,以及携带由 `dsh-compact` 导出的规范来源标记的检查点用户消息。该标记属于压缩功能契约的一部分,而非某个后端包名称。当源提示词已包含合并写入的前缀上下文时,投影只读取其模型不可见的显示内容,因此后续引用该目标不会递归传播先前的快照。投影会排除压缩前已被遮蔽的节点、工具及其结果、推理(reasoning)、注入的上下文、其他插件用户消息、仅用于日志的记录,以及尚未完成的 assistant 分片。因此,重复压缩只会暴露当前表层仍保留的最新折叠检查点谱系及其尾部消息;系统不提供 raw/current 开关,也不恢复被遮蔽的内容。 -系统把一个聚合上下文序列化为 JSON,并置于固定的不可信背景警告之后。该警告要求模型不要遵循被引用会话中的指令、权限声明或工具请求,除非当前用户再次提出这些内容。标签安全序列化会把数据中的每个 `<` 无损转义为 JSON `\u003c`;因此源字符串无法拼出外围类似 XML 的标签。`## My request:` 文本只是路由提示,不是信任边界:被引用数据可以在 JSON 字符串中包含这些词,但无法伪造闭合的 `` 标签,也无法逃逸数据区域。同一个序列化器会独立核算每个源的字节数。该上下文声明 `prompt-prefix` 放置方式,因此 AgentLoop 会持久化一条 `user/message` 或 `steering/message`,其中包含快照、`## My request:` 分隔符和最终生效的直接提示词。其模型不可见封套保留直接显示内容以及来源与保留元数据。因此,目标回放无需新增事件类型或单独的用户角色上下文消息,也能满足「模型可见/日志可重建」不变量。 +系统把一个聚合上下文序列化为 JSON,并置于固定的不可信背景警告之后。该警告要求模型不要遵循被引用会话中的指令、权限声明或工具请求,除非当前用户再次提出这些内容。标签安全序列化会把数据中的每个 `<` 无损转义为 JSON `\u003c`;因此源字符串无法拼出外围类似 XML 的标签,也无法逃逸数据区域。同一个序列化器会独立核算每个源的字节数。AgentLoop 会把快照持久化为一条带来源信息的 `user/message`,紧接在直接 `user/message` 或 `steering/message` 之前。因此,目标回放无需新增事件类型、放置模式或提示词封套,也能满足「模型可见/日志可重建」不变量。 ## 消息所有权 -`send()` 与 `steer()` 会把内容、解析后的来源和上下文一起快照为一条深度冻结、无损 JSON 的收件箱记录。合成的 `inject()` 接受来源和模型不可见的元数据,但不接受附加上下文,因为上下文属于收件箱消息。普通消息被认领后,其附带的上下文会作为 `agent/prompt-submit` 的默认附加上下文公开;提示词被阻止时,系统既不写入用户消息,也不写入上下文。waterfall(瀑布式事件)返回的 allow 结果具有最终权威性,因此监听器包装 `next()` 时会保留下游内容与上下文,除非它有意替换这些值。消息被接纳后,未指定放置方式或指定为 `separate` 时会写入独立的 `context/message`;指定为 `prompt-prefix` 时则会把上下文与最终生效的请求合并写入同一个提示词事件。排空 steering 消息时会绕过 `agent/prompt-submit`,但采用相同的放置方式分流。延迟到达的 steering 转换为排队输入时保留同一条记录;取消、dispose(资源释放)和到达终止态后的丢弃则会同时丢弃消息与上下文。`agent/queued` 会报告已冻结的上下文,使观察事件能够描述完整的保留项。 +TUI 负责快照/直接消息事务,不扩展通用收件箱记录。在 next-step 接受窗口之外,它会在调用 `followup()` 前安装一次性的外层 `agent/prompt-submit` 监听器;获准决策会把快照作为 `additionalContexts` 接收,而被阻止或丢弃的提示词会释放监听器,并且不写入任何消息。提示词准入期间或轮次打开时,TUI 会依次调用 `inject(snapshot)` 和 `steer(prompt)`,AgentLoop 则将两者暂存到同一个安全边界。如果准入在抵达该边界前失败,两者都会保留暂存状态,供重试或后续获准提示词使用;取消或资源释放可能丢弃它们。这一通用交付边界由[上下文分离决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)规定。 -这保留了宿主的驱动语义:TUI 在准备完成后根据 agent 状态决定调用 `send()` 还是 `steer()`,因此只有它的排队路径才会分派 UserPromptSubmit 钩子。引用准备过程不是新的 steering 协议,本身也不会创建轮次。 +引用准备过程不是新的 steering 协议,本身也不会创建轮次。在 next-step 接受窗口之外调用 `followup()` 会分派提示词准入;窗口内的 steering 会绕过它,同时通过共享 outbox 保持快照顺序。 ## 宿主适配器 -TUI 把会话候选与现有 `@` 文件提供方组合在一起。每个候选项显示最新折叠后的会话标题,没有标题时回退到 session id。候选查询遵循编辑器的取消信号;session id、cwd 和提及标签中的外部终端控制字符会被转义,但规范 URI 仍保留原始 id。TUI 只准备包含结构化提及标记的提交;等待快照时禁用重复提交;失败时恢复输入;它把提示词封套的显示内容渲染为用户消息,并把其中的会话引用元数据渲染为精简的来源列表,不在终端中暴露完整 JSON。 +TUI 把会话候选与现有 `@` 文件提供方组合在一起。每个候选项显示最新折叠后的会话标题,没有标题时回退到 session id。候选查询遵循编辑器的取消信号;session id、cwd 和提及标签中的外部终端控制字符会被转义,但规范 URI 仍保留原始 id。TUI 只准备包含结构化提及标记的提交;等待快照时禁用重复提交;失败时恢复输入;它把可读的直接内容渲染为用户消息,并把会话引用来源元数据渲染为精简的来源列表,不在终端中暴露完整 JSON。 [仅面向自动化的 ACP(Agent Client Protocol)传输层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意不挂载会话查询或会话引用服务。 @@ -45,15 +45,15 @@ TUI 把会话候选与现有 `@` 文件提供方组合在一起。每个候选 - **等待 SQLite FTS5**:不予采纳,因为快照正确性依赖按准确 id 读取和规范表层折叠,而不是内容搜索。FTS 只改进候选发现。 - **把提及标记语法放入 `Agent.send()`**:不予采纳,因为这会迫使核心协议解析某个宿主的展示语法,并阻止带类型的非文本宿主复用同一语义层。 - **在每个宿主中分别实现引用**:不予采纳,因为投影、安全警告、保留策略和持久化会在不同宿主之间逐渐偏离。 -- **在提示词旁放置单独的用户角色上下文消息**:不予采纳,因为相邻的两条用户消息会削弱提示词的指示语绑定:在 `@foo what does this session discuss?` 中,模型可能把「this session」解析为当前对话,而不是被引用的快照。 -- **在调用 `send()` 前由宿主合并前缀**:不予采纳,因为 `agent/prompt-submit` 必须只检查和改写直接提示词。最终生效的提示词与附加上下文只有在 AgentLoop 接纳后才汇合;此时 AgentLoop 可以把 `allow.content` 改写一致应用于合并后的模型内容和 `envelope.displayContent`。若由宿主更早组装,就会向该钩子暴露快照字节,或使这两个视图发生偏离。 +- **把上下文附加到 `SendOptions` 和收件箱记录**:不予采纳,因为通用投递将不得不负责贯穿准入、steering、取消和观察的领域事务。领域专用的准入包装层和现有 next-step outbox 可以保持所需配对,而无需扩大每条消息。 +- **在调用 `send()` 前由宿主合并前缀**:不予采纳,因为 `agent/prompt-submit` 必须只检查和改写直接提示词。将快照保留为独立的带来源消息,可以维持该边界,并让 TUI 从直接用户气泡中隐藏背景字节。 - **回放原始源日志或恢复被遮蔽的事件**:不予采纳,因为压缩定义了当前模型表层,并且可能有意淘汰敏感或开销高昂的历史内容。 - **恢复或 fork 源会话**:不予采纳,因为本功能只为一条目标消息提供只读背景,不提供身份或生命周期连续性。 - **在请求时重新读取源会话并注入**:不予采纳,因为这会让引用变得不确定,取消竞态可能改变其字节内容,目标回放也会依赖可变的外部状态。 ## 验证 -单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、会考虑标题的候选排序、终端控制字符转义、投影排除规则、提示词封套的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、冻结的消息所有权、提示词阻止、send/steer 放置方式、标题隔离、功能缺失和精简的 TUI 回放。无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求只包含一条用户消息,其中依次为快照、请求分隔符和当前提示词,并且不包含任一被遮蔽的字符串。 +单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、会考虑标题的候选排序、终端控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、提示词阻止、准入期间的暂存、send/steer 放置方式、标题隔离、功能缺失和精简的 TUI 回放。无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求包含一条带来源的快照消息,后面跟随可读的当前提示词,并且不包含任一被遮蔽的字符串。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml index 83ddffc483..ca9654223d 100644 --- a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.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 -2026-07-02-remove-stream-chunk-mirror.md: 1d9ff86800521eb5ef226575e33a35dcaffd6f6e -2026-07-02-remove-stream-chunk-mirror.zh.md: 26dcc36038efd2857a90c15a675d843d57282ec1 +2026-07-02-remove-stream-chunk-mirror.md: cc75f71407a3c54d71b11e91cea2eb2658649b5e +2026-07-02-remove-stream-chunk-mirror.zh.md: 47e4c42daedadb09b6f51508af84e593ee8218a9 diff --git a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md index 1d9ff86800..cc75f71407 100644 --- a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md +++ b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md @@ -6,7 +6,7 @@ English | [中文](2026-07-02-remove-stream-chunk-mirror.zh.md) ## Problem -The loop records every model token delta as a durable `assistant/chunk` session event AND emitted a parallel live `agent/stream-chunk` Cordis event carrying the identical data. In `packages/core/agent-loop/src/loop.ts` the two sat one line apart: +The loop records every model token delta as a durable `assistant/chunk` session event AND emitted a parallel live `agent/stream-chunk` Cordis event carrying the identical data. In `packages/core/agent-loop/src/agent.ts` the two sat one line apart: ```ts ignore-check const chunkEvent = session.append('assistant/chunk', { turn, step, chunk }) diff --git a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md index 26dcc36038..47e4c42dae 100644 --- a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -agent loop(智能体循环)将模型的每个 token delta 同时记录为持久的 `assistant/chunk` 会话事件,并发射一个携带相同数据的并行实时 `agent/stream-chunk` Cordis 事件。在 `packages/core/agent-loop/src/loop.ts` 中,二者仅相隔一行: +agent loop(智能体循环)将模型的每个 token delta 同时记录为持久的 `assistant/chunk` 会话事件,并发射一个携带相同数据的并行实时 `agent/stream-chunk` Cordis 事件。在 `packages/core/agent-loop/src/agent.ts` 中,二者仅相隔一行: ```ts ignore-check const chunkEvent = session.append('assistant/chunk', { turn, step, chunk }) 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 e441d0bfb1..60181a2ddb 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 @@ -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-17-one-send-one-turn.md: 86c056b53700d0e0c02e04a99cf044fb311f5840 -2026-07-17-one-send-one-turn.zh.md: 3ef9973480481d11d1183760c9fc1f3c247629f4 +# 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 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 86c056b537..dcc6c0aa48 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 @@ -20,11 +20,11 @@ Before enqueueing an item, `send()` checks the agent state and makes a detached, 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. An allowed prompt becomes that turn's `user/message`; a blocked prompt records one durable `prompt/blocked` and closes its one-message turn as `rejected`. Mixed-batch and all-blocked-batch branches do not exist. +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 a separate steering FIFO. While a turn remains open, the loop records that input at the next steering checkpoint, which comes before either a model request or the decision whether to continue. Steering makes another step the default, but continuation or terminal policy can still stop before the step starts. Steering left after the turn closes and its durability checkpoint settles becomes later queued input; terminal `agent/turn-stop`, 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 `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. -`inject()` continues to add model-facing context without submitting an ordinary message; its existing turn-enclosure and flush behavior stays unchanged. `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, including turn close and its checkpoint, so `running` does not prove that a turn is open. +`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. ## Alternatives considered @@ -35,8 +35,8 @@ The no-batching rule applies only to ordinary `send()`. Running `steer()` puts i - Unit and property tests submit sends from the same stack, neighboring microtasks, different producers, and reentrant callbacks; every message gets its own FIFO-ordered turn. - A built-stdio test submits two lines and observes two model requests and two turn boundaries. - Delayed and rejected first-turn checkpoints keep the next turn waiting and prove that its request sees the preceding assistant result. -- Failure-path tests cover prompt veto, listener failure, broad cancellation, disposal, and failure before `turn/start`; recorded turns stay balanced, messages do not merge, and surviving queued work still drains. -- Separate tests cover open-turn, post-turn-close, and idle `steer()`, plus `inject()`, whole-agent status, and `whenIdle()`. +- Failure-path tests cover prompt veto, listener failure, broad cancellation, disposal, and failure before `turn/start`; rejected admission creates no turn, recorded turns stay balanced, messages do not merge, and surviving queued work still drains. +- Separate tests cover open-turn, failed-turn, and idle `steer()`, plus `inject()`, whole-agent status, and `whenIdle()`. ## Consequences 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 3ef9973480..8c12481def 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 @@ -20,11 +20,11 @@ Status: implemented 如果消息 A、B 都进入处理,B 的轮次只能在 A 记录 `turn/end` 且 A 的持久性检查点处理结束后开始。因此,B 的请求能看到 A 在同一会话日志中留下的已关闭结果。检查点错误会照常报告,但处理结束只表示解除这道顺序屏障,不表示失败的写入已经持久化。广义 `cancel()`、dispose(资源释放)或 `turn/start` 之前的失败也可能丢弃尚未启动的队列项,而不打开一个空轮次。 -提示词准入每次只决定一条消息。获准提示词成为该轮次的 `user/message`;被阻止的提示词记录一条持久的 `prompt/blocked`,并让自己的单消息轮次以 `rejected` 关闭。实现中不存在混合批次或全阻止批次分支。 +提示词准入会在轮次打开前,每次只决定一条消息。获准提示词成为该轮次的 `user/message`;被阻止的提示词会被丢弃,不打开轮次,也不写入会话历史。实现中不存在混合批次或全阻止批次分支。 -上述不合批规则只适用于普通 `send()`。agent 运行时,`steer()` 会把输入放入独立的 steering(中途引导)FIFO。只要当前轮次仍然打开,agent loop 就会在下一个 steering 检查点记录该输入;该检查点位于模型请求或继续轮次的决策之前。收到 steering 会把再执行一步作为默认选择,但继续轮次的策略或终止策略仍可在该步骤开始前停止。轮次关闭且其持久性检查点处理结束后,剩余的 steering 会成为后续排队输入;终止性的 `agent/turn-stop`、取消或 dispose 可以将其丢弃。agent 空闲时,`steer()` 委托给 `send()`,因此会创建一个独立的普通队列项。 +上述不合批规则只适用于普通 `send()`。agent 运行时,`steer()` 会把输入放入 outbox。只要当前轮次仍然打开,agent loop 就会在下一个步骤边界记录该输入,而 steering(中途引导)会默认让循环再执行一个步骤。在到达该边界前发生失败,会让 steering 保持暂存且不唤醒 agent;请求错误的重试动作或后续提示词会取走它,而取消或 dispose 可以将其丢弃。agent 空闲时,`steer()` 委托给 `send()`,因此会创建一个独立的普通队列项。 -`inject()` 继续添加面向模型的上下文,而不提交普通消息;其现有的轮次封闭与持久化刷新行为保持不变。`cancel()` 仍是面向整个 agent 的操作,可以清空所有尚未启动的普通输入和 steering,并中止当前步骤。`status` 和 `whenIdle()` 描述的也是整个 agent,而不是某一条消息。多个单消息轮次可以共用一个 `running` 区间,该区间还可能覆盖轮次关闭及其检查点,因此 `running` 不表示轮次一定处于打开状态。 +`inject()` 继续添加面向模型的上下文,而不提交普通消息。轮次打开时,该上下文会留在 outbox 中,等待安全的步骤边界;agent 空闲时,系统会直接追加一条 `user/message`,既不打开轮次,也不运行模型。持久化层独立负责由此产生的即时排空。`cancel()` 仍是面向整个 agent 的操作,可以清空所有尚未启动的普通输入和 steering,并中止当前步骤。`status` 和 `whenIdle()` 描述的也是整个 agent,而不是某一条消息。多个单消息轮次可以共用一个 `running` 区间,因此 `running` 不表示轮次一定处于打开状态。 ## 曾考虑的替代方案 @@ -35,8 +35,8 @@ Status: implemented - 单元测试和性质测试从同一调用栈、相邻微任务、不同生产方和重入回调提交 send;每条消息都会得到一个按 FIFO 排序的独立轮次。 - stdio 构建产物测试提交两行输入,并观察到两个模型请求和两个轮次边界。 - 延迟和拒绝第一个轮次的检查点,都能让下一个轮次保持等待,并证明其请求可以看到前一条助手结果。 -- 失败路径测试覆盖提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 之前的失败;已记录的轮次保持边界平衡,消息不会合并,仍需处理的排队工作也能继续清空。 -- 其他测试分别覆盖轮次打开时、轮次关闭后和空闲时的 `steer()`,以及 `inject()`、面向整个 agent 的状态和 `whenIdle()`。 +- 失败路径测试覆盖提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 之前的失败;准入拒绝不会创建轮次,已记录的轮次保持边界平衡,消息不会合并,仍需处理的排队工作也能继续清空。 +- 其他测试分别覆盖轮次打开时、轮次失败后和空闲时的 `steer()`,以及 `inject()`、面向整个 agent 的状态和 `whenIdle()`。 ## 后果 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 new file mode 100644 index 0000000000..2f7bde6b26 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.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-24-agent-loop-observable-state-machine.md +2026-07-24-agent-loop-observable-state-machine.md: 54730de8aa73342b609d423dc478edb076d7844b +2026-07-24-agent-loop-observable-state-machine.zh.md: 206ac701472f14823300df0c812f2cc818f852f5 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 new file mode 100644 index 0000000000..54730de8aa --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md @@ -0,0 +1,58 @@ +# Agent Note: Collapse agent-loop events around the observable state machine + +Status: implemented + +English | [中文](2026-07-24-agent-loop-observable-state-machine.zh.md) + +## Problem + +The agent loop exposed its control flow as a large set of Cordis events. Separate `pre-step` and `post-step` checkpoints bracketed a step, `session-prefix` and `step-result` transformed request and response messages, `request-error` decided whether a failed request retried inside its turn, and `turn-continuation` plus `turn-stop` composed competing continuation decisions. + +Those events made internal phases public even when the durable session log already owned the corresponding turn and step facts. They also mixed two extension models: some listeners observed a boundary and issued an agent command, while others returned control decisions that the loop interpreted. Understanding the public machine therefore required reconstructing event order, waterfall precedence, and special terminal overrides together. + +Agent lifetime, whole-agent activity, inbox-item progress, and per-turn settlement are independent state dimensions. Treating them as one status or one linear callback sequence makes ordinary questions ambiguous: an agent can remain `running` across several turns, an accepted item can be discarded without opening a turn, and one turn can settle while later work keeps the agent active. + +## Decision + +The public contract exposes four orthogonal state dimensions: + +- Registration lifetime is the `agent/created` to `agent/disposed` interval. Disposal is the terminal registry edge, not an `AgentStatus`. +- Whole-agent activity is `AgentStatus = 'idle' | 'running'`. Consecutive turns may share one `running` interval. +- A FIFO-backed message progresses from `agent/inbox/enqueue` to exactly one `agent/inbox/dequeue` or `agent/inbox/discard`, correlated by `AgentMessageId`. The inbox events describe acceptance, claim, and removal rather than turn completion. +- A claimed turn passes through prompt admission and zero or more request steps. An automatic retry closes the failed turn and immediately opens another; `agent/settled` reports only the terminal turn in that chain and remains distinct from the whole-agent transition to `status === 'idle'`. + +The loop keeps five machine extension events. `agent/prompt-submit` admits, rewrites, or blocks a claimed prompt. `agent/step` is the single awaited between-steps checkpoint and runs before every request is derived. `agent/request` is the waterfall for the frozen call configuration; the configuration comes only from `await next()`, not from a duplicate positional argument. `agent/request-error` serializes ownership of awaited model-request recovery. `agent/turn-stopping` runs when the turn otherwise has no work left; a listener that needs another step records real steering with `agent.steer()`, and the loop decides from that data after all listeners settle. + +Continuation and termination are data rather than returned control enums. Tool calls and accepted steering require another step. A tool result carrying `concludesTurn` ends the tool loop at its step. The loop does not expose general `ContinuationDecision` or terminal-stop return channels. + +A model-request failure closes its step, then enters `agent/request-error` with the exact error, normalized `LlmFailure`, and live turn signal. A listener that owns recovery repairs state, returns `{ kind: 'retry' }`, and stops delegating. The loop closes the failed turn and opens one retry turn over that state without an intervening idle notification; retry is not another step inside the failed turn. `agent/settled` reports the terminal outcome, and `agent/error` remains the live error notification for consumers that report failures independently of turn settlement. The [retry-action decision](2026-07-27-request-error-retry-action.md) supersedes the command-shaped part of this design. + +The event taxonomy removes `agent/pre-step`, `agent/post-step`, `agent/session-prefix`, `agent/step-result`, `agent/turn-continuation`, and `agent/turn-stop`. Durable turn and step boundaries remain session events. Model-facing additions use logged message channels, request configuration uses `agent/request`, response content is recorded as assembled, failed-request recovery uses the `agent/request-error` return action, and end-of-turn continuation uses `agent/turn-stopping` plus steering. + +## Alternatives considered + +**Keep the fine-grained event sequence.** This preserves a dedicated interception point for every internal phase, including request-only prefixes, assistant-message rewriting, post-step work, in-turn request recovery, and terminal stop overrides. It also makes the loop's private sequencing a permanent public contract and lets overlapping seams express conflicting decisions. The decision accepts the lost interception points in exchange for one boundary per supported extension responsibility. + +**Represent disposal as a third `AgentStatus`.** This gives retained handles a terminal status value but duplicates the registry lifecycle already expressed by `agent/disposed`. The decision keeps `AgentStatus` about live activity and makes registration lifetime a separate dimension. + +**Return a retry decision from `agent/request-error`.** This alternative is superseded by the [retry-action decision](2026-07-27-request-error-retry-action.md), which removes the duplicate command and keeps the decision local to the waterfall result. + +**Mirror durable turn and step boundaries as agent events.** This gives live consumers a second event stream for the same facts. The decision keeps the session log as the source of truth and exposes only extension checkpoints or live-only facts that the durable stream cannot carry. + +## Consequences + +The observable machine is smaller and compositional: registration lifetime, activity, item progress, and terminal settlement can be followed independently. In particular, `agent/settled` does not imply `agent.status === 'idle'`; it reports the terminal turn of one drain chain, while `agent/status` reports whether the whole agent is active. + +Plugins no longer rewrite every phase of the loop. There is no request-only message prefix, assistant-message transform, post-step checkpoint, generic continuation enum, generic terminal-stop result, or in-turn request retry. Extensions use the remaining owned channels instead of recreating those phases. + +Continuation plugins publish durable steering rather than returning an unlogged reason. Recovery plugins act after the failed step and return an explicit retry action. This makes every attempt a complete turn while keeping asynchronous repair and policy ownership at one narrow waterfall boundary. + +The inbox lifecycle complements, rather than replaces, the durable session log. `AgentMessageId` correlates acceptance with claim or discard; turn and step numbers, messages, tool activity, and terminal reasons remain session facts. + +## 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) +- [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) +- [Reconstructable requests](../architecture/2026-07-05-reconstructable-requests.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 new file mode 100644 index 0000000000..206ac70147 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.zh.md @@ -0,0 +1,58 @@ +# Agent Note: 围绕可观察状态机收拢 agent loop(智能体循环)事件 + +Status: implemented + +[English](2026-07-24-agent-loop-observable-state-machine.md) | 中文 + +## 问题 + +agent loop 曾将其控制流暴露为大量 Cordis 事件。`pre-step` 和 `post-step` 两个独立检查点分列步骤前后,`session-prefix` 和 `step-result` 分别变换请求消息与响应消息,`request-error` 决定失败的请求是否在当前轮次内重试,`turn-continuation` 与 `turn-stop` 则组合相互竞争的继续执行决策。 + +即使持久会话日志已经记录了对应的轮次与步骤事实,这些事件仍会将内部阶段公开。它们还混用了两种扩展模型:部分监听器观察边界并发出 agent 命令,另一些监听器则返回由循环解释的控制决策。因此,要理解公开状态机,必须同时还原事件顺序、waterfall(瀑布式事件)优先级和特殊的终止覆盖规则。 + +agent 生命周期、agent 整体活动状态、收件箱条目的进度以及每轮次的结算,是彼此独立的状态维度。若将它们视为一个状态或一条线性回调序列,常见问题就会产生歧义:agent 可以在多个轮次之间持续保持 `running`;已接受的条目可以不启动轮次就被丢弃;一个轮次可以完成结算,而后续工作仍让 agent 保持活动。 + +## 决策 + +公开契约暴露四个正交的状态维度: + +- 注册生命周期是从 `agent/created` 到 `agent/disposed` 的区间。dispose(资源释放)是注册表的终止边界,而不是一种 `AgentStatus`。 +- agent 整体活动状态为 `AgentStatus = 'idle' | 'running'`。连续多个轮次可以共用同一个 `running` 区间。 +- 由 FIFO 支撑的消息从 `agent/inbox/enqueue` 开始,最终必然进入 `agent/inbox/dequeue` 或 `agent/inbox/discard` 二者之一,并通过 `AgentMessageId` 关联。收件箱事件描述接受、领取和移除,而不是轮次完成。 +- 已领取的轮次经过提示词准入和零个或多个请求步骤。自动重试会关闭失败轮次并立即开启另一个轮次;`agent/settled` 只报告该重试链的终态轮次,且仍不同于 agent 整体转换到 `status === 'idle'`。 + +循环保留五个状态机扩展事件。`agent/prompt-submit` 对已领取的提示词执行准入、改写或阻断。`agent/step` 是步骤之间唯一需要等待的检查点,在每次派生请求前运行。`agent/request` 是冻结调用配置所用的 waterfall;配置只能来自 `await next()`,不再通过重复的位置参数提供。`agent/request-error` 串行确定需要等待的模型请求恢复由谁负责。当轮次原本已经没有剩余工作时,`agent/turn-stopping` 运行;需要再执行一个步骤的监听器使用 `agent.steer()` 记录真实的 steering(中途引导),循环在所有监听器完成后根据这份数据作出决定。 + +是否继续和终止执行由数据表达,不再由返回的控制枚举表达。工具调用和已接受的 steering 要求再执行一个步骤。携带 `concludesTurn` 的工具结果会在其所属步骤终止工具循环。循环不再暴露通用的 `ContinuationDecision` 或终止停止返回通道。 + +模型请求失败会先关闭当前步骤,再携带准确错误、标准化 `LlmFailure` 和仍有效的轮次信号进入 `agent/request-error`。负责恢复的监听器修复状态、返回 `{ kind: 'retry' }`,并停止继续委托。循环会关闭失败轮次,并基于该状态开启一个重试轮次,中间不发布空闲通知;重试不是失败轮次内的另一个步骤。`agent/settled` 报告终态结果;对于需要脱离轮次结算单独报告失败的消费方,`agent/error` 仍作为实时错误通知保留。[重试动作决策](2026-07-27-request-error-retry-action.md)取代了本设计中命令形式的部分。 + +事件分类体系移除了 `agent/pre-step`、`agent/post-step`、`agent/session-prefix`、`agent/step-result`、`agent/turn-continuation` 和 `agent/turn-stop`。持久的轮次与步骤边界仍由会话事件记录。面向模型的新增内容使用有日志记录的消息通道,请求配置使用 `agent/request`,响应内容按组装后的原样记录,失败请求恢复使用 `agent/request-error` 返回动作,轮次结束时是否继续则使用 `agent/turn-stopping` 加 steering 表达。 + +## 考虑过的替代方案 + +**保留细粒度事件序列。** 这样可以为每个内部阶段保留专用拦截点,包括仅用于请求的前缀、助手消息改写、步骤后处理、轮次内请求恢复以及终止停止覆盖。但这也会使循环的私有执行顺序成为永久的公开契约,并允许相互重叠的 seam 表达彼此冲突的决策。当前决策接受这些拦截点的缺失,以换取每项受支持的扩展职责仅对应一个边界。 + +**将 dispose 表示为第三种 `AgentStatus`。** 这样会让仍被持有的句柄得到一个终止状态值,但也会重复表达 `agent/disposed` 已经体现的注册表生命周期。当前决策让 `AgentStatus` 只表示活动中 agent 的状态,并将注册生命周期作为独立维度。 + +**让 `agent/request-error` 返回重试决策。** 这一替代方案已由[重试动作决策](2026-07-27-request-error-retry-action.md)取代;新决策移除了重复命令,并将决策局限于 waterfall 的返回结果。 + +**将持久的轮次与步骤边界映射为 agent 事件。** 这样会为同一事实向实时消费方提供第二条事件流。当前决策将会话日志保留为真源,仅暴露扩展检查点或持久事件流无法承载的纯实时事实。 + +## 影响 + +可观察状态机更小,也更容易组合:注册生命周期、活动状态、条目进度和终态结算可以分别追踪。尤其是,`agent/settled` 并不意味着 `agent.status === 'idle'`;前者报告一次排空链的终态轮次,`agent/status` 则报告整个 agent 是否处于活动状态。 + +插件不再能够改写循环的每个阶段。不再提供仅用于请求的消息前缀、助手消息变换、步骤后检查点、通用的继续执行枚举、通用的终止停止结果或轮次内请求重试。扩展改用剩余的归属明确的通道,而不是重新构造这些阶段。 + +负责继续执行的插件发布可持久化的 steering,而不是返回未记录到日志中的原因。恢复插件在失败步骤结束后处理错误,并返回显式重试动作。这样,每次尝试都会成为完整轮次,同时异步修复和策略归属集中在一个狭窄的 waterfall 边界。 + +收件箱生命周期用于补充持久会话日志,而非取代它。`AgentMessageId` 将接受操作与领取或丢弃操作关联起来;轮次编号与步骤编号、消息、工具活动和终止原因仍属于会话事实。 + +## 相关内容 + +- [统一通过 send(target × wakeup) 交付 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) +- [可重建的请求](../architecture/2026-07-05-reconstructable-requests.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 new file mode 100644 index 0000000000..e22645e088 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.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-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 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 new file mode 100644 index 0000000000..3057b9fa28 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.md @@ -0,0 +1,29 @@ +# Agent Note: Request-error retry action + +Status: implemented + +English | [中文](2026-07-27-request-error-retry-action.zh.md) + +## Problem + +Model-request recovery was decided inside `agent/request-error` but communicated through `Agent.retry()`. That public command was valid during one narrow waterfall window and while idle, rejected other running states, and required `ReactLoopAgent` to retain a mutable retry window beside the waterfall result. The recovery plugins were the only production callers, so the wider live-agent capability exposed states and behavior unrelated to their policy decision. + +## Decision + +`agent/request-error` returns `RequestErrorAction`, whose handling action is `{ kind: 'retry' }`; the default `undefined` keeps the failed turn terminal. A listener that does not own the failure calls `next()`. A listener that owns it performs any awaited repair and returns the retry action without delegating. + +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. + +## Alternatives considered + +**Keep `Agent.retry()` as the recovery command.** Runtime guards can restrict the command to the request-error window, but the interface still advertises an idle resummon operation with no production consumer and the loop still needs mutable side-channel state to recover a decision already owned by the waterfall. + +**Return an explicit terminal action.** `undefined` already represents the waterfall's unhandled default and composes directly through `next()`. A second `{ kind: 'fail' }` value would add no distinct behavior or ownership information. + +## Consequences + +Recovery ownership, asynchronous repair, and the retry decision share one typed return path. The live-agent interface and concrete loop lose the idle resummon capability and retry-window state. Callers cannot restart arbitrary failed non-request work without submitting a later prompt, while transient and context-overflow policies retain numbered retry turns, durable-history reconstruction, finite private budgets, and cancellation precedence. + +Focused agent-loop tests pin retry chaining, terminal fallthrough, recovery failure, and cancellation races. The llm-retry and compact-basic suites pin their policy-owned action returns, and the ACP, goal-session, and plan-mode integrations pin successor-turn adoption. 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 new file mode 100644 index 0000000000..bcb4e592f0 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 请求错误重试动作 + +Status: implemented + +[English](2026-07-27-request-error-retry-action.md) | 中文 + +## 问题 + +模型请求恢复由 `agent/request-error` 内部决定,却通过 `Agent.retry()` 传达。这个公开命令只在一个狭窄的 waterfall(瀑布式事件)窗口内和空闲时有效,在其他运行状态下会被拒绝,并要求 `ReactLoopAgent` 在 waterfall 结果旁保留一个可变的重试窗口。恢复插件是仅有的生产调用方,因此更宽泛的活跃 agent(智能体)能力暴露了与其策略决策无关的状态与行为。 + +## 决策 + +`agent/request-error` 返回 `RequestErrorAction`,其中负责处理的动作是 `{ kind: 'retry' }`;默认的 `undefined` 会让失败轮次保持终态。不拥有该失败的监听器调用 `next()`。拥有该失败的监听器执行所有需要等待的修复,然后直接返回重试动作而不继续委托。 + +waterfall 结算后,循环读取该动作,关闭失败轮次,并从持久历史开启一个重试轮次。循环在使用该动作时会再次检查轮次信号,因此即使监听器随后返回重试动作,恢复期间发生的取消或资源释放仍会阻止重试。抛出异常的恢复不会产生动作。 + +`Agent` 与 `ReactLoopAgent` 均不暴露 `retry()` 方法。普通新工作通过 `send()` 及其 `followup()`、`steer()` 和 `inject()` 预设进入;只有已处理的模型请求失败才能开启没有提示词的重试轮次。 + +## 曾考虑的替代方案 + +**保留 `Agent.retry()` 作为恢复命令。** 运行时防护检查可以将该命令限制在请求错误窗口内,但接口仍会暴露一个没有生产消费方的空闲无提示词再运行操作,循环也仍需通过可变的旁路状态恢复已经由 waterfall 决定的结果。 + +**返回显式终态动作。** `undefined` 已经表示 waterfall 未处理时的默认值,并可直接通过 `next()` 组合。再添加一个 `{ kind: 'fail' }` 值不会提供不同的行为或归属信息。 + +## 后果 + +恢复归属、异步修复和重试决策共用一条类型化返回路径。活跃 agent 接口与具体循环不再具有空闲无提示词再运行能力和重试窗口状态。调用方如果不提交后续提示词,就无法重启任意失败的非请求工作;瞬时策略与上下文溢出策略则保留编号重试轮次、从持久历史重建、有限的策略私有预算和取消优先级。 + +聚焦的 agent-loop 测试固定了重试链、未处理失败保持终态、恢复失败和取消竞态。llm-retry 与 compact-basic 测试套件固定其策略自有的动作返回,而 ACP、goal-session 和 plan-mode 集成测试固定后继轮次承接。 diff --git a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.md b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.md deleted file mode 100644 index 652c3d410a..0000000000 --- a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.md +++ /dev/null @@ -1,75 +0,0 @@ -# Agent Note: Separate context injection from turn execution - -Status: proposed - -English | [中文](2026-07-24-separate-context-injection-from-turn-execution.zh.md) - -## Problem - -The agent API currently represents supplementary model-facing input in three overlapping ways: callers attach `HookContext[]` through `SendOptions.contexts`, interception and tool hooks return `additionalContexts`, and plugins call `agent.inject()`. These paths eventually write context into the same model history, but they carry different placement, metadata, admission, queue, and turn-lifecycle rules. - -Atomic attachment to an inbox message forces the loop to preserve context through prompt admission, steering conversion, cancellation, and terminal discard. `prompt-prefix` placement then combines context and the direct prompt into one event, requiring a model-hidden envelope so transcript consumers can recover what the user actually wrote. The result makes outbox entries, session projection, and UI replay responsible for a distinction that belongs to the producer. - -Idle `inject()` exposes a second mismatch. Injection does not request model execution, yet the current implementation opens and closes a zero-step `injection` turn solely to satisfy the turn-enclosure invariant and obtain a durability checkpoint. A turn therefore sometimes means “run the agent loop” and sometimes means “persist context without running it.” - -`HookContext` also names its producer rather than its role. The value may come from a native plugin, a hook bridge, prompt admission, or tool post-processing. Its stable meaning is simply additional model-facing context with provenance. - -## Proposal - -Make `inject()` the only caller-facing operation for adding supplementary model-facing input, and define a turn exclusively as one execution of the model loop. - -Remove `SendOptions.contexts`. A caller that owns context delivers it with `inject()` and independently submits the direct message with `send()` or `steer()`. Rename `HookContext` to `AdditionalContext`; retain only `content` and `source`, and remove placement and model-hidden metadata from this shared shape. - -Prompt and tool extension points may 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 enters the outbox together with its returned additional contexts; a blocked prompt writes neither and opens no turn. Tool-produced additional contexts enter the same outbox after the corresponding tool results. - -Every additional context becomes an independent `user/message` whose `source` records provenance. Remove `context/message`, prompt-prefix placement, the stable request delimiter, and the prompt envelope. Transcript and UI consumers distinguish direct user messages from injected context by `source`, not by recovering a hidden direct-prompt field from combined model content. - -## Injection lifecycle - -When a turn is open, `inject()` stages the context in the loop outbox. The loop drains the outbox at a safe step boundary, preserving tool protocol adjacency: a context accepted during an assistant tool-call batch appears only after that batch's complete ordered results. Taking the outbox as a whole makes steering and injected context accepted for one boundary visible to the same following request. - -When no turn is open, `inject()` appends its `user/message` immediately and starts a session flush. It does not increment turn numbering, emit `turn/start` or `turn/end`, change agent status, or run the model. The synchronous API still returns before the asynchronous flush settles; `whenIdle()` and agent disposal include outstanding idle-injection flushes in their quiescence boundary. - -A failed idle flush has no legitimate turn or step coordinates. It is reported through logging or a persistence-owned error surface, not by inventing an `agent/error` payload for a nonexistent turn. The in-memory event remains accepted and a later flush may retry persistence. - -The session invariant therefore permits `user/message` between turns while continuing to require turn enclosure for execution events, steering, assistant output, tools, and package-added events by default. Persistence, recovery, resume, fork, and compaction code must treat a valid out-of-turn `user/message` as committed session history rather than an interrupted or discardable turn tail. - -## Extension and caller semantics - -`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. A caller that invokes `inject(context)` and then `send(prompt)` has already committed context independently; if prompt admission later blocks the prompt, the injected context remains in history. Callers requiring all-or-nothing domain behavior must perform their own preparation before either operation or expose a domain-specific admission seam. - -Cross-session references follow the ordinary composition: the host prepares the snapshot, injects it with session-reference provenance, then sends or steers the readable direct prompt. 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](../../implemented/feature/2026-07-21-cross-session-references.md) while retaining its snapshot and trust-boundary rules. - -This proposal preserves the caller-owned framing decision from [unwrapped injected content](../../implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), the one-item turn rule from [one send, one turn](../../implemented/simplification/2026-07-17-one-send-one-turn.md), and narrows the [turn-enclosure decision](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md) so turns enclose execution rather than every session event. - -## Alternatives considered - -**Keep `SendOptions.contexts` as an atomic attachment.** This preserves all-or-nothing delivery when prompt admission blocks, but it keeps context inside inbox lifecycle state and requires every queue transition and observation event to carry it. The generic agent API should not encode a domain transaction that most callers can express as context injection followed by message delivery. - -**Keep a distinct `context/message` session event.** A separate event makes the out-of-turn exception narrower, but user-role model input would again have two event types with identical projection. `user/message.source` already carries the distinction needed by policy, transcript, and replay consumers. - -**Keep one-shot turns for idle injection.** This retains universal turn enclosure and a convenient flush boundary, but it makes turn counts and turn observers report work that never ran the model. Durability is an independent session concern and can be awaited without fabricating execution. - -**Keep `prompt-prefix` as an optional placement.** Prefix baking can make the context and request appear in one provider message, but it introduces a second representation of the direct prompt and spreads placement handling across admission, steering, logging, replay, and UI code. Producers that require textual framing may include it in their own context content. - -**Let hooks call `inject()` directly instead of returning additional contexts.** Direct injection would erase the extension point's admission ownership: a listener could append context before a downstream listener blocks the operation. Returning `additionalContexts` keeps the waterfall result authoritative while sharing the same post-admission outbox path. - -## Acceptance criteria - -- `SendOptions` and steering inbox records contain no attached contexts; `agent/queued` reports only the retained message and steering facts. -- `AdditionalContext` replaces `HookContext` across prompt interception, tool execution, hook bridges, guards, and context producers, with only `content` and `source`. -- Prompt-prefix placement, prompt envelopes, and `context/message` are absent from public types, durable events, projection, and UI replay. -- Idle `inject()` appends and flushes one sourced `user/message` without a turn or model call; `whenIdle()` and disposal await the flush. -- Active-turn injection and hook-produced contexts drain at safe boundaries after complete tool-result batches and before the request that consumes them. -- Blocked prompt admission opens no turn and appends neither the prompt nor hook-produced additional contexts; independently injected caller context remains. -- Unit, persistence/resume, invariant, ACP/TUI replay, and keyless assembled-application snapshots cover the new event order and durability semantics. - -## Risks - -- Allowing one surface event outside turns weakens a simple invariant and may expose hidden assumptions in persistence scanning, crash repair, forking, compaction, and session queries. -- Consecutive user-role messages replace one baked prompt message; provider adapters and cache behavior must accept and preserve that ordering. -- `inject()` followed by a blocked `send()` leaves context without its intended direct prompt unless the caller accepts the independent-commit contract. -- A synchronous injection API cannot return flush failure. Logging alone is less structured than `agent/error`, while adding a new persistence event solely for this case may create another unnecessary seam. -- Removing attachment, placement, metadata, envelopes, and a durable event type is a broad pre-release migration that must update every producer and consumer atomically. diff --git a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md deleted file mode 100644 index 1064e7a869..0000000000 --- a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md +++ /dev/null @@ -1,75 +0,0 @@ -# Agent Note: 将上下文注入与轮次执行分离 - -Status: proposed - -[English](2026-07-24-separate-context-injection-from-turn-execution.md) | 中文 - -## 问题 - -agent API 目前用三种相互重叠的方式表示面向模型的补充输入:调用方通过 `SendOptions.contexts` 附加 `HookContext[]`,拦截钩子和工具钩子返回 `additionalContexts`,插件则调用 `agent.inject()`。这些路径最终都会把上下文写入同一份模型历史,但各自携带不同的放置、元数据、准入、队列和轮次生命周期规则。 - -将上下文原子附加到收件箱消息后,agent loop(智能体循环)必须让上下文跟随提示词准入、steering(中途引导)转换、取消和终止丢弃的完整生命周期。`prompt-prefix` 放置方式又会把上下文与直接提示词合并为一个事件,因此 transcript(文本记录)消费方需要依赖模型不可见的封套,才能还原用户实际输入。这样一来,outbox 条目、会话投影和 UI 回放都必须处理本应由生产方负责的区分。 - -空闲状态下的 `inject()` 还暴露了另一处语义错位。注入并不请求模型执行,但当前实现仅为了满足轮次封闭不变量并获得持久性检查点,就会打开并关闭一个零步骤的 `injection` 轮次。于是,轮次有时表示「运行 agent loop」,有时却表示「不运行 agent,仅持久化上下文」。 - -`HookContext` 的名字也描述了生产方,而非该值的职责。它可能来自原生插件、hook bridge、提示词准入或工具后处理;其稳定含义只是带来源信息的额外模型上下文。 - -## 提案 - -将 `inject()` 设为调用方添加补充模型输入的唯一操作,并把轮次严格定义为一次模型循环执行。 - -移除 `SendOptions.contexts`。拥有上下文的调用方通过 `inject()` 交付上下文,再独立使用 `send()` 或 `steer()` 提交直接消息。将 `HookContext` 重命名为 `AdditionalContext`;这个共享结构只保留 `content` 和 `source`,移除放置方式与模型不可见元数据。 - -提示词和工具扩展点仍可返回 `additionalContexts`。这些值是扩展点的输出,而不是从调用方收件箱条目捕获的附件。提示词准入在 `run()` 打开轮次之前执行。提示词获准后,它与返回的额外上下文一同进入 outbox;提示词被阻止时,两者都不写入,也不打开轮次。工具产生的额外上下文则在对应工具结果之后进入同一个 outbox。 - -每项额外上下文都成为独立的 `user/message`,并由 `source` 记录来源。移除 `context/message`、prompt-prefix 放置方式、稳定请求分隔符和提示词封套。transcript 与 UI 消费方通过 `source` 区分直接用户消息和注入上下文,无需从合并后的模型内容中恢复隐藏的直接提示词字段。 - -## 注入生命周期 - -轮次打开时,`inject()` 将上下文暂存在 loop outbox 中。agent loop 会在安全的步骤边界排空 outbox,同时保持工具协议要求的相邻关系:在助手工具调用批次期间接纳的上下文,只能出现在该批次所有有序结果之后。系统整体取走 outbox,确保同一边界接纳的 steering 和注入上下文对后续同一次请求可见。 - -没有打开的轮次时,`inject()` 会立即追加对应的 `user/message` 并启动会话刷新。它不会增加轮次编号、发出 `turn/start` 或 `turn/end`、改变 agent 状态,也不会运行模型。同步 API 仍会在异步刷新完成前返回;`whenIdle()` 和 agent dispose(资源释放)会把尚未结束的空闲注入刷新纳入静止边界。 - -空闲刷新失败时不存在合法的轮次或步骤坐标。系统通过日志或持久化所属的错误接口报告该失败,而不是为不存在的轮次伪造 `agent/error` 载荷。内存中的事件仍已接纳,后续刷新可以重试持久化。 - -因此,会话不变量允许 `user/message` 位于两个轮次之间,同时继续要求执行事件、steering、助手输出、工具事件以及默认的包扩展事件均受轮次边界约束。持久化、恢复、resume、fork、压缩和查询逻辑必须把合法的轮次外 `user/message` 当作已提交会话历史,而不是中断轮次或可丢弃的日志尾部。 - -## 扩展点与调用方语义 - -`PromptDecision.content` 仍只替换直接提示词。`PromptDecision.additionalContexts` 和工具结果的 `additionalContexts` 保留 FIFO 顺序及各自来源,但不再选择放置方式。waterfall(瀑布式事件)监听器调用 `next()` 委托时,必须保留下游返回的提示词内容和额外上下文,除非它有意返回替代值。 - -调用方主动注入与钩子产生的额外上下文具有不同的准入归属。钩子的额外上下文只会在该钩子允许提示词或工具结果后落入日志。调用方执行 `inject(context)` 后再执行 `send(prompt)` 时,上下文已独立提交;后续提示词准入即使阻止该提示词,注入上下文仍保留在历史中。需要领域级全有或全无语义的调用方,必须在执行任一操作前自行完成准备,或提供领域专用的准入 seam。 - -跨会话引用使用普通组合方式:宿主先准备快照,以会话引用来源调用 `inject()`,再发送或 steer 可读的直接提示词。目标日志包含两条简单消息,因此来源会话后续变化不会改变回放,transcript 消费方也不需要提示词封套。本提案取代[跨会话引用决策](../../implemented/feature/2026-07-21-cross-session-references.md)中的附件机制,但保留其快照与信任边界规则。 - -本提案保留[移除注入内容封套](../../implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md)确立的调用方自主管理框架原则,以及[一次 send、一个轮次](../../implemented/simplification/2026-07-17-one-send-one-turn.md)确立的单条目轮次规则;同时收窄[轮次封闭决策](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md),使轮次约束执行过程,而不是约束所有会话事件。 - -## 曾考虑的替代方案 - -**保留 `SendOptions.contexts` 作为原子附件。** 提示词准入阻止消息时,这种方式能保留全有或全无交付,但也会让上下文继续成为收件箱生命周期状态的一部分,并迫使每次队列转换和观察事件携带它。大多数调用方都可以通过先注入上下文、再交付消息来表达需求,通用 agent API 不应内置领域事务。 - -**保留独立的 `context/message` 会话事件。** 独立事件可以缩小轮次外事件的例外范围,但面向模型的 user-role 输入会再次拥有两个投影完全相同的事件类型。`user/message.source` 已能为策略、transcript 和回放消费方提供所需区分。 - -**为空闲注入保留一次性轮次。** 这种方式能保留通用轮次封闭和方便的刷新边界,却会让轮次计数与轮次观察方报告从未运行模型的工作。持久性是独立的会话关注点,无需伪造执行即可等待。 - -**保留 `prompt-prefix` 可选放置方式。** 前缀烘焙可以让上下文和请求位于同一条提供方消息中,但它会引入直接提示词的第二种表示,并把放置处理扩散到准入、steering、日志、回放和 UI 代码。需要文本框架的生产方可以直接把它写入自身上下文内容。 - -**让钩子直接调用 `inject()`,而不是返回额外上下文。** 直接注入会破坏扩展点的准入归属:下游监听器阻止操作之前,上游监听器就可能已经追加上下文。返回 `additionalContexts` 能维持 waterfall 结果的最终权威性,同时复用准入后的 outbox 路径。 - -## 验收标准 - -- `SendOptions` 与 steering 收件箱记录不再包含附加上下文;`agent/queued` 只报告保留的消息和 steering 事实。 -- `AdditionalContext` 在提示词拦截、工具执行、hook bridge、guard 和上下文生产方中取代 `HookContext`,且只包含 `content` 与 `source`。 -- 公共类型、持久事件、投影和 UI 回放中均不存在 prompt-prefix 放置方式、提示词封套与 `context/message`。 -- 空闲 `inject()` 在不产生轮次或模型调用的情况下,追加并刷新一条带来源的 `user/message`;`whenIdle()` 和 dispose 会等待该刷新。 -- 活跃轮次注入和钩子产生的上下文会在完整工具结果批次之后的安全边界排空,并在消费它们的请求之前进入日志。 -- 被提示词准入阻止的消息不会打开轮次,也不会追加提示词或钩子产生的额外上下文;调用方此前独立注入的上下文仍保留。 -- 单元测试、持久化与 resume 测试、不变量测试、ACP/TUI 回放测试,以及无需密钥的组装应用快照覆盖新的事件顺序和持久性语义。 - -## 风险 - -- 允许一个表层事件位于轮次之外,会削弱一条简单不变量,并可能暴露持久化扫描、崩溃恢复、fork、压缩和会话查询中的隐含假设。 -- 两条连续的 user-role 消息会取代一条烘焙后的提示词消息;提供方适配器和缓存行为必须接受并保留这一顺序。 -- 如果调用方不能接受独立提交契约,`inject()` 后跟一个被阻止的 `send()` 会留下缺少预期直接提示词的上下文。 -- 同步注入 API 无法返回刷新失败。只记录日志的结构化程度低于 `agent/error`,但仅为此场景增加新的持久化事件也可能产生另一个不必要的 seam。 -- 移除附件、放置方式、元数据、封套和一种持久事件类型,是一次影响面较广的预发布迁移,必须原子更新所有生产方和消费方。 diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 5f1e43eb17..b9d0d6cb33 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -15,18 +15,21 @@ sequenceDiagram participant LLM as ctx.llm participant Tools as ctx.tools participant Session - participant Persistence participant SDK as UI or SDK listener User->>Agent: followup(content) Agent-->>SDK: agent/inbox/enqueue Agent->>Driver: queued work wakes driver Driver-->>SDK: agent/status running - Driver->>Session: turn/start + Note over Agent,Driver: next-step acceptance window opens Driver->>Hooks: agent/prompt-submit waterfall Hooks-->>Driver: authoritative allow, block, or add context - Driver->>Session: user/message or rejected turn/end + alt prompt blocked or admission failed + Driver-->>Driver: append context-only batch or keep steering boundary pending + else prompt allowed + Driver->>Session: turn/start + Driver->>Session: user/message Driver->>Prompt: system-prompt/assemble waterfall - Driver-->>Driver: agent/pre-step serial checkpoint + Driver-->>Driver: agent/step serial checkpoint Driver->>Session: step/start Driver->>LLM: agent/request waterfall, then llm/stream waterfall LLM-->>Driver: StreamChunk* @@ -35,9 +38,8 @@ sequenceDiagram alt final adapter or terminal in-band request failure Driver->>Session: step/end Driver->>Hooks: agent/request-error waterfall - Hooks-->>Driver: retry in a new step or preserve the original error + Hooks-->>Driver: return retry action or preserve the original error else model request succeeded - Driver->>Hooks: agent/step-result waterfall Driver->>Session: assistant/message Driver->>Tools: classify pending call by executionMode loop barriers and bounded rolling pool, reclassify before start @@ -52,19 +54,18 @@ sequenceDiagram end end Driver->>Session: post-tool context and steering (no prompt-submit) - Driver->>Hooks: agent/post-step serial checkpoint Driver->>Session: step/end - Driver->>Hooks: agent/turn-continuation waterfall - Driver->>Hooks: agent/turn-stop serial terminal checkpoint + Driver->>Hooks: agent/turn-stopping serial terminal checkpoint end + Note over Agent,Driver: next-step acceptance window closes Driver->>Session: turn/end - Driver->>Persistence: session/flush parallel checkpoint + end Driver-->>SDK: agent/status idle ``` The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set. -`dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and a fresh retry step, and returns retry only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative. +`dsh-compact-basic` uses `agent/step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative. The returned `agent/prompt-submit` allow is authoritative; listeners wrapping `next()` preserve downstream content and additional contexts unless replacement is intentional. Steering bypasses that waterfall and joins at its durable checkpoint. diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 2f273b2d30..e940800cf9 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: d80eb14c4c703f99d3401e4f69958e78998b8bfa -architecture.zh.md: b392b519310389a1c623b81b797d4c32453a940c +architecture.md: 3b0f72400ab1e6a9157aed2966b4a17c36e0c3ac +architecture.zh.md: fa21c82a686d88a9bbec02180feae1dd0dbf4e47 diff --git a/docs/architecture.md b/docs/architecture.md index d80eb14c4c..3b0f72400a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,7 +6,7 @@ English | [中文](architecture.zh.md) ## Overview -Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed services, typed events, and disposable registrations. +Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, typed events, and disposable registrations. `packages/core/` groups the default agent flow; capabilities remain plugins. @@ -14,11 +14,11 @@ Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed servi | ctx key | Package | Role | |---|---|---| -| — | [`dsh-scope`](../packages/core/scope/README.md) | scoped-context registration and shared layer storage (library) | +| — | [`dsh-scope`](../packages/core/scope/README.md) | scoped-context registrations and shared layer storage (library) | | `ctx.sessions` | `dsh-session` | in-memory event-sourced sessions | -| `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and prompt variables | +| `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and variables | | `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) | -| `ctx.agents` | `dsh-agent` | live agents, delegated creation, `agent/*` events, and process-local initiator scope | +| `ctx.agents` | `dsh-agent` | live agents, delegated creation, `agent/*` events, process-local initiator scope | | `ctx.agentLoop` | `dsh-agent-loop` | concrete `Agent` driver | ### Capability Services @@ -26,47 +26,45 @@ Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed servi | ctx key | Package family | Role | |---|---|---| | `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls | -| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request/surface pressure | +| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request and surface pressure | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution | | `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | managed child-process trees for the bash executors, the LSP host, and the ACP subagent backend | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | owner-scoped persistent terminal sessions | -| `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) | +| `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement through argv wrapping and per-call policy | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home | | `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution | | `ctx.fs` | [`fs/`](../packages/fs/README.md) | filesystem provider primitives and policy events | | `ctx.lsp` | [`lsp/`](../packages/lsp/README.md) | semantic navigation registry | | `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry and progressive disclosure | | `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries | -| `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction; optional model-free result pruning | +| `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction and optional model-free result pruning | | `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers | | `ctx.planMode` | [`plan/`](../packages/plan/README.md) | logged plan collaboration state | -| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry + generic `task_*` control tools | +| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry and generic `task_*` controls | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable session-log storage | -| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | Live-preferred exact/filter/trace interface, SQLite FTS backend, and workspace-authorized model tools | -| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallbacks plus one optional asynchronous provider | -| `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry for package-owned runtime checks | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace interface, SQLite FTS backend, workspace-authorized model tools | +| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallbacks and one optional asynchronous provider | +| `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry of package-owned runtime checks | ## Event -Events form the service extension API; see the [catalog](cordis-catalog/events.md) and [producer/consumer map](event-producer-consumer.md). +Events are the service extension API ([catalog](cordis-catalog/events.md), [producer/consumer map](event-producer-consumer.md)). ### Event Domains -- **Session events** are durable facts appended to the log and emitted through `session/event`. -- **Agent events** carry the live `Agent` for status, prompt admission, request shaping, validation, and continuation. -- **Capability events** let owning seams attach policy and adapters without importing the loop. +- **Session events** are durable log facts emitted through `session/event`. +- **Agent events** carry live `Agent` for status, prompt admission, request shaping, validation, and continuation. +- **Capability events** let owning seams attach policy and adapters without a loop import. ### Interception Semantics -Waterfall events behave like around-middleware: a listener delegates by calling `next()`; returning without it vetoes or takes over. Full rule: [Cordis waterfall semantics](cordis-primer.md#cordis-waterfall-semantics). +Waterfalls are around-middleware: listeners delegate with `next()`; returning without it vetoes or takes over ([semantics](cordis-primer.md#cordis-waterfall-semantics)). ## Default Loop Lifecycle -The loop runs through plugin services and events. - -A **session** is append-only. Each ordinary **turn** claims one queued message; injection claims none. Successors await the preceding checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). 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 `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. 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. @@ -79,96 +77,95 @@ choose declarative identity and fresh/resume path -> enable driving -> agent/session-start(source) -> start driver forever: wait for a queued message - emit agent/status(running) - TURN: - 'turn/start' - claimed message + contexts -> agent/prompt-submit - allowed prompt -> 'user/message' with prompt-prefix context baked in; append separate contexts - blocked prompt -> 'prompt/blocked' -> 'turn/end'(rejected) + claim message -> emit agent/status(running) if starting an interval + open the next-step acceptance window + -> agent/prompt-submit + blocked or failed prompt -> close the window without opening a turn + append a context-only caller batch immediately + keep steering and context staged beside it pending for a later admitted turn + allowed prompt: + 'turn/start' + append prompt + additional contexts as separate 'user/message' events STEP loop: - drain steering with the same prefix/separate context placement (no prompt-submit) + agent/step + drain injected context and steering (steering bypasses prompt-submit) assemble system prompt and tool schemas - agent/session-prefix (first step) - agent/pre-step snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request -> prepare reasoning/default under turn signal -> log request/header -> checkpoint -> llm/stream (frozen, registration-bound) - on final adapter-path or terminal in-band failure: - 'step/end' - agent/request-error(original error, failure facts, immutable prior failures, signal) - retry in the next numbered step or preserve the original error - otherwise: - 'assistant/chunk' - agent/step-result - 'assistant/message' (transformed content or empty success anchor after step-result rejection) - schedule tool calls by ctx.tools.executionMode: - exclusive -> one-call barrier - parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start - each start -> 'tool/call' -> ordered tools/pre-execute -> checkpoint -> concurrent tools/execute - each model-order result -> ordered tools/post-execute -> 'tool/result' - append accepted tool-batch context after all recorded results, then steering - agent/post-step -> checkpoint complete response/results - 'step/end' - agent/turn-continuation - agent/turn-stop (terminal policy) - stop unless tools or continuation policy ask for another step - 'turn/end' - checkpoint persistence and notify idle/running status + agent/request (config only) -> prepare reasoning/default under turn signal -> log request/header -> llm/stream (frozen, registration-bound) + 'assistant/chunk' + 'assistant/message' + schedule tool calls by ctx.tools.executionMode: + exclusive -> one-call barrier + parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start + each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute + each model-order result -> ordered tools/post-execute -> 'tool/result' + drain accepted tool context and steering + 'step/end' + continue for tools or steering unless a result concluded the turn + otherwise agent/turn-stopping -> drain -> continue only for steering + close the next-step acceptance window + 'turn/end' -> agent/settled + start the next waking queued message, or emit agent/status(idle) + +idle inject: + append 'user/message' + do not open a turn or run the model ``` -Steps assemble ordered prompt sections, tool schemas, and variables; unknown references fail turns. `dsh-system-prompt` owns identity and persona; the loop supplies `model` and `cwd` ([ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). +Each step assembles ordered prompt sections, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona; the loop supplies `provider`, `model`, and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -Async `inject()` and post-tool `additionalContexts` settle after results; steering drains before `agent/post-step`. Leftovers queue. Terminal `agent/turn-stop` remains authoritative through close/flush and discards later steering, not queued prompts. +Admission-time and active-turn `inject()` stage for the next step; post-tool `additionalContexts` settles after results. Steering shares that staging boundary and requests another step. Idle `inject()` appends immediately without changing turn numbers; persistence drains eagerly. -Pruning precedes summaries; overflow retries require durable progress. Bounded retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)). +Pruning precedes summaries; overflow retries require durable progress. Recovery uses `agent/request-error` after the failed step and before turn close. Its policy returns a retry action to schedule one retry turn; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)). ### Failure Boundaries -Adapter failures close the step before `agent/request-error` with exact `Error`, `LlmFailure`, and history. Retries open steps; success clears history; exhaustion stores failure on `turn/end`. Failed chunks commit nothing. +Adapter failures close the step before `agent/request-error` receives the exact `Error`, normalized `LlmFailure`, and turn signal. A handling listener returns `{ kind: 'retry' }`; the loop closes the failed turn and opens another from durable history without idle notification. Exhaustion leaves the failed `turn/end` terminal; failed chunks commit neither message nor tool call. -Other failures use `agent/error`. Cancellation and disposal beat recovery; the turn signal also cancels asynchronous model-capability preparation before any request header is committed, and undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The signal retires before `turn/end`. Effective `cancel()` emits its cause, clears queues, and aborts; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). +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)). -Session events are turn-enclosed; reload closes an interrupted tail with a synthetic `interrupted` turn end. Post-close failures use `agent/error`. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). +Turn and step events are turn-enclosed; idle injected `user/message` events may sit between turns. Reload closes an interrupted tail with a synthetic turn end. After close, only `agent/error` reports failures. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). ### Agent Handles -`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins use intent helpers `followup()`, `queue()`, `steer()`, and `inject()`; callers with exact routing facts use mandatory-field `send()` ([decision](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). `cancel()` and `whenIdle()` control lifecycle. Caller, provider, and handle co-own teardown. +`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. ### Agent Scope -Each agent owns a scoped `agent.ctx` over global tool, prompt, and command storage ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)); scoped listeners filter and contributions unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication; typed resolvers derive carrier checks from `Events` and `scopeTarget` ([gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). `AgentLoop` runs inside `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, while turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). See [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). +Each agent owns scoped `agent.ctx`; shared storage overlays its tool, prompt, and command entries on globals while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch; contributions unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). Details: [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md), [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs under `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, but turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). ## State ### Session Log -The session log is authoritative. `deriveMessages()` projects model history; raw `assistant/chunk` events preserve replay and UI fidelity. Fork, resume, transcripts, telemetry, and persistence share that stream. +The session log is authoritative. `deriveMessages()` projects model history; raw `assistant/chunk` events preserve replay and UI fidelity. Fork, resume, transcript rendering, telemetry, and persistence derive from this stream. -**Model-visible ⟺ logged**: `step/start` messages plus the header's session prefix and folded `request/header` reconstruct every request; `dsh-agent-loop/invariant` asserts this through `ctx.invariants` ([decision](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). +**Model-visible ⟺ logged**: messages at `step/start` plus the folded `request/header` reconstruct every request; package-owned `dsh-agent-loop/invariant` can assert this through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). -Durability is a plugin concern; backends buffer synchronous `session/event` notifications. Checkpoints drain before adapter dispatch, recorded top-level tool calls before tool dispatch, complete response/result batches at `agent/post-step`, and final turn ends. `SessionPersistence` stores `SessionEvent` plus `SessionHeader` metadata; JSONL defaults to checksummed Zstandard, with SQLite under one contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). +Durability is a plugin concern. Backends eagerly drain synchronous `session/event` notifications. `session/flush` barriers precede adapter dispatch, top-level tool dispatch, and the next request's `agent/step`. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, while SQLite shares the contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). -`ctx.sessions.appendOutOfBand()` joins plugin-owned log-only events to an open turn or creates a balanced, flushed zero-step turn. `session/title` folds latest-wins with source seqs and provenance; its immediate fallback and sole optional async provider never delay the agent response. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). +`ctx.sessions.appendOutOfBand()` adds plugin-owned log-only events to an open turn or creates a balanced, flushed zero-step turn. `session/title` folds latest-wins with source seqs and provenance; its immediate fallback and sole optional async provider never delay response. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). ### Model Content -Messages use typed blocks from merge-extensible `ContentBlockMap`; the same pattern types `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`. New blocks coordinate adapters, UI, compaction, token metering, and persistence; replay measurements live in [token-meter.md](core-data-structures/token-meter.md). +Messages use typed blocks from merge-extensible `ContentBlockMap`; the pattern also types `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`. New blocks coordinate adapters, UI, compaction, token metering, and persistence; replay measurements live in [token-meter.md](core-data-structures/token-meter.md). -Streaming uses raw chunks and `BlockAssembler`. Each `LlmAdapter.stream()` is one provider attempt; adapters report facts and `agent/request-error` owns recovery. The loop logs chunks and successful provenance/replay state. Remote adapters use per-read idle watchdogs. Replay state crosses routes only when they share an adapter instance ([contract](core-data-structures/llm-streaming.md)). +Streaming uses raw chunks and `BlockAssembler`. Each `LlmAdapter.stream()` is one provider attempt; adapters report normalized failure facts, and a handling `agent/request-error` plugin returns a retry action. The loop logs chunks, successful provenance, and replay state. Remote adapters use per-read idle watchdogs. Replay crosses routes only through a shared adapter instance ([contract](core-data-structures/llm-streaming.md)). ## Extension And Composition ### Capability Pattern -A swappable capability usually splits into **interface / implementation / consumer**: service/events, a backend, and model-facing tools/prompts. Bash is the reference; the [capability graph](capability-seams.md) maps each family. +A swappable capability usually has **interface / implementation / consumer** layers: service/events, backend, and model-facing tools/prompts. Bash is the reference; the [capability graph](capability-seams.md) maps each family. -Exceptions combine layers: LLM interface/consumer; filesystem policy; web registries; named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). +Exceptions combine LLM interface/consumer, filesystem policy, web registries, and named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). -`dsh-workspace-context` composes baselines on `agent/session-prefix` and appends `ctx.fs`-discovered nested changes on `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. +`dsh-workspace-context` injects baseline at the first `agent/step` and appends `ctx.fs`-discovered changes through `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. ### Bundles And Apps -`dsh-agent-spine-demo` bundles a spine and optional goals. App packages own TUI, CLI, ACP automation, and JSON-RPC front doors ([README](../packages/examples/agent-spine-demo/README.md), [acp/](../packages/acp/README.md), [ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK supplies a default only without explicit config ([Python SDK](../python/README.md)). Thin deployments use swappable backends and optional tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +`dsh-agent-spine-demo` bundles a spine and optional goals. App packages own TUI, CLI, ACP automation, and JSON-RPC front doors ([README](../packages/examples/agent-spine-demo/README.md), [acp/](../packages/acp/README.md), [ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK defaults when config is absent ([Python SDK](../python/README.md)). Thin deployments use swappable backends and optional tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). ### Where New Behavior Goes @@ -176,28 +173,21 @@ New behavior attaches to a documented extension point; a loop change updates thi | Goal | Mechanism | |---|---| -| Add a model provider | register an adapter on `ctx.llm` | -| Add a model-facing capability | register on `ctx.tools`; schemas enter prompt assembly | -| Add shell execution | implement and register a `ctx.bash` backend (the local one spawns through `ctx.subprocess`) | -| Add persistent terminal execution | register a `ctx.pty` backend and `dsh-tool-pty` | -| Add a human command | register on `ctx.commands`; adapters discover and dispatch it without a model turn | +| Add a model provider | register its adapter on `ctx.llm` | +| Add a model-facing capability | register on `ctx.tools`; schemas join prompt assembly | +| Add shell execution | implement and register a `ctx.bash` backend; the local backend spawns through `ctx.subprocess` | +| Add persistent terminal execution | register a `ctx.pty` backend plus `dsh-tool-pty` | +| Add a human command | register on `ctx.commands`; adapters discover and dispatch without a model turn | | Add background work | register on `ctx.tasks`; generic `task_*` tools collect or stop it | -| Add filesystem access or policy | implement a `ctx.fs` provider or listen on `fs/*` policy events | -| Confine spawned processes | a `ctx.sandbox` backend; consumers wrap their argv before spawning | -| Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stop` is the serial terminal stop | -| Add a session-stable prefix outside history | compose `agent/session-prefix`; the request header logs it | -| Add UI or editor integration | drive `ctx.agents` and render from `session/event`; terminal-only overlays use `ctx.tui` | -| Add durable session state | add a `SessionEventMap` member and render/replay from the log | -| Add asynchronous session-title generation | register the sole provider on `ctx.sessionTitle` | +| Add filesystem access or policy | implement a `ctx.fs` provider or listen to `fs/*` policy events | +| Confine spawned processes | use a `ctx.sandbox` backend; consumers wrap argv before spawning | +| Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stopping` is the stop boundary | +| Add model-facing context | call `agent.inject()` to append a sourced `user/message` without a turn | +| Add UI or editor integration | drive `ctx.agents`, render from `session/event`; terminal-only overlays use `ctx.tui` | +| Add durable session state | extend `SessionEventMap`; render and replay from the log | +| Add asynchronous session-title generation | register the sole `ctx.sessionTitle` provider | | Manage a same-session objective | use `ctx.goals`; continue through `Agent` and `agent/*` | -| Fork a live session | use `ctx.sessions.fork(source, boundary?, childSessionId?)` | -| Scope a registration to one agent | use that agent's `agent.ctx` (see Agent Scope) | +| Fork a live session | call `ctx.sessions.fork(source, boundary?, childSessionId?)` | +| Scope a registration to one agent | use its `agent.ctx` (see Agent Scope) | -The [extension cookbook](cookbook/extension-cookbook.md) carries plugin skeletons and the feature-to-seam map; step-by-step guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md). - -## Quick Reference -- Domain terms in the [glossary](glossary.md) -- Type definitions in [core-data-structures/](core-data-structures/core.md) -- Exact signatures in the [event](cordis-catalog/events.md) and [service](cordis-catalog/services.md) catalogs -- package contracts in the [package map](../packages/README.md) -- [Agent Notes](../.agents/notes/README.md) +The [extension cookbook](cookbook/extension-cookbook.md) has plugin skeletons and the feature-to-seam map; guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index b392b51931..fa21c82a68 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -6,7 +6,7 @@ ## 概览 -每个 harness 都是一个 [Cordis](cordis-primer.md) 上下文,由各包(package)贡献服务、类型化事件和可释放的注册项。 +每个 harness 都是 [Cordis](cordis-primer.md) 上下文;各包(package)贡献服务、类型化事件和可释放的注册项。 `packages/core/` 汇集默认的 agent(智能体)流程;各项功能仍以插件形式存在。 @@ -14,11 +14,11 @@ | ctx 键 | 包 | 职责 | |---|---|---| -| — | [`dsh-scope`](../packages/core/scope/README.md) | 作用域上下文注册与共享层存储(库) | +| — | [`dsh-scope`](../packages/core/scope/README.md) | 作用域上下文注册项与共享层存储(库) | | `ctx.sessions` | `dsh-session` | 内存中的事件溯源会话 | -| `ctx.systemPrompt` | `dsh-system-prompt` | 有序提示词片段、工具 schema 和提示词变量 | +| `ctx.systemPrompt` | `dsh-system-prompt` | 有序提示词片段、工具 schema 和变量 | | `ctx.tools` | `dsh-tools` | 工具注册表和[执行流水线](tool-execution-pipeline.md) | -| `ctx.agents` | `dsh-agent` | 活跃 agent、委托创建、`agent/*` 事件和进程内发起方作用域 | +| `ctx.agents` | `dsh-agent` | 活跃 agent、委托创建、`agent/*` 事件、进程内发起方作用域 | | `ctx.agentLoop` | `dsh-agent-loop` | 实体 `Agent` 驱动器 | ### 功能服务 @@ -26,49 +26,47 @@ | ctx 键 | 包族 | 职责 | |---|---|---| | `ctx.llm` | [`llm/`](../packages/llm/README.md) | 适配器注册表和模型流式调用 | -| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | 感知回放的单实例请求压力和会话表面压力 | +| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | 感知回放的单实例请求压力与表面压力 | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | 前台和后台命令执行 | | `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | 供 bash 执行器、LSP host 与 ACP subagent 后端使用的受管子进程树 | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | 按 owner 隔离的持久化终端会话 | -| `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 同一执行环境内的进程限制(argv 包装、逐调用策略) | +| `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 通过 argv 包装和逐调用策略限制同一执行环境内的进程 | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | 共享沙箱策略归属点 | | `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | 执行模型编写的程序 | | `ctx.fs` | [`fs/`](../packages/fs/README.md) | 文件系统提供方原语和策略事件 | | `ctx.lsp` | [`lsp/`](../packages/lsp/README.md) | 语义导航注册表 | | `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill(技能)提供方注册表和渐进式披露 | | `ctx.web` | [`web/`](../packages/web/README.md) | 搜索与抓取提供方注册表 | -| `ctx.compact`,`ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | 摘要压缩(compaction);可选的无模型结果裁剪 | +| `ctx.compact`,`ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | 摘要压缩(compaction)和可选的无模型结果裁剪 | | `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | 具名委托提供方 | | `ctx.planMode` | [`plan/`](../packages/plan/README.md) | 落日志的 plan 协作状态 | -| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | 后台任务注册表和通用 `task_*` 控制工具 | +| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | 后台任务注册表和通用 `task_*` 控制 | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | 持久化的同会话目标 | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | 会话日志的持久化存储 | -| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 实时优先的精确检索/过滤/追踪接口、SQLite 全文搜索后端,以及经工作区授权的模型工具 | -| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | 基于日志的回退标题,以及单个可选的异步提供方 | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 实时优先的精确检索/过滤/追踪接口、SQLite 全文搜索后端、经工作区授权的模型工具 | +| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | 基于日志的回退标题和单个可选异步提供方 | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 | ## 事件 -事件构成服务的扩展 API;参见[事件目录](cordis-catalog/events.md)和[生产方与消费方映射](event-producer-consumer.md)。 +事件就是服务的扩展 API([目录](cordis-catalog/events.md)、[生产方与消费方映射](event-producer-consumer.md))。 ### 事件域 -- **会话事件**是追加到日志并通过 `session/event` 发出的持久事实。 +- **会话事件**是通过 `session/event` 发出的持久日志事实。 - **Agent 事件**携带活跃 `Agent`,用于状态、提示词准入、请求塑形、验证和续跑。 - **功能事件**让所属服务边界无需导入循环即可附加策略和适配器。 ### 拦截语义 -waterfall(瀑布式事件)的行为类似环绕中间件:监听器调用 `next()` 即表示委托,直接返回而不调用它则会否决或接管。完整规则见 [Cordis waterfall 语义](cordis-primer.md#cordis-waterfall-semantics)。 +waterfall(瀑布式事件)是环绕中间件:监听器通过 `next()` 委托;不调用它而直接返回会否决或接管([语义](cordis-primer.md#cordis-waterfall-semantics))。 ## 默认循环生命周期 -循环通过插件服务和事件运行。 +**会话**采用仅追加方式。普通**轮次**领取一项已排队的 `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`;拆卸过程保持静默。 +创建时若未提供 id,流程会生成 `-session-`;`sessionId` 用于恢复或创建会话,而 `resumeSessionId` 要求已有历史。恢复流程在发布前还原沿袭关系和委托深度。初始化失败会发出 `agent-loop/config-start-failed`;拆卸过程保持静默。 ### 轮次流程 @@ -79,96 +77,95 @@ choose declarative identity and fresh/resume path -> enable driving -> agent/session-start(source) -> start driver forever: wait for a queued message - emit agent/status(running) - TURN: - 'turn/start' - claimed message + contexts -> agent/prompt-submit - allowed prompt -> 'user/message' with prompt-prefix context baked in; append separate contexts - blocked prompt -> 'prompt/blocked' -> 'turn/end'(rejected) + claim message -> emit agent/status(running) if starting an interval + open the next-step acceptance window + -> agent/prompt-submit + blocked or failed prompt -> close the window without opening a turn + append a context-only caller batch immediately + keep steering and context staged beside it pending for a later admitted turn + allowed prompt: + 'turn/start' + append prompt + additional contexts as separate 'user/message' events STEP loop: - drain steering with the same prefix/separate context placement (no prompt-submit) + agent/step + drain injected context and steering (steering bypasses prompt-submit) assemble system prompt and tool schemas - agent/session-prefix (first step) - agent/pre-step snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request -> prepare reasoning/default under turn signal -> log request/header -> checkpoint -> llm/stream (frozen, registration-bound) - on final adapter-path or terminal in-band failure: - 'step/end' - agent/request-error(original error, failure facts, immutable prior failures, signal) - retry in the next numbered step or preserve the original error - otherwise: - 'assistant/chunk' - agent/step-result - 'assistant/message' (transformed content or empty success anchor after step-result rejection) - schedule tool calls by ctx.tools.executionMode: - exclusive -> one-call barrier - parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start - each start -> 'tool/call' -> ordered tools/pre-execute -> checkpoint -> concurrent tools/execute - each model-order result -> ordered tools/post-execute -> 'tool/result' - append accepted tool-batch context after all recorded results, then steering - agent/post-step -> checkpoint complete response/results - 'step/end' - agent/turn-continuation - agent/turn-stop (terminal policy) - stop unless tools or continuation policy ask for another step - 'turn/end' - checkpoint persistence and notify idle/running status + agent/request (config only) -> prepare reasoning/default under turn signal -> log request/header -> llm/stream (frozen, registration-bound) + 'assistant/chunk' + 'assistant/message' + schedule tool calls by ctx.tools.executionMode: + exclusive -> one-call barrier + parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start + each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute + each model-order result -> ordered tools/post-execute -> 'tool/result' + drain accepted tool context and steering + 'step/end' + continue for tools or steering unless a result concluded the turn + otherwise agent/turn-stopping -> drain -> continue only for steering + close the next-step acceptance window + 'turn/end' -> agent/settled + start the next waking queued message, or emit agent/status(idle) + +idle inject: + append 'user/message' + do not open a turn or run the model ``` -各步骤会组装有序提示词片段、工具 schema 和变量;未知引用会使轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `model` 和 `cwd`([归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 +每个步骤都会组装有序提示词片段、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `provider`、`model` 和 `cwd`([提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 -异步 `inject()` 和工具执行后的 `additionalContexts` 会在结果产生后稳定;steering(中途引导)会在 `agent/post-step` 前排空。余留内容进入队列。终止型 `agent/turn-stop` 在关闭和刷写期间始终具有最终决定权,会丢弃后续 steering,而不丢弃排队提示词。 +接纳期间和活跃轮次内的 `inject()` 会为下一步骤暂存;工具执行后的 `additionalContexts` 会在结果记录完毕后落定。steering 与其共用这一暂存边界,并请求再执行一个步骤。空闲状态下的 `inject()` 会立即追加,且不改变轮次编号;持久化层会尽快排空。 -裁剪先于摘要;溢出重试必须取得持久进展。有界重试在 `agent/request-error` 上组合;取消优先([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md))。 +裁剪先于摘要;溢出重试必须取得持久进展。恢复会在失败步骤关闭后、轮次关闭前使用 `agent/request-error`。其策略返回重试动作以安排一个重试轮次;取消优先([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md))。 ### 失败边界 -适配器故障会先关闭步骤,再进入 `agent/request-error`;该事件会收到准确的 `Error`、`LlmFailure` 和历史记录。重试会开启步骤;成功会清除历史记录;重试耗尽后,故障存入 `turn/end`。失败分片不会提交任何内容。 +适配器故障会先关闭步骤,再由 `agent/request-error` 接收准确的 `Error`、标准化的 `LlmFailure` 和轮次信号。负责处理的监听器返回 `{ kind: 'retry' }`;循环关闭失败轮次,并从持久历史开启另一个轮次,不发出空闲通知。重试耗尽后,失败的 `turn/end` 即为终态记录;失败分片不会提交消息或工具调用。 -其他故障使用 `agent/error`。取消和资源释放均优先于恢复;轮次信号还会在提交任何请求头之前取消异步模型能力准备,尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会发出原因、清空队列并中止;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 +其他故障使用 `agent/error`。取消和资源释放优先于恢复。在提交请求头之前,轮次信号会取消异步模型能力准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 在清空队列和中止前发出原因;观察方不能否决;空闲调用不发事件。持久化层将用户或父级取消记录为 `aborted`,拆卸记录为 `disposed`;拆卸会等待完全停稳。原因只影响报告方式,不影响延迟完成的结果上下文处理([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 -会话事件均位于轮次边界内;重新加载会用合成的 `interrupted` 轮次结束事件闭合中断的日志尾部。关闭后的故障使用 `agent/error`。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 +轮次和步骤事件均位于轮次边界内;空闲时注入的 `user/message` 可以位于两个轮次之间。重新加载会用合成的轮次结束事件闭合中断尾部。关闭后仅由 `agent/error` 报告故障。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 ### Agent 句柄 -`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件使用按意图命名的辅助方法 `followup()`、`queue()`、`steer()` 和 `inject()`;持有确切路由信息的调用方使用各字段均为必填项的 `send()`([决策](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md))。`cancel()` 和 `whenIdle()` 控制生命周期。调用方、提供方和句柄共同拥有拆卸过程。 +`ctx.agents` 拥有活跃 agent,并返回 `AgentHandle { agent, dispose() }`。插件使用全部 `send()` 选项,或 `followup()`、`steer()` 和 `inject()` 预设;`cancel()` 与 `whenIdle()` 控制生命周期。一个需等待完成的 disposer 协调拆卸归属。 ### Agent 作用域 -每个 agent 都拥有一个作用于全局工具、提示词和命令存储的作用域化 `agent.ctx`([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md));作用域监听器会过滤分派,各项贡献会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合;类型化解析器从 `Events` 和 `scopeTarget` 推导载体检查([门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,而轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。参见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。 +每个 agent 都拥有作用域化的 `agent.ctx`;共享存储会将其工具、提示词和命令条目叠加到全局条目之上,同时保留各领域视图([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md))。作用域监听器会过滤分派;贡献都会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合。类型化解析器从合并后的 `Events` 和 `scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。详情见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,但轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。 ## 状态 ### 会话日志 -会话日志是权威依据。`deriveMessages()` 投影出模型历史;原始 `assistant/chunk` 事件保留回放和 UI 保真。fork、恢复、transcript(文本记录)、遥测和持久化共用该事件流。 +会话日志是权威依据。`deriveMessages()` 投影出模型历史;原始 `assistant/chunk` 事件保证回放和 UI 保真。fork、恢复、transcript(文本记录)渲染、遥测和持久化均派生自该事件流。 -**模型可见 ⟺ 已记录**:`step/start` 消息、请求头中的会话前缀和折叠后的 `request/header` 共同重建每个请求;`dsh-agent-loop/invariant` 通过 `ctx.invariants` 断言这一点([决策](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 +**模型可见 ⟺ 已记录**:`step/start` 时的消息与折叠后的 `request/header` 可以重建每个请求;该包的 `dsh-agent-loop/invariant` 可通过 `ctx.invariants` 断言这一点([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 -持久性由插件负责;后端会缓冲同步的 `session/event` 通知。检查点会在适配器分发前排空,在工具分发前刷写已记录的顶层工具调用,在 `agent/post-step` 刷写完整的响应与结果批次,并刷写最终的轮次结束。`SessionPersistence` 存储 `SessionEvent` 和 `SessionHeader` 元数据;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 +持久性由插件负责。后端会尽快排空同步的 `session/event` 通知。`session/flush` 屏障位于适配器分发前、顶层工具分发前,以及下一次请求的 `agent/step`。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 -`ctx.sessions.appendOutOfBand()` 会把插件所属的纯日志事件加入开放轮次,或创建一个平衡且已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq 和来源信息;其即时回退标题和唯一可选异步提供方都不会延迟 agent 响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 +`ctx.sessions.appendOutOfBand()` 会把插件所属的纯日志事件加入开放轮次,或创建一个平衡且已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq 和来源信息;其即时回退标题和唯一可选异步提供方都不会延迟响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 ### 模型内容 -消息使用从可合并扩展的 `ContentBlockMap` 派生的类型化块;`MessageSource`、`FinishReason`、`TurnTrigger` 和 `TurnEndReason` 也采用同一模式定义类型。新增块会协调适配器、UI、压缩、token 计量和持久化;回放计量见 [token-meter.md](core-data-structures/token-meter.md)。 +消息使用从可合并扩展的 `ContentBlockMap` 派生的类型化块;同一模式也为 `MessageSource`、`FinishReason`、`TurnTrigger` 和 `TurnEndReason` 定义类型。新增块会协调适配器、UI、压缩、token 计量和持久化;回放计量见 [token-meter.md](core-data-structures/token-meter.md)。 -流式输出使用原始分片和 `BlockAssembler`。每次 `LlmAdapter.stream()` 调用代表一次提供方尝试;适配器报告事实,`agent/request-error` 负责恢复。循环会记录分片及成功结果的来源信息和回放状态。远程适配器使用逐次读取空闲看门狗。只有当路由共用同一个适配器实例时,回放状态才会跨路由传递([契约](core-data-structures/llm-streaming.md))。 +流式输出使用原始分片和 `BlockAssembler`。每次 `LlmAdapter.stream()` 调用代表一次提供方尝试;适配器报告标准化的故障事实,负责处理的 `agent/request-error` 插件会返回重试动作。循环会记录分片、成功结果的来源信息和回放状态。远程适配器使用逐次读取空闲看门狗。回放仅通过共用的适配器实例跨路由传递([契约](core-data-structures/llm-streaming.md))。 ## 扩展与组合 ### 功能模式 -可替换功能通常拆分为**接口/实现/消费方**:服务和事件、后端,以及面向模型的工具和提示词。Bash 是参考实现;[功能图](capability-seams.md)映射了每个包族。 +可替换功能通常具有**接口/实现/消费方**三层:服务和事件、后端、面向模型的工具和提示词。Bash 是参考实现;[功能图](capability-seams.md)映射了每个包族。 -例外情况会合并不同层次:LLM(大语言模型)合并接口和消费方,文件系统整合策略,web 使用注册表,skill 和 subagent 使用具名提供方。subagent 可以通过 spawn 创建全新实例、fork 一个已完成轮次的前缀,或使用 ACP(Agent Client Protocol)子 agent([subagent.md](core-data-structures/subagent.md))。 +例外情况包括 LLM(大语言模型)合并接口和消费方、文件系统整合策略、web 使用注册表、skill 和 subagent 使用具名提供方。subagent 可以通过 spawn 创建全新实例、fork 一个已完成轮次的前缀,或使用 ACP(Agent Client Protocol)子 agent([subagent.md](core-data-structures/subagent.md))。 -`dsh-workspace-context` 在 `agent/session-prefix` 上组合基线,并在通过 `ctx.fs` 发现嵌套变更后,于 `tools/post-execute` 追加这些变更;其[决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)记录了隔离方式。`dsh-paths` 负责共享路径。 +`dsh-workspace-context` 在第一次 `agent/step` 注入基线,并通过 `tools/post-execute` 追加 `ctx.fs` 发现的变更;其[决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)记录隔离方式。`dsh-paths` 负责共享路径。 ### 组合包与应用 -`dsh-agent-spine-demo` 组合一套主干和可选目标。应用包负责 TUI、CLI(命令行界面)、ACP 自动化入口和 JSON-RPC 入口([README](../packages/examples/agent-spine-demo/README.md)、[acp/](../packages/acp/README.md)、[ui/](../packages/ui/README.md))。`dsh-jsonrpc-agent` 启动外部 `cordis.yml`;Python SDK 仅在没有显式配置时提供默认项([Python SDK](../python/README.md))。轻量部署使用可替换后端和可选工具([examples/](../examples/AGENTS.md)、[可运行接线](cookbook/extension-cookbook.md#runnable-wirings)、[图谱](graph-atlas.md))。 +`dsh-agent-spine-demo` 组合一套主干和可选目标。应用包负责 TUI、CLI(命令行界面)、ACP 自动化入口和 JSON-RPC 入口([README](../packages/examples/agent-spine-demo/README.md)、[acp/](../packages/acp/README.md)、[ui/](../packages/ui/README.md))。`dsh-jsonrpc-agent` 启动外部 `cordis.yml`;Python SDK 在配置缺失时提供默认项([Python SDK](../python/README.md))。轻量部署使用可替换后端和可选工具([examples/](../examples/AGENTS.md)、[可运行接线](cookbook/extension-cookbook.md#runnable-wirings)、[图谱](graph-atlas.md))。 ### 新行为的归属位置 @@ -176,28 +173,21 @@ forever: | 目标 | 机制 | |---|---| -| 添加模型提供方 | 在 `ctx.llm` 上注册适配器 | -| 添加面向模型的功能 | 在 `ctx.tools` 上注册;schema 进入提示词组装流程 | -| 添加 shell 执行 | 实现并注册 `ctx.bash` 后端(本地后端通过 `ctx.subprocess` 生成进程) | +| 添加模型提供方 | 在 `ctx.llm` 上注册其适配器 | +| 添加面向模型的功能 | 在 `ctx.tools` 上注册;schema 加入提示词组装 | +| 添加 shell 执行 | 实现并注册 `ctx.bash` 后端;本地后端通过 `ctx.subprocess` 生成进程 | | 添加持久化终端执行 | 注册 `ctx.pty` 后端和 `dsh-tool-pty` | -| 添加用户命令 | 在 `ctx.commands` 上注册;适配器无需模型轮次即可发现并分派该命令 | +| 添加用户命令 | 在 `ctx.commands` 上注册;适配器无需模型轮次即可发现并分派 | | 添加后台工作 | 在 `ctx.tasks` 上注册;通用 `task_*` 工具负责收集或停止 | | 添加文件系统访问或策略 | 实现 `ctx.fs` 提供方,或监听 `fs/*` 策略事件 | -| 限制生成的进程 | 使用 `ctx.sandbox` 后端;消费方在生成进程前包装 argv | -| 拦截请求、工具或轮次 | 使用相应的 `agent/*` 或 `tools/*` 事件;`agent/turn-stop` 是串行终止判定点 | -| 添加历史记录之外的会话稳定前缀 | 组合 `agent/session-prefix`;请求头会记录该前缀 | -| 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染;仅终端可用的浮层使用 `ctx.tui` | -| 添加持久会话状态 | 添加一个 `SessionEventMap` 成员,并从日志渲染和回放 | -| 添加异步会话标题生成 | 在 `ctx.sessionTitle` 上注册唯一提供方 | +| 限制生成的进程 | 使用 `ctx.sandbox` 后端;消费方在生成前包装 argv | +| 拦截请求、工具或轮次 | 使用相应的 `agent/*` 或 `tools/*` 事件;`agent/turn-stopping` 是停止边界 | +| 添加模型可见上下文 | 调用 `agent.inject()`,追加带来源的 `user/message`,但不创建轮次 | +| 添加 UI 或编辑器集成 | 驱动 `ctx.agents`,从 `session/event` 渲染;仅终端浮层使用 `ctx.tui` | +| 添加持久会话状态 | 扩展 `SessionEventMap`;从日志渲染和回放 | +| 添加异步会话标题生成 | 注册唯一的 `ctx.sessionTitle` 提供方 | | 管理同会话目标 | 使用 `ctx.goals`;通过 `Agent` 和 `agent/*` 续跑 | -| fork 活跃会话 | 使用 `ctx.sessions.fork(source, boundary?, childSessionId?)` | -| 将注册项限定到单个 agent | 使用该 agent 的 `agent.ctx`(参见 Agent 作用域) | +| fork 活跃会话 | 调用 `ctx.sessions.fork(source, boundary?, childSessionId?)` | +| 将注册项限定到单个 agent | 使用其 `agent.ctx`(参见 Agent 作用域) | -[扩展实操手册(cookbook)](cookbook/extension-cookbook.md)提供插件骨架和功能到服务边界的映射;分步指南涵盖[包](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM 适配器](cookbook/adding-an-llm-adapter.md)和 [vendored 包](cookbook/adding-a-vendored-package.md)。 - -## 快速参考 -- [术语表](glossary.md)中的领域术语 -- [core-data-structures/](core-data-structures/core.md) 中的类型定义 -- [事件](cordis-catalog/events.md)和[服务](cordis-catalog/services.md)目录中的准确签名 -- [包索引](../packages/README.md)中的包契约 -- [Agent Note(agent 决策记录)](../.agents/notes/README.md) +[扩展实操手册(cookbook)](cookbook/extension-cookbook.md)提供插件骨架和功能到服务边界的映射;指南涵盖[包](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM 适配器](cookbook/adding-an-llm-adapter.md)和 [vendored 包](cookbook/adding-a-vendored-package.md)。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 873aa3ebab..815e21ee5c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -110,7 +110,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:360`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:147`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-spine-demo` @@ -331,7 +331,7 @@ Requires: `llm` · `tokenMeter` export interface BasicCompactConfig extends CompactPolicyConfig { /** Exact provider/model overrides; duplicate targets fail plugin load. */ modelPolicies?: ModelCompactPolicyConfig[] - /** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */ + /** Enable automatic step-boundary pressure and overflow-recovery listeners. Defaults to `true`. */ auto?: boolean } @@ -424,7 +424,7 @@ export interface Config { } ``` -Source: [`packages/goal/goal/src/index.ts:56`](../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:55`](../packages/goal/goal/src/index.ts) ## `@deepseek-ai/dsh-hooks-claude` @@ -460,7 +460,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-claude/src/index.ts:44`](../packages/hooks/hooks-claude/src/index.ts) +Source: [`packages/hooks/hooks-claude/src/index.ts:45`](../packages/hooks/hooks-claude/src/index.ts) ## `@deepseek-ai/dsh-hooks-codex` @@ -485,7 +485,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-codex/src/index.ts) +Source: [`packages/hooks/hooks-codex/src/index.ts:43`](../packages/hooks/hooks-codex/src/index.ts) ## `@deepseek-ai/dsh-host-apiproxy` @@ -920,7 +920,7 @@ export interface Config { } ``` -Source: [`packages/guard/repeat-tool-guard/src/index.ts:26`](../packages/guard/repeat-tool-guard/src/index.ts) +Source: [`packages/guard/repeat-tool-guard/src/index.ts:27`](../packages/guard/repeat-tool-guard/src/index.ts) ## `@deepseek-ai/dsh-sandbox-local` @@ -1148,7 +1148,7 @@ export interface Config { } ``` -Source: [`packages/session-title/session-title/src/index.ts:70`](../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:69`](../packages/session-title/session-title/src/index.ts) ## `@deepseek-ai/dsh-session-title-all-messages-llm` @@ -1530,7 +1530,7 @@ export interface Config { } ``` -Source: [`packages/goal/tool-goal/src/index.ts:27`](../packages/goal/tool-goal/src/index.ts) +Source: [`packages/goal/tool-goal/src/index.ts:25`](../packages/goal/tool-goal/src/index.ts) ## `@deepseek-ai/dsh-tool-lsp` @@ -1614,7 +1614,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-skill/src/index.ts:19`](../packages/skill/tool-skill/src/index.ts) +Source: [`packages/skill/tool-skill/src/index.ts:20`](../packages/skill/tool-skill/src/index.ts) ## `@deepseek-ai/dsh-tool-subagent` @@ -1756,7 +1756,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:566`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:578`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 4c0e480bee..423737be39 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.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 -adding-a-tool.md: c4deed8e13afcdc8e1a714364b086b8b0da018c1 -adding-a-tool.zh.md: 103c92b596a90192aae5a6b4d1e42fbcf052cf66 +adding-a-tool.md: d06e3d8e3c7da1f71a55bf9c4f56cd4b2cc03697 +adding-a-tool.zh.md: 53f608eba3b26b124f873990fa13ce1572c0baf2 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index c4deed8e13..d06e3d8e3c 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -46,7 +46,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w - **Throwing or returning an invalid value means `isError`.** The registry catches throws and contains schema, renderer, metadata-projector, and lossless-JSON failures before observers run. Throw for infrastructure failures. Represent a successful domain outcome in the canonical value even when its Native renderer explains a non-ideal state, such as a non-zero process exit. - **Honor `exec.signal`.** Cancel in-flight work when it fires. - **Project durable card data with `presentationMeta` (optional).** `output.presentationMeta(args, value)` derives replayable JSON from the same canonical value. The core persists it on `tool/result` and hands it to `presentResult`, so a card that needs result-time facts—such as `write`/`edit` applied hunks—survives replay without persisting the canonical value. The projector is skipped for nested Code dispatches because they have no cards. -- **Use `exec.agent` for async notifications.** `agent.inject(content, {source: {kind: 'plugin', plugin: ''}})` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Guard against disposed agents (try/catch). +- **Use `exec.agent` for async notifications.** `agent.inject({ content, source: { kind: 'plugin', plugin: '' } })` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Guard against disposed agents (try/catch). ## Long-running work diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 103c92b596..53f608eba3 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -46,7 +46,7 @@ export function apply(ctx: Context) { - **抛出异常或返回无效值意味着 `isError`。** 注册表会捕获异常,并在观察者运行前收敛 schema、渲染器、元数据投影器和无损 JSON 失败。基础设施故障请抛异常。成功的领域结果即使表示不理想的状态,也应写入规范值;其 Native 渲染器可以解释该状态,例如进程以非零状态退出。 - **遵守 `exec.signal`。** 信号触发时取消进行中的工作。 - **使用 `presentationMeta` 投影持久化的卡片数据(可选)。** `output.presentationMeta(args, value)` 从同一个规范值派生可回放的 JSON。核心将其持久化在 `tool/result` 上并传给 `presentResult`,因此需要结果期事实的卡片——例如 `write`/`edit` 的已应用 hunk——无需持久化规范值也能在回放中重现。嵌套 Code 分发没有卡片,因此会跳过该投影器。 -- **使用 `exec.agent` 发送异步通知。** `agent.inject(content, {source: {kind: 'plugin', plugin: ''}})` 追加持久化上下文,下一次模型请求会看到它——这不是唤醒(空闲的 agent(智能体)保持空闲)。请防范已 dispose 的 agent(try/catch)。 +- **使用 `exec.agent` 发送异步通知。** `agent.inject({ content, source: { kind: 'plugin', plugin: '' } })` 追加持久化上下文,下一次模型请求会看到它——这不是唤醒(空闲的 agent(智能体)保持空闲)。请防范已 dispose 的 agent(try/catch)。 ## 长时间运行的工作 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index a7e63abdeb..2a8096f502 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.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 -extension-cookbook.md: 0ab337377518f832c0649bc80cf2941751cb934f -extension-cookbook.zh.md: f7c2572d0b91867589636249f29896848f8aec82 +# pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md +extension-cookbook.md: 51a87be037ddbf6d031d3607da7b70470087334c +extension-cookbook.zh.md: 389ac87be0a1cd14a7646374909d79e6e00d8b56 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 0ab3373775..51a87be037 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -54,7 +54,10 @@ export function apply(ctx: Context) { render(event.data.chunk.text) } }) - onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup([{ type: 'text', text }])) + onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup({ + content: [{ type: 'text', text }], + source: { kind: 'user' }, + })) } ``` @@ -97,12 +100,12 @@ Every product feature maps to a listener on a documented extension seam — the | Product feature | Plugin mechanism | |---|---| -| Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` — each interception waterfall returns a typed Decision; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams | +| Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `tools/pre-execute`, `tools/post-execute`, and `agent/turn-stopping`; the waterfall seams return typed decisions, while `agent/turn-stopping` may steer another step; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams | | `/goal` | `ctx.goals` owns durable state, `dsh-goal-session` schedules same-session rounds through the public `Agent`, and separate command/tool producers expose human/model control | | `/loop` | on the `turn/end` session event, `followup()` the next iteration; or force-continue | -| Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and terminal `agent/turn-stop` | +| Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and the structured-output execution's monotonic `concludeTurn()` marker | | Queued + steering messages | core `Agent.followup()` / `Agent.steer()` | -| Context compaction (auto + manual) | the `ctx.compact` seam + `dsh-compact-basic`; automatic pressure runs on serial `agent/post-step`, canonical overflow recovery runs on `agent/request-error`, and manual callers use the same compact service ([compaction Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) | +| Context compaction (auto + manual) | the `ctx.compact` seam + `dsh-compact-basic`; automatic pressure runs on serial `agent/step`, canonical overflow recovery runs on `agent/request-error`, and manual callers use the same compact service ([compaction Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) | | System prompt configurability | `ctx.systemPrompt.section()` with ordering and scope-local shadowing | | AGENTS.md (root) | a section provider reading the file | | AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener | @@ -110,7 +113,7 @@ Every product feature maps to a listener on a documented extension seam — the | ToolSearch / progressive disclosure | replace a scoped `ctx.tools.restrict()` registration as the visible set changes; the registry keeps presentation, lookup, and execution aligned | | Tool deadline / retry / metrics | wrap core dispatch with `tools/execute`; a wrapper may replace `exec.signal`, delegate, and inspect the normalized result in one lexical lifetime | | Final tool-result metrics / audit / capture | observe immutable authoritative outcomes with `tools/result`; use `tools/post-execute` instead only when the plugin must transform the result or attach context | -| Monotonic terminal turn policy | return `{ action: 'stop' }` from serial `agent/turn-stop`, after continuation and steering have already been folded | +| Monotonic terminal turn policy | call `ToolExecution.concludeTurn()` from the successful terminal tool; later tool calls in the same response remain guardable, and the loop stops after the step | | Subprocess sandbox (landlock / sandbox-exec) | use a `ctx.sandbox` backend through `dsh-bash-sandbox`; use `tools/pre-execute` for capability-level denial | | Permission system / AskUserQuestion | return `ask` from `tools/pre-execute` and answer through `ctx.approval`; register a separate model-facing ask tool for ordinary user questions | | Plan mode | Shipped: [`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — logged `plan/mode` state, the `plan:policy` guidance section, `/plan [message]` entry, `/plan off` direct exit, and the user-reviewed `exit_plan_mode` exit; enforcement stays on the independent sandbox/approval axes | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index f7c2572d0b..389ac87be0 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -54,7 +54,10 @@ export function apply(ctx: Context) { render(event.data.chunk.text) } }) - onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup([{ type: 'text', text }])) + onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup({ + content: [{ type: 'text', text }], + source: { kind: 'user' }, + })) } ``` @@ -97,12 +100,12 @@ export function apply(ctx: Context) { | 产品功能 | 插件机制 | |---|---| -| 钩子系统(用户级 + 项目级) | `agent/session-start`、`agent/prompt-submit`、`agent/request`、`agent/step-result`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-continuation` 上的监听器——每个拦截 waterfall 返回一个类型化 Decision;`dsh-hooks-claude` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些 seam 上 | +| 钩子系统(用户级 + 项目级) | `agent/session-start`、`agent/prompt-submit`、`agent/request`、`tools/pre-execute`、`tools/post-execute` 和 `agent/turn-stopping` 上的监听器;waterfall seam 返回类型化决策,`agent/turn-stopping` 则可通过 steering 触发下一步;`dsh-hooks-claude` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些 seam 上 | | `/goal` | `ctx.goals` 管理持久状态,`dsh-goal-session` 通过公共 `Agent` 调度同会话回合,独立的命令/工具生产方分别提供人类/模型控制 | | `/loop` | 在 `turn/end` 会话事件上 `followup()` 下一次迭代;或强制继续 | -| 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和终端 `agent/turn-stop` 来强制输出 | +| 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和结构化输出执行的单调 `concludeTurn()` 标记来强制输出 | | 排队消息 + steering(中途引导) | 核心 `Agent.followup()` / `Agent.steer()` | -| 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + `dsh-compact-basic`;自动压力检查运行在串行 `agent/post-step`,规范化溢出恢复运行在 `agent/request-error`,手动调用方使用同一个压缩服务([压缩 Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) | +| 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + `dsh-compact-basic`;自动压力检查运行在串行 `agent/step`,规范化溢出恢复运行在 `agent/request-error`,手动调用方使用同一个压缩服务([压缩 Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) | | 系统提示词可配置性 | `ctx.systemPrompt.section()`,支持排序与作用域局部覆盖 | | AGENTS.md(根目录) | 一个读取该文件的 section provider | | AGENTS.md(子目录,按需触发)+ 文件变更通知 | 从 watcher / tool-result 监听器调用 `agent.inject()` | @@ -110,7 +113,7 @@ export function apply(ctx: Context) { | ToolSearch / 渐进式披露 | 当可见集变化时替换一个作用域化的 `ctx.tools.restrict()` 注册;注册表保持展示、查找和执行三者对齐 | | 工具截止时间 / 重试 / 指标 | 用 `tools/execute` 包裹核心分发;包装器可替换 `exec.signal`、委托执行,并在同一词法生命周期内检视规范化结果 | | 最终工具结果指标 / 审计 / 捕获 | 用 `tools/result` 观察不可变的权威结果;仅当插件需要变换结果或附加上下文时才使用 `tools/post-execute` | -| 单调终端轮次策略 | 从串行 `agent/turn-stop` 返回 `{ action: 'stop' }`,此时 continuation 和 steering 已折叠完毕 | +| 单调终端轮次策略 | 从成功的终端工具调用 `ToolExecution.concludeTurn()`;同一响应中后续工具调用仍可由守卫阻止,循环在该步骤后停止 | | 子进程沙箱(landlock / sandbox-exec) | 通过 `dsh-bash-sandbox` 使用 `ctx.sandbox` 后端;能力级别的拒绝使用 `tools/pre-execute` | | 权限系统 / AskUserQuestion | 从 `tools/pre-execute` 返回 `ask` 并通过 `ctx.approval` 应答;为普通用户提问注册一个独立的面向模型的 ask 工具 | | Plan mode | 已交付:[`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — 落日志的 `plan/mode` 状态、`plan:policy` 引导段、`/plan [message]` 入口、`/plan off` 直接退出,以及经用户评审的 `exit_plan_mode` 出口;强制约束留在独立的沙箱/审批轴上 | diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index aa53b02ff1..3853120d65 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -15,15 +15,15 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n ### `agent/cancel-requested` — emit -Effective broad cancellation was requested, before queued/steering work is cleared or the active turn is aborted. This observe-only notification cannot veto cancellation; listener failures are contained. +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/steering work + * 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 - resolved typed cancellation cause, including the default. + * @param cause - the explicit typed cancellation cause. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/steering work is clear 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:350`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:308`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,16 +54,16 @@ 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:285`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:247`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit -An agent left the registry; AgentLoop emits this after driver quiescence but before session detachment and scoped-registration unwind. Custom registry users own their driver-ordering contract. +An agent left the registry; AgentLoop emits this after driver quiescence and scoped-registration unwind, but before session detachment. Custom registry users own their driver-ordering contract. ```ts cordis-catalog /** * An agent left the registry; AgentLoop emits this after driver quiescence - * but before session detachment and scoped-registration unwind. Custom + * and scoped-registration unwind, but before session detachment. Custom * registry users own their driver-ordering contract. * @param agent - the exact agent removed from the registry. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. @@ -74,16 +74,16 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:294`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:256`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit -A step or turn errored. The loop reports a failure here (plus the logger) even when the error has no in-turn position for a session `error` event. +A step or turn errored. The machine reports a failure here (plus the logger) even when the error has no in-turn position for a durable record. ```ts cordis-catalog /** - * A step or turn errored. The loop reports a failure here (plus the logger) - * even when the error has no in-turn position for a session `error` event. + * A step or turn errored. The machine reports a failure here (plus the + * logger) even when the error has no in-turn position for a durable record. * @param agent - the agent whose turn errored. * @param turn - the turn in which the failure surfaced. * @param step - the step at which the failure surfaced. @@ -91,12 +91,12 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: Error): void +'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: unknown): void ``` Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:498`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:419`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -117,21 +117,19 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../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:286`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit -Pending inbox items were dropped without delivering them, so every enqueued id receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop` dropping pending steering (in-turn and on the post-turn late-steering drain); and disposal of any still-pending items (before `agent/status('disposed')`). Fires once per drop with every dropped item. +Pending inbox items were dropped without delivering them, so every enqueued id 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 * enqueued id receives exactly one terminal `agent/inbox/dequeue` OR - * `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after - * `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop` - * dropping pending steering (in-turn and on the post-turn late-steering - * drain); and disposal of any still-pending items (before - * `agent/status('disposed')`). Fires once per drop with every dropped item. + * `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. @@ -142,91 +140,40 @@ Pending inbox items were dropped without delivering them, so every enqueued id r Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:340`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit -A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `message` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection through `agent.inject()` or equivalent `send()` routing bypasses the FIFOs and does not emit this. +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. ```ts cordis-catalog /** - * A detached, frozen item entered the agent's inbox (queued or steering - * FIFO). Source defaults are already applied, so `message` holds the exact - * accepted values. This is the enqueue-time live signal; the durable record - * is the eventual `user/message`/`steering/message`. Injection through - * `agent.inject()` or equivalent `send()` routing bypasses the FIFOs - * and does not emit this. - * @param agent - the agent whose inbox received the item. - * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts). + * 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: AgentMessage): void +'agent/inbox/enqueue'(this: Scoped, agent: Agent, message: AgentMessage, placement: InboxPlacement): void ``` -Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [InboxPlacement](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:316`](../../packages/core/agent/src/types.ts) - -### `agent/post-step` — serial - -Awaited serial checkpoint after the response, real or synthetic tool results, injected context, and steering are durable but before `step/end`. A cancelled tool batch reaches this checkpoint with an aborted signal. - -```ts cordis-catalog -/** - * Awaited serial checkpoint after the response, real or synthetic tool - * results, injected context, and steering are durable but before `step/end`. - * A cancelled tool batch reaches this checkpoint with an aborted signal. - * @param agent - the agent whose step is settling. - * @param turn - the open turn number. - * @param step - the open step number. - * @param signal - the turn abort signal. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode serial - */ -'agent/post-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void -``` - -Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) - -Source: [`packages/core/agent/src/types.ts:448`](../../packages/core/agent/src/types.ts) - -### `agent/pre-step` — serial - -Awaited serial checkpoint before `step/start`; appends land outside the pending step and are included when the loop derives request history. `signal` cancels listener work. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - -```ts cordis-catalog -/** - * Awaited serial checkpoint before `step/start`; appends land outside the - * pending step and are included when the loop derives request history. - * `signal` cancels listener work. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @param agent - the agent opening the step. - * @param turn - the open turn number. - * @param step - the pending step number. - * @param signal - the turn abort signal. - * @mode serial - */ -'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void -``` - -Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) - -Source: [`packages/core/agent/src/types.ts:379`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:276`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall -Allow, rewrite, or block one claimed prompt before it becomes a user message. Call `next()` for the unchanged default. A listener wrapping a downstream `allow` must preserve its `content` and `additionalContexts` unless it intentionally replaces them. The signal controls only this turn; listeners may cooperate with it but must not retain it to control another turn. Steering messages do not dispatch this event; they join an open turn at a steering checkpoint. +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. ```ts cordis-catalog /** * Allow, rewrite, or block one claimed prompt before it becomes a user - * message. Call `next()` for the unchanged default. A listener wrapping a - * downstream `allow` must preserve its `content` and `additionalContexts` - * unless it intentionally replaces them. The signal controls only this turn; - * listeners may cooperate with it but must not retain it to control another - * turn. Steering messages do not dispatch this event; they join an open turn - * at a steering checkpoint. + * 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. * @param agent - the agent whose turn claimed the message. * @param content - the claimed message's blocks, as queued. * @param source - the message's resolved source. @@ -239,84 +186,57 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:395`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:336`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall -Replace the frozen call configuration. Model-visible content must use logged channels; this seam cannot mutate messages. Injection here joins the next request because the current step boundary is already fixed. +Replace the frozen call configuration. `await next()` yields the config the machine would use (agent options on the first request, the logged header afterwards); return a replacement to switch. Model-visible content must use logged channels; this seam cannot mutate messages. ```ts cordis-catalog /** - * Replace the frozen call configuration. Model-visible content must use - * logged channels; this seam cannot mutate messages. Injection here joins - * the next request because the current step boundary is already fixed. + * Replace the frozen call configuration. `await next()` yields the config + * the machine would use (agent options on the first request, the logged + * header afterwards); return a replacement to switch. Model-visible + * content must use logged channels; this seam cannot mutate messages. * @param agent - the agent making the model call. * @param turn - the open turn number. * @param step - the step whose request this is. - * @param config - the config the loop would use (frozen); return a replacement to switch. - * @param signal - the current turn's explicit abort signal; ambient - * initiator identity does not imply liveness or cancellation authority. + * @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/request'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, signal: AbortSignal, next: () => Promise): Promise +*/ +'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise): Promise ``` Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:409`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:362`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall -Recover a model-request failure after its failed step has closed. `retry` opens a new numbered step; `fail` preserves the original request error. Call `next()` to delegate to the next recovery listener or the default. +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. ```ts cordis-catalog /** - * Recover a model-request failure after its failed step has closed. `retry` - * opens a new numbered step; `fail` preserves the original request error. - * Call `next()` to delegate to the next recovery listener or the default. + * 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. * @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 request in this consecutive sequence. * @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[], signal: AbortSignal, next: () => Promise): Promise +'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, 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) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:463`](../../packages/core/agent/src/types.ts) - -### `agent/session-prefix` — waterfall - -Compose request-only messages placed before derived history. The frozen result is computed once per loop instance, logged on its anchoring request header, and reused so the provider prefix remains stable. Interrupted composition is discarded. Composition precedes the first `agent/pre-step` and request boundary, so listener appends join the current request. Changing context belongs in history; contributors should prepend to `await next()` to preserve registration order. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - -```ts cordis-catalog -/** - * Compose request-only messages placed before derived history. The frozen - * result is computed once per loop instance, logged on its anchoring request - * header, and reused so the provider prefix remains stable. Interrupted - * composition is discarded. Composition precedes the first `agent/pre-step` - * and request boundary, so listener appends join the current request. - * Changing context belongs in history; contributors should prepend to - * `await next()` to preserve registration order. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @param agent - the agent whose session prefix is being composed. - * @param prefix - the frozen seed; return an extended replacement. - * @param signal - the current turn's explicit abort signal. - * @mode waterfall - */ -'agent/session-prefix'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise -``` - -Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) - -Source: [`packages/core/agent/src/types.ts:424`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:377`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -338,16 +258,41 @@ 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:363`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:321`](../../packages/core/agent/src/types.ts) -### `agent/status` — emit +### `agent/settled` — emit -Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking delivery does not enter `running` synchronously; drive lifecycle from this event. +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 /** - * Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking - * delivery does not enter `running` synchronously; drive lifecycle from this event. + * 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:406`](../../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. + +```ts cordis-catalog +/** + * Agent status changed (`idle` ⇄ `running`). `send()` does not enter + * `running` synchronously; drive lifecycle from this event. * @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. @@ -358,74 +303,57 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking deliver 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:303`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:265`](../../packages/core/agent/src/types.ts) -### `agent/step-result` — waterfall +### `agent/step` — serial -Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …). +Awaited serial checkpoint before EVERY request of a turn is built (the first as well as each post-tools continuation). The single "between steps" extension point: inject context, steer, or edit the session log here — the request's history derives from the log right after this settles. ```ts cordis-catalog /** - * Waterfall: post-process the assembled assistant {@link Message} before - * tool dispatch (validation, content rewriting, …). - * @param agent - the agent that received the step's response. + * Awaited serial checkpoint before EVERY request of a turn is built (the + * first as well as each post-tools continuation). The single "between + * steps" extension point: inject context, steer, or edit the session log + * here — the request's history derives from the log right after this settles. + * @param agent - the agent about to send a request. * @param turn - the open turn number. - * @param step - the step that produced the message. - * @param message - the assistant message as assembled from the stream. - * @param signal - the current turn's explicit abort signal. + * @param step - the step number about to open. + * @param signal - the turn abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode waterfall + * @mode serial */ -'agent/step-result'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, signal: AbortSignal, next: () => Promise): Promise +'agent/step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void ``` -Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:436`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:349`](../../packages/core/agent/src/types.ts) -### `agent/turn-continuation` — waterfall +### `agent/turn-stopping` — serial -Override whether the turn continues. The default continues after tool calls or steering and stops otherwise; a continue reason becomes steering. +The turn is about to close: the model owes no response (no live tool calls, no fresh steering). Awaited before the boundary commits — a listener that objects steers (`agent.steer(...)`) and the machine re-reads its inbox: fresh steering runs another step, none closes the turn. Data decides, so listener order cannot change the outcome. The inverse control (stop a tool loop early) is data too: a tool result carrying `concludesTurn` ends the turn at its step. ```ts cordis-catalog /** - * Override whether the turn continues. The default continues after tool - * calls or steering and stops otherwise; a continue reason becomes steering. - * @param agent - the agent deciding whether to run another step. - * @param turn - the turn being continued or stopped. - * @param defaultDecision - what the loop would do absent an override. - * @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/turn-continuation'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, signal: AbortSignal, next: () => Promise): Promise -``` - -Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) - -Source: [`packages/core/agent/src/types.ts:474`](../../packages/core/agent/src/types.ts) - -### `agent/turn-stop` — serial - -Monotonic terminal-stop checkpoint after continuation and steering are folded; a stop remains authoritative through turn close and flush: steering queued in that window is discarded, while ordinary sends survive. - -```ts cordis-catalog -/** - * Monotonic terminal-stop checkpoint after continuation and steering are - * folded; a stop remains authoritative through turn close and flush: - * steering queued in that window is discarded, while ordinary sends survive. - * @param agent - the agent whose composed continuation outcome may be stopped. - * @param turn - the turn at its terminal-stop checkpoint. + * The turn is about to close: the model owes no response (no live tool + * calls, no fresh steering). Awaited before the boundary commits — a + * listener that objects steers (`agent.steer(...)`) and the machine + * re-reads its inbox: fresh steering runs another step, none closes the + * turn. Data decides, so listener order cannot change the outcome. The + * inverse control (stop a tool loop early) is data too: a tool result + * carrying `concludesTurn` ends the turn at its step. + * @param agent - the agent whose turn is at its stop boundary. + * @param turn - the turn about to close. * @param signal - the current turn's explicit abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode serial */ -'agent/turn-stop'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | ContinuationStop | undefined +'agent/turn-stopping'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | void ``` -Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:485`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:392`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` @@ -448,7 +376,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers Types: [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:353`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:140`](../../packages/core/agent-loop/src/index.ts) ## `approval/*` @@ -591,7 +519,7 @@ Goal mutation accepted by one live agent. The matching context event is already Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/goal/goal/src/types.ts:167`](../../packages/goal/goal/src/types.ts) +Source: [`packages/goal/goal/src/types.ts:169`](../../packages/goal/goal/src/types.ts) ## `llm/*` @@ -641,7 +569,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:79`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:70`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit @@ -662,7 +590,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:89`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:80`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -685,7 +613,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:101`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:92`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -706,7 +634,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:111`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:102`](../../packages/core/session/src/index.ts) ## `slash/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8b3326142b..4636eab20e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -27,7 +27,7 @@ create(id: SessionId, options: AgentOptions = {}, meta: Pick ``` @@ -1366,7 +1366,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:625`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:614`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` @@ -1400,7 +1400,7 @@ register(provider: SessionTitleProvider): () => Promise Types: [Session](../core-data-structures/session.md) · [SessionTitleProvider](../core-data-structures/session-title.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-title/session-title/src/index.ts:284`](../../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:283`](../../packages/session-title/session-title/src/index.ts) ## `ctx.skills` — `SkillService` @@ -1927,7 +1927,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:688`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:700`](../../packages/core/tools/src/index.ts) ## `ctx.tui` — `TuiExtensionService` (abstract seam) diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml index 3f192f0194..94b6bd4c5d 100644 --- a/docs/core-data-structures/compaction.i18n.yaml +++ b/docs/core-data-structures/compaction.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 -compaction.md: 71bbe7d9c17c6a3d35684a6c87d3072ab4f88df1 -compaction.zh.md: 35e9c9ef0050f01c5249d1502bb2782511acc819 +# pnpm run verify-translation-pairing --write docs/core-data-structures/compaction.md +compaction.md: 3ba5edd96c509e064ac7033b175b7ddd3c972452 +compaction.zh.md: d082b0d0545802500278e96ca41a273bce275f53 diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 71bbe7d9c1..3ba5edd96c 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -62,7 +62,7 @@ type CompactionTrigger = 'pressure' | 'context-overflow' `CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, returning `null` when no safe work exists, and `compactRegion(...)` for an explicit inclusive surface range. Every backend marks its replacement `user/message` with the package-exported `COMPACT_CHECKPOINT_SOURCE`; consumers call `isCompactCheckpointSource()` instead of coupling checkpoint recognition to one backend. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration. -Pressure compaction runs at serial `agent/post-step`, after successful assistant output, tool results, buffered context, and steering are durable but before `step/end`. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and authorizes a fresh numbered-step retry only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling. +Pressure compaction runs at serial `agent/step` before request derivation. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and returns a retry action only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling. The seam exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for those edge checks. Both validate current surface membership and reject missing seqs and orphan results; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) owns their cache semantics. diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md index 35e9c9ef00..d082b0d054 100644 --- a/docs/core-data-structures/compaction.zh.md +++ b/docs/core-data-structures/compaction.zh.md @@ -62,7 +62,7 @@ type CompactionTrigger = 'pressure' | 'context-overflow' `CompactService` 暴露 `compactIfNeeded(agent, trigger, signal)` 以执行自动 `pressure` 或 `context-overflow` 策略;没有可安全执行的工作时返回 `null`。它还针对显式、两端均包含的 surface 范围暴露 `compactRegion(...)`。每个后端都使用包导出的 `COMPACT_CHECKPOINT_SOURCE` 标记其替换用的 `user/message`;消费方调用 `isCompactCheckpointSource()`,而不是把检查点识别逻辑耦合到某一个后端。实现必须把传入的 signal 转发给摘要流程。该 seam 不拥有计价 API:单例 [`ctx.tokenMeter`](token-meter.md) 直接拥有估算与回放,而 `dsh-compact-basic` 拥有保留策略、事件排序、按路由执行的摘要调用及其配置。 -压力压缩在串行 `agent/post-step` 中运行:此时成功的 assistant 输出、工具结果、缓冲上下文和 steering(中途引导)已持久化,但 `step/end` 尚未发生。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才批准一个带新编号的步骤重试,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 +压力压缩在串行 `agent/step` 中运行,先于请求推导。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才返回重试动作,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 该 seam 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于这些边缘检查。两者都会验证当前 surface 成员关系,并拒绝缺失的 seq 与遗留结果;其缓存语义由[包契约](../../packages/compact/compact/README.md#tool-pairing-boundaries)规定。 diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 99db2eca3e..63d3cda07b 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: 1fb3288a6d01860220191f0ee1f914804dd2b33e -core.zh.md: 74ddc5f935c8138a7fdda601650f08702dae34d3 +core.md: 357712bd197ac2e0661e6bc61a638aa8a4738356 +core.zh.md: dcb7210d37377da99ac2cd68b1ce18fa6e90e0b8 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 1fb3288a6d..357712bd19 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -263,8 +263,7 @@ interface GenerateOptions { /** * Ordered conversation messages, exactly as the provider sees them (after * the `system` slot). A loop-built request assembles them as - * `EpochHeader.messagePrefix` + the derived history (dsh-agent-loop); a - * hand-built one-shot passes any list. + * the derived history (dsh-agent-loop); a hand-built one-shot passes any list. */ messages: Message[] /** System prompt text (adapters map to the provider's system slot). */ @@ -334,11 +333,11 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` ### The request envelope: `LlmCallConfig` and the logged header -The loop builds each request from logged state. `EpochHeader` records call config, rendered prompt, authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset), and session prefix through full `request/header` snapshots. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). +The loop builds each request from logged state. `EpochHeader` records call config, rendered prompt, and authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset) through full `request/header` snapshots. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). -`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. After the waterfall, the loop prepares the exact model capability under the turn signal, rejects unsupported explicit effort ids without clamping, materializes an adapter-configured default, and logs the effective value. The prepared call keeps one adapter registration through dispatch. `agent/session-prefix` composes request-only prefix messages once per loop instance, and the header records the exact result used. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. +`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. After the waterfall, the loop prepares the exact model capability under the turn signal, rejects unsupported explicit effort ids without clamping, materializes an adapter-configured default, and logs the effective value. The prepared call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. -On the wire, a loop-built request reads in this order: the `system` slot (the rendered prompt assembly) → `messagePrefix` (the frozen session prefix) → the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The prefix never enters the derived history; its durable record is the header events, and the dev invariant recomputes exactly this equation against every loop-built request. +On the wire, a loop-built request reads the `system` slot (the rendered prompt assembly) followed by the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request. FIXME(call-config-shape): revisit which remaining fields are genuinely epoch-level for cache purposes (`model` and the model-owned reasoning effort are explicit; the sampling scalars sit here out of caution). @@ -402,7 +401,7 @@ type SessionEvent = { }[T] ``` -The thirteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. +The twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. ## The agent handle @@ -412,59 +411,51 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types ```ts type-equiv /** - * Options for {@link Agent.followup}, {@link Agent.queue}, and {@link Agent.steer}. - * An omitted source attests direct human input as `{ kind: 'user' }` and may - * authorize policy consumers, so non-human producers must label their content. + * 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 { - source?: MessageSource + /** Queue the item joins. */ + target: SendTarget /** - * Model-facing contexts captured with this inbox item. A queued prompt exposes - * them through the default `agent/prompt-submit` allow decision, while steering - * records them directly at its next checkpoint. + * 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). */ - contexts?: HookContext[] - /** Opaque JSON state retained on the durable message but hidden from the model. */ - meta?: JsonValue + wakeup: boolean } ``` -```ts type-equiv -/** Options specific to durable synthetic context injection. */ -interface InjectOptions { - /** Defaults to `{ kind: 'plugin', plugin: '' }`; non-human producers should identify themselves. */ - source?: MessageSource - /** Opaque JSON state retained on the durable message but hidden from the model. */ - meta?: JsonValue -} -``` +The fixed-preset aliases own `target` and `wakeup`; their `UserMessageData` input carries both content and provenance. -The advanced acceptance form makes every default explicit and rules out attached contexts on injection: +`send` returns the accepted message's opaque `AgentMessageId`, stable across that message's `agent/inbox/*` events: ```ts type-equiv /** - * Fully specified input for {@link Agent.send}. Unlike the intent-named - * helpers, this form applies no defaults: callers provide content, source, - * contexts, metadata (including explicit `undefined`), target, and wakeup. - * The union excludes attached contexts from non-waking next-step injection. - */ -type ResolvedAgentInput = { - content: ContentBlock[] - source: MessageSource - meta: JsonValue | undefined -} & ( - | { target: 'next-turn'; wakeup: boolean; contexts: HookContext[] } - | { target: 'next-step'; wakeup: true; contexts: HookContext[] } - | { target: 'next-step'; wakeup: false; contexts: [] } -) -``` - -FIFO delivery methods return an opaque `AgentMessageId`, stable across that message's `agent/inbox/*` events. Injection returns an id but bypasses those events: - -```ts type-equiv -/** - * Opaque id assigned to one accepted agent input. FIFO inputs carry the same id - * on their `agent/inbox/*` events; injection bypasses those events. + * Opaque id assigned to one accepted {@link Agent.send} message; returned by + * `send` and carried on its `agent/inbox/*` events for correlation. */ type AgentMessageId = Branded<'AgentMessageId'> ``` @@ -473,26 +464,14 @@ The `agent/inbox/*` live events carry one accepted message; injection bypasses t ```ts type-equiv /** - * One accepted FIFO message, carried by the `agent/inbox/*` live events. `id` - * is the value returned by the accepting helper or {@link Agent.send}, - * stable across this message's enqueue, dequeue, and discard events. Source - * defaults, when applicable, are already applied, so these are the exact values - * the item was accepted with. - * `steering` is true for an item drained between steps; otherwise it is claimed - * at a turn boundary. `SendOptions.meta` is intentionally omitted: it is durable - * model-hidden state that lands on the eventual `user/message`/ - * `steering/message`, not live-event routing data. + * One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live + * events. `id` is the value `send` returned to the caller, stable across this + * message's enqueue, dequeue, and discard events. The agent snapshots and + * freezes the accepted content and source before enqueue observers receive it. */ -interface AgentMessage { - /** The id returned by the accepting helper or {@link Agent.send}. */ +interface AgentMessage extends UserMessageData { + /** The id `send` returned for this message. */ id: AgentMessageId - content: ContentBlock[] - source: MessageSource - contexts: HookContext[] - /** Whether the item joined the steering FIFO rather than the queued FIFO. */ - steering: boolean - /** Whether the item wakes the driver or requests another step. */ - wakeup: boolean } ``` @@ -515,10 +494,10 @@ type AgentCancelCause = | { readonly kind: 'parent' } ``` -The structural `Agent` interface exposes four intent helpers plus the fully resolved acceptance method. The concrete driver implements the matrix once, and each helper supplies its fixed routing and defaults. +`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. ```ts type-equiv -/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ +/** Public live-agent handle with aliases over the unified delivery primitive. */ interface Agent { /** The single identity shared with {@link session}. */ readonly id: SessionId @@ -528,90 +507,91 @@ 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 /** - * Queue an ordinary message as its own FIFO-ordered turn and wake the driver. - * Content, resolved source, and attached contexts are detached, validated, - * and frozen together; invalid input throws synchronously before notification - * or enqueue. - * @param content - the prompt content blocks. - * @param options - source, attached contexts, and durable model-hidden meta. + * 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 snapshots and freezes `input` before publishing or queueing it. + * @param input - model-facing content and its producer provenance. + * @param options - target queue and wakeup decision. * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - followup(content: ContentBlock[], options?: SendOptions): AgentMessageId - - /** - * Queue an ordinary message without waking an idle driver. The item retains - * FIFO order and is claimed only after another input wakes the driver. A lone - * queued item leaves `whenIdle()` resolved. - * @param content - the prompt content blocks. - * @param options - source, attached contexts, and durable model-hidden meta. - * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. - */ - queue(content: ContentBlock[], options?: SendOptions): AgentMessageId - - /** - * Submit steering into the running turn and request another step. An open turn - * records it at the next steering checkpoint before a request or continuation - * decision; policy may stop before another step. After turn close and its - * checkpoint, any remainder is queued for a later turn; terminal - * `agent/turn-stop`, cancellation, or disposal may discard it. Idle steering - * becomes a waking ordinary turn. - * @param content - the steering content blocks. - * @param options - source, attached contexts, and durable model-hidden meta. - * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. - */ - steer(content: ContentBlock[], options?: SendOptions): AgentMessageId - - /** - * Append detached model-facing context without running the model. An open-turn - * injection joins at the current log position unless the current tool batch is - * executing; then it waits FIFO until that batch settles and drains before - * turn close even when interrupted. Idle injection uses a one-shot turn and - * durability checkpoint. Disposal awaits idle checkpoints; flush failures - * report through `agent/error`. An omitted source defaults to - * `{ kind: 'plugin', plugin: '' }`. - * @param content - the injected context content blocks. - * @param options - source and durable model-hidden meta. - * @returns the accepted injection's {@link AgentMessageId}; injection emits no `agent/inbox/*` events. - */ - inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId - - /** - * Accept one fully specified input through the same snapshot and routing path - * as the four intent-named helpers. `next-turn` targets the ordinary FIFO; - * `next-step`/wakeup targets steering (falling back to an ordinary waking turn - * while idle); and `next-step` without wakeup injects durable context without - * running the model. Every field is mandatory and no source or routing default - * is applied. Invalid input throws synchronously before notification, enqueue, - * or append. - * @param input - the resolved content, attribution, context, metadata, and routing facts. - * @returns the accepted input's {@link AgentMessageId}, carried by FIFO lifecycle events when applicable. - */ - send(input: ResolvedAgentInput): AgentMessageId + send(input: UserMessageData, options: SendOptions): AgentMessageId /** * 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. Omitted cause - * means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm - * later work. The active turn snapshots and freezes the cause. + * `whenIdle()` resolves after cancellation reaches quiescence. 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 + cancel(cause: AgentCancelCause, options?: CancelOptions): void /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ 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. + * @param input - prompt content and its producer provenance. + * @returns the accepted message's {@link AgentMessageId}. + */ + followup(input: UserMessageData): AgentMessageId + + /** + * 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. + * @param input - steering content and its producer provenance. + * @returns the accepted message's {@link AgentMessageId}. + */ + steer(input: UserMessageData): AgentMessageId + + /** + * 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 + * steering remains pending with it. + * @param input - injected context and its producer provenance. + * @returns the accepted message's {@link AgentMessageId}. + */ + inject(input: UserMessageData): AgentMessageId } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `SessionId` is branded. `running` describes the driver-wide drain interval, which can span turn close, its durability checkpoint, and consecutive queued turns; it does not prove a turn is still open. `AgentOptions` is merge-extensible: core declares `provider?` and `model?` (dispatch requires both after `agent/request`). Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. +`AgentStatus` is `'idle' | 'running'`, and `SessionId` is branded. Disposal removes the agent from the registry and emits `agent/disposed`; it is not a terminal status value. `running` describes the driver-wide drain interval and may span consecutive queued turns; it does not prove a turn is still open. `acceptsNextStep` is the narrower routing predicate for callers that must choose between steering the current admission/turn and submitting a fresh admitted prompt. `AgentOptions` is merge-extensible: core declares `provider?` and `model?` (dispatch requires both after `agent/request`). Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. -The cause is a TypeScript-enforced same-process input. An active `TurnCancellation` holder copies its discriminant into the runtime-only `AbortSignal.reason` and is retired before `turn/end` publication; the frozen `AbortSignal.reason` remains readable after that retirement. `agentInterruptReasonOf(signal)` recognizes `user`, `parent`, and lifecycle-only `disposed` without consulting ambient initiator state. Durable `turn/end` retains the coarse `{ kind: 'aborted' }` outcome; request provenance would require a separate durable event rather than overloading the terminal result. +The cause is a TypeScript-enforced same-process input. An active `TurnCancellation` holder copies its discriminant into the runtime-only `AbortSignal.reason` and is retired before `turn/end` publication; the frozen `AbortSignal.reason` remains readable after that retirement. Only the loop reads the cause (`user`, `parent`, or lifecycle-only `disposed`) back off its own machine-private signal at settlement — there is no public reader, and a signal grants cooperating listeners no classification authority. Durable `turn/end` retains the coarse `{ kind: 'aborted' }` outcome; request provenance would require a separate durable event rather than overloading the terminal result. The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits. @@ -621,78 +601,37 @@ The process-local initiator carried by `ctx.agents` is the exact `Agent` above, ## Interception decisions -Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its `content` reaches the model verbatim as user-role input, while JSON `meta` persists plugin state without exposing it to the model. Absent or `separate` placement becomes an injected `user/message` (plugin/goal source); `prompt-prefix` placement is available to prompt and steering inbox attachments and bakes the context before the effective request in the same message. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance, metadata, and placement. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape. +Prompt and post-tool decisions use the same `UserMessageData` content/source shape as durable user-role input. Each `additionalContexts` entry becomes a separate `user/message`, preserving its provenance. Hook bridges map their native decision fields onto these typed results. Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -```ts type-equiv -/** Model-facing context injected by a listener or atomically attached to one inbox message. */ -interface HookContext { - content: ContentBlock[] - source: MessageSource - /** - * Model placement. Absent or `separate` records an independent injected - * `user/message`; `prompt-prefix` prepends this context and a stable - * request delimiter to the same user-role message as its attached prompt. - */ - placement?: 'separate' | 'prompt-prefix' - /** Opaque JSON state retained in the session event but hidden from the model. */ - meta?: JsonValue -} -``` - -`agent/prompt-submit` returns a `PromptDecision` (allow the turn's claimed queued message — optionally rewriting its `content` or attaching `additionalContexts` — or record `prompt/blocked` and end that zero-step turn as `rejected`): +`agent/prompt-submit` returns a `PromptDecision` before a turn opens. Allow may rewrite the claimed prompt or attach `additionalContexts`; block rejects admission without creating turn events: ```ts type-equiv /** - * Prompt interception result. `allow.content` replaces the prompt. Each - * `additionalContexts` entry follows its declared placement: separate context - * message by default, or a prefix inside the prompt's user-role message. - * `block` records a durable `prompt/blocked` and ends the claimed prompt's - * zero-step turn as rejected. An `allow` returned by a listener is - * authoritative: a listener wrapping `next()` preserves downstream `content` - * and `additionalContexts` unless it intentionally replaces them. + * 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. */ type PromptDecision = - | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } + | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: UserMessageData[] } | { kind: 'block'; reason: string } ``` -`agent/turn-continuation` returns a `ContinuationDecision` (the loop's default is `continue` when the step had tool calls or steering was injected, else `stop`; a `continue` `reason` is recorded as next-step steering in the same turn and therefore carries no context metadata — the typed `/goal` pattern): +`agent/request-error` runs after a failed model step closes and before its turn closes. Listeners can repair durable state or await policy work while the failed turn's signal is still live. A handling listener returns `{ kind: 'retry' }` without calling `next()`; the default `undefined` leaves the failure terminal. ```ts type-equiv -/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ -type ContinuationDecision = - | { action: 'stop' } - | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } } +/** Action returned by a listener that owns model-request recovery. */ +type RequestErrorAction = { kind: 'retry' } | undefined ``` -`agent/request-error` receives the exact original `RequestError` beside its immutable `LlmFailure`, an immutable list of failures that already authorized another request in the consecutive sequence, the turn signal, and `next()`. Recovery plugins route on `failure.code`, not the live error's message; each policy counts only its own codes, and a successful request clears the history: - ```ts type-equiv /** Model-request failure with an optional machine-routable provider code. */ type RequestError = Error & { code?: string } ``` -It returns a `RequestErrorDecision`; `retry` opens a new numbered step after the recovery listener's durable mutation, while `fail` retains the structured failure on `turn/end`: - -```ts type-equiv -/** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()`. */ -type RequestErrorDecision = { action: 'fail' } | { action: 'retry' } -``` - -`agent/post-step` is awaited after assistant output, real or synthetic tool results, buffered context, and steering are durable but before `step/end`. A cancelled tool batch reaches it with an aborted signal after draining; its signature is `(agent, turn, step, signal)`, and replayable facts remain in the session log rather than a transient payload. - -`agent/turn-stop` returns the stop-only `ContinuationStop` subset or `undefined`. The loop calls this serial checkpoint after folding the ordinary decision, its reason, and pending steering; a stop is terminal and discards pending steering. - -```ts type-equiv -/** - * The terminal subset of {@link ContinuationDecision}. A listener on - * `agent/turn-stop` returns this to make the already-composed continuation - * outcome terminal; `undefined` abstains. - */ -type ContinuationStop = Extract -``` +`agent/step` is the single serial boundary before request derivation. `agent/turn-stopping` runs when a turn has no tool or steering continuation, before one final steering drain. `agent/session-start` carries a `SessionStartSource` (why the session lifecycle began; a bridge keys its SessionStart matcher on it): @@ -701,8 +640,6 @@ type ContinuationStop = Extract type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' ``` -`agent/session-prefix` composes a `Message[]` once per loop instance. The deep-frozen result is recorded in the request header and prepended to every derived history, making it the home for session-stable openers. A resumed instance recomposes; mid-session changes use append-only context channels. The waterfall returns content directly because it contributes rather than decides. - ## `ToolDefinition` The one pipeline-authoring type that is core: what every registered tool *is* — a model-facing `ToolSchema` plus an `execute` function and optional final-content and UI callbacks. A tool author rarely constructs it by hand (the `defineTool` DSL builds it with typed args), but it is the contract the registry holds and the loop dispatches through. diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 74ddc5f935..dcb7210d37 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -269,8 +269,7 @@ interface GenerateOptions { /** * Ordered conversation messages, exactly as the provider sees them (after * the `system` slot). A loop-built request assembles them as - * `EpochHeader.messagePrefix` + the derived history (dsh-agent-loop); a - * hand-built one-shot passes any list. + * the derived history (dsh-agent-loop); a hand-built one-shot passes any list. */ messages: Message[] /** System prompt text (adapters map to the provider's system slot). */ @@ -340,11 +339,11 @@ interface ToolSchema { ### 请求信封:`LlmCallConfig` 与记录的 header -循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、渲染后的提示词、权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)以及会话前缀。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 +循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、渲染后的提示词以及权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 -`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall 结束后,循环会在轮次信号控制下完成确切模型的能力准备,拒绝显式指定但不受支持的推理强度 ID(不自动调整),填入适配器配置的默认值,并记录最终生效值。准备完成的调用直至分派完成始终持有同一项适配器注册。`agent/session-prefix` 为每个循环实例组合一次仅用于请求的 prefix 消息,header 记录实际使用的确切结果。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 +`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall 结束后,循环会在轮次信号控制下完成确切模型的能力准备,拒绝显式指定但不受支持的推理强度 ID(不自动调整),填入适配器配置的默认值,并记录最终生效值。准备完成的调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 -在协议格式上,循环构建的请求按此顺序读取:`system` 槽位(渲染后的提示词组装)→ `messagePrefix`(冻结的会话前缀)→ 派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。前缀从不进入派生历史;它的持久记录是 header 事件,开发不变式针对每个循环构建的请求精确重算此等式。 +在协议格式上,循环构建的请求先读取 `system` 槽位(渲染后的提示词组装),再读取派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。开发不变式针对每个循环构建的请求精确重算此等式。 FIXME(call-config-shape):重新审视其余哪些字段出于缓存目的确实属于 epoch 层级(`model` 和模型持有的推理强度已明确属于;采样标量目前出于谨慎保留在此)。 @@ -408,7 +407,7 @@ type SessionEvent = { }[T] ``` -十三种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`prompt/blocked`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及轮次封闭不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 +十二种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及轮次封闭不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 @@ -420,59 +419,51 @@ type SessionEvent = { ```ts type-equiv /** - * Options for {@link Agent.followup}, {@link Agent.queue}, and {@link Agent.steer}. - * An omitted source attests direct human input as `{ kind: 'user' }` and may - * authorize policy consumers, so non-human producers must label their content. + * 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 { - source?: MessageSource + /** Queue the item joins. */ + target: SendTarget /** - * Model-facing contexts captured with this inbox item. A queued prompt exposes - * them through the default `agent/prompt-submit` allow decision, while steering - * records them directly at its next checkpoint. + * 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). */ - contexts?: HookContext[] - /** Opaque JSON state retained on the durable message but hidden from the model. */ - meta?: JsonValue + wakeup: boolean } ``` -```ts type-equiv -/** Options specific to durable synthetic context injection. */ -interface InjectOptions { - /** Defaults to `{ kind: 'plugin', plugin: '' }`; non-human producers should identify themselves. */ - source?: MessageSource - /** Opaque JSON state retained on the durable message but hidden from the model. */ - meta?: JsonValue -} -``` +固定预设的别名方法自带 `target` 与 `wakeup`;其 `UserMessageData` 输入同时携带内容与 provenance。 -高级接收形式会显式给出所有默认值,并禁止为注入附加上下文: +`send` 返回被接收消息的不透明 `AgentMessageId`,该 id 在这条消息的各个 `agent/inbox/*` 事件中保持稳定: ```ts type-equiv /** - * Fully specified input for {@link Agent.send}. Unlike the intent-named - * helpers, this form applies no defaults: callers provide content, source, - * contexts, metadata (including explicit `undefined`), target, and wakeup. - * The union excludes attached contexts from non-waking next-step injection. - */ -type ResolvedAgentInput = { - content: ContentBlock[] - source: MessageSource - meta: JsonValue | undefined -} & ( - | { target: 'next-turn'; wakeup: boolean; contexts: HookContext[] } - | { target: 'next-step'; wakeup: true; contexts: HookContext[] } - | { target: 'next-step'; wakeup: false; contexts: [] } -) -``` - -FIFO 投递方法返回不透明的 `AgentMessageId`,该 id 在同一条消息的各个 `agent/inbox/*` 事件中保持稳定。注入也返回 id,但会绕过这些事件: - -```ts type-equiv -/** - * Opaque id assigned to one accepted agent input. FIFO inputs carry the same id - * on their `agent/inbox/*` events; injection bypasses those events. + * Opaque id assigned to one accepted {@link Agent.send} message; returned by + * `send` and carried on its `agent/inbox/*` events for correlation. */ type AgentMessageId = Branded<'AgentMessageId'> ``` @@ -481,26 +472,14 @@ type AgentMessageId = Branded<'AgentMessageId'> ```ts type-equiv /** - * One accepted FIFO message, carried by the `agent/inbox/*` live events. `id` - * is the value returned by the accepting helper or {@link Agent.send}, - * stable across this message's enqueue, dequeue, and discard events. Source - * defaults, when applicable, are already applied, so these are the exact values - * the item was accepted with. - * `steering` is true for an item drained between steps; otherwise it is claimed - * at a turn boundary. `SendOptions.meta` is intentionally omitted: it is durable - * model-hidden state that lands on the eventual `user/message`/ - * `steering/message`, not live-event routing data. + * One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live + * events. `id` is the value `send` returned to the caller, stable across this + * message's enqueue, dequeue, and discard events. The agent snapshots and + * freezes the accepted content and source before enqueue observers receive it. */ -interface AgentMessage { - /** The id returned by the accepting helper or {@link Agent.send}. */ +interface AgentMessage extends UserMessageData { + /** The id `send` returned for this message. */ id: AgentMessageId - content: ContentBlock[] - source: MessageSource - contexts: HookContext[] - /** Whether the item joined the steering FIFO rather than the queued FIFO. */ - steering: boolean - /** Whether the item wakes the driver or requests another step. */ - wakeup: boolean } ``` @@ -523,10 +502,10 @@ type AgentCancelCause = | { readonly kind: 'parent' } ``` -结构化 `Agent` 接口公开四个按意图命名的辅助方法,以及接受完全解析输入的方法。具体驱动器只需实现一次这套路由矩阵,每个辅助方法提供其固定路由与默认值。 +`Agent` 是覆盖公开活跃 agent 契约的接口。具体驱动器拥有 `followup`/`steer`/`inject` 别名方法,并将它们经由 `send` 的(`target` × `wakeup`)矩阵路由。 ```ts type-equiv -/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ +/** Public live-agent handle with aliases over the unified delivery primitive. */ interface Agent { /** The single identity shared with {@link session}. */ readonly id: SessionId @@ -536,90 +515,91 @@ 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 /** - * Queue an ordinary message as its own FIFO-ordered turn and wake the driver. - * Content, resolved source, and attached contexts are detached, validated, - * and frozen together; invalid input throws synchronously before notification - * or enqueue. - * @param content - the prompt content blocks. - * @param options - source, attached contexts, and durable model-hidden meta. + * 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 snapshots and freezes `input` before publishing or queueing it. + * @param input - model-facing content and its producer provenance. + * @param options - target queue and wakeup decision. * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - followup(content: ContentBlock[], options?: SendOptions): AgentMessageId - - /** - * Queue an ordinary message without waking an idle driver. The item retains - * FIFO order and is claimed only after another input wakes the driver. A lone - * queued item leaves `whenIdle()` resolved. - * @param content - the prompt content blocks. - * @param options - source, attached contexts, and durable model-hidden meta. - * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. - */ - queue(content: ContentBlock[], options?: SendOptions): AgentMessageId - - /** - * Submit steering into the running turn and request another step. An open turn - * records it at the next steering checkpoint before a request or continuation - * decision; policy may stop before another step. After turn close and its - * checkpoint, any remainder is queued for a later turn; terminal - * `agent/turn-stop`, cancellation, or disposal may discard it. Idle steering - * becomes a waking ordinary turn. - * @param content - the steering content blocks. - * @param options - source, attached contexts, and durable model-hidden meta. - * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. - */ - steer(content: ContentBlock[], options?: SendOptions): AgentMessageId - - /** - * Append detached model-facing context without running the model. An open-turn - * injection joins at the current log position unless the current tool batch is - * executing; then it waits FIFO until that batch settles and drains before - * turn close even when interrupted. Idle injection uses a one-shot turn and - * durability checkpoint. Disposal awaits idle checkpoints; flush failures - * report through `agent/error`. An omitted source defaults to - * `{ kind: 'plugin', plugin: '' }`. - * @param content - the injected context content blocks. - * @param options - source and durable model-hidden meta. - * @returns the accepted injection's {@link AgentMessageId}; injection emits no `agent/inbox/*` events. - */ - inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId - - /** - * Accept one fully specified input through the same snapshot and routing path - * as the four intent-named helpers. `next-turn` targets the ordinary FIFO; - * `next-step`/wakeup targets steering (falling back to an ordinary waking turn - * while idle); and `next-step` without wakeup injects durable context without - * running the model. Every field is mandatory and no source or routing default - * is applied. Invalid input throws synchronously before notification, enqueue, - * or append. - * @param input - the resolved content, attribution, context, metadata, and routing facts. - * @returns the accepted input's {@link AgentMessageId}, carried by FIFO lifecycle events when applicable. - */ - send(input: ResolvedAgentInput): AgentMessageId + send(input: UserMessageData, options: SendOptions): AgentMessageId /** * 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. Omitted cause - * means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm - * later work. The active turn snapshots and freezes the cause. + * `whenIdle()` resolves after cancellation reaches quiescence. 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 + cancel(cause: AgentCancelCause, options?: CancelOptions): void /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ 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. + * @param input - prompt content and its producer provenance. + * @returns the accepted message's {@link AgentMessageId}. + */ + followup(input: UserMessageData): AgentMessageId + + /** + * 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. + * @param input - steering content and its producer provenance. + * @returns the accepted message's {@link AgentMessageId}. + */ + steer(input: UserMessageData): AgentMessageId + + /** + * 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 + * steering remains pending with it. + * @param input - injected context and its producer provenance. + * @returns the accepted message's {@link AgentMessageId}. + */ + inject(input: UserMessageData): AgentMessageId } ``` -`AgentStatus` 为 `'idle' | 'running' | 'disposed'`,`SessionId` 是品牌类型。`running` 描述整个驱动器的排空区间,可能跨越轮次关闭、其持久化检查点以及连续的排队轮次;它不能证明某个轮次仍然打开。`AgentOptions` 可合并扩展:core 声明 `provider?` 与 `model?`(在 `agent/request` 后,分发要求两者都存在)。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 +`AgentStatus` 为 `'idle' | 'running'`,`SessionId` 是品牌类型。dispose(资源释放)会把 agent 从注册表移除并发出 `agent/disposed`;它不是一个终态 status 值。`running` 描述整个驱动器的排空区间,可能跨越连续的排队轮次;它不能证明某个轮次仍然打开。对于需要在把输入作为 steering 加入当前提示词准入/轮次,还是提交为一个新的待准入提示词之间做选择的调用方,`acceptsNextStep` 才是更窄且准确的路由判断条件。`AgentOptions` 可合并扩展:core 声明 `provider?` 与 `model?`(在 `agent/request` 后,分发要求两者都存在)。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 -cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancellation` 持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`,并在发布 `turn/end` 前退役;冻结后的 `AbortSignal.reason` 仍可读取。`agentInterruptReasonOf(signal)` 无需查询环境中的 initiator 状态,即可识别 `user`、`parent` 与仅用于生命周期的 `disposed`。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 +cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancellation` 持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`,并在发布 `turn/end` 前退役;冻结后的 `AbortSignal.reason` 仍可读取。只有 loop 会在结算时从自己机器私有的 signal 上读回 cause(`user`、`parent` 或仅用于生命周期的 `disposed`)——不存在公开的读取器,signal 也不授予协作监听器任何分类权限。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 [事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、检查点与 waterfall(瀑布式事件)契约。轮次和步骤边界是持久会话事件,而不是 agent emit。 @@ -629,78 +609,37 @@ cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancella ## 拦截决策 -每个 `agent/*` 拦截 waterfall 都返回一个小型、特定于 seam 的类型化联合——统一的 Decision 惯用形状([tools.md](tools.md) 中工具 seam 的 `PreToolDecision`/`PostToolDecision` 也采用相同形状)。CC/Codex 钩子桥接层把其 `permissionDecision`/`decision`/`continue`/`additionalContext` 字段映射到这些联合上;原生插件则直接返回它们。提示词决策与工具后决策共享一种面向模型的上下文形状 `HookContext`,它必须携带 `source`(缺少 source 会默认成 `{kind:'user'}`,从而把插件上下文错标为用户提示词)。其中的 `content` 作为 user-role 输入逐字到达模型,而 JSON `meta` 持久保存插件状态但不向模型暴露。未指定放置方式或指定为 `separate` 时,上下文会成为一条注入的 `user/message`(来源类别为插件或 goal);`prompt-prefix` 放置方式可用于提示词和 steering 收件箱附件,会在同一条消息中把上下文置于最终生效的请求之前。两种决策都携带 `additionalContexts[]`,使每一项保留各自的 provenance、元数据与放置方式。Continuation reason 则是 steering 消息,并有意使用更窄的 content/source 形状。 +提示词决策与工具后决策使用与持久 user-role 输入相同的 `UserMessageData` content/source 形状。每个 `additionalContexts` 条目都会成为一条独立的 `user/message`,保留各自的 provenance。钩子桥接层把其原生决策字段映射到这些类型化结果上。 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -```ts type-equiv -/** Model-facing context injected by a listener or atomically attached to one inbox message. */ -interface HookContext { - content: ContentBlock[] - source: MessageSource - /** - * Model placement. Absent or `separate` records an independent injected - * `user/message`; `prompt-prefix` prepends this context and a stable - * request delimiter to the same user-role message as its attached prompt. - */ - placement?: 'separate' | 'prompt-prefix' - /** Opaque JSON state retained in the session event but hidden from the model. */ - meta?: JsonValue -} -``` - -`agent/prompt-submit` 返回 `PromptDecision`(允许该轮次已领取的排队消息——可选地改写其 `content` 或附加 `additionalContexts`——或者记录 `prompt/blocked` 并以 `rejected` 结束这个零步骤轮次): +`agent/prompt-submit` 在轮次打开前返回 `PromptDecision`。allow 可以改写已领取的提示词或附加 `additionalContexts`;block 拒绝准入且不产生任何轮次事件: ```ts type-equiv /** - * Prompt interception result. `allow.content` replaces the prompt. Each - * `additionalContexts` entry follows its declared placement: separate context - * message by default, or a prefix inside the prompt's user-role message. - * `block` records a durable `prompt/blocked` and ends the claimed prompt's - * zero-step turn as rejected. An `allow` returned by a listener is - * authoritative: a listener wrapping `next()` preserves downstream `content` - * and `additionalContexts` unless it intentionally replaces them. + * 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. */ type PromptDecision = - | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } + | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: UserMessageData[] } | { kind: 'block'; reason: string } ``` -`agent/turn-continuation` 返回 `ContinuationDecision`(步骤有工具调用或注入了 steering 时,循环默认为 `continue`,否则为 `stop`;`continue` 的 `reason` 会记录为同一轮次中下一个步骤的 steering,因此不携带上下文元数据——即类型化 `/goal` 模式): +`agent/request-error` 在失败的模型步骤关闭之后、其轮次关闭之前运行。listener 可以在失败轮次的 signal 仍然存活时修复持久状态或 await 策略工作。处理该错误的 listener 返回 `{ kind: 'retry' }` 且不调用 `next()`;默认的 `undefined` 会让失败保持终态。 ```ts type-equiv -/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ -type ContinuationDecision = - | { action: 'stop' } - | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } } +/** Action returned by a listener that owns model-request recovery. */ +type RequestErrorAction = { kind: 'retry' } | undefined ``` -`agent/request-error` 接收确切的原始 `RequestError`、其不可变 `LlmFailure`、在连续序列中已批准另一次请求的不可变失败列表、轮次信号以及 `next()`。恢复插件按 `failure.code` 路由,而不是按活跃错误的消息路由;每项策略只统计自身的 code,一次成功请求会清空历史: - ```ts type-equiv /** Model-request failure with an optional machine-routable provider code. */ type RequestError = Error & { code?: string } ``` -它返回 `RequestErrorDecision`;`retry` 在恢复 listener 的持久变更之后打开一个带新编号的步骤,而 `fail` 在 `turn/end` 上保留结构化失败: - -```ts type-equiv -/** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()`. */ -type RequestErrorDecision = { action: 'fail' } | { action: 'retry' } -``` - -`agent/post-step` 会在 assistant 输出、真实或合成的工具结果、缓冲上下文与 steering 持久化之后、`step/end` 之前被 await。被取消的工具批次在排空后携带 aborted signal 到达这里;其签名为 `(agent, turn, step, signal)`,可回放事实保留在会话日志中,而不是瞬态 payload 中。 - -`agent/turn-stop` 返回仅停止的 `ContinuationStop` 子集或 `undefined`。循环在折叠普通决策、其 reason 和待处理 steering 之后调用此串行检查点;stop 是终态,会丢弃待处理的 steering。 - -```ts type-equiv -/** - * The terminal subset of {@link ContinuationDecision}. A listener on - * `agent/turn-stop` returns this to make the already-composed continuation - * outcome terminal; `undefined` abstains. - */ -type ContinuationStop = Extract -``` +`agent/step` 是请求推导前唯一的串行边界。`agent/turn-stopping` 在轮次没有工具或 steering(中途引导)后续时运行,先于最后一次 steering 排空。 `agent/session-start` 携带 `SessionStartSource`(会话生命周期为何开始;桥接层据此匹配其 SessionStart): @@ -709,8 +648,6 @@ type ContinuationStop = Extract type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' ``` -`agent/session-prefix` 在每个循环实例中组合一次 `Message[]`。深度冻结的结果被记录在请求 header 中,并前置于每次派生历史,使其成为会话稳定开场白的归属。恢复的实例会重新组合;会话中途的变更使用仅追加的上下文通道。该 waterfall 直接返回内容,因为它是贡献而非决策。 - ## `ToolDefinition` 唯一属于核心的流水线编写类型:每个已注册工具*是什么*——一个面向模型的 `ToolSchema` 加上一个 `execute` 函数,以及可选的最终内容回调与 UI 回调。工具作者很少手动构造它(`defineTool` DSL 会用类型化参数构建),但它是注册表持有、循环分发所经过的契约。 diff --git a/docs/core-data-structures/goal.i18n.yaml b/docs/core-data-structures/goal.i18n.yaml index 47dc0c1b1c..0cdd666254 100644 --- a/docs/core-data-structures/goal.i18n.yaml +++ b/docs/core-data-structures/goal.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 -goal.md: 2e8d296eeda6e5f69c0f92829e347b7f55f41fa9 -goal.zh.md: a9c946e7cd37cf948c7ac0f3e4d0ea35ac80d614 +goal.md: 704a93320cc38d1b9400edc2d9ad2342bc11dccd +goal.zh.md: b2e083843a70823bf6a6b43e046b1f38f4e11e22 diff --git a/docs/core-data-structures/goal.md b/docs/core-data-structures/goal.md index 2e8d296eed..704a93320c 100644 --- a/docs/core-data-structures/goal.md +++ b/docs/core-data-structures/goal.md @@ -107,6 +107,8 @@ interface GoalMessageSource { readonly revision: number /** Zero for state changes; positive for admitted continuation rounds. */ readonly round: number + /** Complete durable mutation carried only by round-zero state-change messages. */ + readonly change?: GoalChangeMeta } ``` diff --git a/docs/core-data-structures/goal.zh.md b/docs/core-data-structures/goal.zh.md index a9c946e7cd..b2e083843a 100644 --- a/docs/core-data-structures/goal.zh.md +++ b/docs/core-data-structures/goal.zh.md @@ -107,6 +107,8 @@ interface GoalMessageSource { readonly revision: number /** Zero for state changes; positive for admitted continuation rounds. */ readonly round: number + /** Complete durable mutation carried only by round-zero state-change messages. */ + readonly change?: GoalChangeMeta } ``` diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index 3d35da5691..8574840a6a 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/llm-streaming.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/llm-streaming.md -llm-streaming.md: 9151ba569af6b640144c1850d616b6f762b68a8b -llm-streaming.zh.md: ae4a6843b9bf4f09169b17271c7edea76f8f2051 +llm-streaming.md: a6aaaf3fed2ab25821efdf308c7297526ee7f3b5 +llm-streaming.zh.md: 521df7d1dad620cea322865fbefa8e4e2615fa78 diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 9151ba569a..a6aaaf3fed 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -59,13 +59,13 @@ Every adapter MUST obey these, and every consumer may rely on them: - **`usage` before `finish`, nothing after `finish`.** Defer both to the provider's end-of-stream marker so a trailing usage-only chunk can't violate the ordering. - **Tool-call `arguments` stay raw JSON strings end-to-end.** Partial fragments stream via `argumentsDelta`; a provider that hands back parsed objects re-stringifies at `block-end`. -- **Two sanctioned error paths, one fact shape.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted', failure}` (provider in-band errors, for adapters that can't throw mid-stream). `LlmError.failure` carries the same `LlmFailure`. The final adapter boundary preserves the exact thrown `Error` object and associates immutable facts with that call; the agent loop closes the failed step and offers the error, facts, and immutable prior-retried facts to `agent/request-error`. Absent recovery the structured failure becomes the turn error, and no normal assistant message or tool side effect is committed for that attempt. +- **Two sanctioned error paths, one fact shape.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted', failure}` (provider in-band errors, for adapters that can't throw mid-stream). `LlmError.failure` carries the same `LlmFailure`. The final adapter boundary preserves the exact thrown `Error` object and associates immutable facts with that call; the agent loop closes the failed step and offers the error and facts to `agent/request-error`. A handling listener returns `{ kind: 'retry' }` after its awaited repair; absent recovery the structured failure becomes the turn error, and no normal assistant message or tool side effect is committed for that attempt. - **One adapter call is one provider attempt.** Adapters disable library retries. Agent-level recovery opens another durable numbered step; direct `ctx.llm.stream()` callers remain single-attempt. - **Provider stalls are bounded at the transport.** Both shipping remote adapters expose positive finite `streamIdleTimeoutMs` with a five-minute default. The watchdog arms only while iterator `next()` is outstanding, uses one stable signal for the whole request, maps its own expiry to `TIMEOUT`, and keeps an earlier caller abort as `ABORTED`. - **Context overflow has one canonical code.** Both DeepSeek adapters classify explicit provider detail through `isContextWindowExceededError()` and surface `CONTEXT_WINDOW_EXCEEDED`, whether the failure arrives as a thrown HTTP `LlmError` or an in-band finish error. Consumers route on the code, never provider text. - **An empty completion is a retryable error, not a silent success.** Both adapters map a terminal `stop` finish that carried no content blocks to `finish {kind:'error'}` with the canonical `EMPTY_RESPONSE` code, and `dsh-llm-retry` retries it by default; see [empty model responses are retryable](../../.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md). - **Every provider HTTP request carries the app-attribution header.** Adapters send `attributionHeaders()` (below) - the `User-Agent` baseline - and prove it with a wire-level test (mock server asserting the received header, or the library's header hook for a library-backed adapter). -- **Replay state is adapter-owned.** A successful `finish` may carry lossless-JSON state needed to reconstruct a native provider response. The loop stores it with the assembled assistant message unless an `agent/step-result` listener rewrote the content. On a later request, `LlmService` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content and provenance without the private state. +- **Replay state is adapter-owned.** A successful `finish` may carry lossless-JSON state needed to reconstruct a native provider response. The loop stores it with the assembled assistant message. On a later request, `LlmService` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content and provenance without the private state. This contract is pinned down by two deliberately independent implementations: `dsh-llm-deepseek` (direct fetch, SSE framing via `eventsource-parser`) and `dsh-llm-pi-ai` (a generic multi-provider adapter through `@earendil-works/pi-ai`). The library-backed adapter exercises the finish-chunk error path, while transport-boundary tests prove each idle watchdog stops its actual request. diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index ae4a6843b9..521df7d1da 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -59,13 +59,13 @@ interface LlmFailure { - **`usage` 在 `finish` 之前,`finish` 之后不再有任何分片。** 将两者都推迟到提供方的流结束标记,这样尾部的 usage-only 分片就不会违反顺序。 - **工具调用的 `arguments` 全程保持原始 JSON 字符串。** 部分片段通过 `argumentsDelta` 流式传输;如果提供方返回的是已解析的对象,适配器在 `block-end` 时重新序列化为字符串。 -- **两条受支持的错误路径,一种事实形状。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。最终适配器边界保留被抛出的确切 `Error` 对象,并将不可变事实关联到该调用;agent loop(智能体循环)关闭失败的步骤,再把错误、事实与不可变的先前已重试事实提供给 `agent/request-error`。若未恢复,结构化失败会成为轮次错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 +- **两条受支持的错误路径,一种事实形状。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。最终适配器边界保留被抛出的确切 `Error` 对象,并将不可变事实关联到该调用;agent loop(智能体循环)关闭失败的步骤,再把错误与事实提供给 `agent/request-error`。处理该错误的 listener 在其 await 的修复完成后返回 `{ kind: 'retry' }`;若未恢复,结构化失败会成为轮次错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 - **一次适配器调用就是一次提供方尝试。** 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的步骤;直接调用 `ctx.llm.stream()` 的调用方仍然只尝试一次。 - **提供方停顿在传输层受到时限约束。** 两个已交付的远程适配器都暴露正数且有限的 `streamIdleTimeoutMs`,默认五分钟。watchdog 只在 iterator `next()` 尚未完成时启动,整个请求使用同一个稳定 signal,把自身到期映射为 `TIMEOUT`,并把更早发生的调用方中止保留为 `ABORTED`。 - **上下文溢出只有一个规范 code。** 两个 DeepSeek 适配器都通过 `isContextWindowExceededError()` 对提供方的显式细节分类并暴露 `CONTEXT_WINDOW_EXCEEDED`,无论失败以抛出的 HTTP `LlmError` 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。 - **空 completion 是可重试错误,而不是静默的成功结果。** 两个适配器都把没有携带任何内容块的终止性 `stop` 结束映射为携带规范 `EMPTY_RESPONSE` code 的 `finish {kind:'error'}`,`dsh-llm-retry` 默认会重试它;详见[空模型响应可重试](../../.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md)。 - **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线,并通过协议级测试加以证明(mock 服务器断言收到的 header,或对基于库的适配器使用库的 header 钩子)。 -- **回放状态归适配器所有。** 成功的 `finish` 可以携带重建提供方原生响应所需的无损 JSON 状态。除非 `agent/step-result` listener 改写了内容,否则循环会将其与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,`LlmService` 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方无关的内容与 provenance,不会收到私有状态。 +- **回放状态归适配器所有。** 成功的 `finish` 可以携带重建提供方原生响应所需的无损 JSON 状态。循环会将其与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,`LlmService` 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方无关的内容与 provenance,不会收到私有状态。 该契约由两个有意保持独立的实现锁定:`dsh-llm-deepseek`(直接 fetch,SSE(Server-Sent Events)分帧经由 `eventsource-parser`)和 `dsh-llm-pi-ai`(通过 `@earendil-works/pi-ai` 实现的通用多提供方适配器)。基于库的适配器覆盖 finish 分片错误路径,而传输边界测试证明每个空闲 watchdog 都会停止其实际请求。 diff --git a/docs/core-data-structures/session-reference.i18n.yaml b/docs/core-data-structures/session-reference.i18n.yaml index 2fd211e4e2..27d4d6fc3d 100644 --- a/docs/core-data-structures/session-reference.i18n.yaml +++ b/docs/core-data-structures/session-reference.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 -session-reference.md: 4898cdd641427a023dde63bfc9759300964c7fac -session-reference.zh.md: 8e36d70241f2565a587bd3c1ee270d99dba47d71 +session-reference.md: a19df1702429be23ff6ef4f7062a68b8286c3644 +session-reference.zh.md: 4a8b7c2b9dcb02f130d551d4951a771328a842b9 diff --git a/docs/core-data-structures/session-reference.md b/docs/core-data-structures/session-reference.md index 4898cdd641..a19df17024 100644 --- a/docs/core-data-structures/session-reference.md +++ b/docs/core-data-structures/session-reference.md @@ -38,15 +38,15 @@ interface SessionReferenceCandidate { ## Prepared messages -Preparation preserves readable current-message content and returns at most one aggregated context. The host binds `contexts` to that exact `followup()` or `steer()` call. +Preparation preserves readable current-message content and returns at most one aggregated context. ```ts type-equiv -/** Message payload and the zero-or-one durable snapshot contexts bound to it. */ +/** Direct message content and optional referenced-session context. */ interface PreparedReferencedMessage { /** Readable message content after host mention tokens are removed. */ content: ContentBlock[] - /** Empty without references; otherwise one aggregated untrusted context. */ - contexts: HookContext[] + /** Aggregated untrusted snapshot, absent when the message has no references. */ + additionalContext?: UserMessageData } ``` diff --git a/docs/core-data-structures/session-reference.zh.md b/docs/core-data-structures/session-reference.zh.md index 8e36d70241..4a8b7c2b9d 100644 --- a/docs/core-data-structures/session-reference.zh.md +++ b/docs/core-data-structures/session-reference.zh.md @@ -38,15 +38,15 @@ interface SessionReferenceCandidate { ## 预备消息 -预备过程保留可读的当前消息内容,并最多返回一个聚合上下文。宿主会把 `contexts` 绑定到该次确切的 `followup()` 或 `steer()` 调用。 +预备过程保留可读的当前消息内容,并最多返回一个聚合上下文。 ```ts type-equiv -/** Message payload and the zero-or-one durable snapshot contexts bound to it. */ +/** Direct message content and optional referenced-session context. */ interface PreparedReferencedMessage { /** Readable message content after host mention tokens are removed. */ content: ContentBlock[] - /** Empty without references; otherwise one aggregated untrusted context. */ - contexts: HookContext[] + /** Aggregated untrusted snapshot, absent when the message has no references. */ + additionalContext?: UserMessageData } ``` diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index df053d0a24..539beff405 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.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/session.md -session.md: 52170c584d6a0734c499e52183f91d1b07c02862 -session.zh.md: 77f7ae1a48b14fc1ac3fb693c30701d167b2612e +session.md: 058236cb628f0e517fe18b0e4276dba46bd6b0b0 +session.zh.md: 2d8022c7892828a30094728a1449d16dddd2fd89 diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 52170c584d..058236cb62 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -12,28 +12,17 @@ The append-only event types. Merge-extensible: a plugin declares extra event typ ```ts type-equiv /** - * Shared payload for user, injected-context, and steering prompt messages. A + * Shared payload for user, injected-context, and steering messages. A * direct human prompt, a synthetic `agent.inject()` context, and mid-turn * steering all project into the model transcript as verbatim user-role content; * they are told apart by `source` (a non-`user` kind marks injected context), - * not by event type. `meta` carries durable model-hidden producer state. + * not by event type. */ -interface PromptMessageData { - /** Exact model-facing blocks, including any baked prompt-prefix contexts. */ +interface UserMessageData { + /** Exact model-facing blocks. */ content: ContentBlock[] - /** Producer provenance for the direct prompt. */ + /** Producer provenance. */ source: MessageSource - /** Present only when prompt-prefix contexts were baked into `content`. */ - envelope?: PromptMessageEnvelope - /** - * Opaque durable JSON state retained on the event but hidden from the model - * projection. It is the intended channel for a future framing directive (a - * producer declares the frame, a dedicated renderer applies it — see the - * deferred note in - * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), - * so the surface keeps projecting `content` verbatim rather than wrapping it. - */ - meta?: JsonValue } ``` @@ -46,10 +35,7 @@ interface PromptMessageData { */ interface SessionEventMap { /** - * Opens turn `turn`. `trigger` records what started it — one claimed queued - * message or an idle-time injection. The turn is the durability/replay - * boundary: every event sits between a `turn/start` and its matching - * `turn/end` (the turn-enclosure invariant). + * Opens turn `turn`. `trigger` records what started the model loop. */ 'turn/start': { turn: number; trigger: TurnTrigger } /** @@ -68,16 +54,10 @@ interface SessionEventMap { * (the queued message claimed for this turn), a synthetic `agent.inject()` * context (file-change notices, subdir AGENTS.md, skill content, cron * notifications, …), or an admitted goal continuation round. All three - * project their `content` verbatim; `source` (with a non-`user` kind marking - * injected context) is the only channel that tells them apart. An idle - * injection wraps this event in a one-shot turn so the log stays turn-enclosed. + * project their `content` verbatim; `source` tells them apart. An idle + * injection may append this event between turns without running the model. */ - 'user/message': PromptMessageData - /** - * Durable record of a prompt veto and its reason. It is log-only: the blocked - * prompt never enters the model-visible surface, and its turn runs zero steps. - */ - 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } + 'user/message': UserMessageData /** Raw stream chunk — token-level replay fidelity. */ 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } /** @@ -114,7 +94,7 @@ interface SessionEventMap { meta?: JsonValue } /** Steering content injected between steps of a running turn. */ - 'steering/message': PromptMessageData & { turn: number } + 'steering/message': UserMessageData & { turn: number } /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ 'todo/write': { todos: TodoItem[] } /** @@ -125,7 +105,7 @@ interface SessionEventMap { } ``` -`PromptMessageData.content` is always the exact model-facing content. When attached context declares `prompt-prefix` placement, AgentLoop concatenates its blocks, a `## My request:` delimiter, and the effective direct prompt into that array. The optional model-hidden `envelope` retains `displayContent` plus ordered prefix-context source/metadata descriptors, so transcript, title, and re-reference consumers can present the human prompt without changing reconstructable history. `displayPromptContent()` performs that selection and falls back to `content` for ordinary and older events. +`UserMessageData` is the durable `content` and `source` base shared by ordinary prompts, injected context, and steering. Live inbox events extend the same shape with an `AgentMessageId`; the loop adds only driver-owned routing state while an item remains pending. ### `OutOfBandSessionEventMap` — narrow late-append opt-in @@ -166,13 +146,13 @@ interface TodoItem { ### The request header event: `request/header` -The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas + the session prefix) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a later changed request records another full snapshot with reason `'change'`. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message. +The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a later changed request records another full snapshot with reason `'change'`. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message. ```ts type-equiv /** - * Logged request state outside derived history: call config, system prompt, - * tools, and prefix. The latest full `request/header` snapshot reconstructs it; - * canonical empty optional fields are absent. + * Logged request state outside derived history: call config, system prompt, and + * tools. The latest full `request/header` snapshot reconstructs it; canonical + * empty optional fields are absent. */ interface EpochHeader { /** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */ @@ -181,18 +161,10 @@ interface EpochHeader { system?: string /** Assembled tool schemas; absent for a tool-less request. */ tools?: ToolSchema[] - /** - * The session prefix: request-only messages sent BEFORE the entire derived - * history (the `agent/session-prefix` waterfall's product, composed once - * per loop instance and reused for every request it sends). Not session - * history — `deriveMessages()` never returns it — so the header is its - * only durable record; absent when the instance composed none. - */ - messagePrefix?: Message[] } ``` -Canonical form: an empty system prompt, an empty tool list, and an empty session prefix are absent fields, matching how requests are built. `messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product (the request is `messagePrefix + derived history`); it is composed once per loop instance and included in every full snapshot that instance records. Legacy v0 logs containing the removed `request/header-delta` event or its full-snapshot `fallback` reason are rejected at seed, append, and persistence-load boundaries rather than replayed incompletely. +Canonical form represents an empty system prompt or tool list as an absent field, matching how requests are built. Legacy v0 logs containing the removed `request/header-delta` event or its full-snapshot `fallback` reason are rejected at seed, append, and persistence-load boundaries rather than replayed incompletely. ## `SessionEvent` — one log entry @@ -484,7 +456,7 @@ declare class Session { - `user/message` → a user message carrying exact `content`; an optional envelope remains log-only display metadata. - `assistant/message` → an assistant message with the event's provider/model provenance and optional adapter-private replay state. Raw `assistant/chunk` events are replay/UI data and are **skipped** in derivation (the assembled message is authoritative). An **empty-content** `assistant/message` is also skipped — a max-tokens step cut off with no content still records an `assistant/message` to host its usage/provenance, but a content-less assistant turn must not enter the provider transcript. - `tool/result` → a user message carrying a `tool-result` block. -- `user/message` (injected context, i.e. non-`user` source) → a user-role message carrying its `content` verbatim at its chronological position. Optional JSON `meta` remains in the event log and is never rendered. +- `user/message` (injected context, i.e. non-`user` source) → a user-role message carrying its `content` verbatim at its chronological position; provenance and domain data live in its typed source. - `steering/message` → a user-role message carrying exact `content` at its chronological position; an optional envelope remains log-only display metadata. Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and does not project into a message. Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message, so their usage chunk is the durable accounting record. An operational error's step number is on `turn/end.reason` for `kind: 'error'`, with normalized `LlmFailure` facts for a final model-request failure and message/code for other live errors. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data. @@ -506,14 +478,12 @@ An explicit `boundary` lets callers fork from a previous completed turn even if */ interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } + /** Recovery turn reopened over the repaired current session log. */ + retry: { kind: 'retry' } /** - * An out-of-band context injection (`agent.inject()`) made while the agent - * was idle. The loop wraps the injected `user/message` (a non-`user` source, - * plugin by default) in a one-shot turn (`turn/start` → `user/message` → - * `turn/end`) so every event in the log stays turn-enclosed — the - * durability/replay boundary is the turn, and a bare event between turns would - * otherwise be indistinguishable from a crash tail on reload. The trigger's - * `source` mirrors that message's producer. + * 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 } } @@ -536,7 +506,8 @@ interface TurnEndReasonMap { * 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 turn failures retain their live Error message/code. + * as one `failure`; other thrown values retain their rendered message and a + * real `HarnessError` code when present. */ error: { kind: 'error'; step: number } & ( | { failure: LlmFailure; message?: never; code?: never } @@ -545,11 +516,6 @@ interface TurnEndReasonMap { disposed: { kind: 'disposed' } /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } - /** - * Policy blocked the turn's claimed prompt before the first step. The - * zero-step turn still records a balanced durable boundary and veto reason. - */ - rejected: { kind: 'rejected'; reason: string } /** * A persistence backend closed a crash-orphaned turn on reload. The loop never * emits this marker, and the events recorded before the crash remain intact. @@ -558,7 +524,7 @@ interface TurnEndReasonMap { } ``` -`max-tokens` mirrors the model-call `FinishReason` of the same name: any `max-tokens` step in a turn makes the whole turn end `max-tokens` rather than `completed` (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one — but only over `completed`: the `disposed`/`aborted`/`error` outcomes take precedence. `rejected` is a zero-step turn whose claimed prompt an `agent/prompt-submit` hook blocked (the ACP bridge maps it to `cancelled`). `interrupted` is the one reason no loop emits — it is synthesized by crash recovery (see [persistence.md](persistence.md)). Both maps are merge-extensible. +`max-tokens` mirrors the model-call `FinishReason` of the same name: any `max-tokens` step in a turn makes the whole turn end `max-tokens` rather than `completed` (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one — but only over `completed`: the `disposed`/`aborted`/`error` outcomes take precedence. `interrupted` is the one reason no loop emits — it is synthesized by crash recovery (see [persistence.md](persistence.md)). Both maps are merge-extensible. ## The turn-enclosure invariant @@ -568,7 +534,7 @@ Every session event lives **inside** a turn (between a `turn/start` and its `tur A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history), but, like every event, they must sit inside an open turn. The full per-event enumeration — core and plugin-contributed alike, with payloads and provenance — is the generated [persistence log event catalog](../persistence-catalog.md); the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md). -The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record — its injected `user/message` is the durable evidence — because it has no open turn to enclose one (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)). +The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` and the pre-turn `UserPromptSubmit` admission seam get no `hook/*` record because neither has an open turn to enclose one; allowed context is instead evidenced by its sourced `user/message` (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)). ## Durability contract diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 77f7ae1a48..2d8022c789 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -12,28 +12,17 @@ ```ts type-equiv /** - * Shared payload for user, injected-context, and steering prompt messages. A + * Shared payload for user, injected-context, and steering messages. A * direct human prompt, a synthetic `agent.inject()` context, and mid-turn * steering all project into the model transcript as verbatim user-role content; * they are told apart by `source` (a non-`user` kind marks injected context), - * not by event type. `meta` carries durable model-hidden producer state. + * not by event type. */ -interface PromptMessageData { - /** Exact model-facing blocks, including any baked prompt-prefix contexts. */ +interface UserMessageData { + /** Exact model-facing blocks. */ content: ContentBlock[] - /** Producer provenance for the direct prompt. */ + /** Producer provenance. */ source: MessageSource - /** Present only when prompt-prefix contexts were baked into `content`. */ - envelope?: PromptMessageEnvelope - /** - * Opaque durable JSON state retained on the event but hidden from the model - * projection. It is the intended channel for a future framing directive (a - * producer declares the frame, a dedicated renderer applies it — see the - * deferred note in - * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), - * so the surface keeps projecting `content` verbatim rather than wrapping it. - */ - meta?: JsonValue } ``` @@ -46,10 +35,7 @@ interface PromptMessageData { */ interface SessionEventMap { /** - * Opens turn `turn`. `trigger` records what started it — one claimed queued - * message or an idle-time injection. The turn is the durability/replay - * boundary: every event sits between a `turn/start` and its matching - * `turn/end` (the turn-enclosure invariant). + * Opens turn `turn`. `trigger` records what started the model loop. */ 'turn/start': { turn: number; trigger: TurnTrigger } /** @@ -68,16 +54,10 @@ interface SessionEventMap { * (the queued message claimed for this turn), a synthetic `agent.inject()` * context (file-change notices, subdir AGENTS.md, skill content, cron * notifications, …), or an admitted goal continuation round. All three - * project their `content` verbatim; `source` (with a non-`user` kind marking - * injected context) is the only channel that tells them apart. An idle - * injection wraps this event in a one-shot turn so the log stays turn-enclosed. + * project their `content` verbatim; `source` tells them apart. An idle + * injection may append this event between turns without running the model. */ - 'user/message': PromptMessageData - /** - * Durable record of a prompt veto and its reason. It is log-only: the blocked - * prompt never enters the model-visible surface, and its turn runs zero steps. - */ - 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } + 'user/message': UserMessageData /** Raw stream chunk — token-level replay fidelity. */ 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } /** @@ -114,7 +94,7 @@ interface SessionEventMap { meta?: JsonValue } /** Steering content injected between steps of a running turn. */ - 'steering/message': PromptMessageData & { turn: number } + 'steering/message': UserMessageData & { turn: number } /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ 'todo/write': { todos: TodoItem[] } /** @@ -125,7 +105,7 @@ interface SessionEventMap { } ``` -`PromptMessageData.content` 始终是确切的模型可见内容。当附加上下文声明 `prompt-prefix` 放置方式时,AgentLoop 会依次把它的块、一个 `## My request:` 分隔符以及最终生效的直接提示词拼接进该数组。可选且对模型隐藏的 `envelope` 会保留 `displayContent`,以及按顺序排列的前缀上下文来源/元数据描述信息,使 transcript(文本记录)、标题与重新引用消费方无需改变可重建历史,就能呈现人类提示词。`displayPromptContent()` 负责该选择,并为普通事件和较早的事件回退到 `content`。 +`UserMessageData` 是普通提示词、注入上下文与 steering(中途引导)共享的持久 `content` + `source` 基础形状。实时收件箱事件在同一形状上扩展一个 `AgentMessageId`;条目待处理期间,loop 只额外附加驱动器自有的路由状态。 ### `OutOfBandSessionEventMap`:受限的带外追加显式准入 @@ -168,13 +148,13 @@ interface TodoItem { ### 请求头事件:`request/header` -请求信封(即 `EpochHeader`:调用配置 + 渲染后的系统提示词 + 已组装的工具 schema + 会话前缀)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;之后请求发生变化时,系统会以 reason `'change'` 记录另一份完整快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。 +请求信封(即 `EpochHeader`:调用配置 + 渲染后的系统提示词 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;之后请求发生变化时,系统会以 reason `'change'` 记录另一份完整快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。 ```ts type-equiv /** - * Logged request state outside derived history: call config, system prompt, - * tools, and prefix. The latest full `request/header` snapshot reconstructs it; - * canonical empty optional fields are absent. + * Logged request state outside derived history: call config, system prompt, and + * tools. The latest full `request/header` snapshot reconstructs it; canonical + * empty optional fields are absent. */ interface EpochHeader { /** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */ @@ -183,18 +163,10 @@ interface EpochHeader { system?: string /** Assembled tool schemas; absent for a tool-less request. */ tools?: ToolSchema[] - /** - * The session prefix: request-only messages sent BEFORE the entire derived - * history (the `agent/session-prefix` waterfall's product, composed once - * per loop instance and reused for every request it sends). Not session - * history — `deriveMessages()` never returns it — so the header is its - * only durable record; absent when the instance composed none. - */ - messagePrefix?: Message[] } ``` -规范形式:空系统提示词、空工具列表和空会话前缀都表示为字段缺失,与请求构建方式一致。`messagePrefix` 是 `agent/session-prefix` waterfall(瀑布式事件)产物的持久记录(请求 = `messagePrefix + derived history`);每个 agent loop 实例只组合一次,并包含在该实例记录的每份完整快照中。包含已移除的 `request/header-delta` 事件或完整快照原因为 `fallback` 的旧版 v0 日志,会在 seed、append 和持久化加载边界被拒绝,而不会以不完整方式回放。 +规范形式:空系统提示词和空工具列表都表示为字段缺失,与请求构建方式一致。包含已移除的 `request/header-delta` 事件或完整快照原因为 `fallback` 的旧版 v0 日志,会在 seed、append 和持久化加载边界被拒绝,而不会以不完整方式回放。 ## `SessionEvent`:一条日志条目 @@ -484,9 +456,9 @@ declare class Session { `Session.deriveMessages()` 将事件日志投影为模型看到的 `Message[]`。它是缓存的(每个 surface 节点在首次出现时投影一次;surface 重写触发重建)且冻结的(每次调用返回一个新数组,引用共享的深冻结消息,因此通过投影修改已记录的历史在类型上不可表达)。`deriveEventMessage(event)` 是折叠所应用的逐节点纯函数,公开暴露以便外部重建器和开发不变式检查能以完全相同的规则投影日志前缀,不会与缓存产生分歧。投影规则: - `user/message` → 一条携带确切 `content` 的 user 消息;可选 envelope 仅作为日志中的展示元数据保留。 -- `assistant/message` → 一条 assistant 消息,包含事件的提供方/模型溯源信息和可选的适配器私有回放状态。原始 `assistant/chunk` 事件属于回放/UI 数据,在派生时会被**跳过**(组装后的消息才是权威)。**内容为空的** `assistant/message` 也会跳过:因 max-tokens 而截断且无内容的步骤仍会记录一条 `assistant/message` 以承载用量和溯源信息,但无内容的 assistant 轮次不得进入提供方 transcript。 +- `assistant/message` → 一条 assistant 消息,包含事件的提供方/模型溯源信息和可选的适配器私有回放状态。原始 `assistant/chunk` 事件属于回放/UI 数据,在派生时会被**跳过**(组装后的消息才是权威)。**内容为空的** `assistant/message` 也会跳过:因 max-tokens 而截断且无内容的步骤仍会记录一条 `assistant/message` 以承载用量和溯源信息,但无内容的 assistant 轮次不得进入提供方 transcript(文本记录)。 - `tool/result` → 一条携带 `tool-result` 块的 user 消息。 -- `user/message`(注入上下文,即非 `user` 来源)→ 按时间顺序在相应位置生成一条 user-role 消息,并原样承载其 `content`。可选的 JSON `meta` 保留在事件日志中,绝不渲染。 +- `user/message`(注入上下文,即非 `user` 来源)→ 按时间顺序在相应位置生成一条 user-role 消息,并原样承载其 `content`;溯源信息与领域数据都在其类型化的 source 中。 - `steering/message` → 按时间顺序在相应位置生成一条携带确切 `content` 的 user-role 消息;可选 envelope 仅作为日志中的展示元数据保留。 其余所有事件(`turn/*`、`step/*`、插件所有的 `llm/retry`)均为结构信息,不会投影为消息。token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息,因此其用量分片是持久化的记账记录。操作错误的步骤号记录在 `turn/end.reason`(`kind: 'error'`)中;如果是最终模型请求失败,其中包含规范化的 `LlmFailure` 事实,其他实时错误则包含消息/代码。由于这一尚未发布的格式有意不提供兼容性承诺,seed/load 校验会拒绝缺少提供方和模型的请求头,以及缺少提供方/模型溯源信息的 assistant 消息,而不会猜测历史数据应走的提供方路由。 @@ -508,14 +480,12 @@ declare class Session { */ interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } + /** Recovery turn reopened over the repaired current session log. */ + retry: { kind: 'retry' } /** - * An out-of-band context injection (`agent.inject()`) made while the agent - * was idle. The loop wraps the injected `user/message` (a non-`user` source, - * plugin by default) in a one-shot turn (`turn/start` → `user/message` → - * `turn/end`) so every event in the log stays turn-enclosed — the - * durability/replay boundary is the turn, and a bare event between turns would - * otherwise be indistinguishable from a crash tail on reload. The trigger's - * `source` mirrors that message's producer. + * 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 } } @@ -540,7 +510,8 @@ interface TurnEndReasonMap { * 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 turn failures retain their live Error message/code. + * as one `failure`; other thrown values retain their rendered message and a + * real `HarnessError` code when present. */ error: { kind: 'error'; step: number } & ( | { failure: LlmFailure; message?: never; code?: never } @@ -549,11 +520,6 @@ interface TurnEndReasonMap { disposed: { kind: 'disposed' } /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } - /** - * Policy blocked the turn's claimed prompt before the first step. The - * zero-step turn still records a balanced durable boundary and veto reason. - */ - rejected: { kind: 'rejected'; reason: string } /** * A persistence backend closed a crash-orphaned turn on reload. The loop never * emits this marker, and the events recorded before the crash remain intact. @@ -562,7 +528,7 @@ interface TurnEndReasonMap { } ``` -`max-tokens` 与模型调用中同名的 `FinishReason` 对应:只要轮次内有任何步骤以 `max-tokens` 结束,整个轮次就以 `max-tokens` 而不是 `completed` 结束(即使之后继续执行,截断事实仍优先),让消费方能够区分正常停止和截断停止;但它只优先于 `completed`,`disposed`/`aborted`/`error` 结果的优先级更高。`rejected` 表示一个零步骤轮次,其已认领的提示词被 `agent/prompt-submit` 钩子阻止(ACP(Agent Client Protocol)桥接层将其映射为 `cancelled`)。`interrupted` 是唯一不会由任何 loop 发出的原因:它由崩溃恢复合成(见 [persistence.md](persistence.md))。两个 map 均可通过合并扩展。 +`max-tokens` 与模型调用中同名的 `FinishReason` 对应:只要轮次内有任何步骤以 `max-tokens` 结束,整个轮次就以 `max-tokens` 而不是 `completed` 结束(即使之后继续执行,截断事实仍优先),让消费方能够区分正常停止和截断停止;但它只优先于 `completed`,`disposed`/`aborted`/`error` 结果的优先级更高。`interrupted` 是唯一不会由任何 loop 发出的原因:它由崩溃恢复合成(见 [persistence.md](persistence.md))。两个 map 均可通过合并扩展。 ## 轮次封闭不变式 @@ -572,7 +538,7 @@ interface TurnEndReasonMap { 插件可以通过 declaration merging 添加额外的 `SessionEventMap` 类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史),但与所有事件一样,必须位于一个打开的轮次内。完整的逐事件枚举(核心与插件贡献的,含 payload 与溯源信息)见生成的[持久化日志事件目录](../persistence-catalog.md);压缩 seam 的 `compact/*` 语义在 [compaction.md](compaction.md) 中讨论。 -钩子桥接层的 `hook/invoked` / `hook/result` 溯源对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。轮次中间的钩子点(`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`)在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 不生成 `hook/*` 记录:它注入的 `user/message` 已是持久证据,而且当时没有已打开的轮次可容纳该记录(见[钩子桥接 Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md))。 +钩子桥接层的 `hook/invoked` / `hook/result` 溯源对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。轮次中间的钩子点(`PreToolUse`/`PostToolUse`/`Stop`)在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 与轮次开始前的 `UserPromptSubmit` 准入 seam 都不生成 `hook/*` 记录,因为两者都没有已打开的轮次可容纳该记录;被放行的上下文改由其带来源的 `user/message` 作为持久证据(见[钩子桥接 Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md))。 ## 持久性契约 diff --git a/docs/core-data-structures/skills.i18n.yaml b/docs/core-data-structures/skills.i18n.yaml index 2f67387224..81c105f515 100644 --- a/docs/core-data-structures/skills.i18n.yaml +++ b/docs/core-data-structures/skills.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 -skills.md: fc9599713dcfddec9719ed746b66ea0217b86cf5 -skills.zh.md: 0eb4c0aa69ed56117c7508358c0d47e3b3e95fcb +skills.md: 1b82da1c59f0159404e6ea792bf95ea4477e0a03 +skills.zh.md: 38450cce01aa81e7898aefffed7f4e27d09b0204 diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index fc9599713d..1b82da1c59 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -154,6 +154,6 @@ interface Config { ## Session catalog and tool contract -`dsh-tool-skill` contributes a user-role `` through `agent/session-prefix`. The catalog contains sorted skill `name` and normalized, XML-escaped `description` only; it omits bodies, paths, sources, providers, and routing hints. Prefix discovery forwards the caller's abort signal through `SkillLookupOptions`. `catalogDescriptionMaxLength` is the consumer config for the description bound, with default `500` and integer minimum `3`. Its request-only, header-logged lifecycle is defined by the [session-prefix Agent Note](../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md). +`dsh-tool-skill` injects a durable user-role `` at the first `agent/step` of a live session. The catalog contains sorted skill `name` and normalized, XML-escaped `description` only; it omits bodies, paths, sources, providers, and routing hints. Discovery forwards the step's abort signal through `SkillLookupOptions`. `catalogDescriptionMaxLength` is the consumer config for the description bound, with default `500` and integer minimum `3`. The model-facing `skill({ name })` tool validates the kebab-case name, loads the complete definition for the calling agent cwd, reports an unresolved skill as unknown or no longer available, rejects `disableModelInvocation` skills, and returns a tool result containing ``, ``, and ``. `resourceBase` resolves explicitly referenced scripts, references, and assets only as needed; the loaded result does not enumerate a skill directory. The tool result is the model-visible path for complete instructions. diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index 0eb4c0aa69..38450cce01 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -154,6 +154,6 @@ interface Config { ## 会话目录与工具契约 -`dsh-tool-skill` 通过 `agent/session-prefix` 贡献一条 user-role ``。目录只包含已排序的 skill `name` 和规范化、经 XML 转义的 `description`;不包含正文、路径、来源、提供方或路由提示。Prefix 发现通过 `SkillLookupOptions` 转发调用方的 abort signal。`catalogDescriptionMaxLength` 是消费方用于 description 上限的配置,默认值为 `500`,整数最小值为 `3`。其仅用于请求、记录在 header 中的生命周期由 [session-prefix Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md)定义。 +`dsh-tool-skill` 在存活会话的第一个 `agent/step` 注入一条持久的 user-role ``。目录只包含已排序的 skill `name` 和规范化、经 XML 转义的 `description`;不包含正文、路径、来源、提供方或路由提示。发现通过 `SkillLookupOptions` 转发该步骤的 abort signal。`catalogDescriptionMaxLength` 是消费方用于 description 上限的配置,默认值为 `500`,整数最小值为 `3`。 面向模型的 `skill({ name })` 工具校验 kebab-case 名称,为调用方 agent 的 cwd 加载完整定义,将未解析的 skill 报告为 unknown 或 no longer available,拒绝 `disableModelInvocation` 的 skill,并返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。工具结果是模型获取完整指令的可见路径。 diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml index 7c82b890b7..75a4199f18 100644 --- a/docs/core-data-structures/tools.i18n.yaml +++ b/docs/core-data-structures/tools.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 -tools.md: 250e869397f8ecb128d5b644ff7506376d0657c6 -tools.zh.md: 96fc9d3eeda0240e195beb11bea088d5606d4757 +tools.md: 65b1d398238d3779def303d2d3a36bd641778bd7 +tools.zh.md: 2fe3eb3dbca2447cfc06da25a930de92aae34898 diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 250e869397..65b1d39823 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -215,7 +215,16 @@ interface ToolRunContext extends ToolExecution { * the agent loop. Contexts retain their individual source and metadata and * are emitted in call order. */ - deferContext(context: HookContext): void + deferContext(context: UserMessageData): void + /** + * Mark a successful final result as terminal for the current agent turn. + * The marker rides this execution's own result (`concludesTurn` exists only + * on {@link ToolExecutionSuccess}); a composite that dispatches nested + * calls forwards it from the nested result, exactly like + * `additionalContexts`, so only an authoritative nested success can + * conclude the enclosing run. + */ + concludeTurn(): void } ``` @@ -320,7 +329,9 @@ interface ToolExecutionSuccess { readonly content: ContentBlock[] readonly error?: never readonly meta?: JsonValue - readonly additionalContexts?: HookContext[] + readonly additionalContexts?: UserMessageData[] + /** The agent loop stops after committing this successful result batch. */ + readonly concludesTurn?: true } ``` @@ -332,7 +343,8 @@ interface ToolExecutionFailure { readonly value?: never readonly content: ContentBlock[] readonly meta?: JsonValue - readonly additionalContexts?: HookContext[] + readonly additionalContexts?: UserMessageData[] + readonly concludesTurn?: never } ``` @@ -368,9 +380,9 @@ type PreToolDecision = * next request, or block by turning corrective feedback into an error result. */ type PostToolDecision = - | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: HookContext[] } - | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: HookContext[] } - | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] } + | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessageData[] } + | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessageData[] } + | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessageData[] } ``` Call `next()` for the default or return a decision to short-circuit. Pre-policy may deny or ask; only `allowed-once` proceeds, while a non-grant, missing approval channel or service, or agent-less request becomes a denial. Guards may still impose a final denial. Arguments cannot be rewritten because history, audit, UI, and execution must agree. diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md index 96fc9d3eed..2fe3eb3dbc 100644 --- a/docs/core-data-structures/tools.zh.md +++ b/docs/core-data-structures/tools.zh.md @@ -215,7 +215,16 @@ interface ToolRunContext extends ToolExecution { * the agent loop. Contexts retain their individual source and metadata and * are emitted in call order. */ - deferContext(context: HookContext): void + deferContext(context: UserMessageData): void + /** + * Mark a successful final result as terminal for the current agent turn. + * The marker rides this execution's own result (`concludesTurn` exists only + * on {@link ToolExecutionSuccess}); a composite that dispatches nested + * calls forwards it from the nested result, exactly like + * `additionalContexts`, so only an authoritative nested success can + * conclude the enclosing run. + */ + concludeTurn(): void } ``` @@ -320,7 +329,9 @@ interface ToolExecutionSuccess { readonly content: ContentBlock[] readonly error?: never readonly meta?: JsonValue - readonly additionalContexts?: HookContext[] + readonly additionalContexts?: UserMessageData[] + /** The agent loop stops after committing this successful result batch. */ + readonly concludesTurn?: true } ``` @@ -332,7 +343,8 @@ interface ToolExecutionFailure { readonly value?: never readonly content: ContentBlock[] readonly meta?: JsonValue - readonly additionalContexts?: HookContext[] + readonly additionalContexts?: UserMessageData[] + readonly concludesTurn?: never } ``` @@ -368,9 +380,9 @@ type PreToolDecision = * next request, or block by turning corrective feedback into an error result. */ type PostToolDecision = - | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: HookContext[] } - | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: HookContext[] } - | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] } + | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessageData[] } + | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessageData[] } + | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessageData[] } ``` 调用 `next()` 获取默认决策,或直接返回一个决策以短路。前置策略可以 deny 或 ask;只有 `allowed-once` 才继续执行,而未授权、缺少审批通道或服务、或无 agent 的请求都会变为拒绝。Guard 仍可施加最终拒绝。参数不可被改写,因为历史记录、审计、UI 和执行必须保持一致。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 2fa8767acf..5ff92cfcf2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,37 +7,34 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy` | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy` | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:448`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:379`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:409`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:463`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:424`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:363`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:436`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:474`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:485`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:140`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:308`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:247`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:256`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:419`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:286`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:276`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:336`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:362`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:377`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:321`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | +| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:406`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:265`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:349`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:392`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:169`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:53`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:70`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`user-approval`](../packages/ui/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:220`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:234`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:227`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index c039792ee8..013778b633 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:337`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:366`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:398`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:269`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) ## Events @@ -150,7 +150,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -166,7 +166,7 @@ Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/types.ts) ### `compact/*` @@ -315,22 +315,6 @@ Source: [`packages/ui/permission/src/index.ts:36`](../packages/ui/permission/src Source: [`packages/plan/plan-mode/src/index.ts:40`](../packages/plan/plan-mode/src/index.ts) -### `prompt/*` - -#### `prompt/blocked` — log-only - -```ts persistence-catalog -/** - * Durable record of a prompt veto and its reason. It is log-only: the blocked - * prompt never enters the model-visible surface, and its turn runs zero steps. - */ -'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } -``` - -Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) - -Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts) - ### `request/*` #### `request/header` — log-only @@ -343,7 +327,7 @@ Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -377,7 +361,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:34`](../packages/s Types: [SessionTitleEventData](core-data-structures/session-title.md) -Source: [`packages/session-title/session-title/src/index.ts:96`](../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:95`](../packages/session-title/session-title/src/index.ts) #### `session/title-llm-request` — log-only @@ -396,10 +380,10 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:44`](../packages ```ts persistence-catalog /** Steering content injected between steps of a running turn. */ -'steering/message': PromptMessageData & { turn: number } +'steering/message': UserMessageData & { turn: number } ``` -Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:250`](../packages/core/session/src/types.ts) ### `step/*` @@ -410,7 +394,7 @@ Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -419,7 +403,7 @@ Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:202`](../packages/core/session/src/types.ts) ### `todo/*` @@ -432,7 +416,7 @@ Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:307`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts) ### `tool/*` @@ -449,7 +433,7 @@ Source: [`packages/core/session/src/types.ts:307`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -526,7 +510,7 @@ Source: [`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/c Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:295`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts) ### `turn/*` @@ -544,23 +528,20 @@ Source: [`packages/core/session/src/types.ts:295`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:200`](../packages/core/session/src/types.ts) #### `turn/start` — log-only ```ts persistence-catalog /** - * Opens turn `turn`. `trigger` records what started it — one claimed queued - * message or an idle-time injection. The turn is the durability/replay - * boundary: every event sits between a `turn/start` and its matching - * `turn/end` (the turn-enclosure invariant). + * Opens turn `turn`. `trigger` records what started the model loop. */ 'turn/start': { turn: number; trigger: TurnTrigger } ``` Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) ### `user/*` @@ -572,11 +553,10 @@ Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/ * (the queued message claimed for this turn), a synthetic `agent.inject()` * context (file-change notices, subdir AGENTS.md, skill content, cron * notifications, …), or an admitted goal continuation round. All three - * project their `content` verbatim; `source` (with a non-`user` kind marking - * injected context) is the only channel that tells them apart. An idle - * injection wraps this event in a one-shot turn so the log stays turn-enclosed. + * project their `content` verbatim; `source` tells them apart. An idle + * injection may append this event between turns without running the model. */ -'user/message': PromptMessageData +'user/message': UserMessageData ``` -Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts) diff --git a/docs/user/develop/framework/events.i18n.yaml b/docs/user/develop/framework/events.i18n.yaml index 9704eff7c5..b20764a24d 100644 --- a/docs/user/develop/framework/events.i18n.yaml +++ b/docs/user/develop/framework/events.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 -events.md: 0c57681a55ea0200fe8f33293176fc94f09a4ce5 -events.zh.md: 3e14739d4a97ba014d545c9f226000507aaeacef +events.md: 5cd5d22f854d0b4e271e892cbdb1ccebe687ae49 +events.zh.md: 5fd4d5de53897e32523ab478626965ad7c9602ba diff --git a/docs/user/develop/framework/events.md b/docs/user/develop/framework/events.md index 0c57681a55..5cd5d22f85 100644 --- a/docs/user/develop/framework/events.md +++ b/docs/user/develop/framework/events.md @@ -101,7 +101,7 @@ declare module 'cordis' { ## Cordis events and session records -Harness Cordis events use `namespace/action` names, including `agent/pre-step`, `agent/request`, `agent/step-result`, `tools/result`, and `session/event`. The generated [event catalog](../../../cordis-catalog/events.md) records complete signatures and modes. +Harness Cordis events use `namespace/action` names, including `agent/step`, `agent/request`, `agent/request-error`, `tools/result`, and `session/event`. The generated [event catalog](../../../cordis-catalog/events.md) records complete signatures and modes. `turn/*`, `step/*`, `tool/call`, `tool/result`, and `compact/*` are durable session-event types, not same-named Cordis events. To observe them, listen to `session/event` and inspect `event.type`. diff --git a/docs/user/develop/framework/events.zh.md b/docs/user/develop/framework/events.zh.md index 3e14739d4a..5fd4d5de53 100644 --- a/docs/user/develop/framework/events.zh.md +++ b/docs/user/develop/framework/events.zh.md @@ -101,7 +101,7 @@ declare module 'cordis' { ## Cordis 事件与会话记录 -Harness 的 Cordis 事件遵循 `namespace/action` 命名,例如 `agent/pre-step`、`agent/request`、`agent/step-result`、`tools/result` 和 `session/event`。完整签名与触发模式见[Events 目录](../../../cordis-catalog/events.md)。 +Harness 的 Cordis 事件遵循 `namespace/action` 命名,例如 `agent/step`、`agent/request`、`agent/request-error`、`tools/result` 和 `session/event`。完整签名与触发模式见[Events 目录](../../../cordis-catalog/events.md)。 `turn/*`、`step/*`、`tool/call`、`tool/result` 和 `compact/*` 是持久化的会话事件类型,不是同名 Cordis 事件。需要观察它们时,监听 `session/event` 并检查 `event.type`。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 4fb6f9493b..e193c45893 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -195,10 +195,10 @@ const SCENARIOS: Scenario[] = [ headerClass: 'advanced', configPath: ADVANCED_CONFIG, }, - // Prompt-submit blocks are authored keylessly: they persist a rejected turn - // and hook events without starting a model step, so their logs still compare. - { name: 'hook-cc-promptsubmit-block', hasModelTurn: false, comparesLog: true, recorded: false }, - { name: 'hook-codex-promptsubmit-block', hasModelTurn: false, comparesLog: true, recorded: false }, + // Prompt-submit blocks are authored keylessly. Admission rejects before a + // turn opens, so only the ACP stop reason is observable and no log is harvested. + { name: 'hook-cc-promptsubmit-block', hasModelTurn: false, recorded: false }, + { name: 'hook-codex-promptsubmit-block', hasModelTurn: false, recorded: false }, // The mid-turn seams fire during a real model turn, so each is recorded with its hook active // (the model's reaction to a deny/block/force-continue is part of the captured transcript). // SessionStart/SubagentStart are excluded because detached injection races log diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl index d598b34e37..efeb40d2ab 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl @@ -12,7 +12,7 @@ {"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}} {"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} -{"type":"user/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal-session snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"} +{"type":"user/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0,"change":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal-session snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}}},"surfaceOp":"append"} {"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -49,6 +49,6 @@ {"type":"step/start","seq":47,"time":0,"data":{"turn":3,"step":1}} {"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} -{"type":"user/message","seq":50,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"paused\",\"maxGoalRounds\":2},\"roundsStarted\":2,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"} -{"type":"step/end","seq":51,"time":0,"data":{"turn":3,"step":1}} -{"type":"turn/end","seq":52,"time":0,"data":{"turn":3,"reason":{"kind":"aborted"}}} +{"type":"step/end","seq":50,"time":0,"data":{"turn":3,"step":1}} +{"type":"turn/end","seq":51,"time":0,"data":{"turn":3,"reason":{"kind":"aborted"}}} +{"type":"user/message","seq":52,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"paused\",\"maxGoalRounds\":2},\"roundsStarted\":2,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0,"change":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}}},"surfaceOp":"append"} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index 95023a0ed8..cbb154f49b 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -2,32 +2,33 @@ {"type":"turn/start","seq":0,"time":1785014475014,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785014475015,"data":{"content":[{"type":"text","text":"Using ONE run_code program, call tools.read on nested/task.txt. After the program finishes, answer the workspace handshake question using the newly discovered instructions: What is the Code Mode workspace handshake?"}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785014475022,"data":{"title":"Using ONE run_code program, call","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1785014475034,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785014475035,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nWorkspace snapshot root instruction.\n\n"}]}]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1785014475456,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":6,"time0":1785014475457,"data":{"turn":1,"step":1,"index":0,"dt":[139,42,1,0,0,0,40,1,0,0,0,43,0,0,0,39,43,1,0,0,0,40,1,40,0,0,1,42,0,1,0,0,40,0,0,1,0,0,44,0,1,39,0,1,0],"texts":["The"," user"," wants"," me"," to"," read"," the"," file"," `","n","ested","/t","ask",".txt","`"," using"," a"," `","run","_code","`"," program",","," and"," then"," answer"," the"," question"," \"","What"," is"," the"," Code"," Mode"," workspace"," hand","shake","?\""," based"," on"," the"," contents"," of"," that"," file","."]}} -{"type":"assistant/chunk","seq":52,"time":1785014476183,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":53,"time0":1785014476183,"data":{"turn":1,"step":1,"index":1,"dt":[41,1,0,0,41,0,0,1,41,1,0,0,40,0,42,0,0,0,1,0,40,1,0,0,0,0,41,0,0,42,1,0,41,1,0,0,42,0,0,0,0,41],"id":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","args":["","{","\"","code","\"",": ","\"","\\n","const"," result"," ="," await"," tools",".read","({"," file","_path",":"," \\\"","n","ested","/t","ask",".txt","\\\""," });\\n","return"," result",";\\n","\"",", ","\"","description","\"",": ","\"","Read"," nested","/t","ask",".txt","\"","}"]}} -{"type":"assistant/chunk","seq":96,"time":1785014476731,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read the file `nested/task.txt` using a `run_code` program, and then answer the question \"What is the Code Mode workspace handshake?\" based on the contents of that file."}}}} -{"type":"assistant/chunk","seq":97,"time":1785014476732,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","arguments":"{\"code\": \"\\nconst result = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn result;\\n\", \"description\": \"Read nested/task.txt\"}"}}}} -{"type":"assistant/chunk","seq":98,"time":1785014476732,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":6200,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":46}}}} -{"type":"assistant/chunk","seq":99,"time":1785014476732,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":100,"time":1785014476736,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read the file `nested/task.txt` using a `run_code` program, and then answer the question \"What is the Code Mode workspace handshake?\" based on the contents of that file."},{"type":"tool-call","id":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","arguments":"{\"code\": \"\\nconst result = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn result;\\n\", \"description\": \"Read nested/task.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":6200,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":46}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99],"surfaceOp":"append"} -{"type":"tool/call","seq":101,"time":1785014476737,"data":{"turn":1,"step":1,"callId":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","arguments":"{\"code\": \"\\nconst result = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn result;\\n\", \"description\": \"Read nested/task.txt\"}"}} -{"type":"tool/code-dispatch-start","seq":102,"time":1785014476837,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"}}} -{"type":"tool/code-dispatch","seq":103,"time":1785014476842,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"/tmp/acp-snap-cwd-muJYhO/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}]}} -{"type":"tool/result","seq":104,"time":1785014476847,"data":{"turn":1,"step":1,"callId":"call_00_hD8d0VcXXFVMtn64GSoC9264","content":[{"type":"text","text":"{\n \"path\": \"/tmp/acp-snap-cwd-muJYhO/nested/task.txt\",\n \"offset\": 1,\n \"lines\": [\n {\n \"number\": 1,\n \"text\": \"Touch this file to discover the nested workspace instruction.\"\n }\n ],\n \"totalLines\": 1\n}"}],"isError":false},"sourceEventSeqs":[101],"surfaceOp":"append"} -{"type":"user/message","seq":105,"time":1785014476847,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} -{"type":"step/end","seq":106,"time":1785014476850,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":107,"time":1785014476854,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":108,"time":1785014477311,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":109,"time0":1785014477311,"data":{"turn":1,"step":2,"index":0,"dt":[108,56,1,0,26,0,0,42,0,43,1,42,1,0,0,0,0,42,0,0,0,1,0,40,0,0,1,0,0,43],"texts":["The"," nested","/","AG","ENTS",".md"," file"," provides"," the"," instruction",":"," when"," asked"," for"," the"," Code"," Mode"," workspace"," hand","shake",","," answer"," exactly"," `","CODE","_M","ODE","_CONT","EXT","_OK","`."]}} -{"type":"assistant/chunk","seq":140,"time":1785014477799,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":141,"time0":1785014477799,"data":{"turn":1,"step":2,"index":1,"dt":[43,40,0,0,0,1,42,0,0,1,0,0,41,0],"texts":["**","Code"," Mode"," workspace"," hand","shake",":**"," `","CODE","_M","ODE","_CONT","EXT","_OK","`"]}} -{"type":"assistant/chunk","seq":156,"time":1785014477967,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The nested/AGENTS.md file provides the instruction: when asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK`."}}}} -{"type":"assistant/chunk","seq":157,"time":1785014477967,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"**Code Mode workspace handshake:** `CODE_MODE_CONTEXT_OK`"}}}} -{"type":"assistant/chunk","seq":158,"time":1785014477968,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":226,"outputTokens":47,"cacheReadTokens":6272,"reasoningTokens":31}}}} -{"type":"assistant/chunk","seq":159,"time":1785014477968,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":160,"time":1785014477968,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The nested/AGENTS.md file provides the instruction: when asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK`."},{"type":"text","text":"**Code Mode workspace handshake:** `CODE_MODE_CONTEXT_OK`"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":226,"outputTokens":47,"cacheReadTokens":6272,"reasoningTokens":31}},"sourceEventSeqs":[108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159],"surfaceOp":"append"} -{"type":"step/end","seq":161,"time":1785014477972,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":162,"time":1785014477972,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":3,"time":1785122256262,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nWorkspace snapshot root instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2119a7072358cc727f8d9c4cb7388e905b075fe6"}]}},"surfaceOp":"append"} +{"type":"step/start","seq":4,"time":1785122256264,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":1785122256265,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":6,"time":1785014475457,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":7,"time0":1785014475596,"data":{"turn":1,"step":1,"index":0,"dt":[42,1,0,0,0,40,1,0,0,0,43,0,0,0,39,43,1,0,0,0,40,1,40,0,0,1,42,0,1,0,0,40,0,0,1,0,0,44,0,1,39,0,1,0,126],"texts":["The"," user"," wants"," me"," to"," read"," the"," file"," `","n","ested","/t","ask",".txt","`"," using"," a"," `","run","_code","`"," program",","," and"," then"," answer"," the"," question"," \"","What"," is"," the"," Code"," Mode"," workspace"," hand","shake","?\""," based"," on"," the"," contents"," of"," that"," file","."]}} +{"type":"assistant/chunk","seq":53,"time":1785014476183,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":54,"time0":1785014476224,"data":{"turn":1,"step":1,"index":1,"dt":[1,0,0,41,0,0,1,41,1,0,0,40,0,42,0,0,0,1,0,40,1,0,0,0,0,41,0,0,42,1,0,41,1,0,0,42,0,0,0,0,41,89],"id":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","args":["","{","\"","code","\"",": ","\"","\\n","const"," result"," ="," await"," tools",".read","({"," file","_path",":"," \\\"","n","ested","/t","ask",".txt","\\\""," });\\n","return"," result",";\\n","\"",", ","\"","description","\"",": ","\"","Read"," nested","/t","ask",".txt","\"","}"]}} +{"type":"assistant/chunk","seq":97,"time":1785014476732,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read the file `nested/task.txt` using a `run_code` program, and then answer the question \"What is the Code Mode workspace handshake?\" based on the contents of that file."}}}} +{"type":"assistant/chunk","seq":98,"time":1785014476732,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","arguments":"{\"code\": \"\\nconst result = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn result;\\n\", \"description\": \"Read nested/task.txt\"}"}}}} +{"type":"assistant/chunk","seq":99,"time":1785014476732,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":6200,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":46}}}} +{"type":"assistant/chunk","seq":100,"time":1785122256269,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":101,"time":1785122256269,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read the file `nested/task.txt` using a `run_code` program, and then answer the question \"What is the Code Mode workspace handshake?\" based on the contents of that file."},{"type":"tool-call","id":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","arguments":"{\"code\": \"\\nconst result = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn result;\\n\", \"description\": \"Read nested/task.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":6200,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":46}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100],"surfaceOp":"append"} +{"type":"tool/call","seq":102,"time":1785122256269,"data":{"turn":1,"step":1,"callId":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","arguments":"{\"code\": \"\\nconst result = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn result;\\n\", \"description\": \"Read nested/task.txt\"}"}} +{"type":"tool/code-dispatch-start","seq":103,"time":1785122256332,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"}}} +{"type":"tool/code-dispatch","seq":104,"time":1785122256336,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"/tmp/acp-snap-cwd-muJYhO/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}]}} +{"type":"tool/result","seq":105,"time":1785122256338,"data":{"turn":1,"step":1,"callId":"call_00_hD8d0VcXXFVMtn64GSoC9264","content":[{"type":"text","text":"{\n \"path\": \"/tmp/acp-snap-cwd-muJYhO/nested/task.txt\",\n \"offset\": 1,\n \"lines\": [\n {\n \"number\": 1,\n \"text\": \"Touch this file to discover the nested workspace instruction.\"\n }\n ],\n \"totalLines\": 1\n}"}],"isError":false},"sourceEventSeqs":[102],"surfaceOp":"append"} +{"type":"user/message","seq":106,"time":1785122256338,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} +{"type":"step/end","seq":107,"time":1785122256338,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":108,"time":1785122256347,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":109,"time":1785014477311,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":110,"time0":1785014477419,"data":{"turn":1,"step":2,"index":0,"dt":[56,1,0,26,0,0,42,0,43,1,42,1,0,0,0,0,42,0,0,0,1,0,40,0,0,1,0,0,43,41],"texts":["The"," nested","/","AG","ENTS",".md"," file"," provides"," the"," instruction",":"," when"," asked"," for"," the"," Code"," Mode"," workspace"," hand","shake",","," answer"," exactly"," `","CODE","_M","ODE","_CONT","EXT","_OK","`."]}} +{"type":"assistant/chunk","seq":141,"time":1785014477799,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":142,"time0":1785014477842,"data":{"turn":1,"step":2,"index":1,"dt":[40,0,0,0,1,42,0,0,1,0,0,41,0,0],"texts":["**","Code"," Mode"," workspace"," hand","shake",":**"," `","CODE","_M","ODE","_CONT","EXT","_OK","`"]}} +{"type":"assistant/chunk","seq":157,"time":1785014477967,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The nested/AGENTS.md file provides the instruction: when asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK`."}}}} +{"type":"assistant/chunk","seq":158,"time":1785014477968,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"**Code Mode workspace handshake:** `CODE_MODE_CONTEXT_OK`"}}}} +{"type":"assistant/chunk","seq":159,"time":1785014477968,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":226,"outputTokens":47,"cacheReadTokens":6272,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":160,"time":1785122256351,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":161,"time":1785122256351,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The nested/AGENTS.md file provides the instruction: when asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK`."},{"type":"text","text":"**Code Mode workspace handshake:** `CODE_MODE_CONTEXT_OK`"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":226,"outputTokens":47,"cacheReadTokens":6272,"reasoningTokens":31}},"sourceEventSeqs":[109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160],"surfaceOp":"append"} +{"type":"step/end","seq":162,"time":1785122256351,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":163,"time":1785122256351,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 4539ba077a..49fb484c8d 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n 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 }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n 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': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\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 callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n 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 rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n 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(input: UserMessageData, options: SendOptions): AgentMessageId;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(input: UserMessageData): AgentMessageId;\n steer(input: UserMessageData): AgentMessageId;\n inject(input: UserMessageData): AgentMessageId;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n 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 }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n 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': UserMessageData;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\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 callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': UserMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessageData[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessageData[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessageData): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n 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 }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n 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 }\n export interface UserMessageData {\n content: ContentBlock[];\n source: MessageSource;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl b/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl index f164c7fe62..69b5a3033e 100644 --- a/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl +++ b/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl @@ -8,12 +8,14 @@ {"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}}}} {"type":"step/end","seq":7,"time":0,"data":{"turn":1,"step":1}} {"type":"llm/retry","seq":8,"time":0,"data":{"turn":1,"step":1,"retry":1,"maxRetries":2,"delayMs":1,"failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}} -{"type":"step/start","seq":9,"time":0,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"Recovered."}}} -{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Recovered."}}}} -{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":15,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"Recovered."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":12,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} -{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":17,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/end","seq":9,"time":1785047244285,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}}} +{"type":"turn/start","seq":10,"time":1785047244285,"data":{"turn":2,"trigger":{"kind":"retry"}}} +{"type":"step/start","seq":11,"time":1785047244289,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"Recovered."}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Recovered."}}}} +{"type":"assistant/chunk","seq":15,"time":1785047244294,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":16,"time":1785047244294,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":17,"time":1785047244294,"data":{"turn":2,"step":1,"content":[{"type":"text","text":"Recovered."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":12,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":1785047244294,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":19,"time":1785047244294,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 602e5aeb0d..8bbcdce51d 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -14,8 +14,8 @@ {"type":"assistant/chunk","seq":127,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":128,"time":1784821261753,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127],"surfaceOp":"append"} {"type":"tool/call","seq":129,"time":1784821261754,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":130,"time":1784821261758,"data":{"id":"8e278621-09a9-4c76-a785-74aea76cc120","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":131,"time":1784821261759,"data":{"id":"8e278621-09a9-4c76-a785-74aea76cc120","outcome":"allowed-once"}} +{"type":"approval/asked","seq":130,"time":1784821261758,"data":{"id":"d198d24f-84a1-43fd-949a-68c0bed774f1","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":131,"time":1784821261759,"data":{"id":"d198d24f-84a1-43fd-949a-68c0bed774f1","outcome":"allowed-once"}} {"type":"tool/result","seq":132,"time":1784821261775,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[129],"surfaceOp":"append"} {"type":"step/end","seq":133,"time":1784821261781,"data":{"turn":1,"step":1}} {"type":"step/start","seq":134,"time":1784821261782,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index e2970ebad1..57fb6e7182 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -14,8 +14,8 @@ {"type":"assistant/chunk","seq":151,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":152,"time":1784821263293,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151],"surfaceOp":"append"} {"type":"tool/call","seq":153,"time":1784821263294,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":154,"time":1784821263300,"data":{"id":"f55fc10d-f2f1-435f-8c85-076bafaa5f85","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":155,"time":1784821263301,"data":{"id":"f55fc10d-f2f1-435f-8c85-076bafaa5f85","outcome":"rejected"}} +{"type":"approval/asked","seq":154,"time":1784821263300,"data":{"id":"6ab15565-ea18-4ff2-9245-9fbe784defb9","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":155,"time":1784821263301,"data":{"id":"6ab15565-ea18-4ff2-9245-9fbe784defb9","outcome":"rejected"}} {"type":"tool/result","seq":156,"time":1784821263302,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[153],"surfaceOp":"append"} {"type":"step/end","seq":157,"time":1784821263307,"data":{"turn":1,"step":1}} {"type":"step/start","seq":158,"time":1784821263307,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index 5b32c37cb6..39640f7337 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -14,9 +14,9 @@ {"type":"assistant/chunk","seq":85,"time":1784045703749,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":86,"time":1784821264893,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85],"surfaceOp":"append"} {"type":"tool/call","seq":87,"time":1784821264893,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} -{"type":"approval/asked","seq":88,"time":1784821264898,"data":{"id":"d1a35247-af6d-4df7-9c62-e53d1be0a3e7","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} -{"type":"approval/decided","seq":89,"time":1784821264898,"data":{"id":"d1a35247-af6d-4df7-9c62-e53d1be0a3e7","outcome":"allowed-once"}} -{"type":"tool/result","seq":90,"time":1784821264906,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[87],"surfaceOp":"append"} +{"type":"approval/asked","seq":88,"time":1784821264898,"data":{"id":"91c62564-8228-4e09-8afb-f9bcdd5d7ca3","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} +{"type":"approval/decided","seq":89,"time":1784821264898,"data":{"id":"91c62564-8228-4e09-8afb-f9bcdd5d7ca3","outcome":"allowed-once"}} +{"type":"tool/result","seq":90,"time":1784821264906,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/private/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[87],"surfaceOp":"append"} {"type":"step/end","seq":91,"time":1784821264911,"data":{"turn":1,"step":1}} {"type":"step/start","seq":92,"time":1784821264912,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":93,"time":1784821264916,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 76a172d5cf..2f43472b97 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -16,8 +16,8 @@ {"type":"tool/call","seq":54,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":55,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":56,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"8a5510f4-93b7-4e70-b082-20d13dec386d","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"8a5510f4-93b7-4e70-b082-20d13dec386d","outcome":"rejected"}} +{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"161ec4d8-e80a-45c0-a586-9453a5d09766","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"161ec4d8-e80a-45c0-a586-9453a5d09766","outcome":"rejected"}} {"type":"tool/result","seq":59,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","seq":60,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":61,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/session.jsonl index dd6745b770..3d6badf9da 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/session.jsonl @@ -1,6 +1 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"hook/invoked","seq":1,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"claude","handlerId":"claude:UserPromptSubmit:1"}} -{"type":"hook/result","seq":2,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"claude:UserPromptSubmit:1","decision":"block","exitCode":2,"stderrSummary":"blocked by policy hook","durationMs":0}} -{"type":"prompt/blocked","seq":3,"time":0,"data":{"content":[{"type":"text","text":"Delete everything in the repo."}],"source":{"kind":"user"},"reason":"blocked by policy hook"}} -{"type":"turn/end","seq":4,"time":0,"data":{"turn":1,"reason":{"kind":"rejected","reason":"blocked by policy hook"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl index ce959c93d7..8b48c62b32 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl @@ -1,21 +1,19 @@ {"type":"session","version":0,"id":"d03c3a83-1238-4e2e-ad9a-b86a61840a40","createdAt":1783352160541,"cwd":"/tmp/acp-snap-cwd-QUDqlk","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352160545,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"hook/invoked","seq":1,"time":1783352160546,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"claude","handlerId":"claude:UserPromptSubmit:1"}} -{"type":"hook/result","seq":2,"time":1783352160564,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"claude:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":17.45639600000004}} -{"type":"user/message","seq":3,"time":1783352160564,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1783352160564,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} -{"type":"session/title","seq":5,"time":1783352160564,"data":{"title":"What is my favorite color?","messageSeqs":[3],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":6,"time":1783352160565,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":7,"time":1783352160566,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":8,"time":1783352161228,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":9,"time0":1783352161229,"data":{"turn":1,"step":1,"index":0,"dt":[106,28,0,29,0,0,1,0,27,1,0,0,28,0,0,28,1,0],"texts":["The"," user","'s"," favorite"," color"," is"," te","al",","," as"," stated"," in"," the"," context"," provided"," by"," the"," plugin","."]}} -{"type":"assistant/chunk","seq":28,"time":1783352161478,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":29,"time":1783352161478,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"te"}}} -{"type":"assistant/chunk","seq":30,"time":1783352161511,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"al"}}} -{"type":"assistant/chunk","seq":31,"time":1783352161512,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user's favorite color is teal, as stated in the context provided by the plugin."}}}} -{"type":"assistant/chunk","seq":32,"time":1783352161512,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"teal"}}}} -{"type":"assistant/chunk","seq":33,"time":1783352161512,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2892,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":34,"time":1783352161512,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":35,"time":1783352161515,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user's favorite color is teal, as stated in the context provided by the plugin."},{"type":"text","text":"teal"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2892,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"} -{"type":"step/end","seq":36,"time":1783352161516,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":37,"time":1783352161516,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":1,"time":1785122243327,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"user/message","seq":2,"time":1785122243327,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} +{"type":"session/title","seq":3,"time":1785122243327,"data":{"title":"What is my favorite color?","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":4,"time":1785122243354,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":1785122243354,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":6,"time":1785122243359,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":7,"time0":1783352160565,"data":{"turn":1,"step":1,"index":0,"dt":[1,662,1,106,28,0,29,0,0,1,0,27,1,0,0,28,0,0],"texts":["The"," user","'s"," favorite"," color"," is"," te","al",","," as"," stated"," in"," the"," context"," provided"," by"," the"," plugin","."]}} +{"type":"assistant/chunk","seq":26,"time":1783352161477,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":27,"time":1783352161478,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"te"}}} +{"type":"assistant/chunk","seq":28,"time":1783352161478,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"al"}}} +{"type":"assistant/chunk","seq":29,"time":1783352161478,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user's favorite color is teal, as stated in the context provided by the plugin."}}}} +{"type":"assistant/chunk","seq":30,"time":1783352161478,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"teal"}}}} +{"type":"assistant/chunk","seq":31,"time":1783352161511,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2892,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":32,"time":1783352161512,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":33,"time":1785122243360,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user's favorite color is teal, as stated in the context provided by the plugin."},{"type":"text","text":"teal"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2892,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"} +{"type":"step/end","seq":34,"time":1785122243360,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":35,"time":1785122243360,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/session.jsonl index 126a761309..3d6badf9da 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/session.jsonl @@ -1,6 +1 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"hook/invoked","seq":1,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"codex","handlerId":"codex:UserPromptSubmit:1"}} -{"type":"hook/result","seq":2,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"block","exitCode":2,"stderrSummary":"blocked by codex policy hook","durationMs":0}} -{"type":"prompt/blocked","seq":3,"time":0,"data":{"content":[{"type":"text","text":"Delete everything in the repo."}],"source":{"kind":"user"},"reason":"blocked by codex policy hook"}} -{"type":"turn/end","seq":4,"time":0,"data":{"turn":1,"reason":{"kind":"rejected","reason":"blocked by codex policy hook"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl index 4ba2fcf5e1..a0edc4ec28 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl @@ -1,21 +1,19 @@ {"type":"session","version":0,"id":"0bebc0f4-a089-4fde-9b6e-db9532cfd4de","createdAt":1783352209682,"cwd":"/tmp/acp-snap-cwd-aopaZV","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352209686,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"hook/invoked","seq":1,"time":1783352209687,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"codex","handlerId":"codex:UserPromptSubmit:1"}} -{"type":"hook/result","seq":2,"time":1783352209706,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":19.49695100000008}} -{"type":"user/message","seq":3,"time":1783352209707,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1783352209707,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} -{"type":"session/title","seq":5,"time":1783352209707,"data":{"title":"What is my favorite color?","messageSeqs":[3],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":6,"time":1783352209709,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":7,"time":1783352209710,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":8,"time":1783352210353,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":9,"time0":1783352210353,"data":{"turn":1,"step":1,"index":0,"dt":[117,31,26,28,1,0,0,0,29,0,0,27,1,0,27,1,27,0,1,0,0,28,0,0,0,29,0,0,1,0,0,27,0,0,0,0,1],"texts":["The"," user"," asked"," about"," their"," favorite"," color",","," and"," the"," context"," tells"," me"," they"," previously"," stated"," it","'s"," te","al","."," They"," asked"," me"," to"," reply"," with"," just"," the"," color"," and"," stop",","," without"," using"," any"," tools","."]}} -{"type":"assistant/chunk","seq":47,"time":1783352210787,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":48,"time":1783352210787,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"te"}}} -{"type":"assistant/chunk","seq":49,"time":1783352210787,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"al"}}} -{"type":"assistant/chunk","seq":50,"time":1783352210788,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked about their favorite color, and the context tells me they previously stated it's teal. They asked me to reply with just the color and stop, without using any tools."}}}} -{"type":"assistant/chunk","seq":51,"time":1783352210788,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"teal"}}}} -{"type":"assistant/chunk","seq":52,"time":1783352210788,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2891,"outputTokens":41,"cacheReadTokens":0,"reasoningTokens":38}}}} -{"type":"assistant/chunk","seq":53,"time":1783352210788,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":54,"time":1783352210790,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user asked about their favorite color, and the context tells me they previously stated it's teal. They asked me to reply with just the color and stop, without using any tools."},{"type":"text","text":"teal"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2891,"outputTokens":41,"cacheReadTokens":0,"reasoningTokens":38}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53],"surfaceOp":"append"} -{"type":"step/end","seq":55,"time":1783352210790,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":56,"time":1783352210790,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":1,"time":1785122250005,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"user/message","seq":2,"time":1785122250006,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} +{"type":"session/title","seq":3,"time":1785122250006,"data":{"title":"What is my favorite color?","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":4,"time":1785122250036,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":1785122250036,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":6,"time":1785122250040,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":7,"time0":1783352209709,"data":{"turn":1,"step":1,"index":0,"dt":[1,643,0,117,31,26,28,1,0,0,0,29,0,0,27,1,0,27,1,27,0,1,0,0,28,0,0,0,29,0,0,1,0,0,27,0,0],"texts":["The"," user"," asked"," about"," their"," favorite"," color",","," and"," the"," context"," tells"," me"," they"," previously"," stated"," it","'s"," te","al","."," They"," asked"," me"," to"," reply"," with"," just"," the"," color"," and"," stop",","," without"," using"," any"," tools","."]}} +{"type":"assistant/chunk","seq":45,"time":1783352210754,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":46,"time":1783352210754,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"te"}}} +{"type":"assistant/chunk","seq":47,"time":1783352210755,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"al"}}} +{"type":"assistant/chunk","seq":48,"time":1783352210787,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked about their favorite color, and the context tells me they previously stated it's teal. They asked me to reply with just the color and stop, without using any tools."}}}} +{"type":"assistant/chunk","seq":49,"time":1783352210787,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"teal"}}}} +{"type":"assistant/chunk","seq":50,"time":1783352210787,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2891,"outputTokens":41,"cacheReadTokens":0,"reasoningTokens":38}}}} +{"type":"assistant/chunk","seq":51,"time":1783352210788,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":52,"time":1785122250042,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user asked about their favorite color, and the context tells me they previously stated it's teal. They asked me to reply with just the color and stop, without using any tools."},{"type":"text","text":"teal"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2891,"outputTokens":41,"cacheReadTokens":0,"reasoningTokens":38}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51],"surfaceOp":"append"} +{"type":"step/end","seq":53,"time":1785122250043,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":54,"time":1785122250043,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl index 26fd2bf5c1..fe94785585 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":4}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":4}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","content":[{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": 1784876318672,\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted 36006 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-035d1d054/session-ac29d2afe494/505bce11df84-session_event_read.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","content":[{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": 1785122211371,\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted 36006 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-035d1d054/session-ac29d2afe494/505bce11df84-session_event_read.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index 0881d11f46..36f5202825 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -2,29 +2,30 @@ {"type":"turn/start","seq":0,"time":1783654655602,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783654655603,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783654655603,"data":{"title":"Load the snapshot-skill skill with","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1783654655608,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783654655608,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}]}]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":6,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Load the requested skill."}}} -{"type":"assistant/chunk","seq":7,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_skill_load","name":"skill","argumentsDelta":"{\"name\":\"snapshot-skill\"}"}}} -{"type":"assistant/chunk","seq":9,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Load the requested skill."}}}} -{"type":"assistant/chunk","seq":10,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}}}} -{"type":"assistant/chunk","seq":11,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}}} -{"type":"assistant/chunk","seq":12,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1783654655609,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"} -{"type":"tool/call","seq":14,"time":1783654655609,"data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}} -{"type":"tool/result","seq":15,"time":1783654655610,"data":{"turn":1,"step":1,"callId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: /var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-DhYwNW/.dsh/skills/snapshot-skill\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\nFollow these snapshot-only instructions.\nResolve referenced resources relative to this skill directory.\n\n"}],"isError":false},"sourceEventSeqs":[14],"surfaceOp":"append"} -{"type":"step/end","seq":16,"time":1783654655610,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":17,"time":1783654655610,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":18,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":19,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The skill is loaded."}}} -{"type":"assistant/chunk","seq":20,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":21,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"DONE"}}} -{"type":"assistant/chunk","seq":22,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The skill is loaded."}}}} -{"type":"assistant/chunk","seq":23,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":24,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}}}} -{"type":"assistant/chunk","seq":25,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":26,"time":1783654655611,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The skill is loaded."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}},"sourceEventSeqs":[18,19,20,21,22,23,24,25],"surfaceOp":"append"} -{"type":"step/end","seq":27,"time":1783654655611,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":28,"time":1783654655611,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":3,"time":1784903324926,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}],"source":{"kind":"plugin","plugin":"dsh-tool-skill"}},"surfaceOp":"append"} +{"type":"step/start","seq":4,"time":1784903324927,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":1784903324928,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":6,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":7,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Load the requested skill."}}} +{"type":"assistant/chunk","seq":8,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":9,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_skill_load","name":"skill","argumentsDelta":"{\"name\":\"snapshot-skill\"}"}}} +{"type":"assistant/chunk","seq":10,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Load the requested skill."}}}} +{"type":"assistant/chunk","seq":11,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}}}} +{"type":"assistant/chunk","seq":12,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1784903324935,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1784903324935,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}},"sourceEventSeqs":[6,7,8,9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1784903324936,"data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}} +{"type":"tool/result","seq":16,"time":1784903324944,"data":{"turn":1,"step":1,"callId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: /var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-DhYwNW/.dsh/skills/snapshot-skill\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\nFollow these snapshot-only instructions.\nResolve referenced resources relative to this skill directory.\n\n"}],"isError":false},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1784903324944,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1784903324952,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":20,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The skill is loaded."}}} +{"type":"assistant/chunk","seq":21,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":22,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"DONE"}}} +{"type":"assistant/chunk","seq":23,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The skill is loaded."}}}} +{"type":"assistant/chunk","seq":24,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":25,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}}}} +{"type":"assistant/chunk","seq":26,"time":1784903324956,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":27,"time":1784903324956,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The skill is loaded."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}},"sourceEventSeqs":[19,20,21,22,23,24,25,26],"surfaceOp":"append"} +{"type":"step/end","seq":28,"time":1784903324956,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":29,"time":1784903324956,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl index 2270fc0845..f39968c84f 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl @@ -18,7 +18,7 @@ {"type":"turn/start","seq":38,"time":1783352137162,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":39,"time":1783352137163,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":40,"time":1783352137163,"data":{"turn":2,"step":1}} -{"type":"request/header","seq":41,"time":1783352137163,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} +{"type":"request/header","seq":41,"time":1785142305260,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} {"type":"assistant/chunk","seq":42,"time":1783352137783,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":43,"time0":1783352137783,"data":{"turn":2,"step":1,"index":0,"dt":[178,28,31,26,0,0,0,28,1,0,0,0,0,28,0,0,0,0,28,28,1,0,0,28,0,0,29,0,0,28,1,28,1],"texts":["The"," user"," asked"," me"," to"," remember"," the"," project"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," now"," they","'re"," asking"," what"," it"," is","."," I"," should"," just"," reply"," with"," that"," word","."]}} {"type":"assistant/chunk","seq":77,"time":1783352138275,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} @@ -26,7 +26,7 @@ {"type":"assistant/chunk","seq":82,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."}}}} {"type":"assistant/chunk","seq":83,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}} {"type":"assistant/chunk","seq":84,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}}}} -{"type":"assistant/chunk","seq":85,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":86,"time":1783352138308,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85],"surfaceOp":"append"} -{"type":"step/end","seq":87,"time":1783352138308,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":88,"time":1783352138308,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"assistant/chunk","seq":85,"time":1785142305270,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":86,"time":1785142305270,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85],"surfaceOp":"append"} +{"type":"step/end","seq":87,"time":1785142305270,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":88,"time":1785142305270,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl index a664a20f76..dd63a6f0d2 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl @@ -18,7 +18,7 @@ {"type":"turn/start","seq":32,"time":1783352147508,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":33,"time":1783352147509,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":34,"time":1783352147509,"data":{"turn":2,"step":1}} -{"type":"request/header","seq":35,"time":1783352147509,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} +{"type":"request/header","seq":35,"time":1785142306299,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} {"type":"assistant/chunk","seq":36,"time":1783352147925,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":37,"time0":1783352147925,"data":{"turn":2,"step":1,"index":0,"dt":[94,29,1,0,27,0,1,0,0,0,29,0,0,0,35,0,0,0,0,26,29,31,0,30,0,0,27,1,27,0],"texts":["The"," user"," is"," asking"," me"," to"," recall"," the"," project"," cod","ew","ord"," that"," was"," mentioned"," earlier"," in"," the"," conversation","."," I"," was"," told"," to"," remember"," it",":"," SA","FF","RON","."]}} {"type":"assistant/chunk","seq":68,"time":1783352148313,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} @@ -26,7 +26,7 @@ {"type":"assistant/chunk","seq":72,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."}}}} {"type":"assistant/chunk","seq":73,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}} {"type":"assistant/chunk","seq":74,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}}}} -{"type":"assistant/chunk","seq":75,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":76,"time":1783352148345,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75],"surfaceOp":"append"} -{"type":"step/end","seq":77,"time":1783352148345,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":78,"time":1783352148345,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"assistant/chunk","seq":75,"time":1785142306309,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":76,"time":1785142306309,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75],"surfaceOp":"append"} +{"type":"step/end","seq":77,"time":1785142306309,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":78,"time":1785142306309,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index 883e685a6d..fdb7dd59b5 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -2,24 +2,25 @@ {"type":"turn/start","seq":0,"time":1783778297065,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783778297066,"data":{"title":"Read nested/task.txt with the read","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1783778297069,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783778297070,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}]}]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":6,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_read","name":"read","argumentsDelta":"{\"file_path\":\"nested/task.txt\"}"}}} -{"type":"assistant/chunk","seq":7,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}}}} -{"type":"assistant/chunk","seq":8,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":9,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":10,"time":1783778297070,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"tool/call","seq":11,"time":1783778297070,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} -{"type":"tool/result","seq":12,"time":1783778297072,"data":{"turn":1,"step":1,"callId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} -{"type":"user/message","seq":13,"time":1783778297072,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"} -{"type":"step/end","seq":14,"time":1783778297072,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":15,"time":1783778297072,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":16,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":17,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":18,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":19,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":20,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":21,"time":1783778297073,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} -{"type":"step/end","seq":22,"time":1783778297073,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":23,"time":1783778297073,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":3,"time":1784903339799,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]}},"surfaceOp":"append"} +{"type":"step/start","seq":4,"time":1784903339799,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":1784903339800,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":6,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":7,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_read","name":"read","argumentsDelta":"{\"file_path\":\"nested/task.txt\"}"}}} +{"type":"assistant/chunk","seq":8,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}}}} +{"type":"assistant/chunk","seq":9,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":10,"time":1784903339801,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":11,"time":1784903339801,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} +{"type":"tool/call","seq":12,"time":1784903339802,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} +{"type":"tool/result","seq":13,"time":1784903339813,"data":{"turn":1,"step":1,"callId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[12],"surfaceOp":"append"} +{"type":"user/message","seq":14,"time":1784903339813,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":1784903339813,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":16,"time":1784903339820,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":17,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":18,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":19,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":20,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":21,"time":1784903339821,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":22,"time":1784903339821,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1784903339821,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":24,"time":1784903339822,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/cordis-agent/tests/cordis-tools.e2e.ts b/examples/cordis-agent/tests/cordis-tools.e2e.ts index b5f8e27564..9d6b409433 100644 --- a/examples/cordis-agent/tests/cordis-tools.e2e.ts +++ b/examples/cordis-agent/tests/cordis-tools.e2e.ts @@ -42,11 +42,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const agent = ctx.agentLoop.create(SessionId('cordis-e2e-listener'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ + agent.followup({ content: [{ type: 'text', text: 'Use cordis_mount to mount a plugin that listens to the \'agent/status\' ' + 'cordis event and logs every change with console.log. Reply "mounted" once done.', - }]) + }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) // The WORLD check: the turn's own running→idle transition must have driven @@ -58,7 +58,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif }) expect(resultText(mid)).toContain('dyn-') - agent.followup([{ type: 'text', text: 'Now unmount the plugin you just mounted.' }]) + agent.followup({ content: [{ type: 'text', text: 'Now unmount the plugin you just mounted.' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const after = await ctx.tools.execute({ @@ -72,14 +72,14 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif ctx = await cordisHarness() const agent = ctx.agentLoop.create(SessionId('cordis-e2e-selftool'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ + agent.followup({ content: [{ type: 'text', text: 'Give yourself a new tool: use cordis_mount to mount a plugin with ' + 'inject ["tools"] that calls harness.registerTool(ctx, harness.defineTool({...})) ' + 'to register a tool named reverse_text with one required string parameter ' + '"text", returning the text reversed. Then CALL reverse_text with the ' + 'exact text "harness" and report its exact output.', - }]) + }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) // World checks: the tool exists in the registry, was invoked as a real tool call, and its @@ -119,7 +119,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif ctx = await cordisHarness() const agent = ctx.agentLoop.create(SessionId('cordis-e2e-compose'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ + agent.followup({ content: [{ type: 'text', text: 'Mount TWO separate plugins with cordis_mount. First a provider: apply calls ' + 'ctx.provide(\'shouter\', { shout: (s) => s.toUpperCase() }). Second a consumer with ' @@ -127,7 +127,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif + 'a tool named shout_text with one required string parameter "text" whose execute returns ' + 'ctx.shouter.shout(args.text) as a text content block. Then CALL shout_text with "quiet" ' + 'and report the exact output.', - }]) + }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) // World checks: the service is really in the store, the tool really ran. @@ -144,7 +144,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif .flatMap(event => event.data.content.filter(block => block.type === 'text').map(block => block.text)) expect(shoutResults.some(text => text.includes('QUIET'))).toBe(true) - agent.followup([{ type: 'text', text: 'Now unmount ONLY the provider plugin (the one that provided shouter).' }]) + agent.followup({ content: [{ type: 'text', text: 'Now unmount ONLY the provider plugin (the one that provided shouter).' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) // The consumer must have been parked by cordis itself: service gone, diff --git a/examples/headless-agent/goal.cordis.snapshot.yml b/examples/headless-agent/goal.cordis.snapshot.yml index b853410ef0..f6eeeb05ec 100644 --- a/examples/headless-agent/goal.cordis.snapshot.yml +++ b/examples/headless-agent/goal.cordis.snapshot.yml @@ -1,12 +1,18 @@ -# Replay counterpart to goal.cordis.yml; only the live model is replaced. +# Replay counterpart to goal.cordis.yml. It includes cordis.yml directly because +# a config patch cannot target an entry behind a nested include, then restates +# the goal overlay while replacing the live model with keyless replay. - id: base name: '@cordisjs/plugin-include' config: - path: ./goal.cordis.yml + path: ./cordis.yml patches: - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' disabled: true - insert: + - id: goal + name: '@deepseek-ai/dsh-goal' + - id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index 0619f8f547..824bdfa580 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -314,12 +314,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p ctx = await codeModeHarness(workdir) const agent = ctx.agentLoop.create(SessionId('e2e-code-mode'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ + agent.followup({ content: [{ type: 'text', text: 'Using one run_code program: run `echo alpha-7` with the bash tool, run `echo beta-9` with the bash tool, ' + 'then write both outputs joined by a plus sign into combined.txt (bash heredoc or redirect), ' + 'and return only the joined string.', - }]) + }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const events: SessionEvent[] = [...agent.session.events] @@ -366,21 +366,17 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, }) - handle.agent.followup([{ + handle.agent.followup({ content: [{ type: 'text', text: 'Use one run_code program to call tools.read on pkg/deep/task.txt. After it finishes, answer: Code Mode workspace handshake?', - }]) + }], source: { kind: 'user' } }) await waitForIdle(ctx, handle.agent) const events: SessionEvent[] = [...handle.agent.session.events] const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read') const outerResult = events.find(event => event.type === 'tool/result') const workspaceContext = events.find(event => event.type === 'user/message' - && event.data.source.kind === 'plugin' - && typeof event.data.meta === 'object' - && event.data.meta !== null - && !Array.isArray(event.data.meta) - && event.data.meta.kind === 'workspace-instructions') + && event.data.source.kind === 'workspace-instructions') expect(dispatch).toBeDefined() expect(outerResult).toBeDefined() expect(workspaceContext).toBeDefined() diff --git a/examples/headless-agent/tests/coding-task.e2e.ts b/examples/headless-agent/tests/coding-task.e2e.ts index 4a827858bb..3b7e200582 100644 --- a/examples/headless-agent/tests/coding-task.e2e.ts +++ b/examples/headless-agent/tests/coding-task.e2e.ts @@ -56,12 +56,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) const agent = ctx.agentLoop.create(SessionId('e2e-task'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ + agent.followup({ content: [{ type: 'text', text: 'In the current directory, `node add.test.js` fails because add.js has a bug. ' + 'Fix add.js so the test passes, run `node add.test.js` to verify, and report the result. ' + 'Do not modify add.test.js.', - }]) + }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) // The agent claims success… diff --git a/examples/headless-agent/tests/compaction.e2e.ts b/examples/headless-agent/tests/compaction.e2e.ts index a96b7f3611..48b73c3e6e 100644 --- a/examples/headless-agent/tests/compaction.e2e.ts +++ b/examples/headless-agent/tests/compaction.e2e.ts @@ -46,12 +46,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa }) const agent = ctx.agentLoop.create(SessionId('e2e-compaction'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ + agent.followup({ content: [{ type: 'text', text: 'Read file1.txt, file2.txt, file3.txt, and file4.txt one at a ' + 'time using cat (a separate bash command for each). After reading all four, tell me how ' + 'many files you read and the number mentioned in file1.txt.', - }]) + }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const events = [...agent.session.events] diff --git a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts index 0f0b02c1d9..72aa7b199d 100644 --- a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts +++ b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts @@ -59,7 +59,7 @@ export const inject = ['llm'] /** Register the keyless `cli-mock` adapter. */ export function apply(ctx: Context): void { ctx.llm.registerAdapter(['cli-mock'], new CliMockAdapter()) - ctx.on('agent/request', async (_agent, _turn, step, _config, _signal, next) => { + ctx.on('agent/request', async (_agent, _turn, step, _signal, next) => { const config = await next() return step === 2 ? { ...config, reasoningEffort: OFF } : config }) diff --git a/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts b/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts index 254870eca9..cb0e072d2e 100644 --- a/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts +++ b/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts @@ -7,7 +7,7 @@ export const name = 'seed-goal' export const inject = ['goals'] export function apply(ctx: Context): void { - ctx.on('agent/pre-step', (agent) => { + ctx.on('agent/step', (agent) => { if (ctx.goals.get(agent) !== undefined) return ctx.goals.create(agent, { objective: 'Prove the composed goal survives in the session log', diff --git a/examples/headless-agent/tests/full-loop.e2e.ts b/examples/headless-agent/tests/full-loop.e2e.ts index 4f61ec3fa3..9f50a77937 100644 --- a/examples/headless-agent/tests/full-loop.e2e.ts +++ b/examples/headless-agent/tests/full-loop.e2e.ts @@ -30,7 +30,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) const agent = ctx.agentLoop.create(SessionId('e2e-loop'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }]) + agent.followup({ content: [{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const events = [...agent.session.events] diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index f474cb5460..aee715a279 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -272,14 +272,16 @@ describe('headless stream-json snapshots', () => { const goalChanges = records.filter((record) => { if (record.type !== 'user/message') return false const data = record.data as JsonObject | undefined - const meta = data?.meta as JsonObject | undefined - return meta?.kind === 'goal/change' + const source = data?.source as JsonObject | undefined + const change = source?.change as JsonObject | undefined + return source?.kind === 'goal' && change?.kind === 'goal/change' }) expect(goalChanges).toHaveLength(1) const data = goalChanges[0]?.data as JsonObject | undefined - const meta = data?.meta as JsonObject | undefined - const goal = meta?.goal as JsonObject | undefined - expect(meta?.operation).toBe('create') + const source = data?.source as JsonObject | undefined + const change = source?.change as JsonObject | undefined + const goal = change?.goal as JsonObject | undefined + expect(change?.operation).toBe('create') expect(goal).toMatchObject({ objective: 'Finish the headless goal-tool snapshot proof', phase: 'active', diff --git a/examples/headless-agent/tests/resume.e2e.ts b/examples/headless-agent/tests/resume.e2e.ts index a4ded767da..2c9a96e099 100644 --- a/examples/headless-agent/tests/resume.e2e.ts +++ b/examples/headless-agent/tests/resume.e2e.ts @@ -41,7 +41,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses sessionId: SESSION_ID, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, })).agent - first.followup([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }]) + first.followup({ content: [{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }], source: { kind: 'user' } }) await waitForIdle(ctx, first) await ctx.fiber.dispose() ctx = undefined @@ -58,7 +58,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // The prior user turn is in the rehydrated log before the model is asked. expect(JSON.stringify(resumed.session.deriveMessages())).toContain(SECRET) - resumed.followup([{ type: 'text', text: 'What was the code I asked you to remember? Reply with just the code.' }]) + resumed.followup({ content: [{ type: 'text', text: 'What was the code I asked you to remember? Reply with just the code.' }], source: { kind: 'user' } }) await waitForIdle(ctx, resumed) // The model recalls it — only possible from the resumed history. diff --git a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl index 16518b2d03..55b4078534 100644 --- a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl @@ -21,7 +21,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":23,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":23,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0,"change":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} diff --git a/examples/headless-agent/tests/todo-write.e2e.ts b/examples/headless-agent/tests/todo-write.e2e.ts index c3053572d9..9a9ccb72cc 100644 --- a/examples/headless-agent/tests/todo-write.e2e.ts +++ b/examples/headless-agent/tests/todo-write.e2e.ts @@ -28,10 +28,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a ctx = await codingHarness(workdir, { persona: TODO_SYSTEM_PROMPT }) const agent = ctx.agentLoop.create(SessionId('e2e-todo'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ type: 'text', text: + agent.followup({ content: [{ type: 'text', text: 'Use the todo_write tool to record a plan of exactly two steps: first ' + '"inspect the failing test" (in_progress), then "apply the fix" (pending). ' - + 'Send both in one todo_write call, then reply with the single word DONE.' }]) + + 'Send both in one todo_write call, then reply with the single word DONE.' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const events = [...agent.session.events] diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts index d3e35b6ce5..517fa6adab 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts +++ b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts @@ -80,10 +80,19 @@ class ScriptedTuiAdapter extends LlmAdapter { throw new Error('the scripted TUI request did not apply the selected model and reasoning effort') } const lastMessage = options.messages.at(-1) - const lastText = (lastMessage?.content ?? []) - .filter(block => block.type === 'text') - .map(block => block.text) - .join('\n') + // The loop appends plugin-sourced context (the plan-mode notice, the + // tool-skill catalog) AFTER the admitted prompt, so the scripted trigger + // may sit one or more user messages back: scan the whole trailing run of + // user-role messages since the last assistant message. + const trailingUserTexts: string[] = [] + for (let index = options.messages.length - 1; index >= 0; index--) { + const message = options.messages[index] + if (message?.role !== 'user') break + for (const block of message.content) { + if (block.type === 'text') trailingUserTexts.push(block.text) + } + } + const lastText = trailingUserTexts.join('\n') if (lastText.includes(DEFAULT_MODE_PROBE)) { if (options.system?.includes('Stay in plan mode for this scripted TUI test.')) { throw new Error('the scripted TUI request retained plan guidance after /plan off') diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt index 099bf63b81..026bed95b4 100644 --- a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt @@ -11,18 +11,18 @@ buffer 2| " deepseek-v4-flash • main-session" style 1-34 dim 3| -4| " Entering plan mode (applies from the next step). Use /plan off to leave. " - style 1-72 fg=bright-black -5| -6| "▌ " +4| "▌ " style 0-0 fg=bright-blue -7| "▌ You " +5| "▌ You " style 0-0 fg=bright-blue style 2-4 fg=bright-blue bold -8| "▌ Reply with exactly the word: ONE. No tools. " +6| "▌ Reply with exactly the word: ONE. No tools. " style 0-0 fg=bright-blue -9| "▌ " +7| "▌ " style 0-0 fg=bright-blue +8| +9| " Entering plan mode (applies from the next step). Use /plan off to leave. " + style 1-72 fg=bright-black 10| 11| " Reasoning " style 1-9 fg=bright-black italic @@ -36,20 +36,20 @@ buffer 17| " Leaving plan mode (applies from the next step). " style 1-47 fg=bright-black 18| -19| " Context · plan-mode " - style 1-19 dim -20| " The user switched this session back to the default mode. " - style 1-56 fg=bright-black -21| -22| "▌ " +19| "▌ " style 0-0 fg=bright-blue -23| "▌ You " +20| "▌ You " style 0-0 fg=bright-blue style 2-4 fg=bright-blue bold -24| "▌ Reply with exactly the word: TWO. No tools. " +21| "▌ Reply with exactly the word: TWO. No tools. " style 0-0 fg=bright-blue -25| "▌ " +22| "▌ " style 0-0 fg=bright-blue +23| +24| " Context · plan-mode " + style 1-19 dim +25| " The user switched this session back to the default mode. " + style 1-56 fg=bright-black 26| 27| " Reasoning " style 1-9 fg=bright-black italic diff --git a/packages/acp/acp/src/codec.ts b/packages/acp/acp/src/codec.ts index 2a88af1184..8d4693d9d5 100644 --- a/packages/acp/acp/src/codec.ts +++ b/packages/acp/acp/src/codec.ts @@ -19,7 +19,6 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason { return 'max_tokens' case 'aborted': case 'disposed': - case 'rejected': case 'interrupted': return 'cancelled' case 'error': diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index 3ca4767c67..a6228b9615 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -77,6 +77,12 @@ interface SessionRecord { resolve: (reason: StopReason) => void reject: (error: Error) => void turn: number | undefined + /** + * A failed turn's terminal reason, held until quiescence: a retry action + * closes the failed turn and opens a successor that adopts the prompt, so + * rejecting at `turn/end` would race the recovery. + */ + pendingError: Extract | undefined } | undefined } @@ -125,15 +131,11 @@ export function apply(ctx: Context, config: AcpConfig): void { inflight.resolve(reason) } - const settleFromTurnEnd = ( + const rejectFromError = ( inflight: NonNullable, - reason: TurnEndReason, + reason: Extract, ): void => { - if (reason.kind === 'error') { - inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`)) - return - } - inflight.resolve(turnEndToStopReason(reason)) + inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`)) } // Emit only committed assistant text. Raw chunks, reasoning, tools, plans, @@ -162,10 +164,22 @@ export function apply(ctx: Context, config: AcpConfig): void { if (inflight.turn === undefined && event.data.trigger.kind === 'message' && event.data.trigger.source.kind === 'user') { inflight.turn = event.data.turn + } else if (inflight.pendingError !== undefined && event.data.trigger.kind === 'retry') { + // A recovery policy opened a retry turn on the failed history: the + // prompt rides it instead of rejecting on the failed turn's end. + inflight.turn = event.data.turn + inflight.pendingError = undefined } } else if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) { - record.inflight = undefined - settleFromTurnEnd(inflight, event.data.reason) + if (event.data.reason.kind === 'error') { + // Hold the rejection: request recovery may adopt the prompt with a + // successor turn; quiescence without one delivers this error. + inflight.turn = undefined + inflight.pendingError = event.data.reason + } else { + record.inflight = undefined + inflight.resolve(turnEndToStopReason(event.data.reason)) + } } } }) @@ -243,23 +257,47 @@ export function apply(ctx: Context, config: AcpConfig): void { const text = acpPromptToText(params.prompt) if (text.trim().length === 0) throw invalidParams('empty prompt') + // Not driving a retired agent is this bridge's contract: an + // agent-loop-only reload disposes the loop's agents while the bridge + // record survives, so validate the record against the live registry + // before sending — a disposed machine would accept the item silently. + if (ctx.agents.get(record.agent.id) !== record.agent) { + throw internalError('prompt was not queued: the agent was disposed outside the bridge') + } const stopReason = await new Promise((resolve, reject) => { // Arm the slot before followup() so a listener-driven synchronous // turn cannot slip past correlation; a synchronous followup() - // failure (an agent disposed outside the bridge, e.g. an - // agent-loop-only reload) must free the slot again or the session + // failure (invalid input) must free the slot again or the session // would reject every later prompt as already in flight. - record.inflight = { resolve, reject, turn: undefined } + const inflight: NonNullable = { + resolve, reject, turn: undefined, pendingError: undefined, + } + record.inflight = inflight try { - record.agent.followup([{ type: 'text', text }]) + record.agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } }) + // The machine's send() contains listener failures and accepts + // any typed input; this guards a future synchronous throw so the + // slot cannot wedge. + /* v8 ignore start -- future-proofing guard, see above */ } catch (error: unknown) { record.inflight = undefined - // followup() throws only Errors (disposed agent / invalid input); - // the String arm is a defensive fallback for a non-Error throw. - /* v8 ignore next */ const detail = error instanceof Error ? error.message : String(error) throw internalError(`prompt was not queued: ${detail}`) } + /* v8 ignore stop */ + // Admission is pre-turn and retries outlive their failed turn, so a + // turnless slot settles only at quiescence: a held failure rejects + // (no retry adopted the prompt); no turn at all means admission + // discarded the prompt — report cancelled. + void record.agent.whenIdle().then(() => { + if (record.inflight !== inflight || inflight.turn !== undefined) return + record.inflight = undefined + if (inflight.pendingError !== undefined) { + rejectFromError(inflight, inflight.pendingError) + return + } + inflight.resolve('cancelled') + }) }) return { stopReason } }, diff --git a/packages/acp/acp/tests/codec.spec.ts b/packages/acp/acp/tests/codec.spec.ts index 2fdf544500..7f5441e4df 100644 --- a/packages/acp/acp/tests/codec.spec.ts +++ b/packages/acp/acp/tests/codec.spec.ts @@ -9,7 +9,6 @@ describe('ACP automation codec', () => { [{ kind: 'max-tokens' }, 'max_tokens'], [{ kind: 'aborted' }, 'cancelled'], [{ kind: 'disposed' }, 'cancelled'], - [{ kind: 'rejected', reason: 'blocked' }, 'cancelled'], [{ kind: 'interrupted' }, 'cancelled'], [{ kind: 'error', step: 1, message: 'boom' }, 'end_turn'], ] diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index 1303cde0b2..ee57d51baf 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -21,7 +21,7 @@ describe('ACP connection ownership', () => { await harness.acpFiber.dispose() await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' }) - expect(agent.status).toBe('disposed') + expect(agent.status).toBe('idle') expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) @@ -44,7 +44,7 @@ describe('ACP connection ownership', () => { await harness.closeClientTransport() await harness.acpFiber.dispose() - expect(agent.status).toBe('disposed') + expect(agent.status).toBe('idle') expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined() }) @@ -58,10 +58,10 @@ describe('ACP connection ownership', () => { await vi.waitFor(() => { expect(agent.status).toBe('running') }) await harness.abortClientTransport() - await vi.waitFor(() => { expect(agent.status).toBe('disposed') }) await vi.waitFor(() => { expect(harness!.ctx.agents.get(SessionId(sessionId)) === undefined).toBe(true) }) + expect(agent.status).toBe('idle') }) it('disconnect and plugin disposal share one quiescence boundary', async () => { @@ -73,7 +73,7 @@ describe('ACP connection ownership', () => { await vi.waitFor(() => { expect(agent.status).toBe('running') }) await Promise.all([harness.closeClientTransport(), harness.acpFiber.dispose()]) - expect(agent.status).toBe('disposed') + expect(agent.status).toBe('idle') expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) diff --git a/packages/acp/acp/tests/edges.spec.ts b/packages/acp/acp/tests/edges.spec.ts index 1a16647d01..e4929e59fb 100644 --- a/packages/acp/acp/tests/edges.spec.ts +++ b/packages/acp/acp/tests/edges.spec.ts @@ -49,7 +49,7 @@ describe('ACP automation output boundary', () => { sessionId: SessionId('foreign'), agentOptions: { provider: 'mock', model: 'mock' }, }) - agent.followup([{ type: 'text', text: 'go' }]) + agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) await agent.whenIdle() expect(harness.updates).toHaveLength(0) }) diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index cf081d1f2a..19c1bbe0dc 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -48,7 +48,7 @@ describe('ACP prompt lifecycle', () => { it('rejects an ordinary plugin failure through the same prompt boundary', async () => { harness = await makeBridgeHarness({ script: [textResponse('must not run')] }) - harness.ctx.on('agent/pre-step', () => { throw new Error('plugin pre-step failed') }) + harness.ctx.on('agent/step', () => { throw new Error('plugin pre-step failed') }) const sessionId = await newSession(harness) await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) .rejects.toThrow(/turn failed: plugin pre-step failed/) @@ -72,7 +72,7 @@ describe('ACP prompt lifecycle', () => { harness.ctx.on('agent/inbox/enqueue', (subject) => { if (subject === agent && !injected) { injected = true - agent.inject([{ type: 'text', text: 'context' }], { source: { kind: 'plugin', plugin: 'test' } }) + agent.inject({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } }) } }) @@ -166,4 +166,40 @@ describe('ACP prompt lifecycle', () => { .resolves.toEqual({ stopReason: 'end_turn' }) await vi.waitFor(() => { expect(messageText(harness!)).toBe('next') }) }) + + it('a retry turn adopts the prompt instead of rejecting at the failed turn end', async () => { + harness = await makeBridgeHarness({ script: [errorResponse('transient boom'), textResponse('recovered')] }) + // A recovery policy: schedule one retry for the failed request. + let retried = false + harness.ctx.on('agent/request-error', async (_subject) => { + if (!retried) { + retried = true + return { kind: 'retry' } + } + }) + const sessionId = await newSession(harness) + const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) + expect(result.stopReason).toBe('end_turn') + await vi.waitFor(() => { expect(messageText(harness!)).toBe('recovered') }) + }) + + it('a failed turn with no retry still rejects, at quiescence', async () => { + harness = await makeBridgeHarness({ script: [errorResponse('terminal boom')] }) + let offered = 0 + harness.ctx.on('agent/request-error', async () => { offered += 1 }) + const sessionId = await newSession(harness) + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) + .rejects.toThrow(/turn failed: terminal boom/) + expect(offered).toBe(1) + }) + + it('an admission-blocked prompt settles cancelled instead of hanging', async () => { + harness = await makeBridgeHarness({ script: [] }) + harness.ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy said no' })) + const sessionId = await newSession(harness) + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) + .resolves.toEqual({ stopReason: 'cancelled' }) + // The blocked prompt opened no turn and streamed nothing. + expect(messageText(harness)).toBe('') + }) }) diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 434f53ca2a..fcfaae08d4 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -111,11 +111,12 @@ describe('bash tool through the agent loop', () => { const location = ctx.sessionPersistence.locate(agent.session.header) expect(location?.kind).toBe('jsonl') - agent.followup([{ type: 'text', text: 'inspect the current session' }]) + agent.followup({ content: [{ type: 'text', text: 'inspect the current session' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const result = findEvent(events(agent), 'tool/result') expect(resultText(result)).toBe(`${dshHome}\n1\nsession-env-id\n${location?.path}\nunset\nabsent\n`) + await ctx.sessions.flush(agent.session) expect(existsSync(location!.path)).toBe(true) const header = JSON.parse(readFileSync(location!.path, 'utf8').split('\n')[0]!) as { type: string; id: string } expect(header).toMatchObject({ type: 'session', id: 'session-env-id' }) @@ -130,7 +131,7 @@ describe('bash tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' }) - agent.followup([{ type: 'text', text: 'run echo integration-ok' }]) + agent.followup({ content: [{ type: 'text', text: 'run echo integration-ok' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const log = events(agent) @@ -162,7 +163,7 @@ describe('bash tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' }) - agent.followup([{ type: 'text', text: 'run exit 9' }]) + agent.followup({ content: [{ type: 'text', text: 'run exit 9' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const toolResult = findEvent(events(agent), 'tool/result') @@ -182,7 +183,7 @@ describe('bash tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' }) - agent.followup([{ type: 'text', text: 'run echo bg-ok in the background' }]) + agent.followup({ content: [{ type: 'text', text: 'run echo bg-ok in the background' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const firstResult = findEvent(events(agent), 'tool/result') @@ -202,7 +203,7 @@ describe('bash tool through the agent loop', () => { expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' }) // The next turn collects the output through the generic task tool. - agent.followup([{ type: 'text', text: 'collect it' }]) + agent.followup({ content: [{ type: 'text', text: 'collect it' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const readResult = findEvent(events(agent), 'tool/result', 'last') expect(readResult.data.isError).toBe(false) diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index e1b2c42962..8cd57c4eb3 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -87,7 +87,6 @@ export interface ContextMessageNode { time: number content: readonly ContentBlock[] source: unknown - meta?: unknown } /** A tool result paired (when in-window) with its call head. */ diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 0f40d9bf2a..d72c1af8e3 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -46,7 +46,6 @@ function materializeNode( return { kind: 'context', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, - meta: event.data.meta, } } return { diff --git a/packages/client/runtime/tests/queue-store.spec.ts b/packages/client/runtime/tests/queue-store.spec.ts index 360f7c1a9d..6fe9f33c80 100644 --- a/packages/client/runtime/tests/queue-store.spec.ts +++ b/packages/client/runtime/tests/queue-store.spec.ts @@ -20,7 +20,8 @@ const rid = (id: string): RpcId => id as RpcId function queuedFrame(body: string, rpcId: string, steering = false): MuxFrame { return { type: 'session/queued', sessionId: SID, content: text(body), - source: { kind: 'user', rpcId: rid(rpcId) } as never, steering, + source: { kind: 'user', rpcId: rid(rpcId) } as never, + steering, } } @@ -41,7 +42,8 @@ describe('queue intake', () => { session.handleMuxEnvelope(rid('env-2'), { type: 'session/queued', sessionId: SID, content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never], - source: { kind: 'plugin', plugin: 'loop' }, steering: false, + source: { kind: 'plugin', plugin: 'loop' }, + steering: false, }) expect(session.getSnapshot().queue).toEqual([{ key: 'f:env-2', preview: 'hi [image]' }]) }) @@ -85,22 +87,22 @@ describe('queue retirement (host queuedMirror rules)', () => { it('steering/message drains the source-matched steering row only', () => { const session = makeSession() - session.handleMuxEnvelope(rid('e1'), queuedFrame('普通', 'p-1')) - session.handleMuxEnvelope(rid('e2'), queuedFrame('插话', 'p-2', true)) + session.handleMuxEnvelope(rid('e1'), queuedFrame('普通', 'p-1')) // idle → non-steering + session.handleMuxEnvelope(rid('e3'), queuedFrame('插话', 'p-2', true)) // Loop-authored steering (different source) must not consume the user entry. const foreignSteering = { seq: 0, time: 1, type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: text('loop'), source: { kind: 'plugin', plugin: 'loop' } }, } as never - session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: foreignSteering }) + session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: foreignSteering }) expect(session.getSnapshot().queue).toHaveLength(2) const matchedSteering = { seq: 1, time: 2, type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: text('插话'), source: { kind: 'user', rpcId: rid('p-2') } }, } as never - session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: matchedSteering }) + session.handleMuxEnvelope(rid('e5'), { type: 'session/event', sessionId: SID, event: matchedSteering }) expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-1']) }) @@ -108,7 +110,7 @@ describe('queue retirement (host queuedMirror rules)', () => { const session = makeSession() session.handleRunning(true) session.handleMuxEnvelope(rid('e1'), queuedFrame('一', 'p-1')) - session.handleMuxEnvelope(rid('e2'), queuedFrame('二', 'p-2', true)) + session.handleMuxEnvelope(rid('e2'), queuedFrame('二', 'p-2')) session.handleRunning(false) expect(session.getSnapshot().queue).toEqual([]) }) @@ -144,6 +146,19 @@ describe('queue reconnect semantics', () => { await session.resync() expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-fresh']) }) + + it('replayed steering retires without a replayed turn/start', () => { + const session = makeSession() + session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 }) + session.handleMuxEnvelope(rid('e2'), queuedFrame('重连插话', 'p-steer', true)) + const committed = { + seq: 6, time: 2, + type: 'steering/message', surfaceOp: 'append', + data: { turn: 1, content: text('重连插话'), source: { kind: 'user', rpcId: rid('p-steer') } }, + } as never + session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: committed }) + expect(session.getSnapshot().queue).toEqual([]) + }) }) describe('manager buffering of queued frames', () => { diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 3c1f3cf85e..571104dac8 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -153,7 +153,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) case 'context': return (
- +
) default: diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index b96821f761..bf1266981e 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -103,7 +103,7 @@ describe('MessageItem arms', () => { it('context and unknown nodes render their JSON rows', () => { const ctxView = render( - , + , ) expect(ctxView.getByText(/上下文注入/)).toBeTruthy() const unknownView = render( diff --git a/packages/compact/compact-basic/README.i18n.yaml b/packages/compact/compact-basic/README.i18n.yaml index 5b5c817fe8..794d20e67a 100644 --- a/packages/compact/compact-basic/README.i18n.yaml +++ b/packages/compact/compact-basic/README.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 -README.md: 2f65b0d8f223c4de8999006d005778e7087e8a4d -README.zh.md: c0be5d7dc92a60c649b792bfa181c0df7a12db9f +# pnpm run verify-translation-pairing --write packages/compact/compact-basic/README.md +README.md: 775355f1ac1a7c79c16f66a5b2489d73df7b960d +README.zh.md: 2f7ccc7dd00fa5599d3d6bbe66e81d3312d38dd9 diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 2f65b0d8f2..775355f1ac 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -10,16 +10,16 @@ This is the implementation tier of the compaction capability — see the [interf This backend owns the compaction policy: -- **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering. +- **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Step-boundary pressure therefore includes the actual system prompt, tools, routing, assistant completion, tool results, buffered context, and steering. - **Routed policy** — proactive pressure resolves capacity from the adapter that owns the latest durable provider/model route, then scales the default policy plus an optional exact-target override into concrete token budgets. Model discovery remains advisory and is not consulted. -- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure post-step checks never prune. +- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure step checks never prune. - **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes. The optional pruner can repair an oversized closed tool unit when its text-bearing result is the removable bulk; indivisible non-tool units and non-prunable tool remainders remain out of scope. - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. - **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. It sets `GenerateOptions.purpose` to `compaction`, which adapters may forward as request attribution (the DeepSeek adapter sends `x-deepseek-harness-compact: 1`) without touching the model-visible body. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. - **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint. -- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes. +- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/step` listener checks pressure before request derivation. A canonical provider overflow is offered through `agent/request-error` after the failed step; the plugin compacts there and returns a retry action only after durable surface progress. - **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure. -- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational post-step failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress. +- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational pressure failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress. The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`. @@ -38,7 +38,7 @@ Every setting is optional. Top-level policy fields are defaults for every routed | `compactionRetries` | no (default `1`) | Extra attempts after the first when pressure remains above threshold. | | `maxOverflowRetries` | no (default `1`) | Maximum retries after canonical context-window overflow; `0` disables recovery only. | | `modelPolicies` | no (default `[]`) | Exact `{ provider, model, ...partialPolicy }` overrides; matching uses both fields and does not depend on `listModels()`. | -| `auto` | no (default `true`) | Register post-step pressure and overflow-recovery listeners. Set `false` for manual-only. | +| `auto` | no (default `true`) | Register step-boundary pressure and overflow-recovery listeners. Set `false` for manual-only. | Every `modelPolicies` entry accepts the policy fields above except `auto` and `modelPolicies` itself. If an entry supplies either retention field, it replaces the default policy's retention choice; otherwise retention is inherited. Summarization provider/model remain a pair inside each entry. diff --git a/packages/compact/compact-basic/README.zh.md b/packages/compact/compact-basic/README.zh.md index c0be5d7dc9..2f7ccc7dd0 100644 --- a/packages/compact/compact-basic/README.zh.md +++ b/packages/compact/compact-basic/README.zh.md @@ -10,16 +10,16 @@ 该后端拥有压缩策略: -- **测量**:单例 `ctx.tokenMeter` 会在同一个已消费日志 revision 上为最新规范已记录 envelope 与当前表层计价。因此,步骤后压力会包含实际系统提示词、工具、前缀、路由、assistant 完成、工具结果、缓冲上下文与 steering。 +- **测量**:单例 `ctx.tokenMeter` 会在同一个已消费日志 revision 上为最新规范已记录 envelope 与当前表层计价。因此,步骤边界压力会包含实际系统提示词、工具、路由、assistant 完成、工具结果、缓冲上下文与 steering。 - **路由策略**:主动压力从拥有最新持久提供方/模型路由的适配器解析容量,再将默认策略与可选的精确目标覆盖缩放为具体 token 预算。模型发现仍只提供建议,不会被咨询。 -- **不依赖模型的剪枝**:在压力或规范溢出符合条件后,可选的 [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) 服务会在选择范围之前改写超大工具结果。Compact-basic 通过 `ctx.tokenMeter` 重新测量;如果压力已回到安全范围,就跳过摘要,否则摘要已剪枝表层。低于压力的步骤后检查绝不剪枝。 +- **不依赖模型的剪枝**:在压力或规范溢出符合条件后,可选的 [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) 服务会在选择范围之前改写超大工具结果。Compact-basic 通过 `ctx.tokenMeter` 重新测量;如果压力已回到安全范围,就跳过摘要,否则摘要已剪枝表层。低于压力的步骤检查绝不剪枝。 - **保留**:压缩最旧的完整表层单元,同时保留近期尾部,并通过 [`dsh-compact` 边界 helper](../compact/README.md#tool-pairing-boundaries) 保持工具调用/结果 cut 平衡。轮次边界不会保护失控轮次内的旧步骤。开启且不可分的尾部在关闭前会拒绝压缩。当闭合的超大工具单元以文本型结果为可移除主体时,可选 pruner 可以修复它;不可分的非工具单元与不可剪枝的工具剩余部分不在范围内。 - **收敛**:最多按 `compactionRetries` 重试头部检查点压缩;拒绝不能缩小源内容的摘要,如果重试仍无法回到阈值以下,则抛出异常。 - **摘要**:直接 `llm/stream` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 agent(智能体)目标,而不运行仅用于 loop 的 `agent/request` seam。该调用会逐字回放会话自身的系统提示词、工具与已遮蔽区域消息,并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache,而非使它失效。它将 `GenerateOptions.purpose` 设为 `compaction`,适配器可将其作为请求归因转发(DeepSeek 适配器发送 `x-deepseek-harness-compact: 1`),但不会触碰模型可见主体。只有返回文本会进入检查点;会排除可能泄露私有推理或产生遗留调用的 reasoning 与工具调用。 - **框定**:替换 user 消息使用 `` 标签标记已建立的检查点上下文。原始摘要保留在溯源事件上,后续自动周期会合并之前的检查点。 -- **生命周期**:`compactRegion()` 会更改 `agent.session`,并记录开始、摘要、替换与结束。异步摘要后,它会拒绝已改变的表层节点快照,而不相关的仅日志事件可以追加,不会使已选 span 失效。串行 `agent/post-step` listener 会在成功输出与工具工作持久后、`step/end` 之前检查压力。规范提供方溢出会在失败步骤关闭后通过 `agent/request-error` 处理。 +- **生命周期**:`compactRegion()` 会更改 `agent.session`,并记录开始、摘要、替换与结束。异步摘要后,它会拒绝已改变的表层节点快照,而不相关的仅日志事件可以追加,不会使已选 span 失效。串行 `agent/step` listener 会在派生请求之前检查压力。规范提供方溢出会在失败步骤之后经由 `agent/request-error` 交给本插件;插件在此执行压缩,并且只在表层取得持久进展后才返回重试动作。 - **溢出恢复**:提供方已确认的溢出不需容量元数据。它会绕过常规压力与保留,执行剪枝,再尝试一次最大平衡头部缩减,并留下最新不可分单元。只要 `surface.replaceGeneration` 前进,就允许重试,包括剪枝在后续摘要工作抛出异常前已落地的情况。如果没有替换、精确目标上限已耗尽、已取消,或遇到未知/非规范错误,则保留原始提供方失败。 -- **失败处理**:不匹配的 `compact/start` 是惰性崩溃标记,因为没有摘要替换落地。区域失败会记录错误结束;除非剪枝已落地,否则表层保持不变。操作性步骤后失败会发出警告并继续;只当之前没有替换使表层前进时,溢出恢复失败才保留原始提供方错误。取消在任何进展后仍具有最高权威。 +- **失败处理**:不匹配的 `compact/start` 是惰性崩溃标记,因为没有摘要替换落地。区域失败会记录错误结束;除非剪枝已落地,否则表层保持不变。操作性压力失败会发出警告并继续;只当之前没有替换使表层前进时,溢出恢复失败才保留原始提供方错误。取消在任何进展后仍具有最高权威。 受保护的 `summarize()` 方法是唯一的子类 hook。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍位于 `ctx.tokenMeter`。hook 会将摘要块与它使用的调用 envelope 一并返回(`{ summary, provider, model, maxTokens? }`),并记录在 `compact/summary` 上。 @@ -38,7 +38,7 @@ | `compactionRetries` | 否(默认 `1`) | 压力仍高于阈值时,在首次尝试后进行的额外尝试次数。 | | `maxOverflowRetries` | 否(默认 `1`) | 规范上下文窗口溢出后的最大重试次数;`0` 只禁用恢复。 | | `modelPolicies` | 否(默认 `[]`) | 精确的 `{ provider, model, ...partialPolicy }` 覆盖;匹配使用两个字段,不依赖 `listModels()`。 | -| `auto` | 否(默认 `true`) | 注册步骤后压力与溢出恢复 listener。设为 `false` 则仅手动执行。 | +| `auto` | 否(默认 `true`) | 注册步骤边界压力与溢出恢复 listener。设为 `false` 则仅手动执行。 | 每个 `modelPolicies` 配置项都接受上述策略字段,但不接受 `auto` 和 `modelPolicies` 自身。如果配置项提供任意一个保留字段,就替换默认策略的保留选择;否则继承保留设置。摘要提供方/模型在每个配置项内仍然成对。 diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 7920197739..79a30a1e3b 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -111,6 +111,8 @@ export class BasicCompactService extends CompactService { readonly config: ResolvedConfig private readonly warnedPressureConfigTargets = new Set() + private readonly overflowRetries = new WeakMap() + private readonly overflowAgents = new WeakMap() constructor(ctx: Context, config: BasicCompactConfig = {}) { super(ctx) @@ -119,8 +121,8 @@ export class BasicCompactService extends CompactService { } /** - * Register the automatic post-step pressure and context-overflow recovery - * listeners. `compactIfNeeded` stays dynamically dispatched so subclass + * Register automatic between-step pressure and model-request overflow + * recovery. `compactIfNeeded` stays dynamically dispatched so subclass * overrides are honored at event time. */ private _registerAutomaticCompaction(): void { @@ -133,7 +135,7 @@ export class BasicCompactService extends CompactService { ) } - ctx.on('agent/post-step', async ( + ctx.on('agent/step', async ( agent: Agent, _turn: number, _step: number, @@ -142,35 +144,45 @@ export class BasicCompactService extends CompactService { if (signal.aborted) return try { const result = await this.compactIfNeeded(agent, 'pressure', signal) - if (result !== null) logResult(result, 'post-step pressure') + if (result !== null) logResult(result, 'step pressure') } catch (error: unknown) { if (error instanceof TargetPressureConfigError) { if (this.warnedPressureConfigTargets.has(error.targetKey)) return this.warnedPressureConfigTargets.add(error.targetKey) } const message = error instanceof Error ? error.message : String(error) - ctx.logger.warn(`post-step compaction failed: ${message}; continuing the turn`) + ctx.logger.warn(`step compaction failed: ${message}; continuing the turn`) } }) + ctx.on('agent/settled', (agent) => { + this.overflowRetries.delete(agent) + }) + + // A successful response starts a fresh overflow-recovery sequence even + // when tool calls continue the same turn into another request. + ctx.on('session/event', (session, event) => { + if (event.type !== 'assistant/message') return + const agent = this.overflowAgents.get(session) + if (agent !== undefined) this.overflowRetries.delete(agent) + }) + ctx.on('agent/request-error', async ( agent, _turn, _step, _error, failure, - priorFailures, signal, next, ) => { - const priorOverflowFailures = priorFailures.filter( - item => item.code === CONTEXT_WINDOW_EXCEEDED_CODE, - ).length if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next() + this.overflowAgents.set(agent.session, agent) const target = routedTarget(agent.session) if (target === undefined) return next() const policy = resolveTargetPolicy(this.config, target) - if (priorOverflowFailures >= policy.maxOverflowRetries) return next() + const retries = this.overflowRetries.get(agent) ?? 0 + if (retries >= policy.maxOverflowRetries) return next() const generation = agent.session.surface.replaceGeneration let result: CompactionResult | null @@ -181,27 +193,29 @@ export class BasicCompactService extends CompactService { // A model-free prune can land before later summary work fails. That // durable reduction is sufficient retry proof; do not discard it just // because the optional second phase threw. Cancellation still wins. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while recovery is awaited. if (!signal.aborted && agent.session.surface.replaceGeneration > generation) { ctx.logger.warn( `context-overflow compaction failed after durable surface progress: ${message}; ` + 'retrying from the replacement surface', ) - return { action: 'retry' } + this.overflowRetries.set(agent, retries + 1) + return { kind: 'retry' } } ctx.logger.warn( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while recovery is awaited. `context-overflow compaction failed: ${message}; ${signal.aborted ? 'cancellation prevents retry' : 'preserving the original request error'}`, ) return next() } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while compaction is awaited. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while compaction is awaited. if (signal.aborted || agent.session.surface.replaceGeneration <= generation) return next() if (result !== null) logResult(result, 'context overflow recovery') - return { action: 'retry' } + this.overflowRetries.set(agent, retries + 1) + return { kind: 'retry' } }) } @@ -228,12 +242,12 @@ export class BasicCompactService extends CompactService { } /** - * Compact for replayed post-step pressure or one provider-confirmed context + * Compact for replayed step-boundary pressure or one provider-confirmed context * overflow. Both triggers price the latest durable routed request envelope; * overflow bypasses the normal threshold and retained-tail policy so it can * force one useful balanced reduction. * @param agent - agent whose latest durable routed request is measured. - * @param trigger - normal post-step pressure or context-overflow recovery. + * @param trigger - normal step-boundary pressure or context-overflow recovery. * @param signal - live turn cancellation signal forwarded to summarization. * @returns the latest summary compaction result, or `null` when no summary ran. */ diff --git a/packages/compact/compact-basic/src/region.ts b/packages/compact/compact-basic/src/region.ts index 3f1d330205..28c7766dcb 100644 --- a/packages/compact/compact-basic/src/region.ts +++ b/packages/compact/compact-basic/src/region.ts @@ -177,10 +177,10 @@ export async function compactSurfaceRegion( /** * Reconstruct the last routed request's cacheable prefix for the shadowed - * region: its system prompt and tool schemas, then the request-only message - * prefix followed by the region's own derived messages in surface order. The - * summarizer appends only the compaction instruction after this, so the call - * is a genuine prefix of the conversation and reuses the provider's KV cache. + * region: its system prompt and tool schemas, then the region's own derived + * messages in surface order. The summarizer appends only the compaction + * instruction after this, so the call is a genuine prefix of the conversation + * and reuses the provider's KV cache. * @param session - session supplying the request header and per-node projection. * @param shadowedSeqs - the surface-node seqs, in order, being compacted. * @returns the replayed conversation prefix to condense. @@ -199,7 +199,7 @@ function buildSummarizationInput( return { ...header?.system === undefined ? {} : { system: header.system }, ...header?.tools === undefined ? {} : { tools: header.tools }, - messages: [...header?.messagePrefix ?? [], ...regionMessages], + messages: regionMessages, } } diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts index ce4f28f8b3..dce9f486df 100644 --- a/packages/compact/compact-basic/src/summarizer.ts +++ b/packages/compact/compact-basic/src/summarizer.ts @@ -78,7 +78,7 @@ export interface SummarizationInput { readonly system?: string /** The conversation's tool schemas, reused for prefix-cache alignment; absent when the request carried none. */ readonly tools?: readonly ToolSchema[] - /** The request prefix followed by the shadowed region, in surface order, that precedes the compaction instruction. */ + /** The shadowed region, in surface order, that precedes the compaction instruction. */ readonly messages: readonly Message[] } diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index c322f508ac..f9f46051ec 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -38,7 +38,7 @@ export interface ModelCompactPolicyConfig extends CompactPolicyConfig { export interface BasicCompactConfig extends CompactPolicyConfig { /** Exact provider/model overrides; duplicate targets fail plugin load. */ modelPolicies?: ModelCompactPolicyConfig[] - /** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */ + /** Enable automatic step-boundary pressure and overflow-recovery listeners. Defaults to `true`. */ auto?: boolean } diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index b89a9d1950..c3f9ff8d90 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -22,7 +22,7 @@ import type { } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' -import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents, type Agent, type RequestErrorAction } from '@deepseek-ai/dsh-agent' import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' const SIGNAL = new AbortController().signal @@ -76,7 +76,10 @@ function createContext(contextWindow = 1_000): Context { } function agent(session: Session, model?: string): Agent { - return { session, options: model === undefined ? {} : { provider: model, model } } as Agent + return { + session, + options: model === undefined ? {} : { provider: model, model }, + } as Agent } /** Flatten every text fragment the summarizer received, recursing tool-result blocks. */ @@ -568,7 +571,7 @@ describe('pressure measurement and retention', () => { expect(session.surface.nodes.length).toBeLessThan(8) }) - it('counts the durable routed request envelope without putting its prefix on the surface', async () => { + it('counts the durable routed request envelope without putting it on the surface', async () => { const compact = service({ auto: false, thresholdRatio: 0.9, @@ -577,22 +580,15 @@ describe('pressure measurement and retention', () => { const session = conversation(2, 'x'.repeat(600)) expect(await compactIfNeeded(compact, session)).toBeNull() - const prefix = [{ role: 'user' as const, content: [{ type: 'text' as const, text: 'p'.repeat(600) }] }] session.append('request/header', { header: { config: { provider: MODEL, model: MODEL }, - system: 's'.repeat(600), - messagePrefix: prefix, + system: 's'.repeat(2_000), }, reason: 'resume', }) const result = await compactIfNeeded(compact, session) expect(result).not.toBeNull() - expect(prefix).toHaveLength(1) - // The routed request prefix must not reach the surface as its own message - // (the compaction summary itself is an expected plugin-sourced checkpoint). - expect(session.events.some(event => event.type === 'user/message' - && event.data.content.some(block => block.type === 'text' && block.text.includes('p'.repeat(600))))).toBe(false) }) it('uses the latest logged request envelope without an AgentOptions override', async () => { @@ -822,13 +818,12 @@ describe('compaction region transaction', () => { expect(replay.deriveMessages()).toEqual(session.deriveMessages()) }) - it('replays the latest routed header prefix so the summarizer reuses the cache', async () => { + it('replays the latest routed header so the summarizer reuses the cache', async () => { const compact = service() const session = conversation(3) const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }] - const messagePrefix: Message[] = [{ role: 'user', content: [{ type: 'text', text: 'SESSION PREFIX' }] }] session.append('request/header', { - header: { config: { provider: MODEL, model: MODEL }, system: 'CONVERSATION SYSTEM', tools, messagePrefix }, + header: { config: { provider: MODEL, model: MODEL }, system: 'CONVERSATION SYSTEM', tools }, reason: 'resume', }) const nodes = session.surface.nodes @@ -837,7 +832,6 @@ describe('compaction region transaction', () => { const { input } = compact.calls[0]! expect(input.system).toBe('CONVERSATION SYSTEM') expect(input.tools).toEqual(tools) - expect(input.messages[0]).toEqual(messagePrefix[0]) expect(summarizedText(input)).toContain('fixture user 1') }) @@ -1289,22 +1283,21 @@ describe('default one-shot summarizer', () => { describe('automatic listener and loader composition', () => { function postStep(ctx: Context, owner: Agent, signal = SIGNAL): Promise { - return agentEvents(ctx, owner).serial('agent/post-step', 1, 1, signal) + return agentEvents(ctx, owner).serial('agent/step', 1, 1, signal) } function recover( ctx: Context, owner: Agent, error: Error & { code?: string }, - retryAttempt = 0, signal = SIGNAL, - next: () => Promise<{ action: 'fail' | 'retry' }> = () => Promise.resolve({ action: 'fail' }), - ): Promise<{ action: 'fail' | 'retry' }> { + next: () => Promise = () => Promise.resolve(undefined), + ): Promise { const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' } - const priorFailures = Object.freeze(Array.from({ length: retryAttempt }, () => failure)) + const turn = owner.session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 1 return agentEvents(ctx, owner).waterfall( - 'agent/request-error', 1, 1, error, failure, priorFailures, signal, next, - ) + 'agent/request-error', turn, 1, error, failure, signal, next, + ).then(action => action?.kind === 'retry') } function overflow(message = 'provider overflow'): Error & { code: string } { @@ -1413,7 +1406,7 @@ describe('automatic listener and loader composition', () => { expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(threshold) const decision = await recover(ctx, agent(session, 'unconfigured-agent-fallback'), overflow()) - expect(decision).toEqual({ action: 'retry' }) + expect(decision).toBe(true) expect(session.surface.replaceGeneration).toBe(beforeGeneration + 1) expect(session.events.some(event => event.type === 'compact/summary')).toBe(true) expect(session.surface.nodes).toContain(retainedSeq) @@ -1432,7 +1425,7 @@ describe('automatic listener and loader composition', () => { }) const session = oversizedToolResult() - expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' }) + expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true) expect(session.surface.replaceGeneration).toBe(1) expect(session.events.some(event => event.type === 'compact/summary')).toBe(false) expect(compact.calls).toHaveLength(0) @@ -1451,7 +1444,7 @@ describe('automatic listener and loader composition', () => { }) const session = toolConversation() - expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' }) + expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true) expect(session.events.some(event => event.type === 'compact/summary')).toBe(true) expect(compact.calls).toHaveLength(1) expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned') @@ -1473,7 +1466,7 @@ describe('automatic listener and loader composition', () => { compact.error = new Error('summary unavailable after prune') const session = oversizedToolResult(3_000, true) - expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' }) + expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true) expect(session.surface.replaceGeneration).toBe(1) expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2) expect(session.events.findLast(event => event.type === 'compact/end')?.data) @@ -1497,8 +1490,7 @@ describe('automatic listener and loader composition', () => { compact.error = new Error('summary cancelled after prune') const session = oversizedToolResult(3_000, true) - expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal)) - .toEqual({ action: 'fail' }) + expect(await recover(ctx, agent(session, MODEL), overflow(), controller.signal)).toBe(false) expect(session.surface.replaceGeneration).toBe(1) }) @@ -1512,7 +1504,7 @@ describe('automatic listener and loader composition', () => { const newestAssistant = session.surface.nodes.at(-2)! const newestResult = session.surface.nodes.at(-1)! - expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' }) + expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true) const currentAssistant = session.surface.nodes.find(node => node === newestAssistant) const currentResult = session.surface.nodes.find(node => node === newestResult) expect(currentAssistant).toBeDefined() @@ -1536,7 +1528,7 @@ describe('automatic listener and loader composition', () => { } vi.spyOn(compact, 'compactIfNeeded').mockResolvedValue(fakeResult) - expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' }) + expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) expect(session.surface.replaceGeneration).toBe(0) }) @@ -1551,7 +1543,6 @@ describe('automatic listener and loader composition', () => { ctx, agent(conversation(2), MODEL), overflow(), - 0, SIGNAL, () => { calls += 1 @@ -1569,7 +1560,7 @@ describe('automatic listener and loader composition', () => { compact.error = new Error('summary unavailable') const original = overflow('original provider overflow') - expect(await recover(ctx, agent(conversation(3), MODEL), original)).toEqual({ action: 'fail' }) + expect(await recover(ctx, agent(conversation(3), MODEL), original)).toBe(false) expect(original).toMatchObject({ message: 'original provider overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE, @@ -1588,12 +1579,12 @@ describe('automatic listener and loader composition', () => { const original = overflow('original provider failure') let delegations = 0 - const decision = await recover(ctx, agent(session, MODEL), original, 0, SIGNAL, () => { + const decision = await recover(ctx, agent(session, MODEL), original, SIGNAL, () => { delegations += 1 - return Promise.resolve({ action: 'fail' }) + return Promise.resolve(undefined) }) - expect(decision).toEqual({ action: 'fail' }) + expect(decision).toBe(false) expect(delegations).toBe(1) expect(session.surface.replaceGeneration).toBe(generation) expect(original).toMatchObject({ @@ -1612,7 +1603,7 @@ describe('automatic listener and loader composition', () => { reason: 'resume', }) expect(await recover(ctx, agent(session, MODEL), overflow('unlisted-model overflow'))) - .toEqual({ action: 'retry' }) + .toBe(true) }) it('delegates canonical overflow when no durable routed target exists', async () => { @@ -1624,21 +1615,19 @@ describe('automatic listener and loader composition', () => { trigger: { kind: 'message', source: { kind: 'user' } }, }) - await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toEqual({ action: 'fail' }) + await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toBe(false) }) - it('honors retry caps, non-context failures, and cancellation', async () => { + it('honors retry caps and ignores non-context failures', async () => { const ctx = createContext() const compact = new TestCompactService(ctx, { maxOverflowRetries: 1 }) const compactSpy = vi.spyOn(compact, 'compactIfNeeded') const owner = agent(conversation(3), MODEL) expect(await recover(ctx, owner, Object.assign(new Error('rate limit'), { code: 'RATE_LIMIT' }))) - .toEqual({ action: 'fail' }) - expect(await recover(ctx, owner, overflow(), 1)).toEqual({ action: 'fail' }) - - const controller = new AbortController() - controller.abort('cancelled') - expect(await recover(ctx, owner, overflow(), 0, controller.signal)).toEqual({ action: 'fail' }) + .toBe(false) + expect(await recover(ctx, owner, overflow())).toBe(true) + compactSpy.mockClear() + expect(await recover(ctx, owner, overflow())).toBe(false) expect(compactSpy).not.toHaveBeenCalled() }) @@ -1653,9 +1642,11 @@ describe('automatic listener and loader composition', () => { }], }) const compactSpy = vi.spyOn(compact, 'compactIfNeeded') + const owner = agent(conversation(3), MODEL) - expect(await recover(ctx, agent(conversation(3), MODEL), overflow(), 1)) - .toEqual({ action: 'fail' }) + expect(await recover(ctx, owner, overflow())).toBe(true) + compactSpy.mockClear() + expect(await recover(ctx, owner, overflow())).toBe(false) expect(compactSpy).not.toHaveBeenCalled() }) @@ -1667,8 +1658,7 @@ describe('automatic listener and loader composition', () => { const session = conversation(3) const generation = session.surface.replaceGeneration - expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal)) - .toEqual({ action: 'fail' }) + expect(await recover(ctx, agent(session, MODEL), overflow(), controller.signal)).toBe(false) expect(session.surface.replaceGeneration).toBe(generation + 1) }) @@ -1683,7 +1673,7 @@ describe('automatic listener and loader composition', () => { await postStep(ctx, agent(session, MODEL)) const summaries = session.events.filter(event => event.type === 'compact/summary').length expect(summaries).toBe(1) - expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' }) + expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) expect(session.events.filter(event => event.type === 'compact/summary')).toHaveLength(summaries) }) @@ -1697,7 +1687,7 @@ describe('automatic listener and loader composition', () => { const session = conversation(4) await postStep(ctx, agent(session, MODEL)) expect(session.events.some(event => event.type === 'compact/start')).toBe(false) - expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' }) + expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) }) it('loads and disposes the real zero-config service stack', async () => { @@ -1726,6 +1716,6 @@ describe('automatic listener and loader composition', () => { const session = conversation(4) await postStep(ctx, agent(session, MODEL)) expect(session.events.some(event => event.type === 'compact/start')).toBe(false) - expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' }) + expect(await recover(ctx, agent(session, MODEL), overflow())).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 429bbac3e4..d12cc63596 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -15,7 +15,7 @@ import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import * as LlmRetry from '@deepseek-ai/dsh-llm-retry' -import { SessionId, type SurfaceEvent } from '@deepseek-ai/dsh-session' +import { Session, SessionId, type SessionEvent, type SurfaceEvent } from '@deepseek-ai/dsh-session' /** * CBR-001 regression through the real loop. A replacement checkpoint has a high @@ -175,39 +175,43 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { }) } -function seedOverflowHistory(agent: Agent): void { +function overflowHistorySeed(): SessionEvent[] { + const session = new Session(SessionId('overflow-history-seed')) for (let turn = 1; turn <= 2; turn += 1) { const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY' - agent.session.append('turn/start', { + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } }, }) - agent.session.append('user/message', { + session.append('user/message', { content: [{ type: 'text', text: `${sentinel} ${'old context '.repeat(200)}` }], source: { kind: 'user' }, }, { surfaceOp: 'append' }) - agent.session.append('step/start', { turn, step: 1 }) - agent.session.append('assistant/message', { + session.append('step/start', { turn, step: 1 }) + session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn, step: 1, content: [{ type: 'text', text: `historical response ${turn} ${'detail '.repeat(200)}` }], }, { surfaceOp: 'append' }) - agent.session.append('step/end', { turn, step: 1 }) - agent.session.append('turn/end', { turn, reason: { kind: 'completed' } }) + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) } + return [...session.events] } describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => { it('uses the model actually routed by agent/request for post-step pressure', async () => { const { ctx } = await harness(8) - ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' })) + ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ + ...await next(), provider: 'mock', model: 'mock', + })) try { const agent = ctx.agentLoop.create(SessionId('routed-pressure'), { provider: 'unconfigured-agent-fallback', model: 'unconfigured-agent-fallback', }) - agent.followup([{ type: 'text', text: 'do a routed multi-step task' }]) + agent.followup({ content: [{ type: 'text', text: 'do a routed multi-step task' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) expect(agent.session.requestHeader()?.config.model).toBe('mock') @@ -221,11 +225,11 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () } }) - it('runs automatic pressure after the current tool result and before step/end', async () => { + it('runs automatic pressure between the completed tool step and the next step', async () => { const { ctx } = await harness(8) try { const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' }) - agent.followup([{ type: 'text', text: 'do tool work' }]) + agent.followup({ content: [{ type: 'text', text: 'do tool work' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const events = [...agent.session.events] @@ -235,13 +239,19 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () event.type === 'tool/result' && event.seq < compactStart!.seq, ) if (precedingResult?.type !== 'tool/result') throw new Error('expected a durable tool result before compaction') - const stepEnd = events.find(event => + const precedingStepEnd = events.find(event => event.type === 'step/end' && event.data.step === precedingResult.data.step + && event.seq > precedingResult.seq, + ) + const nextStepStart = events.find(event => + event.type === 'step/start' + && event.data.step === precedingResult.data.step + 1 && event.seq > compactStart!.seq, ) expect(precedingResult.seq).toBeLessThan(compactStart!.seq) - expect(compactStart!.seq).toBeLessThan(stepEnd!.seq) + expect(precedingStepEnd!.seq).toBeLessThan(compactStart!.seq) + expect(compactStart!.seq).toBeLessThan(nextStepStart!.seq) } finally { await ctx.fiber.dispose() } @@ -251,7 +261,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () const { ctx } = await harness(8) try { const agent = ctx.agentLoop.create(SessionId('repro'), { provider: 'mock', model: 'mock' }) - agent.followup([{ type: 'text', text: 'do a long multi-step task' }]) + agent.followup({ content: [{ type: 'text', text: 'do a long multi-step task' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const events = [...agent.session.events] @@ -291,7 +301,9 @@ describe('context-overflow recovery across the real loop and compact-basic', () await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeterService) ctx.llm.registerAdapter(['mock'], adapter) - ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' })) + ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ + ...await next(), provider: 'mock', model: 'mock', + })) await ctx.plugin(BasicCompactService, { thresholdRatio: 1, retainTokens: 100, @@ -301,13 +313,16 @@ describe('context-overflow recovery across the real loop and compact-basic', () }) try { - const agent = ctx.agentLoop.create(SessionId(`overflow-${delivery}`), { - provider: 'unconfigured-agent-fallback', - model: 'unconfigured-agent-fallback', + const { agent } = await ctx.agentLoop.createAgent(ctx, { + sessionId: SessionId(`overflow-${delivery}`), + seed: overflowHistorySeed(), + agentOptions: { + provider: 'unconfigured-agent-fallback', + model: 'unconfigured-agent-fallback', + }, }) - seedOverflowHistory(agent) - agent.followup([{ type: 'text', text: 'continue from history' }]) + agent.followup({ content: [{ type: 'text', text: 'continue from history' }], source: { kind: 'user' } }) await agent.whenIdle() expect(adapter.conversationRequests).toHaveLength(2) @@ -318,11 +333,17 @@ describe('context-overflow recovery across the real loop and compact-basic', () expect(retry).not.toContain('OLD HISTORY SENTINEL') const events = [...agent.session.events] - const failedEnd = events.find(event => + const failedStepEnd = events.find(event => event.type === 'step/end' && event.data.turn === 3 && event.data.step === 1, )! + const failedEnd = events.find(event => + event.type === 'turn/end' && event.data.turn === 3, + )! const retryStart = events.find(event => - event.type === 'step/start' && event.data.turn === 3 && event.data.step === 2, + event.type === 'turn/start' && event.data.turn === 4, + )! + const retryStep = events.find(event => + event.type === 'step/start' && event.data.turn === 4 && event.data.step === 1, )! const compaction = events.filter(event => event.type === 'compact/start' @@ -334,7 +355,11 @@ describe('context-overflow recovery across the real loop and compact-basic', () 'compact/summary', 'compact/end', ]) - expect(compaction.every(event => event.seq > failedEnd.seq && event.seq < retryStart.seq)).toBe(true) + expect(retryStart.seq).toBeGreaterThan(failedEnd.seq) + expect(compaction.every(event => + event.seq > failedStepEnd.seq && event.seq < failedEnd.seq, + )).toBe(true) + expect(retryStep.seq).toBeGreaterThan(retryStart.seq) expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, @@ -368,17 +393,20 @@ describe('context-overflow recovery across the real loop and compact-basic', () }) try { - const agent = ctx.agentLoop.create(SessionId('alternating-recovery'), { provider: 'mock', model: 'mock' }) - seedOverflowHistory(agent) - agent.followup([{ type: 'text', text: 'continue from history' }]) + const { agent } = await ctx.agentLoop.createAgent(ctx, { + sessionId: SessionId('alternating-recovery'), + seed: overflowHistorySeed(), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + agent.followup({ content: [{ type: 'text', text: 'continue from history' }], source: { kind: 'user' } }) await agent.whenIdle() expect(adapter.conversationRequests).toHaveLength(3) expect(adapter.summaryRequests).toHaveLength(1) expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data)) - .toEqual([expect.objectContaining({ step: 2, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })]) - expect(agent.session.events.filter(event => event.type === 'step/start').slice(-3).map(event => event.data.step)) - .toEqual([1, 2, 3]) + .toEqual([expect.objectContaining({ turn: 4, step: 1, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })]) + expect(agent.session.events.filter(event => event.type === 'turn/start').slice(-3).map(event => event.data.turn)) + .toEqual([3, 4, 5]) expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, diff --git a/packages/context/README.i18n.yaml b/packages/context/README.i18n.yaml index f21e4bb973..7de339cd9a 100644 --- a/packages/context/README.i18n.yaml +++ b/packages/context/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 -README.md: bc3237b98732c23e6a2b120e055f7713b91f9b7c -README.zh.md: b195a4c0b96b1f6f0b66bc6efa99a4a66b3c2fa2 +README.md: a5244dfe99a714605744b57d33f97359d4d6fa4e +README.zh.md: 2c1bdd6e5b290a771094719daa6e4d7c3bf577db diff --git a/packages/context/README.md b/packages/context/README.md index bc3237b987..a5244dfe99 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -8,6 +8,6 @@ Product plugins that add model-visible request context without defining a tool. |---|---|---| | `session-reference/` | Bounded current-surface snapshots of other sessions | `ctx.sessionReferences` | | `time-context/` | Durable per-step current time and elapsed-time context | (none) | -| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/session-prefix` + `tools/post-execute`) | +| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/step` + `tools/post-execute`) | The [`workspace-context` decision record](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split. diff --git a/packages/context/README.zh.md b/packages/context/README.zh.md index b195a4c0b9..2c1bdd6e5b 100644 --- a/packages/context/README.zh.md +++ b/packages/context/README.zh.md @@ -8,6 +8,6 @@ |---|---|---| | `session-reference/` | 其他会话当前表层的有界快照 | `ctx.sessionReferences` | | `time-context/` | 持久的逐步骤当前时间与耗时上下文 | (无) | -| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` 工作区上下文 loader | (监听 `agent/session-prefix` + `tools/post-execute`) | +| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` 工作区上下文 loader | (监听 `agent/step` + `tools/post-execute`) | [`workspace-context` 决策记录](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)解释了它的逐 agent/会话隔离与生命周期拆分。 diff --git a/packages/context/session-reference/README.i18n.yaml b/packages/context/session-reference/README.i18n.yaml index 83155d4c0e..dfe048d65a 100644 --- a/packages/context/session-reference/README.i18n.yaml +++ b/packages/context/session-reference/README.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 -README.md: c995256511742c193e064cf808fc89194b444974 -README.zh.md: e2e67cfee745c84d6c85e8792e53c50bf2648293 +# pnpm run verify-translation-pairing --write packages/context/session-reference/README.md +README.md: 2ca461f88b266b4dec1ffa4132c8cb17455f4b8e +README.zh.md: 9f8fd0bace9b37b2f7885eded7686ecac8c625ba diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index c995256511..2ca461f88b 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -2,19 +2,19 @@ English | [中文](README.zh.md) -`ctx.sessionReferences` prepares bounded, read-only snapshots of other sessions as prompt-prefix context. It consumes `ctx.sessionQuery` and the backend-independent compact checkpoint marker; SQLite FTS is not required. The standard TUI bundle mounts it, while other hosts may call the service directly. +`ctx.sessionReferences` prepares bounded, read-only snapshots of other sessions as sourced model-facing context. It consumes `ctx.sessionQuery` and the backend-independent compact checkpoint marker; SQLite FTS is not required. The standard TUI bundle mounts it, while other hosts may call the service directly. ## Public API - `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id or cwd, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses its latest log-backed title as the mention label and falls back to the session id; titles and message bodies are not searched. -- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `HookContext`. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `followup()` or `steer()`. +- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `UserMessageData` context. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `followup()` or `steer()`. - `encodeSessionReferenceUri()` and `decodeSessionReferenceUri()` implement `dsh-session:` so every JavaScript string id round-trips exactly. `formatSessionReferenceMention()` emits `@[label](uri)`, and `parseSessionReferenceText()` replaces Markdown mentions or bare canonical URIs with readable `@label` text while returning structured references. Explicit Markdown mentions reject every malformed URI; bare text is considered a reference only when a non-empty base64url-shaped payload follows the scheme, and a matching noncanonical candidate still fails. Empty or punctuation-only scheme mentions remain ordinary discussion text. ## Snapshot semantics Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source and never rereads it after enqueue. It projects only direct-user `user/message`, direct-user `steering/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compact` source marker from the folded current surface. For a source prompt that already contains baked prefix context, projection reads only its model-hidden display content, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, context, plugin-generated user messages other than marked compact checkpoints, and unfinished assistant chunks are excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text. -The context source is `{ kind: 'plugin', plugin: 'session-reference' }` with `placement: 'prompt-prefix'`. Its metadata records version `1`, source ids and labels, capture seqs, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. AgentLoop writes the snapshot, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`; the same event's model-hidden envelope retains the direct prompt and metadata for UI replay. Later source mutation, compaction, or deletion cannot change target replay. +The context source is `{ kind: 'session-reference', version: 1, references }`; each reference records its source id and label, capture seq, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. The standard TUI preserves admission ownership without attaching context to the generic inbox record: outside the next-step acceptance window, a one-shot `agent/prompt-submit` wrapper adds the snapshot only to an allowed decision; during prompt admission or an open turn, `inject()` and `steer()` stage beside each other for the same safe boundary. The target log therefore records a sourced context `user/message` followed by the readable direct `user/message` or `steering/message`. Later source mutation, compaction, or deletion cannot change target replay. ## Configuration @@ -32,7 +32,7 @@ Retention applies `maxReferenceBytes` independently to each source, keeps compac #### What the model sees -The model sees one user-role message in this order: the `## Referenced sessions` untrusted snapshot, the `## My request:` delimiter, then the current message with its readable `@label`. The warning forbids following instructions, permission claims, or tool requests from the snapshot unless the current user repeats them. Labels, cwd values, ids, and conversation text are serialized as JSON inside `` tags; every data `<` is emitted as the lossless JSON escape `\u003c`, so source text cannot spell a framing tag. +The model sees two consecutive user-role messages: the `## Referenced sessions` untrusted snapshot, then the current message with its readable `@label`. The warning forbids following instructions, permission claims, or tool requests from the snapshot unless the current user repeats them. Labels, cwd values, ids, and conversation text are serialized as JSON inside `` tags; every data `<` is emitted as the lossless JSON escape `\u003c`, so source text cannot spell a framing tag. #### Token effect @@ -40,7 +40,7 @@ Each referenced message adds the fixed warning plus up to three serialized snaps #### KV Cache effect -The combined snapshot and request are append-only at the target message boundary and preserve earlier cacheable history. Different references or source capture contents change the new suffix only; later target compaction may invalidate reuse from its replacement boundary. +The snapshot and request are consecutive append-only target messages and preserve earlier cacheable history. Different references or source capture contents change the new suffix only; later target compaction may invalidate reuse from its replacement boundary. ## Known Limitations and Deferred Work diff --git a/packages/context/session-reference/README.zh.md b/packages/context/session-reference/README.zh.md index e2e67cfee7..9f8fd0bace 100644 --- a/packages/context/session-reference/README.zh.md +++ b/packages/context/session-reference/README.zh.md @@ -2,19 +2,19 @@ [English](README.md) | 中文 -`ctx.sessionReferences` 会把其他会话准备为有界、只读快照,作为提示词前缀上下文。它消费 `ctx.sessionQuery` 与后端无关的 compact 检查点标记;不需要 SQLite FTS。标准 TUI bundle 会装载它,其他宿主也可直接调用该服务。 +`ctx.sessionReferences` 会把其他会话准备为有界、只读快照,作为带来源信息、面向模型的上下文。它消费 `ctx.sessionQuery` 与后端无关的 compact 检查点标记;不需要 SQLite FTS。标准 TUI bundle 会装载它,其他宿主也可直接调用该服务。 ## 公开 API - `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 id 或 cwd 进行不区分大小写的筛选,再按同 cwd、无 cwd、其他 cwd 记录排序,同时保持每组内的 `listSessions()` 创建顺序。每个已选候选会话都使用最新的日志支持标题作为 mention label,并回退到会话 id;不搜索标题与消息主体。 -- `prepare(agent, content, references, signal?)` 会保留首次 mention 顺序、对 id 去重,并拒绝自引用或超过已配置不同源上限的情况。它会并行读取所有源,返回与输入脱离的内容,外加零个或一个聚合 `HookContext`。任何无效引用、读取失败、取消或预算失败都会在宿主调用 `followup()` 或 `steer()` 之前被拒绝。 +- `prepare(agent, content, references, signal?)` 会保留首次 mention 顺序、对 id 去重,并拒绝自引用或超过已配置不同源上限的情况。它会并行读取所有源,返回与输入脱离的内容,外加零个或一个聚合 `UserMessageData` 上下文。任何无效引用、读取失败、取消或预算失败都会在宿主调用 `followup()` 或 `steer()` 之前被拒绝。 - `encodeSessionReferenceUri()` 与 `decodeSessionReferenceUri()` 实现 `dsh-session:`,因此每个 JavaScript 字符串 id 都能精确往返。`formatSessionReferenceMention()` 发出 `@[label](uri)`,`parseSessionReferenceText()` 将 Markdown mention 或裸规范 URI 替换为可读的 `@label` 文本,并返回结构化引用。显式 Markdown mention 会拒绝每个格式错误的 URI;只当 scheme 后跟非空、符合 base64url 形状的 payload 时,裸文本才被视为引用,匹配但非规范的候选项仍会失败。空 scheme mention 或只含标点符号的 scheme mention 仍是普通讨论文本。 ## 快照语义 准备阶段会对每个不同源调用一次 `ctx.sessionQuery.readSurface()`,入队后绝不重读。它仅投影折叠后当前表层中的直接 user `user/message`、直接 user `steering/message`、assistant 文本,以及 `user/message` 检查点;这类检查点携带规范 `dsh-compact` 源标记。对于已经包含烘焙前缀上下文的源提示词,投影只读取其对模型隐藏的显示内容,以防止快照递归传播。已遮蔽的压缩前事件、工具、reasoning、上下文、除已标记 compact 检查点外的插件生成 user 消息,以及未完成的 assistant chunk 均会被排除。因此,已压缩源贡献的是最新检查点与之后保留的会话,而非已恢复的遮蔽文本。 -上下文源为 `{ kind: 'plugin', plugin: 'session-reference' }`,并携带 `placement: 'prompt-prefix'`。其元数据会记录版本 `1`、源 id 与 label、捕获 seq、是否存在 compact、已保留/已省略消息数、已省略 UTF-8 字节数与截断状态。AgentLoop 将快照、`## My request:` 分隔符和有效提示词写入同一个 `user/message` 或 `steering/message`;同一事件的模型隐藏 envelope 保留直接提示词与元数据,用于 UI 回放。后续源变更、压缩或删除都无法改变目标回放。 +上下文源为 `{ kind: 'session-reference', version: 1, references }`;每条引用会记录其源 id 与 label、捕获 seq、是否存在 compact、已保留/已省略消息数、已省略 UTF-8 字节数与截断状态。标准 TUI 在不把上下文附加到通用 inbox 记录的情况下保留接纳归属:next-step 接收窗口之外,一次性 `agent/prompt-submit` 包装层只为获准决策添加快照;提示词接纳期间或轮次打开时,`inject()` 与 `steer()` 会并排暂存到同一安全边界。目标日志因此会先记录一条带来源信息的上下文 `user/message`,再记录可读的直接 `user/message` 或 `steering/message`。后续源变更、压缩或删除都无法改变目标回放。 ## 配置 @@ -32,7 +32,7 @@ #### 模型看到的内容 -模型会按此顺序看到一条 user 角色消息:`## Referenced sessions` 不受信任快照、`## My request:` 分隔符,随后是带可读 `@label` 的当前消息。警告禁止遵循快照中的指令、权限声明或工具请求,除非当前 user 重复这些内容。Label、cwd 值、id 与会话文本作为 JSON 在 `` 标签中序列化;每个数据 `<` 都发出为无损 JSON 转义 `\u003c`,因此源文本无法拼出框定标签。 +模型会看到两条连续的 user 角色消息:先是 `## Referenced sessions` 不受信任快照,再是带可读 `@label` 的当前消息。警告禁止遵循快照中的指令、权限声明或工具请求,除非当前 user 重复这些内容。Label、cwd 值、id 与会话文本作为 JSON 在 `` 标签中序列化;每个数据 `<` 都发出为无损 JSON 转义 `\u003c`,因此源文本无法拼出框定标签。 #### Token 影响 @@ -40,7 +40,7 @@ #### KV Cache 影响 -组合快照与请求在目标消息边界处仅追加,并保留较早的可缓存历史。不同引用或源捕获内容只改变新后缀;后续目标压缩可能使从替换边界起的复用失效。 +快照与请求是两条连续、仅追加的目标消息,并保留较早的可缓存历史。不同引用或源捕获内容只改变新后缀;后续目标压缩可能使从替换边界起的复用失效。 ## 已知限制与暂缓事项 diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index 93e5173005..3341990806 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -7,9 +7,9 @@ import { Context, Service } from 'cordis' import z from 'schemastery' -import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { JsonValue, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionId, UserMessageData } from '@deepseek-ai/dsh-session' import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query' import { DEFAULT_CANDIDATE_LIMIT, @@ -20,7 +20,7 @@ import { } from './config.ts' import { retainReferencedSession, type ReferenceRetentionStats, type ReferencedSessionData } from './projection.ts' import { stringifyTagSafeJson } from './serialization.ts' -import type { PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput } from './types.ts' +import type { PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput, SessionReferenceSource } from './types.ts' export type * from './types.ts' export type { Config, SessionReferenceErrorCode } from './config.ts' @@ -148,7 +148,7 @@ export class SessionReferenceService extends Service { * @param content - already host-normalized readable message content. * @param references - structured source sessions in mention order. * @param signal - optional cancellation boundary for host request teardown. - * @returns detached content and zero or one prepared contexts. + * @returns detached content and optional referenced-session context. */ async prepare( agent: Agent, @@ -158,7 +158,7 @@ export class SessionReferenceService extends Service { ): Promise { const acceptedContent = structuredClone(content) const inputs = normalizeReferences(agent.id, references, this.config.maxReferences) - if (inputs.length === 0) return { content: acceptedContent, contexts: [] } + if (inputs.length === 0) return { content: acceptedContent } assertNotCancelled(signal) let prepared: PreparedSource[] try { @@ -181,7 +181,7 @@ export class SessionReferenceService extends Service { const rendered = this.renderSources(prepared) const prompt = renderPrompt(rendered.map(source => source.data)) - const meta = { + const source: SessionReferenceSource = { kind: 'session-reference', version: 1, references: rendered.map((source, index) => ({ @@ -191,14 +191,12 @@ export class SessionReferenceService extends Service { ...source.stats, inputIndex: index, })), - } satisfies JsonValue - const context: HookContext = { - source: { kind: 'plugin', plugin: 'session-reference' }, - content: [{ type: 'text', text: prompt }], - placement: 'prompt-prefix', - meta, } - return { content: acceptedContent, contexts: [context] } + const additionalContext: UserMessageData = { + source, + content: [{ type: 'text', text: prompt }], + } + return { content: acceptedContent, additionalContext } } private renderSources(sources: readonly PreparedSource[]): RenderedSource[] { diff --git a/packages/context/session-reference/src/projection.ts b/packages/context/session-reference/src/projection.ts index dea29b38ee..d23622ee3b 100644 --- a/packages/context/session-reference/src/projection.ts +++ b/packages/context/session-reference/src/projection.ts @@ -1,7 +1,6 @@ /** Current-surface projection and byte-bounded rendering. */ import { isCompactCheckpointSource } from '@deepseek-ai/dsh-compact' -import { displayPromptContent } from '@deepseek-ai/dsh-session' import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query' import { assertNever } from '@deepseek-ai/dsh-llm' import { TextRetainer } from '@deepseek-ai/dsh-retention' @@ -41,13 +40,13 @@ function projectSessionConversation(snapshot: SessionSurfaceSnapshot): Projected case 'user/message': { const checkpoint = isCompactCheckpointSource(event.data.source) if (!checkpoint && event.data.source.kind !== 'user') break - const text = textContent(displayPromptContent(event.data)) + const text = textContent(event.data.content) if (text !== '') conversation.push({ role: 'user', text, checkpoint, originalText: text, omittedBytes: 0 }) break } case 'steering/message': { if (event.data.source.kind !== 'user') break - const text = textContent(displayPromptContent(event.data)) + const text = textContent(event.data.content) if (text !== '') conversation.push({ role: 'user', text, checkpoint: false, originalText: text, omittedBytes: 0 }) break } diff --git a/packages/context/session-reference/src/types.ts b/packages/context/session-reference/src/types.ts index 03176ee32a..3804ae3677 100644 --- a/packages/context/session-reference/src/types.ts +++ b/packages/context/session-reference/src/types.ts @@ -1,8 +1,31 @@ /** Public session-reference request, candidate, and preparation records. */ -import type { HookContext } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionId, UserMessageData } from '@deepseek-ai/dsh-session' + +/** Durable provenance for one prepared cross-session context. */ +export interface SessionReferenceSource { + kind: 'session-reference' + version: 1 + references: { + sessionId: string + label: string + capturedThroughSeq: number | null + compacted: boolean + originalMessages: number + retainedMessages: number + omittedMessages: number + omittedBytes: number + truncated: boolean + inputIndex: number + }[] +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + 'session-reference': SessionReferenceSource + } +} /** One source session selected by a host. */ export interface SessionReferenceInput { @@ -24,12 +47,12 @@ export interface SessionReferenceCandidate { createdAt: number } -/** Message payload and the zero-or-one durable snapshot contexts bound to it. */ +/** Direct message content and optional referenced-session context. */ export interface PreparedReferencedMessage { /** Readable message content after host mention tokens are removed. */ content: ContentBlock[] - /** Empty without references; otherwise one aggregated untrusted context. */ - contexts: HookContext[] + /** Aggregated untrusted snapshot, absent when the message has no references. */ + additionalContext?: UserMessageData } /** Text-only projected conversation item. */ diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index 4bcca1a10f..c3ae5dacf1 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -241,11 +241,9 @@ describe('session reference discovery and preparation', () => { [{ sessionId: source.id, label: 'source' }], ) expect(prepared.content).toEqual([{ type: 'text', text: 'use @source' }]) - expect(prepared.contexts).toHaveLength(1) - const context = prepared.contexts[0] + const context = prepared.additionalContext if (context?.content[0]?.type !== 'text') throw new Error('expected text context') - expect(context.source).toEqual({ kind: 'plugin', plugin: 'session-reference' }) - expect(context.placement).toBe('prompt-prefix') + expect(context.source).toMatchObject({ kind: 'session-reference' }) expect(context.content[0].text).toContain('untrusted, read-only snapshot') expect(promptData(context.content[0].text)).toEqual([{ sessionId: 'source', @@ -259,7 +257,7 @@ describe('session reference discovery and preparation', () => { { role: 'assistant', text: 'visible answer' }, ], }]) - expect(context.meta).toMatchObject({ + expect(context.source).toMatchObject({ kind: 'session-reference', version: 1, references: [{ @@ -279,21 +277,17 @@ describe('session reference discovery and preparation', () => { expect(context.content[0].text).not.toContain('later source mutation') }) - it('projects only the direct prompt when a source message contains baked prefix context', async () => { + it('excludes injected context when projecting a referenced session', async () => { const ctx = await harness() const target = ctx.sessions.create(SessionId('target')) const source = ctx.sessions.create(SessionId('source')) source.append('user/message', { - content: [ - { type: 'text', text: 'nested referenced snapshot must not propagate' }, - { type: 'text', text: '\n\n## My request:\n' }, - { type: 'text', text: 'direct source question' }, - ], + content: [{ type: 'text', text: 'nested referenced snapshot must not propagate' }], + source: { kind: 'plugin', plugin: 'session-reference' }, + }, { surfaceOp: 'append' }) + source.append('user/message', { + content: [{ type: 'text', text: 'direct source question' }], source: { kind: 'user' }, - envelope: { - displayContent: [{ type: 'text', text: 'direct source question' }], - prefixContexts: [{ source: { kind: 'plugin', plugin: 'session-reference' } }], - }, }, { surfaceOp: 'append' }) const prepared = await ctx.sessionReferences.prepare( @@ -301,7 +295,7 @@ describe('session reference discovery and preparation', () => { [{ type: 'text', text: 'inspect source' }], [{ sessionId: source.id }], ) - const context = prepared.contexts[0] + const context = prepared.additionalContext if (context?.content[0]?.type !== 'text') throw new Error('expected text context') expect(promptData(context.content[0].text)).toMatchObject([{ conversation: [{ role: 'user', text: 'direct source question' }], @@ -325,7 +319,7 @@ describe('session reference discovery and preparation', () => { [{ type: 'text', text: 'use @source' }], [{ sessionId: source.id }], ) - const context = prepared.contexts[0] + const context = prepared.additionalContext if (context?.content[0]?.type !== 'text') throw new Error('expected text context') const prompt = context.content[0].text expect(prompt).toMatch(/^## Referenced sessions\n/u) @@ -350,14 +344,14 @@ describe('session reference discovery and preparation', () => { const content = [{ type: 'text' as const, text: 'go' }] const withoutReferences = await ctx.sessionReferences.prepare(agent, content, []) - expect(withoutReferences).toEqual({ content, contexts: [] }) + expect(withoutReferences).toEqual({ content }) expect(withoutReferences.content).not.toBe(content) await expect(ctx.sessionReferences.prepare(agent, content, [ { sessionId: one.id, label: 'first' }, { sessionId: one.id, label: 'ignored duplicate' }, { sessionId: two.id }, - ])).resolves.toMatchObject({ contexts: [{ meta: { references: [{ label: 'first' }, { label: 'two' }] } }] }) + ])).resolves.toMatchObject({ additionalContext: { source: { references: [{ label: 'first' }, { label: 'two' }] } } }) await expect(ctx.sessionReferences.prepare(agent, content, [{ sessionId: target.id }])) .rejects.toThrow(expectCode('SESSION_REFERENCE_SELF_REFERENCE')) await expect(ctx.sessionReferences.prepare(agent, content, [null as never])) @@ -428,14 +422,14 @@ describe('session reference discovery and preparation', () => { ) const prepared = await ctx.sessionReferences.prepare(fakeAgent(target), [{ type: 'text', text: 'go' }], [{ sessionId: source.id }]) - const context = prepared.contexts[0] + const context = prepared.additionalContext if (context?.content[0]?.type !== 'text') throw new Error('expected text context') const data = promptData(context.content[0].text) as unknown[] expect(Buffer.byteLength(stringifyTagSafeJson(data[0]), 'utf8')).toBeLessThanOrEqual(360) expect(context.content[0].text).toContain('checkpoint') expect(context.content[0].text).toContain('latest-') expect(context.content[0].text).toContain('omitted') - expect(context.meta).toMatchObject({ references: [{ truncated: true, compacted: true }] }) + expect(context.source).toMatchObject({ references: [{ truncated: true, compacted: true }] }) }) it('applies the full byte limit independently to each of three references', async () => { @@ -462,7 +456,7 @@ describe('session reference discovery and preparation', () => { [{ type: 'text', text: 'go' }], sources.map(source => ({ sessionId: source.id })), ) - const context = prepared.contexts[0] + const context = prepared.additionalContext if (context?.content[0]?.type !== 'text') throw new Error('expected text context') const data = promptData(context.content[0].text) as unknown[] const sizes = data.map(source => Buffer.byteLength(stringifyTagSafeJson(source), 'utf8')) @@ -495,18 +489,12 @@ describe('session reference discovery and preparation', () => { [{ type: 'text', text: 'use @source' }], [{ sessionId: source.id }], ) - const context = prepared.contexts[0] + const context = prepared.additionalContext if (context === undefined) throw new Error('expected prepared context') + target.append('user/message', context, { surfaceOp: 'append' }) target.append('user/message', { - content: [...context.content, { type: 'text', text: '\n\n## My request:\n' }, ...prepared.content], + content: prepared.content, source: { kind: 'user' }, - envelope: { - displayContent: prepared.content, - prefixContexts: [{ - source: context.source, - ...context.meta === undefined ? {} : { meta: context.meta }, - }], - }, }, { surfaceOp: 'append' }) const before = target.deriveMessages() @@ -533,7 +521,7 @@ describe('session reference discovery and preparation', () => { expect(ctx.sessions.get(source.id)).toBeUndefined() expect(target.deriveMessages()).toEqual(before) expect(JSON.stringify(before)).toContain('durable referenced fact') - expect(JSON.stringify(before)).toContain('## My request:') + expect(JSON.stringify(before)).toContain('use @source') expect(JSON.stringify(before)).not.toContain('later source mutation') expect(new Session(SessionId('replayed-target'), target.events).deriveMessages()).toEqual(before) }) diff --git a/packages/context/time-context/README.i18n.yaml b/packages/context/time-context/README.i18n.yaml index 8f0067c24b..f5cd8dfd5d 100644 --- a/packages/context/time-context/README.i18n.yaml +++ b/packages/context/time-context/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 -README.md: db6d4e2cc9b68f94fe7cacd85c302c4242c88930 -README.zh.md: 06e13824109f76242aaae2d302e984a8598cbc98 +README.md: 9fe818855439466b2a3e349cd54a2f408cf5ec10 +README.zh.md: 337ce3613d7b17134db7cf85a808881017f4e2c3 diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index db6d4e2cc9..9fe8188554 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -20,7 +20,7 @@ When `timeZone` is omitted, the plugin resolves the Node process's system zone o ## Timing semantics -The plugin prepends an `agent/pre-step` listener. When an injection is due, it appends one injected `user/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing. +The plugin prepends an `agent/step` listener. When an injection is due, it appends one injected `user/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing. Positive-interval scheduling scans the raw durable session events for the latest `user/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently. diff --git a/packages/context/time-context/README.zh.md b/packages/context/time-context/README.zh.md index 06e1382410..337ce3613d 100644 --- a/packages/context/time-context/README.zh.md +++ b/packages/context/time-context/README.zh.md @@ -20,7 +20,7 @@ ## 时序语义 -该插件会前置一个 `agent/pre-step` listener。需要注入时,它会追加一条注入的 `user/message`,通过 `agent.inject()` 完成,时机位于 `step/start` 和普通自动压缩之前,其源为 `{ kind: 'plugin', plugin: 'time-context' }`。被抑制的尝试不追加任何内容。 +该插件会前置一个 `agent/step` listener。需要注入时,它会追加一条注入的 `user/message`,通过 `agent.inject()` 完成,时机位于 `step/start` 和普通自动压缩之前,其源为 `{ kind: 'plugin', plugin: 'time-context' }`。被抑制的尝试不追加任何内容。 正间隔调度会扫描原始持久会话事件,查找最新的上述源 `user/message`,包括已被压缩遮蔽的 reading。因此,调度可以跨轮次和已恢复进程应用,不需要进程本地 cache 状态。它会降低追加频率与历史增长,但绝不移除现有 reading,且每个会话独立调度。 diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index fcf9e36efc..8b080206e1 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -156,7 +156,7 @@ export function apply(ctx: Context, config: Config): void { } const resolvedTimeZone = formatter.resolvedOptions().timeZone - ctx.on('agent/pre-step', ( + ctx.on('agent/step', ( agent: Agent, turn: number, step: number, @@ -173,9 +173,6 @@ export function apply(ctx: Context, config: Config): void { const previous = step === 1 ? precedingMessageTime(agent) : precedingStepContextTime(agent, turn) - agent.inject( - [{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }], - { source: { kind: 'plugin', plugin: name } }, - ) + agent.inject({ content: [{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }], source: { kind: 'plugin', plugin: name } }) }, { prepend: true }) } diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 424363d4b1..993bfcc095 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -41,15 +41,12 @@ function sessionAgent(session: Session, id = 'agent'): Agent { options: {}, session, status: 'running', + acceptsNextStep: true, ctx: new Context(), followup: () => AgentMessageId('stub'), - queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), - inject(content, options) { - session.append('user/message', { - content, - source: options?.source ?? { kind: 'user' }, - }, { surfaceOp: 'append' }) + inject(input) { + session.append('user/message', input, { surfaceOp: 'append' }) return AgentMessageId('stub') }, send: () => AgentMessageId('stub'), @@ -85,7 +82,7 @@ async function fire( step: number, signal: AbortSignal = SIGNAL, ): Promise { - await agentEvents(ctx, agent).serial('agent/pre-step', turn, step, signal) + await agentEvents(ctx, agent).serial('agent/step', turn, step, signal) } function textResponse(text: string): StreamChunk[] { @@ -294,7 +291,7 @@ describe('durable step context', () => { const agent = sessionAgent(session) openMessageTurn(session, 1) let ordinarySawContext = false - ctx.on('agent/pre-step', (subject) => { + ctx.on('agent/step', (subject) => { ordinarySawContext = subject.session.events.some(event => event.type === 'user/message') }) @@ -363,22 +360,22 @@ describe('real agent-loop request history', () => { it.each([ ['throws', 'error'], ['cancels', 'aborted'], - ] as const)('retains the preparation reading when a later pre-step listener %s', async (mode, reasonKind) => { + ] as const)('discards the pending preparation reading when a later step listener %s', async (mode, reasonKind) => { const adapter = new ScriptedAdapter([textResponse('unused')]) const ctx = await loopHarness(adapter) let laterSawReading = false - ctx.on('agent/pre-step', (subject) => { + ctx.on('agent/step', (subject) => { laterSawReading = contextTexts(subject.session).length === 1 if (mode === 'throws') throw new Error('later pre-step failure') subject.cancel({ kind: 'user' }) }) const agent = ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' }) - agent.followup([{ type: 'text', text: 'start' }]) + agent.followup({ content: [{ type: 'text', text: 'start' }], source: { kind: 'user' } }) await agent.whenIdle() - expect(laterSawReading).toBe(true) - expect(contextTexts(agent.session)).toHaveLength(1) + expect(laterSawReading).toBe(false) + expect(contextTexts(agent.session)).toHaveLength(0) expect(adapter.requests).toHaveLength(0) expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false) const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') @@ -400,7 +397,7 @@ describe('real agent-loop request history', () => { })) const agent = ctx.agentLoop.create(SessionId('loop'), { provider: 'mock', model: 'mock' }) - agent.followup([{ type: 'text', text: 'start' }]) + agent.followup({ content: [{ type: 'text', text: 'start' }], source: { kind: 'user' } }) await agent.whenIdle() expect(adapter.requests).toHaveLength(2) diff --git a/packages/context/workspace-context/README.i18n.yaml b/packages/context/workspace-context/README.i18n.yaml index 2eb8fef513..8191413d37 100644 --- a/packages/context/workspace-context/README.i18n.yaml +++ b/packages/context/workspace-context/README.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 -README.md: 0edea95866bf606216b6c24d2667152a479f757a -README.zh.md: 0d5503dba2a816acf5fe7075278f98d31d769f48 +# pnpm run verify-translation-pairing --write packages/context/workspace-context/README.md +README.md: df75b29dd3e8dbb504aac9e9885c32a809cbf70f +README.zh.md: 8bd926302f09ecdf453c7832b3a15b0e7fcc1b2a diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index 0edea95866..df75b29dd3 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -Per-session workspace instruction loading for `AGENTS.md`-compatible files. The plugin freezes the initial user-global and project instruction chain into the request prefix, then discovers nested files and reports later changes or removals through durable context messages after successful filesystem tool calls. +Per-session workspace instruction loading for `AGENTS.md`-compatible files. The plugin injects the initial user-global and project instruction chain into durable history, then discovers nested files and reports later changes or removals after successful filesystem tool calls. ## Lifecycle -The baseline is composed once per agent-loop instance on `agent/session-prefix`. It reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. The prefix is placed before all derived history, recorded in `EpochHeader.messagePrefix`, and reused verbatim for that loop instance. Because the plugin prepends its contribution before delegating, a later-registered skills catalog appears after workspace instructions. +The baseline is injected at the first `agent/step` of each live session. It reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. The durable sourced `user/message` enters the same request as the claimed prompt. The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. Each configured candidate name is an independent scope in its directory: a newly present file is attached through the result's `additionalContexts`; a changed file appends a replacement; a file that disappears or becomes a per-directory duplicate of an earlier candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable. @@ -14,7 +14,7 @@ Instruction reads use the optional `ctx.fs` provider. The plugin does not static ## Prompt Shape -Baseline instructions are request-only user-role prefix messages framed with the familiar system-reminder pattern: +Baseline instructions are durable user-role messages framed with the familiar system-reminder pattern: ```md @@ -30,7 +30,7 @@ Instructions from: AGENTS.md ``` -Newly reached scopes use a durable injected `user/message` (plugin source): +Newly reached scopes use a durable sourced `user/message`: ```md @@ -44,15 +44,15 @@ These instructions apply to work under `packages/app`. Use them as guidance when A same-file edit starts with `Updated instructions from: ` and says to use the new content instead of the previously loaded content. When a candidate disappears or becomes a per-directory duplicate of an earlier candidate, the message is `Instructions removed: ` followed by `The previously loaded instructions from this file no longer apply.` Literal `` text inside an instruction file is escaped so file content cannot close the plugin-owned frame. -The plugin owns the complete `` framing, and every injected `user/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping. +The plugin owns the complete `` framing, and every injected `user/message` reaches the model verbatim with no core wrapper. ## State And Refresh -Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. +Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; the complete startup or resume baseline also carries `baseline: true`. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. -An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only. +An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter the source, pending state, and version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. -The frozen baseline itself is not rewritten mid-instance. Its initial path/digest map is retained as comparison state; the next successful filesystem touch appends any baseline replacement or removal. A resumed loop recomposes the current baseline and also reconciles still-visible dynamic scopes during prefix composition. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop composes its prefix. +The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface; the next successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends its replacement or removal. The in-memory scope marker and provider-version cache only select and accelerate probes. A hot plugin remount retains a baseline only when its typed event remains visible, while rebuilding current scope and version tracking; otherwise it injects a current baseline. A resumed loop always recomposes the current baseline and also reconciles still-visible dynamic scopes before its first request. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop prepares its baseline. ## Configuration @@ -75,15 +75,15 @@ The user-global file is always `$DSH_HOME/AGENTS.md` with no local overlay; both Rendering preserves the most specific instruction files first. It drops whole broader files before truncating the most-specific file and emits a visible `Workspace instruction budget ...` notice naming omitted and truncated paths. The rendered bytes never exceed `maxBytes`. -Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in structured session metadata. +Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in the structured message source. ## Model Experience -### Baseline session prefix +### Baseline context #### What the model sees -At the first request of each loop instance, the model receives one user-role prefix message containing the bounded user-global and project instruction chain in broad-to-specific order. +At the first request of each loop instance, the model receives one durable user-role message containing the bounded user-global and project instruction chain in broad-to-specific order. ##### Baseline instruction template @@ -103,17 +103,17 @@ Instructions from: AGENTS.md #### Token effect -The rendered baseline is frozen and resent on every request in that loop instance. `maxBytes` bounds the complete message, broader files are omitted before the most-specific file is truncated, and an empty chain contributes zero tokens. +The rendered baseline is appended once and remains in derived history until compaction. `maxBytes` bounds the complete message, broader files are omitted before the most-specific file is truncated, and an empty chain contributes zero tokens. #### KV Cache effect -Prefix-stable within one loop instance because the baseline is frozen. A new or resumed instance recomposes it, so instruction, precedence, cwd, candidate, or byte-budget changes may invalidate reuse from the first changed baseline token. +Append-only after the existing reusable prefix. A new or resumed instance may append a recomposed baseline, so instruction, precedence, cwd, candidate, or byte-budget changes affect cache reuse from that history position. ### Newly discovered scope context #### What the model sees -After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained injected `user/message` with the newly applicable instruction file. +After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained sourced `user/message` with the newly applicable instruction file. ##### Additional instruction template @@ -162,7 +162,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **Discovery follows structured fs tools, not shell navigation** — a `bash` command that changes directories does not trigger nested instruction discovery because shell syntax and per-call shell state are not a reliable filesystem seam. -- **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, or when a resumed loop recomposes its prefix. +- **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, or when a resumed loop prepares its baseline. - **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; project scopes load `AGENTS.local.md`/`CLAUDE.local.md` overlays by default, but the user-global `$DSH_HOME` scope has no local overlay and other custom names require explicit candidate configuration. - **Per-directory dedup is content-based** — sibling candidates collapse only when byte-identical after trimming leading and trailing whitespace; a `CLAUDE.md` that symlinks its sibling `AGENTS.md` resolves to the same content and collapses like any duplicate, while a distinct real copy that has drifted from `AGENTS.md` loads in full alongside it. - **Symlinked instruction files are followed across the trust boundary** — a candidate whose final component is a symlink is resolved and its target loaded, so a cloned repository can surface off-tree file content as lower-authority workspace guidance (it never overrides system, developer, or direct user instructions). Confine `ctx.fs` with the filesystem policy gate or an OS sandbox when loading untrusted repositories. diff --git a/packages/context/workspace-context/README.zh.md b/packages/context/workspace-context/README.zh.md index 0d5503dba2..8bd926302f 100644 --- a/packages/context/workspace-context/README.zh.md +++ b/packages/context/workspace-context/README.zh.md @@ -2,19 +2,19 @@ [English](README.md) | 中文 -为每个会话加载与 `AGENTS.md` 兼容的工作区指令文件。该插件会将初始 user 全局指令与项目指令链冻结到请求前缀中,随后发现嵌套文件,并在成功的文件系统工具调用后通过持久上下文消息报告后续变更或移除。 +为每个会话加载与 `AGENTS.md` 兼容的工作区指令文件。该插件会将初始 user 全局指令与项目指令链注入持久历史,随后发现嵌套文件,并在成功的文件系统工具调用后报告后续变更或移除。 ## 生命周期 -基线会在每个 agent-loop 实例的 `agent/session-prefix` 上组合一次。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。前缀放在所有派生历史之前,记录在 `EpochHeader.messagePrefix` 中,并为该 loop 实例逐字复用。因为插件在委托之前前置自身贡献,后注册的 skill catalog 会出现在工作区指令之后。 +基线会在每个实时会话的第一个 `agent/step` 注入。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。这条持久的带来源 `user/message` 与被认领的提示词进入同一个请求。 该插件还会监听 `tools/post-execute` 中成功的第一方 `read`、`write` 和 `edit` 调用。每次 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope:新出现的文件通过结果的 `additionalContexts` 附加;已改变文件追加替换;文件消失或成为同一目录中较早候选文件的重复项时,追加移除通知。原生调用与 Code Mode 子分派共享该路径:`run_code` 将每个嵌套上下文延迟到外层结果,因此 loop 仍会在工具调用/结果相邻关系完成后追加更新。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell,解析任意 shell 语法也不可靠。 -指令读取使用可选 `ctx.fs` 提供方。该插件不会静态注入 `fs`,因此没有提供方的产品树仍可启动,指令加载在提供方出现前不执行任何操作。它会解析每个候选文件并获取结果状态,因此会跟随最终组件 symlink 到其目标:指向常规文件的链接会加载目标内容,缺失路径或非文件目标(包括指向目录的链接)则已确认不存在。resolve 或 stat 异常会改为将该候选文件的 scope 标记为暂时不可用。前缀取消与动态工具取消会传播到解析、元数据探测与流式读取。文件加载后的提供方失败会视为暂时不可用,而非文件已删除的证据。 +指令读取使用可选 `ctx.fs` 提供方。该插件不会静态注入 `fs`,因此没有提供方的产品树仍可启动,指令加载在提供方出现前不执行任何操作。它会解析每个候选文件并获取结果状态,因此会跟随最终组件 symlink 到其目标:指向常规文件的链接会加载目标内容,缺失路径或非文件目标(包括指向目录的链接)则已确认不存在。resolve 或 stat 异常会改为将该候选文件的 scope 标记为暂时不可用。步骤取消与动态工具取消会传播到解析、元数据探测与流式读取。文件加载后的提供方失败会视为暂时不可用,而非文件已删除的证据。 ## 提示词形状 -基线指令是仅请求的 user 角色前缀消息,使用熟悉的 system-reminder 模式框定: +基线指令是持久的 user 角色消息,使用熟悉的 system-reminder 模式框定: ```md @@ -30,7 +30,7 @@ Instructions from: AGENTS.md ``` -新达到的 scope 使用持久注入 `user/message`(插件源): +新达到的 scope 使用持久的带来源 `user/message`: ```md @@ -44,15 +44,15 @@ These instructions apply to work under `packages/app`. Use them as guidance when 同一文件的编辑以 `Updated instructions from: ` 开头,并说明使用新内容替代之前加载的内容。候选文件消失或成为同一目录中较早候选文件的重复项时,消息是 `Instructions removed: `,后跟 `The previously loaded instructions from this file no longer apply.`。指令文件中的字面 `` 文本会转义,因此文件内容无法关闭插件拥有的 frame。 -该插件拥有完整 `` framing,每个注入的 `user/message`(无论来自此插件还是其他插件)都会不加包装地逐字达到模型,成为 user 角色消息。 +该插件拥有完整 `` framing,每个注入的 `user/message` 都会在没有核心包装的情况下逐字达到模型。 ## 状态与刷新 -模型可见文本不含隐藏状态标记。每个动态上下文事件改为携带 JSON 元数据,其中包含经版本化的 `{ action, scope, path, digest? }` 变更列表。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。匹配的持久 `user/message` 会确认 pending 转换。如果所属 `step/end` 在匹配上下文进入日志之前到达,插件会清除 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。 +模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整的启动或恢复基线还会携带 `baseline: true`。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。匹配的持久 `user/message` 会确认 pending 转换。如果所属 `step/end` 在匹配上下文进入日志之前到达,插件会清除 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。 -路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 元数据 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在会话日志中,而空的内存版本 cache 只会导致一次确认读取。压缩会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入元数据、pending 状态和版本 cache;已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新元数据。 +路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入来源、pending 状态和版本 cache;已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。 -冻结基线自身不会在实例中途改写。其初始路径/digest map 保留为比较状态;下一次成功文件系统 touch 会追加任何基线替换或移除。恢复的 loop 会重新组合当前基线,并在前缀组合期间对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在恢复 loop 组合前缀时可见。 +初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态;下一次成功的文件系统 touch 会在压缩后重新添加未变的基线 scope,或追加其替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会注入当前基线。恢复的 loop 始终重新组合当前基线,并在第一个请求前对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在恢复 loop 准备基线时可见。 ## 配置 @@ -75,15 +75,15 @@ user 全局文件始终是 `$DSH_HOME/AGENTS.md`,没有本地 overlay;两个 渲染会优先保留最具体的指令文件。它会先丢弃完整的较宽泛文件,再截断最具体文件,并发出可见 `Workspace instruction budget ...` 通知,其中指名已省略与已截断路径。渲染后字节数绝不超过 `maxBytes`。 -即使提供方元数据省略大小,或文件在元数据探测后增长,指令内容仍会通过 `streamText()` 在 `maxSourceBytes` 下读取。超大文件会被忽略;在动态对账期间,它会暂时不可用,而不是被移除。该插件不保留进程级 cache,绝不缓存指令文本。其会话本地 scope cache 只将提供方版本用作快速失效信号;失效后,对有界读取计算的 SHA-1 仍是存储在结构化会话元数据中的跨提供方内容身份。 +即使提供方元数据省略大小,或文件在元数据探测后增长,指令内容仍会通过 `streamText()` 在 `maxSourceBytes` 下读取。超大文件会被忽略;在动态对账期间,它会暂时不可用,而不是被移除。该插件不保留进程级 cache,绝不缓存指令文本。其会话本地 scope cache 只将提供方版本用作快速失效信号;失效后,对有界读取计算的 SHA-1 仍是存储在结构化消息来源中的跨提供方内容身份。 ## 模型体验 -### 基线会话前缀 +### 基线上下文 #### 模型看到的内容 -在每个 loop 实例的第一个请求中,模型会收到一条 user 角色前缀消息,其中按从宽泛到具体的顺序包含有界 user 全局指令与项目指令链。 +在每个 loop 实例的第一个请求中,模型会收到一条持久 user 角色消息,其中按从宽泛到具体的顺序包含有界 user 全局指令与项目指令链。 ##### 基线指令模板 @@ -103,17 +103,17 @@ Instructions from: AGENTS.md #### Token 影响 -渲染后基线会被冻结,并在该 loop 实例的每个请求中重发。`maxBytes` 会限制完整消息,较宽泛文件在最具体文件截断之前被省略,空指令链不产生 token。 +渲染后基线只追加一次,并保留在派生历史中直到压缩。`maxBytes` 会限制完整消息,较宽泛文件在最具体文件截断之前被省略,空指令链不产生 token。 #### KV Cache 影响 -由于基线已冻结,前缀在同一 loop 实例内保持稳定。新建或恢复的实例会重新组合它,因此指令、优先级、cwd、候选文件或字节预算变更可能使从第一个改变的基线 token 起的复用失效。 +仅追加,位于现有可复用前缀之后。新建或恢复的实例可能追加重新组合的基线,因此指令、优先级、cwd、候选文件或字节预算变更会从该历史位置起影响缓存复用。 ### 新发现的 scope 上下文 #### 模型看到的内容 -成功的第一方文件系统调用达到更深目录后,下一个请求会包含一条保留的注入 `user/message`,其中包含新适用的指令文件。 +成功的第一方文件系统调用达到更深目录后,下一个请求会包含一条保留的带来源 `user/message`,其中包含新适用的指令文件。 ##### 附加指令模板 @@ -162,7 +162,7 @@ The previously loaded instructions from this file no longer apply. ## 已知限制与暂缓事项 - **发现跟随结构化 fs 工具,而非 shell 导航**:更改目录的 `bash` 命令不会触发嵌套指令发现,因为 shell 语法与每次调用 shell 状态不是可靠的文件系统 seam。 -- **刷新由 touch 驱动**:没有 watcher;外部编辑会在下一次成功的第一方 `read`、`write` 或 `edit` 时可见,也会在恢复 loop 重新组合前缀时可见。 +- **刷新由 touch 驱动**:没有 watcher;外部编辑会在下一次成功的第一方 `read`、`write` 或 `edit` 时可见,也会在恢复 loop 准备基线时可见。 - **候选语义有意保持简单**:不解释小写名称、`.claude/rules/` 与 `@path` import;项目 scope 默认加载 `AGENTS.local.md`/`CLAUDE.local.md` overlay,但 user 全局 `$DSH_HOME` scope 没有本地 overlay,其他自定义名称需要显式候选配置。 - **每目录去重基于内容**:只有在去除首尾空白后字节完全一致时,才折叠同级候选文件。`CLAUDE.md` 若 symlink 到同级 `AGENTS.md`,会解析为相同内容,并像任何重复项一样折叠;从 `AGENTS.md` 漂移的独立实体副本则会与它一起完整加载。 - **Symlink 指令文件会跨越信任边界跟随**:最终组件是 symlink 的候选文件会被解析并加载其目标,因此克隆仓库可以将树外文件内容呈现为较低权限的工作区指引(它绝不会覆盖 system、developer 或直接 user 指令)。加载不受信任仓库时,请用文件系统策略门禁或 OS 沙箱限制 `ctx.fs`。 diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 5bd858a8a0..bdae7fdeb4 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -1,7 +1,7 @@ /** * Workspace instruction loader for AGENTS.md-compatible files. * - * Baseline instructions are frozen into `agent/session-prefix`; successful fs + * Baseline instructions enter durable context before the first request; successful fs * tool touches reconcile nested, changed, and removed instructions through * `tools/post-execute` for the next model request. Plugin lifecycle reads use * the optional `ctx.fs` provider, so providerless products mount it as a no-op. @@ -11,7 +11,6 @@ import type { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { Message } from '@deepseek-ai/dsh-llm' import type { PostToolDecision, ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { Config, resolveConfig, type ResolvedConfig } from './config.ts' import { loadBaselineInstructionSet } from './files.ts' @@ -44,27 +43,53 @@ export type { export { renderWorkspaceContext } from './render.ts' export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts' +function hasVisibleBaseline(agent: Agent): boolean { + return agent.session.surface.nodes.some((seq) => { + const event = agent.session.events[seq] + return event?.type === 'user/message' + && event.data.source.kind === 'workspace-instructions' + && event.data.source.baseline === true + }) +} + export function apply(ctx: Context, config: Config): void { const resolved: ResolvedConfig = resolveConfig(config) const pendingNestedChanges = new WeakMap>() - const baselineInstructionStates = new WeakMap>() + const baselineSessions = new WeakSet() const instructionVersions: InstructionVersionCache = new WeakMap() const pendingVersionUpdates = new Map() + const baselineLoaded = new WeakSet() + // Sessions whose lifecycle start this mount witnessed. A startup or resume + // emits agent/session-start before the first step; a hot remount attaches to + // an already-live session and never sees it. Resumes always re-compose the + // baseline from current files. Hot remounts retain a baseline only while its + // typed event remains model-visible. + const lifecycleWitnessed = new WeakSet() const pendingByParent = new Map() + ctx.on('agent/session-start', (agent: Agent) => { + lifecycleWitnessed.add(agent.session) + }) + ctx.on('session/event', (session, event) => { observeInstructionSessionEvent(session, event, pendingNestedChanges, instructionVersions) }) - ctx.on('agent/session-prefix', async (agent: Agent, _prefix, signal, next): Promise => { - const rest = await next() - if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) return rest + ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise => { + if (baselineLoaded.has(agent.session)) return + if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) { + baselineLoaded.add(agent.session) + return + } const fileSystem = ctx.get('fs') - if (fileSystem === undefined) return rest + if (fileSystem === undefined) { + baselineLoaded.add(agent.session) + return + } /* v8 ignore next -- normal agents carry an absolute session cwd. */ const cwd = agent.session.header.cwd ?? process.cwd() const instructions = await loadBaselineInstructionSet({ @@ -78,27 +103,34 @@ export function apply(ctx: Context, config: Config): void { signal, }, fileSystem) const baseline = baselineInstructionState(instructions?.included ?? []) - baselineInstructionStates.set(agent.session, baseline.changes) + baselineSessions.add(agent.session) instructionVersions.set(agent.session, baseline.versions) const update = await reconcileInstructionContext( agent, resolved, pendingNestedChanges, - baselineInstructionStates, instructionVersions, fileSystem, { includeBaselineScopes: false, signal }, ) if (update !== undefined) { - agent.inject(update.context.content, { - source: update.context.source, - meta: update.context.meta, - }) + agent.inject({ content: update.context.content, source: update.context.source }) applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions) } - if (instructions === undefined || instructions.rendered.text.length === 0) return rest - return [workspaceContextMessage(instructions.rendered.text), ...rest] + const keepVisibleBaseline = !lifecycleWitnessed.has(agent.session) && hasVisibleBaseline(agent) + if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) { + const baselineMessage = workspaceContextMessage(instructions.rendered.text) + agent.inject({ + content: baselineMessage.content, + source: { + kind: 'workspace-instructions', + baseline: true, + changes: [...baseline.changes.values()], + }, + }) + } + baselineLoaded.add(agent.session) }) ctx.on('tools/post-execute', async ( @@ -122,7 +154,7 @@ export function apply(ctx: Context, config: Config): void { result, resolved, pendingNestedChanges, - baselineInstructionStates, + baselineSessions, instructionVersions, fileSystem, ) diff --git a/packages/context/workspace-context/src/invariant.ts b/packages/context/workspace-context/src/invariant.ts index d9f56417b8..f6c99ea10e 100644 --- a/packages/context/workspace-context/src/invariant.ts +++ b/packages/context/workspace-context/src/invariant.ts @@ -15,7 +15,7 @@ export const name = 'workspace-context-invariant' export const inject = ['invariants'] /** - * No runtime invariant: replay intentionally tolerates unknown or malformed workspace metadata, + * No runtime invariant: replay intentionally tolerates unknown or malformed workspace sources, * while focused pipeline tests own its private pending/cache state transitions. */ const install: InvariantInstaller = () => {} diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index 61db3f527b..b05ddbf280 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -4,9 +4,9 @@ * @module @deepseek-ai/dsh-workspace-context/state */ -import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import type { Message } from '@deepseek-ai/dsh-llm' -import type { JsonValue, Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, UserMessageData } from '@deepseek-ai/dsh-session' import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs' import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { ResolvedConfig } from './config.ts' @@ -33,9 +33,22 @@ import { export const name = 'workspace-context' -const PLUGIN_SOURCE = { kind: 'plugin', plugin: name } as const const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit']) +/** Durable provenance and reconciliation facts for one workspace context. */ +export interface WorkspaceInstructionSource { + kind: 'workspace-instructions' + /** Marks the complete startup/resume baseline rather than a later delta. */ + baseline?: true + changes: WorkspaceInstructionChange[] +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + 'workspace-instructions': WorkspaceInstructionSource + } +} + /** Dynamic state waiting for the loop to append its returned context event. */ export interface PendingInstructionChange { change: WorkspaceInstructionChange @@ -66,28 +79,19 @@ export interface InstructionVersionUpdate { /** Rendered reconciliation plus cache transitions awaiting final policy. */ export interface ReconciledInstructionContext { - context: WorkspaceHookContext + context: UserMessageData versionUpdates: InstructionVersionUpdate[] } -/** Plugin-owned context with required replay metadata. */ -export interface WorkspaceHookContext extends HookContext { - meta: JsonValue -} - -function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): WorkspaceHookContext { - const serializedChanges: JsonValue[] = changes.map(change => ({ - action: change.action, - scope: change.scope, - path: change.path, - ...change.digest !== undefined ? { digest: change.digest } : {}, - })) - const meta: JsonValue = { kind: 'workspace-instructions', version: 1, changes: serializedChanges } - return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, meta } +function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): UserMessageData { + return { + content: [{ type: 'text', text }], + source: { kind: 'workspace-instructions', changes }, + } } /** - * Build the request-prefix message for a rendered baseline. + * Build the user-role message for a rendered baseline. * @param text - complete plugin-owned system-reminder text. * @returns a user-role prefix message. */ @@ -103,20 +107,21 @@ function filePathFromExecution(exec: ToolExecution): string | undefined { return filePath.length > 0 ? filePath : undefined } -function isWorkspaceContextSource(source: unknown): source is typeof PLUGIN_SOURCE { +function isWorkspaceContextSource( + source: unknown, +): source is { kind: 'workspace-instructions'; changes: unknown[] } { return typeof source === 'object' && source !== null - && 'kind' in source && source.kind === 'plugin' - && 'plugin' in source && source.plugin === name + && 'kind' in source && source.kind === 'workspace-instructions' + && 'changes' in source && Array.isArray(source.changes) } -function isRecord(value: JsonValue | undefined): value is { [key: string]: JsonValue } { +function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -function workspaceInstructionChanges(meta: JsonValue | undefined): WorkspaceInstructionChange[] { - if (!isRecord(meta) || meta.kind !== 'workspace-instructions' || meta.version !== 1 || !Array.isArray(meta.changes)) return [] +function workspaceInstructionChanges(source: { changes: unknown[] }): WorkspaceInstructionChange[] { const changes: WorkspaceInstructionChange[] = [] - for (const value of meta.changes) { + for (const value of source.changes) { if (!isRecord(value)) continue if (value.action !== 'set' && value.action !== 'replace' && value.action !== 'remove') continue if (typeof value.scope !== 'string' || typeof value.path !== 'string') continue @@ -146,7 +151,7 @@ function visibleInstructionChanges( const visible = new Map() for (const [seq, event] of agent.session.events.entries()) { if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue - const changes = workspaceInstructionChanges(event.data.meta) + const changes = workspaceInstructionChanges(event.data.source) for (const change of changes) { const waiting = pending.get(change.scope) if (waiting !== undefined && seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) { @@ -283,7 +288,7 @@ export function observeInstructionSessionEvent( switch (event.type) { case 'user/message': { if (!isWorkspaceContextSource(event.data.source)) return - for (const change of workspaceInstructionChanges(event.data.meta)) { + for (const change of workspaceInstructionChanges(event.data.source)) { const waiting = pending.get(change.scope) if (waiting !== undefined && event.seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) { pending.delete(change.scope) @@ -322,14 +327,14 @@ export function observeInstructionSessionEvent( */ export function commitPendingInstructionContexts( agent: Agent, - contexts: readonly HookContext[] | undefined, + contexts: readonly UserMessageData[] | undefined, pendingBySession: WeakMap>, ): WorkspaceInstructionChange[] { const committed: WorkspaceInstructionChange[] = [] const step = openStep(agent.session) for (const context of contexts ?? []) { if (!isWorkspaceContextSource(context.source)) continue - const changes = workspaceInstructionChanges(context.meta) + const changes = workspaceInstructionChanges(context.source) if (changes.length === 0) continue const pending = pendingChangesFor(agent.session, pendingBySession) for (const change of changes) { @@ -375,50 +380,49 @@ function relativeScope(projectRoot: string, dir: string): string { * @param agent - session owner whose visible surface supplies durable state. * @param resolved - normalized plugin configuration. * @param pendingBySession - short pending window before returned context is logged. - * @param baselineBySession - frozen baseline comparison state per session. * @param versionCache - per-session scope metadata used to skip unchanged reads. * @param fileSystem - provider used for current file probes. - * @param options - touched path and whether baseline scopes should be checked. + * @param options - touched path and whether baseline scopes should participate. * @returns rendered context plus deferred cache updates, or undefined when unchanged/unavailable. */ export async function reconcileInstructionContext( agent: Agent, resolved: ResolvedConfig, pendingBySession: WeakMap>, - baselineBySession: WeakMap>, versionCache: InstructionVersionCache, fileSystem: FileSystem, options: { touchedPath?: string; includeBaselineScopes: boolean; signal?: AbortSignal }, ): Promise { const session = agent.session const pending = pendingChangesFor(session, pendingBySession) - const visible = visibleInstructionChanges(agent, pending) - const effective = new Map(baselineBySession.get(session) ?? []) - for (const [scope, change] of visible) effective.set(scope, change) + const effective = visibleInstructionChanges(agent, pending) /* v8 ignore next -- normal agents carry an absolute session cwd. */ const cwd = session.header.cwd ?? process.cwd() // TODO(frozen-project-root): retain the baseline root for the loop instance; // recomputing it after marker edits reinterprets the existing relative scope keys. const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal) const scopes = new Set() - const addDirScopes = (directory: string): void => { - for (const candidate of resolved.instructionFileCandidates) scopes.add(candidateScopeKey(directory, candidate)) - for (const candidate of resolved.localInstructionFileCandidates) scopes.add(candidateScopeKey(directory, candidate)) + const baselineScopes = new Set() + const addDirScopes = (target: Set, directory: string): void => { + for (const candidate of resolved.instructionFileCandidates) target.add(candidateScopeKey(directory, candidate)) + for (const candidate of resolved.localInstructionFileCandidates) target.add(candidateScopeKey(directory, candidate)) } - const addProjectScopes = (dir: string): void => { - addDirScopes(relativeScope(projectRoot, dir)) + const addProjectScopes = (target: Set, dir: string): void => { + addDirScopes(target, relativeScope(projectRoot, dir)) } + baselineScopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE)) + for (const dir of ancestorChain(projectRoot, cwd)) addProjectScopes(baselineScopes, dir) if (options.includeBaselineScopes) { - scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE)) - for (const dir of ancestorChain(projectRoot, cwd)) addProjectScopes(dir) + for (const scope of baselineScopes) scopes.add(scope) } for (const scope of effective.keys()) { + if (!options.includeBaselineScopes && baselineScopes.has(scope)) continue const { directory } = decodeScopeKey(scope) if (directory === USER_GLOBAL_DIRECTORY) scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE)) - else addDirScopes(directory) + else addDirScopes(scopes, directory) } if (options.touchedPath !== undefined) { - for (const dir of descendantDirsBetween(cwd, options.touchedPath)) addProjectScopes(dir) + for (const dir of descendantDirsBetween(cwd, options.touchedPath)) addProjectScopes(scopes, dir) } const versions = versionStatesFor(session, versionCache) @@ -530,7 +534,7 @@ export async function reconcileInstructionContext( * @param result - original tool result before post-execute decisions. * @param resolved - normalized plugin configuration. * @param pendingNestedChanges - per-session pending transition maps. - * @param baselineInstructionStates - retained baseline comparison state. + * @param baselineSessions - sessions whose configured baseline scopes should be probed. * @param versionCache - per-session scope metadata used to skip unchanged reads. * @param fileSystem - provider used for current file probes. * @returns rendered context plus deferred cache updates, or undefined for irrelevant/failed/unchanged calls. @@ -541,7 +545,7 @@ export async function dynamicInstructionContext( result: ToolExecutionResult, resolved: ResolvedConfig, pendingNestedChanges: WeakMap>, - baselineInstructionStates: WeakMap>, + baselineSessions: WeakSet, versionCache: InstructionVersionCache, fileSystem: FileSystem, ): Promise { @@ -549,10 +553,10 @@ export async function dynamicInstructionContext( const touchedPath = filePathFromExecution(exec) if (touchedPath === undefined) return undefined return reconcileInstructionContext( - agent, resolved, pendingNestedChanges, baselineInstructionStates, versionCache, fileSystem, + agent, resolved, pendingNestedChanges, versionCache, fileSystem, { touchedPath, - includeBaselineScopes: baselineInstructionStates.has(agent.session), + includeBaselineScopes: baselineSessions.has(agent.session), signal: exec.signal, }, ) diff --git a/packages/context/workspace-context/tests/workspace-context.e2e.ts b/packages/context/workspace-context/tests/workspace-context.e2e.ts index fbeacb5496..4f70f6fa6d 100644 --- a/packages/context/workspace-context/tests/workspace-context.e2e.ts +++ b/packages/context/workspace-context/tests/workspace-context.e2e.ts @@ -78,7 +78,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode it('obeys a probe instruction loaded from the workspace', async () => { const live = await harness() - live.agent.followup([{ type: 'text', text: 'Workspace context handshake?' }]) + live.agent.followup({ content: [{ type: 'text', text: 'Workspace context handshake?' }], source: { kind: 'user' } }) await waitForIdle(live.ctx, live.agent) expect(finalText([...live.agent.session.events])).toContain(PROBE) @@ -90,7 +90,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode await writeFile(join(workdir!, 'pkg/AGENTS.md'), `If the user asks for the nested instruction handshake, reply with exactly this string and nothing else: ${NESTED_PROBE}.\n`) await writeFile(join(workdir!, 'pkg/deep/file.txt'), 'This file exists only to trigger nested workspace instructions.\n') - live.agent.followup([{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }]) + live.agent.followup({ content: [{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }], source: { kind: 'user' } }) await waitForIdle(live.ctx, live.agent) expect(finalText([...live.agent.session.events])).toContain(NESTED_PROBE) @@ -99,20 +99,18 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode it('appends changed baseline instructions after a real file-tool touch without rewriting the frozen prefix', async () => { const live = await harness() await writeFile(join(workdir!, 'trigger.txt'), 'This file triggers workspace instruction reconciliation.\n') - live.agent.followup([{ type: 'text', text: 'Workspace context handshake?' }]) + live.agent.followup({ content: [{ type: 'text', text: 'Workspace context handshake?' }], source: { kind: 'user' } }) await waitForIdle(live.ctx, live.agent) await writeFile(join(workdir!, 'AGENTS.md'), `The old workspace handshake no longer applies. If the user asks for the updated workspace context handshake, reply with exactly this string and nothing else: ${UPDATED_PROBE}.\n`) - live.agent.followup([{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }]) + live.agent.followup({ content: [{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }], source: { kind: 'user' } }) await waitForIdle(live.ctx, live.agent) const events = [...live.agent.session.events] const update = events.find(event => event.type === 'user/message' - && typeof event.data.meta === 'object' - && event.data.meta !== null - && !Array.isArray(event.data.meta) - && event.data.meta.kind === 'workspace-instructions') - expect(update?.type === 'user/message' && update.data.meta).toMatchObject({ + && event.data.source.kind === 'workspace-instructions' + && event.data.source.baseline !== true) + expect(update?.type === 'user/message' && update.data.source).toMatchObject({ changes: [{ action: 'replace', scope: candidateScopeKey('.', 'AGENTS.md'), path: 'AGENTS.md' }], }) const updateText = update?.type === 'user/message' diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index c8c33f3a4b..102829a0e8 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -6,8 +6,8 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session' -import AgentRegistry, { AgentMessageId, type Agent, type HookContext } from '@deepseek-ai/dsh-agent' +import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent, type UserMessageData } from '@deepseek-ai/dsh-session' +import AgentRegistry, { agentEvents, AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { @@ -177,15 +177,11 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { options: {}, session, status: 'idle', + acceptsNextStep: false, followup: () => AgentMessageId('stub'), - queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), - inject(content, options) { - session.append('user/message', { - content, - source: options?.source ?? { kind: 'user' }, - ...options?.meta !== undefined ? { meta: options.meta } : {}, - }, { surfaceOp: 'append' }) + inject(input) { + session.append('user/message', input, { surfaceOp: 'append' }) return AgentMessageId('stub') }, send: () => AgentMessageId('stub'), @@ -205,30 +201,34 @@ function blocksText(blocks: { type: string; text?: string }[] | undefined): stri return blocks?.map(block => block.type === 'text' ? block.text ?? '' : '').join('\n') ?? '' } -function workspaceContextOf(result: { additionalContexts?: HookContext[] }): HookContext | undefined { +function workspaceContextOf(result: { additionalContexts?: UserMessageData[] }): UserMessageData | undefined { return result.additionalContexts?.find(context => - context.source.kind === 'plugin' && context.source.plugin === 'workspace-context') + context.source.kind === 'workspace-instructions') } -function workspaceChangeContext(scope: string, digest: string): HookContext { +function baselineEvents(agent: Agent): SessionEvent[] { + return agent.session.events.filter(event => + event.type === 'user/message' + && event.data.source.kind === 'workspace-instructions' + && event.data.source.baseline === true) +} + +function workspaceChangeContext(scope: string, digest: string): UserMessageData { return { content: [{ type: 'text', text: `instructions for ${scope}` }], - source: { kind: 'plugin', plugin: 'workspace-context' }, - meta: { + source: { kind: 'workspace-instructions', - version: 1, changes: [{ action: 'set', scope, path: `${scope}/AGENTS.md`, digest }], }, } } -function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: HookContext[] }): number | undefined { +function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: UserMessageData[] }): number | undefined { let lastSeq: number | undefined for (const context of result.additionalContexts ?? []) { lastSeq = agent.session.append('user/message', { content: context.content, source: context.source, - ...context.meta !== undefined ? { meta: context.meta } : {}, }, { surfaceOp: 'append' }).seq } return lastSeq @@ -237,11 +237,8 @@ function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: H const composedPrefixes = new WeakMap() async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise { - const empty: Message[] = [] - const prefix = await ctx.waterfall( - 'agent/session-prefix', agent, empty, AbortSignal.timeout(1000), - () => Promise.resolve(empty), - ) + await agentEvents(ctx, agent).serial('agent/step', 1, 1, AbortSignal.timeout(1000)) + const prefix = agent.session.deriveMessages() composedPrefixes.set(agent, prefix) return prefix } @@ -931,7 +928,7 @@ describe('workspace context request injection', () => { kind: 'accept' as const, })) expect(accepted.kind).toBe('accept') - expect(workspaceContextOf(accepted)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) + expect(workspaceContextOf(accepted)?.source).toMatchObject({ kind: 'workspace-instructions' }) expect(blocksText(workspaceContextOf(accepted)?.content)).toContain('nested package rule') } finally { await ctx.fiber.dispose() @@ -940,7 +937,7 @@ describe('workspace context request injection', () => { } }) - it('contributes baseline instructions through the frozen session prefix instead of durable history', async () => { + it('contributes baseline instructions through durable injected history', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -952,7 +949,17 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) - expect(agent.session.deriveMessages()).toEqual([]) + expect(baselineEvents(agent)).toHaveLength(1) + expect(baselineEvents(agent)[0]).toMatchObject({ + type: 'user/message', + data: { + source: { + kind: 'workspace-instructions', + baseline: true, + changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], + }, + }, + }) expect(composedPrefixes.get(agent)).toHaveLength(1) expect(derivedText(agent)).toContain('') expect(derivedText(agent)).toContain('Instructions from: AGENTS.md') @@ -965,7 +972,7 @@ describe('workspace context request injection', () => { } }) - it('returns one baseline contribution per session-prefix composition without appending context events', async () => { + it('injects one durable baseline contribution on the first step only', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -979,7 +986,7 @@ describe('workspace context request injection', () => { const second = await composeBaselinePrefix(ctx, agent) expect(second).toEqual(first) - expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0) + expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1) expect(derivedText(agent)).toContain('repo rule') } finally { await rm(root, { recursive: true, force: true }) @@ -987,6 +994,116 @@ describe('workspace context request injection', () => { } }) + it('retains a visible baseline after a plugin remount', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + await write(join(root, 'file.txt'), 'hello') + const ctx = new Context() + const fiber = await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + await composeBaselinePrefix(ctx, agent) + + // Hot remount over the live session: the durable baseline remains + // visible, so the fresh mount does not append a duplicate. + await fiber.dispose() + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await composeBaselinePrefix(ctx, agent) + + expect(baselineEvents(agent)).toHaveLength(1) + + await write(join(root, 'AGENTS.md'), 'updated repo rule') + const update = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-after-remount'), + name: 'read', + arguments: { file_path: 'file.txt' }, + agent, + }) + expect(workspaceContextOf(update)?.source).toMatchObject({ + changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], + }) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('restores a compacted baseline on a hot plugin remount', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + const ctx = new Context() + await ctx.plugin(LocalFileSystem, { cwd: '/' }) + const fiber = await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + await composeBaselinePrefix(ctx, agent) + const baseline = baselineEvents(agent)[0] + expect(baseline).toBeDefined() + + agent.session.append('user/message', { + content: [{ type: 'text', text: 'compacted summary' }], + source: { kind: 'plugin', plugin: 'compact' }, + }, { + surfaceOp: { op: 'replace', start: baseline!.seq, end: baseline!.seq }, + sourceEventSeqs: [baseline!.seq], + }) + + await fiber.dispose() + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await composeBaselinePrefix(ctx, agent) + + expect(baselineEvents(agent)).toHaveLength(2) + expect(blocksText(agent.session.deriveMessages().at(-1)?.content)).toContain('repo rule') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('recomposes the baseline from current files when a resumed session edited it offline', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'old root rule') + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const original = stubAgent(root) + await composeBaselinePrefix(ctx, original) + + // Offline edit to the baseline file, then resume on a fresh session whose + // seeded log already carries the original baseline. A resumed session is + // registered after this mount's apply(), so the remount guard never seeds + // it: its first step re-composes a fresh baseline from current files, + // reflecting the offline edit before the first resumed request. The old + // baseline stays in history unmutated (note: resume without mutating an + // earlier history event). + await write(join(root, 'AGENTS.md'), 'new root rule after offline edit') + const resumed = stubAgent(root, [...original.session.events]) + + // Resume announces its lifecycle start before the first step. + agentEvents(ctx, resumed).emit('agent/session-start', 'resume') + await composeBaselinePrefix(ctx, resumed) + + const baselines = baselineEvents(resumed) + expect(baselines).toHaveLength(2) + const latest = baselines.at(-1) + expect(latest?.type === 'user/message' && blocksText(latest.data.content)) + .toContain('new root rule after offline edit') + const original0 = baselines[0] + expect(original0?.type === 'user/message' && blocksText(original0.data.content)) + .toContain('old root rule') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('tracks only baseline files that were actually included under the byte budget', async () => { const root = await tempRepo() const home = await tempRepo() @@ -1009,7 +1126,7 @@ describe('workspace context request injection', () => { } }) - it('places workspace instructions before later session-prefix contributors such as a skills catalog', async () => { + it('places workspace instructions before later step contributors such as a skills catalog', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -1017,9 +1134,8 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => { - const rest = await next() - return [{ role: 'user', content: [{ type: 'text', text: 'Available skills' }] }, ...rest] + ctx.on('agent/step', (agent) => { + agent.inject({ content: [{ type: 'text', text: 'Available skills' }], source: { kind: 'plugin', plugin: 'test-skills' } }) }) const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) @@ -1051,7 +1167,7 @@ describe('workspace context request injection', () => { callId: CallId('read-after-baseline-change'), name: 'read', arguments: { file_path: 'file.txt' }, agent, }) - expect(workspaceContextOf(result)?.meta).toMatchObject({ + expect(workspaceContextOf(result)?.source).toMatchObject({ changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], }) expect(blocksText(workspaceContextOf(result)?.content)).toContain('Updated instructions from: AGENTS.md') @@ -1080,7 +1196,7 @@ describe('workspace context request injection', () => { callId: CallId('read-after-baseline-remove'), name: 'read', arguments: { file_path: 'file.txt' }, agent, }) - expect(workspaceContextOf(result)?.meta).toMatchObject({ + expect(workspaceContextOf(result)?.source).toMatchObject({ changes: [{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], }) expect(blocksText(workspaceContextOf(result)?.content)).toContain('Instructions removed: AGENTS.md') @@ -1151,7 +1267,9 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) - expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0) + expect(agent.session.events.filter(event => + event.type === 'user/message' && event.data.source.kind !== 'user', + )).toHaveLength(1) expect(derivedText(agent)).not.toContain('workspace-context:') } finally { await rm(root, { recursive: true, force: true }) @@ -1274,7 +1392,7 @@ describe('workspace context request injection', () => { } }) - it('aborts an in-flight baseline stream with the session-prefix signal', async () => { + it('aborts an in-flight baseline stream with the step signal', async () => { const root = join(await tempRepo(), 'virtual-repo') const home = join(await tempRepo(), 'virtual-home') const ctx = new Context() @@ -1286,11 +1404,7 @@ describe('workspace context request injection', () => { await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const controller = new AbortController() const reason = new Error('cancel prefix') - const empty: Message[] = [] - const pending = ctx.waterfall( - 'agent/session-prefix', stubAgent(root), empty, controller.signal, - () => Promise.resolve(empty), - ) + const pending = agentEvents(ctx, stubAgent(root)).serial('agent/step', 1, 1, controller.signal) await fs.started.promise controller.abort(reason) @@ -1520,7 +1634,7 @@ describe('workspace context request injection', () => { } }) - it('cleans up its agent/session-prefix listener when the plugin fiber is disposed', async () => { + it('cleans up its agent/step listener when the plugin fiber is disposed', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -1717,16 +1831,18 @@ describe('dynamic nested workspace context injection', () => { }, })) - agent.followup([{ type: 'text', text: 'read and abort' }]) + agent.followup({ content: [{ type: 'text', text: 'read and abort' }], source: { kind: 'user' } }) await agent.whenIdle() - expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1) + expect(agent.session.events.filter(event => + event.type === 'user/message' && event.data.source.kind !== 'user', + )).toHaveLength(0) - agent.followup([{ type: 'text', text: 'retry the read' }]) + agent.followup({ content: [{ type: 'text', text: 'retry the read' }], source: { kind: 'user' } }) await agent.whenIdle() const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') - // The aborted batch drained its accepted context before step close, so the - // retry sees durable history without producing a duplicate instruction. + // Cancellation discards the aborted step's pending context. The next + // successful read discovers and durably injects it once. expect(contexts).toHaveLength(1) expect(adapter.requests).toHaveLength(3) expect(adapter.requests[2]?.messages.map(blocks => blocksText(blocks.content)).join('\n')) @@ -1811,19 +1927,18 @@ describe('dynamic nested workspace context injection', () => { }) expect(result.isError).toBe(false) - expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) - expect(workspaceContextOf(result)?.meta).toMatchObject({ + expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions' }) + expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions', - version: 1, changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md'), }], }) - const meta = workspaceContextOf(result)?.meta - const firstChange = typeof meta === 'object' && meta !== null && !Array.isArray(meta) && Array.isArray(meta.changes) - ? meta.changes[0] + const source = workspaceContextOf(result)?.source + const firstChange = source?.kind === 'workspace-instructions' + ? source.changes[0] : undefined const changeDigest = typeof firstChange === 'object' && firstChange !== null && !Array.isArray(firstChange) ? firstChange.digest @@ -1902,9 +2017,9 @@ describe('dynamic nested workspace context injection', () => { agent: stubAgent(root), }) - const meta = workspaceContextOf(result)?.meta - const changes = typeof meta === 'object' && meta !== null && !Array.isArray(meta) && Array.isArray(meta.changes) - ? meta.changes + const source = workspaceContextOf(result)?.source + const changes = source?.kind === 'workspace-instructions' + ? source.changes : [] expect(changes).toEqual(expect.arrayContaining([ expect.objectContaining({ action: 'set', path: join('pkg', 'AGENTS.md') }), @@ -1999,6 +2114,7 @@ describe('dynamic nested workspace context injection', () => { const instructionPath = join(root, 'pkg/AGENTS.md') fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(instructionPath, { type: 'file', content: 'nested package rule' }) + fs.omitSizes.add(instructionPath) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) @@ -2123,7 +2239,7 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-after-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(changed)?.meta).toMatchObject({ + expect(workspaceContextOf(changed)?.source).toMatchObject({ kind: 'workspace-instructions', changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) @@ -2169,7 +2285,7 @@ describe('dynamic nested workspace context injection', () => { }) // Removing one candidate only removes its own scope; the sibling scope is untouched. - expect(workspaceContextOf(removed)?.meta).toMatchObject({ + expect(workspaceContextOf(removed)?.source).toMatchObject({ changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(removed)?.content)).toContain(`Instructions removed: ${join('pkg', 'AGENTS.md')}`) @@ -2197,7 +2313,7 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-nested-dup-siblings'), name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent, }) - expect(workspaceContextOf(result)?.meta).toMatchObject({ + expect(workspaceContextOf(result)?.source).toMatchObject({ changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) const text = blocksText(workspaceContextOf(result)?.content) @@ -2277,7 +2393,7 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-after-dup-convergence'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(converged)?.meta).toMatchObject({ + expect(workspaceContextOf(converged)?.source).toMatchObject({ changes: [{ action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: join('pkg', 'CLAUDE.md') }], }) expect(blocksText(workspaceContextOf(converged)?.content)).toContain(`Instructions removed: ${join('pkg', 'CLAUDE.md')}`) @@ -2311,7 +2427,7 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-after-earlier-converges'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(converged)?.meta).toMatchObject({ + expect(workspaceContextOf(converged)?.source).toMatchObject({ changes: [ { action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }, { action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: join('pkg', 'CLAUDE.md') }, @@ -2348,9 +2464,8 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-after-remove'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(removed)?.meta).toEqual({ + expect(workspaceContextOf(removed)?.source).toEqual({ kind: 'workspace-instructions', - version: 1, changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(removed)?.content)).toBe([ @@ -2395,7 +2510,7 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-after-symlink-dir'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(removed)?.meta).toMatchObject({ + expect(workspaceContextOf(removed)?.source).toMatchObject({ changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(removed)?.content)).toContain(`Instructions removed: ${join('pkg', 'AGENTS.md')}`) @@ -2434,7 +2549,7 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-after-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(restored)?.meta).toMatchObject({ + expect(workspaceContextOf(restored)?.source).toMatchObject({ changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(restored)?.content)).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`) @@ -2538,7 +2653,7 @@ describe('dynamic nested workspace context injection', () => { await composeBaselinePrefix(ctx, resumed) const update = resumed.session.events.findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user') - expect(update?.type === 'user/message' && update.data.meta).toMatchObject({ + expect(update?.type === 'user/message' && update.data.source).toMatchObject({ changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(update?.type === 'user/message' && blocksText(update.data.content)).toContain('new nested rule after resume') @@ -2600,6 +2715,63 @@ describe('dynamic nested workspace context injection', () => { } }) + it('re-arms an unchanged baseline after compaction removes it from the surface', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'root rule') + await write(join(root, 'file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + await composeBaselinePrefix(ctx, agent) + const baseline = baselineEvents(agent)[0] + expect(baseline).toBeDefined() + + const whileVisible = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-visible-baseline'), + name: 'read', + arguments: { file_path: 'file.txt' }, + agent, + }) + agent.session.append('user/message', { + content: [{ type: 'text', text: 'compacted summary' }], + source: { kind: 'plugin', plugin: 'compact' }, + }, { + surfaceOp: { op: 'replace', start: baseline!.seq, end: baseline!.seq }, + sourceEventSeqs: [baseline!.seq], + }) + + const rearmed = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-compacted-baseline'), + name: 'read', + arguments: { file_path: 'file.txt' }, + agent, + }) + appendAdditionalContexts(agent, rearmed) + const afterRearm = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-rearmed-baseline'), + name: 'read', + arguments: { file_path: 'file.txt' }, + agent, + }) + + expect(whileVisible.additionalContexts).toBeUndefined() + expect(workspaceContextOf(rearmed)?.source).toMatchObject({ + changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], + }) + expect(blocksText(workspaceContextOf(rearmed)?.content)).toContain('root rule') + expect(afterRearm.additionalContexts).toBeUndefined() + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('does not treat markdown headings inside instruction content as loaded instruction metadata', async () => { const root = await tempRepo() const home = await tempRepo() @@ -2692,31 +2864,23 @@ describe('dynamic nested workspace context injection', () => { { type: 'reasoning', text: 'Additional instructions from: pkg/AGENTS.md' }, { type: 'text', text: 'Updated instructions from: pkg/AGENTS.md' }, ], - source: { kind: 'plugin', plugin: 'workspace-context' }, - meta: { + source: { kind: 'workspace-instructions', - version: 1, changes: [ null, { action: 'unknown', scope: 'pkg', path: join('pkg', 'AGENTS.md') }, { action: 'set', scope: 'pkg', path: 42 }, { action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md'), digest: 42 }, ], - }, + } as never, }, { surfaceOp: 'append' }) agent.session.append('user/message', { content: [{ type: 'text', text: 'stale metadata version' }], - source: { kind: 'plugin', plugin: 'workspace-context' }, - meta: { kind: 'workspace-instructions', version: 0, changes: [] }, + source: { kind: 'workspace-instructions', changes: 'invalid' } as never, }, { surfaceOp: 'append' }) agent.session.append('user/message', { content: [{ type: 'text', text: 'foreign plugin context' }], source: { kind: 'plugin', plugin: 'other' }, - meta: { - kind: 'workspace-instructions', - version: 1, - changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md'), digest: 'spoof' }], - }, }, { surfaceOp: 'append' }) const result = await ctx.tools.execute({ @@ -2885,8 +3049,8 @@ describe('dynamic nested workspace context injection', () => { }) expect(blocksText(result.content)).toContain('downstream replacement') expect(result.additionalContexts).toHaveLength(2) - expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) - expect(workspaceContextOf(result)?.meta).toMatchObject({ + expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions' }) + expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions', changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) @@ -3247,7 +3411,6 @@ describe('workspace context pending state', () => { const otherWorkspaceEvent = agent.session.append('user/message', { content: otherContext.content, source: otherContext.source, - ...otherContext.meta !== undefined ? { meta: otherContext.meta } : {}, }, { surfaceOp: 'append' }) observeInstructionSessionEvent(agent.session, otherWorkspaceEvent, pending, versions) expect(pending.get(agent.session)?.has('pkg')).toBe(true) @@ -3256,7 +3419,6 @@ describe('workspace context pending state', () => { const confirmed = agent.session.append('user/message', { content: context.content, source: context.source, - ...context.meta !== undefined ? { meta: context.meta } : {}, }, { surfaceOp: 'append' }) observeInstructionSessionEvent(agent.session, confirmed, pending, versions) @@ -3281,6 +3443,29 @@ describe('workspace context pending state', () => { expect(versions.has(agent.session)).toBe(false) }) + it('keeps an unrelated scope\'s version fast path when a step-close discard empties only its own scope', () => { + const agent = stubAgent('/') + const pending = new WeakMap>() + const versions: InstructionVersionCache = new WeakMap() + agent.session.append('step/start', { turn: 1, step: 1 }) + commitPendingInstructionContexts(agent, [workspaceChangeContext('pkg', 'one')], pending) + versions.set(agent.session, new Map([ + ['pkg', { + path: join('pkg', 'AGENTS.md'), version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one', + }], + ['other', { + path: join('other', 'AGENTS.md'), version: FsVersion('v2'), digest: 'two', trimmedDigest: 'two', + }], + ])) + + const ended = agent.session.append('step/end', { turn: 1, step: 1 }) + observeInstructionSessionEvent(agent.session, ended, pending, versions) + + expect(pending.has(agent.session)).toBe(false) + expect(versions.get(agent.session)?.has('pkg')).toBe(false) + expect(versions.get(agent.session)?.has('other')).toBe(true) + }) + it('rolls back only the exact current transition and releases empty session state', () => { const agent = stubAgent('/') const pending = new WeakMap>() @@ -3291,6 +3476,13 @@ describe('workspace context pending state', () => { expect(commitPendingInstructionContexts(agent, [{ content: [], source: { kind: 'plugin', plugin: 'workspace-context' }, }], pending)).toEqual([]) + // A workspace-instructions source whose change list filters to nothing + // must not mint per-session pending state. + expect(commitPendingInstructionContexts(agent, [{ + content: [], + source: { kind: 'workspace-instructions', changes: [] }, + }], pending)).toEqual([]) + expect(pending.has(agent.session)).toBe(false) const committed = commitPendingInstructionContexts(agent, [ workspaceChangeContext('first', 'one'), diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 4cecb115ea..14198b7119 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -72,7 +72,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise', - jsDoc: '/**\n * Create an owned agent on a caller-supplied session id.\n * @param ownerCtx - caller context that structurally owns the transaction.\n * @param options - identities, session seed/metadata, loop options, setup, and cancellation.\n * @returns the published handle.\n */', + jsDoc: '/**\n * Create an owned agent on a caller-supplied session id.\n * @param ownerCtx - caller context that structurally owns the lifecycle.\n * @param options - identities, session seed/metadata, loop options, setup, and cancellation.\n * @returns the published handle.\n */', }, { signature: 'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise', @@ -602,7 +602,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise', - jsDoc: '/**\n * Snapshot all references before enqueue and return one aggregated durable context.\n * @param agent - target agent; references to it are rejected.\n * @param content - already host-normalized readable message content.\n * @param references - structured source sessions in mention order.\n * @param signal - optional cancellation boundary for host request teardown.\n * @returns detached content and zero or one prepared contexts.\n */', + jsDoc: '/**\n * Snapshot all references before enqueue and return one aggregated durable context.\n * @param agent - target agent; references to it are rejected.\n * @param content - already host-normalized readable message content.\n * @param references - structured source sessions in mention order.\n * @param signal - optional cancellation boundary for host request teardown.\n * @returns detached content and optional referenced-session context.\n */', }, ], }, @@ -1003,8 +1003,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ 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/steering 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 - resolved typed cancellation cause, including the default.\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/steering work is cleared or the active turn is aborted.', + 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', @@ -1017,14 +1017,14 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/disposed', mode: 'emit', signature: '\'agent/disposed\'(this: Scoped, agent: Agent): void', - jsDoc: '/**\n * An agent left the registry; AgentLoop emits this after driver quiescence\n * but before session detachment and scoped-registration unwind. Custom\n * registry users own their driver-ordering contract.\n * @param agent - the exact agent removed from the registry.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'An agent left the registry; AgentLoop emits this after driver quiescence but before session detachment and scoped-registration unwind.', + jsDoc: '/**\n * An agent left the registry; AgentLoop emits this after driver quiescence\n * and scoped-registration unwind, but before session detachment. Custom\n * registry users own their driver-ordering contract.\n * @param agent - the exact agent removed from the registry.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'An agent left the registry; AgentLoop emits this after driver quiescence and scoped-registration unwind, but before session detachment.', }, { name: 'agent/error', mode: 'emit', - signature: '\'agent/error\'(this: Scoped, agent: Agent, turn: number, step: number, error: Error): void', - jsDoc: '/**\n * A step or turn errored. The loop reports a failure here (plus the logger)\n * even when the error has no in-turn position for a session `error` event.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/error\'(this: Scoped, agent: Agent, turn: number, step: number, error: unknown): void', + jsDoc: '/**\n * A step or turn errored. The machine reports a failure here (plus the\n * logger) even when the error has no in-turn position for a durable record.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A step or turn errored.', }, { @@ -1038,57 +1038,36 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/inbox/discard', mode: 'emit', signature: '\'agent/inbox/discard\'(this: Scoped, agent: Agent, messages: AgentMessage[]): void', - jsDoc: '/**\n * Pending inbox items were dropped without delivering them, so every\n * enqueued id receives exactly one terminal `agent/inbox/dequeue` OR\n * `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after\n * `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop`\n * dropping pending steering (in-turn and on the post-turn late-steering\n * drain); and disposal of any still-pending items (before\n * `agent/status(\'disposed\')`). 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 */', + jsDoc: '/**\n * Pending inbox items were dropped without delivering them, so every\n * enqueued id 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 enqueued id 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: AgentMessage): void', - jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection through\n * `agent.inject()` or equivalent `send()` routing bypasses the FIFOs\n * and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'A detached, frozen item entered the agent\'s inbox (queued or steering FIFO).', - }, - { - name: 'agent/post-step', - mode: 'serial', - signature: '\'agent/post-step\'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void', - jsDoc: '/**\n * Awaited serial checkpoint after the response, real or synthetic tool\n * results, injected context, and steering are durable but before `step/end`.\n * A cancelled tool batch reaches this checkpoint with an aborted signal.\n * @param agent - the agent whose step is settling.\n * @param turn - the open turn number.\n * @param step - the open step number.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', - summary: 'Awaited serial checkpoint after the response, real or synthetic tool results, injected context, and steering are durable but before `step/end`.', - }, - { - name: 'agent/pre-step', - mode: 'serial', - signature: '\'agent/pre-step\'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void', - jsDoc: '/**\n * Awaited serial checkpoint before `step/start`; appends land outside the\n * pending step and are included when the loop derives request history.\n * `signal` cancels listener work.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - the agent opening the step.\n * @param turn - the open turn number.\n * @param step - the pending step number.\n * @param signal - the turn abort signal.\n * @mode serial\n */', - summary: 'Awaited serial checkpoint before `step/start`; appends land outside the pending step and are included when the loop derives request history.', + signature: '\'agent/inbox/enqueue\'(this: Scoped, agent: Agent, message: AgentMessage, 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.', }, { name: 'agent/prompt-submit', mode: 'waterfall', signature: '\'agent/prompt-submit\'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default. A listener wrapping a\n * downstream `allow` must preserve its `content` and `additionalContexts`\n * unless it intentionally replaces them. The signal controls only this turn;\n * listeners may cooperate with it but must not retain it to control another\n * turn. Steering messages do not dispatch this event; they join an open turn\n * at a steering checkpoint.\n * @param agent - the agent whose turn claimed the message.\n * @param content - the claimed message\'s blocks, as queued.\n * @param source - the message\'s resolved 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.', + 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 content - the claimed message\'s blocks, as queued.\n * @param source - the message\'s resolved 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.', }, { name: 'agent/request', mode: 'waterfall', - signature: '\'agent/request\'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Replace the frozen call configuration. Model-visible content must use\n * logged channels; this seam cannot mutate messages. Injection here joins\n * the next request because the current step boundary is already fixed.\n * @param agent - the agent making the model call.\n * @param turn - the open turn number.\n * @param step - the step whose request this is.\n * @param config - the config the loop would use (frozen); return a replacement to switch.\n * @param signal - the current turn\'s explicit abort signal; ambient\n * initiator identity does not imply liveness or cancellation authority.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + signature: '\'agent/request\'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise): Promise', + jsDoc: '/**\n * Replace the frozen call configuration. `await next()` yields the config\n * the machine would use (agent options on the first request, the logged\n * header afterwards); return a replacement to switch. Model-visible\n * content must use logged channels; this seam cannot mutate messages.\n * @param agent - the agent making the model call.\n * @param turn - the open turn number.\n * @param step - the step whose request this is.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n*/', summary: 'Replace the frozen call configuration.', }, { 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[], signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Recover a model-request failure after its failed step has closed. `retry`\n * opens a new numbered step; `fail` preserves the original request error.\n * Call `next()` to delegate to the next recovery listener or the default.\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 request in this consecutive sequence.\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: 'Recover a model-request failure after its failed step has closed.', - }, - { - name: 'agent/session-prefix', - mode: 'waterfall', - signature: '\'agent/session-prefix\'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Compose request-only messages placed before derived history. The frozen\n * result is computed once per loop instance, logged on its anchoring request\n * header, and reused so the provider prefix remains stable. Interrupted\n * composition is discarded. Composition precedes the first `agent/pre-step`\n * and request boundary, so listener appends join the current request.\n * Changing context belongs in history; contributors should prepend to\n * `await next()` to preserve registration order.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - the agent whose session prefix is being composed.\n * @param prefix - the frozen seed; return an extended replacement.\n * @param signal - the current turn\'s explicit abort signal.\n * @mode waterfall\n */', - summary: 'Compose request-only messages placed before derived history.', + signature: '\'agent/request-error\'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, 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 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.', }, { name: 'agent/session-start', @@ -1097,33 +1076,33 @@ 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`, or → `disposed`). A waking\n * delivery does not enter `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 */', - summary: 'Agent status changed (`idle` ⇄ `running`, or → `disposed`).', + 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 */', + summary: 'Agent status changed (`idle` ⇄ `running`).', }, { - name: 'agent/step-result', - mode: 'waterfall', - signature: '\'agent/step-result\'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Waterfall: post-process the assembled assistant {@link Message} before\n * tool dispatch (validation, content rewriting, …).\n * @param agent - the agent that received the step\'s response.\n * @param turn - the open turn number.\n * @param step - the step that produced the message.\n * @param message - the assistant message as assembled from the stream.\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: 'Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …).', - }, - { - name: 'agent/turn-continuation', - mode: 'waterfall', - signature: '\'agent/turn-continuation\'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Override whether the turn continues. The default continues after tool\n * calls or steering and stops otherwise; a continue reason becomes steering.\n * @param agent - the agent deciding whether to run another step.\n * @param turn - the turn being continued or stopped.\n * @param defaultDecision - what the loop would do absent an override.\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: 'Override whether the turn continues.', - }, - { - name: 'agent/turn-stop', + name: 'agent/step', mode: 'serial', - signature: '\'agent/turn-stop\'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | ContinuationStop | undefined', - jsDoc: '/**\n * Monotonic terminal-stop checkpoint after continuation and steering are\n * folded; a stop remains authoritative through turn close and flush:\n * steering queued in that window is discarded, while ordinary sends survive.\n * @param agent - the agent whose composed continuation outcome may be stopped.\n * @param turn - the turn at its terminal-stop checkpoint.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', - summary: 'Monotonic terminal-stop checkpoint after continuation and steering are folded; a stop remains authoritative through turn close and flush: steering queued in that window is discarded, while ordinary sends survive.', + signature: '\'agent/step\'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void', + jsDoc: '/**\n * Awaited serial checkpoint before EVERY request of a turn is built (the\n * first as well as each post-tools continuation). The single "between\n * steps" extension point: inject context, steer, or edit the session log\n * here — the request\'s history derives from the log right after this settles.\n * @param agent - the agent about to send a request.\n * @param turn - the open turn number.\n * @param step - the step number about to open.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', + summary: 'Awaited serial checkpoint before EVERY request of a turn is built (the first as well as each post-tools continuation).', + }, + { + name: 'agent/turn-stopping', + mode: 'serial', + signature: '\'agent/turn-stopping\'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | void', + jsDoc: '/**\n * The turn is about to close: the model owes no response (no live tool\n * calls, no fresh steering). Awaited before the boundary commits — a\n * listener that objects steers (`agent.steer(...)`) and the machine\n * re-reads its inbox: fresh steering runs another step, none closes the\n * turn. Data decides, so listener order cannot change the outcome. The\n * inverse control (stop a tool loop early) is data too: a tool result\n * carrying `concludesTurn` ends the turn at its step.\n * @param agent - the agent whose turn is at its stop boundary.\n * @param turn - the turn about to close.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', + summary: 'The turn is about to close: the model owes no response (no live tool calls, no fresh steering).', }, { name: 'approval/request', @@ -1376,7 +1355,7 @@ 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 ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n}', + 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(input: UserMessageData, options: SendOptions): AgentMessageId;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(input: UserMessageData): AgentMessageId;\n steer(input: UserMessageData): AgentMessageId;\n inject(input: UserMessageData): AgentMessageId;\n}', }, { name: 'AgentCancelCause', @@ -1400,7 +1379,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AgentStatus', - declaration: 'export type AgentStatus = \'idle\' | \'running\' | \'disposed\';', + declaration: 'export type AgentStatus = \'idle\' | \'running\';', }, { name: 'ApprovalOutcome', @@ -1640,7 +1619,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'EpochHeader', - declaration: 'export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n}', + declaration: 'export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n}', }, { name: 'FileDiff', @@ -1738,14 +1717,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'GoalView', declaration: 'export interface GoalView extends GoalSnapshot {\n readonly roundsStarted: number;\n readonly createdAt: number;\n readonly updatedAt: number;\n readonly activation: GoalActivation;\n}', }, - { - name: 'HookContext', - declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: \'separate\' | \'prompt-prefix\';\n meta?: JsonValue;\n}', - }, - { - name: 'InjectOptions', - declaration: 'export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n}', - }, { name: 'InvariantFailure', declaration: 'export type InvariantFailure = (message: string) => never;', @@ -1844,7 +1815,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PreparedReferencedMessage', - declaration: 'export interface PreparedReferencedMessage {\n content: ContentBlock[];\n contexts: HookContext[];\n}', + declaration: 'export interface PreparedReferencedMessage {\n content: ContentBlock[];\n additionalContext?: UserMessageData;\n}', }, { name: 'PresetOption', @@ -1858,18 +1829,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PromptAssembly', declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record;\n}', }, - { - name: 'PromptMessageData', - declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n}', - }, - { - name: 'PromptMessageEnvelope', - declaration: 'export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n}', - }, - { - name: 'PromptPrefixContext', - declaration: 'export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n}', - }, { name: 'PromptSection', declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}', @@ -1970,10 +1929,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'RequestHeaderReason', declaration: 'export type RequestHeaderReason = \'initial\' | \'resume\' | \'change\';', }, - { - name: 'ResolvedAgentInput', - declaration: 'export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n} & ({\n target: \'next-turn\';\n wakeup: boolean;\n contexts: HookContext[];\n} | {\n target: \'next-step\';\n wakeup: true;\n contexts: HookContext[];\n} | {\n target: \'next-step\';\n wakeup: false;\n contexts: [\n ];\n});', - }, { name: 'ResumeAgentOptions', declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', @@ -2008,7 +1963,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SendOptions', - declaration: 'export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n}', + declaration: 'export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n}', + }, + { + name: 'SendTarget', + declaration: 'export type SendTarget = \'next-turn\' | \'next-step\';', }, { name: 'Session', @@ -2024,7 +1983,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\': PromptMessageData;\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\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 callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': PromptMessageData & {\n turn: number;\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 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\': UserMessageData;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\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 callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': UserMessageData & {\n turn: number;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n}', }, { name: 'SessionEventMetadataFilter', @@ -2464,7 +2423,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecutionFailure', - declaration: 'export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n}', + declaration: 'export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessageData[];\n readonly concludesTurn?: never;\n}', }, { name: 'ToolExecutionInput', @@ -2480,7 +2439,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecutionSuccess', - declaration: 'export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n}', + declaration: 'export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessageData[];\n readonly concludesTurn?: true;\n}', }, { name: 'ToolExecutionToken', @@ -2520,7 +2479,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolRunContext', - declaration: 'export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n}', + declaration: 'export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessageData): void;\n concludeTurn(): void;\n}', }, { name: 'ToolSchema', @@ -2584,7 +2543,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 rejected: {\n kind: \'rejected\';\n reason: string;\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}', + 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', @@ -2592,12 +2551,16 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TurnTriggerMap', - declaration: 'export interface TurnTriggerMap {\n message: {\n kind: \'message\';\n source: MessageSource;\n };\n injection: {\n kind: \'injection\';\n source: MessageSource;\n };\n}', + 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}', }, { name: 'UserInteractionProvider', declaration: 'export interface UserInteractionProvider {\n ask(request: AskUserQuestionRequest): Promise;\n}', }, + { + name: 'UserMessageData', + declaration: 'export interface UserMessageData {\n content: ContentBlock[];\n source: MessageSource;\n}', + }, { name: 'WebFetchBody', declaration: 'export type WebFetchBody = {\n readonly kind: \'html\';\n readonly content: string;\n} | {\n readonly kind: \'text\';\n readonly content: string;\n};', diff --git a/packages/cordis/tool-cordis/tests/integration.spec.ts b/packages/cordis/tool-cordis/tests/integration.spec.ts index 7f917e5b3c..9cedbd2a33 100644 --- a/packages/cordis/tool-cordis/tests/integration.spec.ts +++ b/packages/cordis/tool-cordis/tests/integration.spec.ts @@ -47,7 +47,7 @@ describe('cordis tools through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-cordis'), { provider: 'mock', model: 'mock' }) - agent.followup([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }]) + agent.followup({ content: [{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const log = agent.session.events diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 9af1ca9fdc..9ce4afa55c 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: 6ad982ffff7b73e16ee39f9c29da787e37547de4 -README.zh.md: c72df198774f0f8009cc5ab43932e69187757745 +README.md: ab6df4d49f05ff00b16a210830e7fd21504a0a9f +README.zh.md: 5adb2a11ba3de2bb6fc7ff57b9d6dd07ac7f650e diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 6ad982ffff..ab6df4d49f 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -14,7 +14,7 @@ Creation and resume are one rollback-covered transaction: construct a private se The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear. -Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; the id becomes reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`. +Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain → unwind scope → detach agent → detach session; the id becomes reusable after private scope cleanup. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, and per-step assembly goes through `assembleContextFor(agent)`. - `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): Agent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and normally mints `${label}-session-` before calling this boundary. An app may instead supply a stable exact `sessionId`: first use creates it, while a remount with persistence already present resumes its materialized history. `resumeSessionId` requires and loads an existing persisted id and is mutually exclusive with `sessionId`. This keeps default fresh restarts collision-free without retaining a second live routing identity. @@ -52,31 +52,31 @@ Configured agents start automatically. A model call requires both `provider` and ### Internal concrete driver -The concrete `ReactLoopAgent` adapter, its `Inbox`, `runLoop`, and instance-bound publication/start 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 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. -`ReactLoopAgent.send()` implements the public fully resolved acceptance path. The `followup()`/`queue()`/`steer()`/`inject()` helpers resolve every optional field before delegating to it; direct callers provide mandatory content, source, contexts, metadata, target, and wakeup facts through `ResolvedAgentInput`. `followup()` and `queue()` join the ordinary FIFO, respectively waking or leaving an idle driver parked. If claimed, an ordinary item is the sole message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` or equivalent `send()` routing enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `inject()` and non-waking next-step acceptance require an empty context tuple, bypass both FIFOs, and append durable context directly: an open-turn injection defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append. +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`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. -### Loop lifecycle (`loop.ts`) +### Loop lifecycle (`agent.ts`) The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. Package-private orchestration entry points recover the exact Agent, derive `agent.session` once, and let operation-local helpers capture it instead of forwarding the concrete driver or per-operation `Session` through shallow interfaces. A helper keeps an explicit `Session` when that is its actual interface, while creation, persistence load, unpublished setup, services, workers, processes, persistence, and wire protocols retain their explicit identities. The [agent service](../agent/README.md#initiating-agent-scope) owns propagation, teardown, and detached-work rules. -Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history. +Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. The anchor records the assembled content as-is, retains exact chunk provenance (`[]` for a stream with no chunks), and includes usage when available; empty content stays out of derived message history. 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 `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. AgentLoop privately owns one cancellation holder whose explicit signal spans prompt policy, assembly, every step, model and tool work, recovery, continuation, and terminal stop; it retires the holder immediately before publishing `turn/end`, while the driver may remain `running` through the durability flush. An effective `cancel()` emits the typed runtime-only `user | parent` cause before clearing pending work and cooperatively aborting the holder; notification failures cannot veto cancellation, work queued by a notification observer is cleared, work queued by a later abort observer belongs to the next turn, and idle cancellation emits nothing. Durable `turn/end` remains coarse `aborted`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. Disposal wins terminal classification, and work that ignores the signal must settle before quiescence. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. Terminal continuation stops remain authoritative through turn close and durability flush. +Plugin failure ends the current turn, not the loop. A model-request failure first closes its step and enters `agent/request-error` with the exact live error, normalized provider facts, and the turn signal. 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. An unhandled failure is terminal. Other failures close directly. 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, then drains accepted batch context before the turn closes through the normal abort path. +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. ### What belongs to plugins Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy: - Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute` → `tools/execute` → `tools/post-execute` → definition-owned `finalizeContent` → `tools/result` pipeline; exact event signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md) -- Compaction: pressure on `agent/post-step`; canonical context overflow on `agent/request-error` -- Transient model recovery: `dsh-llm-retry` on `agent/request-error`, with finite code-specific budgets and non-surface `llm/retry` status events +- Compaction: pressure on `agent/step`; canonical overflow repair on `agent/request-error` +- Transient model recovery: `dsh-llm-retry` records and waits its finite backoff on `agent/request-error`, then returns a retry action - Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation - Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while generic [`ctx.tasks`](../../tasks/tasks/) plus [`dsh-tool-subagent`](../../subagent/tool-subagent/) own background collection. -- Persistence: `session/event` + `session/flush` +- Persistence: eager write-behind from `session/event`; `session/flush` is an explicit observation barrier - UI: `session/event` (assistant token stream, boundaries, tool activity) + `agent/*` control events (`agent/status`, `agent/created`/`agent/disposed`) ## Model Experience @@ -85,15 +85,15 @@ Everything that goes beyond "call the model, run the tools, repeat" belongs to p #### What the model sees -For each step, the loop sends the rendered per-agent system prompt, visible tool schemas, the frozen session prefix, and the session's derived messages. It supplies `model` and `cwd` variable values but no additional fixed prose. +For each step, the loop sends the rendered per-agent system prompt, visible tool schemas, and the session's derived messages. It supplies `provider`, `model`, and `cwd` variable values but no additional fixed prose. #### Token effect -System text, schemas, and prefix are paid again on every step. Per-agent scoping chooses the initial contributions, while the authoritative assembly waterfall can alter the final request and makes its listener responsible for protocol coherence. +System text and schemas are paid again on every step. Per-agent scoping chooses the contributions, while the authoritative assembly waterfall can alter the final request and makes its listener responsible for protocol coherence. #### KV Cache effect -Append-only only while system text, schemas, session prefix, and earlier history remain byte-identical under the same provider and model route. A token-bearing assembly rewrite or composition change may invalidate reuse from the first altered request token. +Append-only only while system text, schemas, and earlier history remain byte-identical under the same provider and model route. A token-bearing assembly rewrite or composition change may invalidate reuse from the first altered request token. ### Retained message history @@ -103,7 +103,7 @@ Accepted user messages, assistant messages, tool calls and results, injected con #### Token effect -Input grows with every surface message until a compaction replacement shadows older nodes; a multi-step tool turn resends the accumulated prefix and history each step. +Input grows with every surface message until a compaction replacement shadows older nodes; a multi-step tool turn resends the accumulated history each step. #### KV Cache effect @@ -128,4 +128,4 @@ Append-only; each synthetic result follows the reusable request prefix and does - **Classification is unary** — calls whose safety depends on comparing siblings or resources must remain exclusive ([rationale](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)). - **Config labels are fresh by default** — omitting `sessionId` creates a fresh `${id}-session-` on every startup; exact resume-or-create behavior requires an explicit stable `sessionId`, while `resumeSessionId` requires existing persisted history. - **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona/tool composition is available only through the programmatic `ctx.agents.create()` / `resume()` factory options. -- **No built-in turn budget** — the default continuation is `continue` whenever a step had tool calls or steering; bounding a runaway turn requires an `agent/turn-continuation` force-stop plugin. +- **No built-in turn budget** — tool calls or steering continue the current turn; a policy that bounds runaway turns must cancel from an existing lifecycle seam such as `agent/turn-stopping`. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index c72df19877..5adb2a11ba 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -14,7 +14,7 @@ 调用方 fiber 与 AgentLoop 提供方共同拥有 agent。`AgentFactory.createAgent(ownerCtx, options)` 与 `resume(ownerCtx, options)` 显式接收调用方所有权,而工厂为 `sessions`/`llm`/`tools`/`systemPrompt` 保留自身的依赖上下文;这样,调用方可以只注入 `agents`,而不会缩减新 agent 的服务接口。调用方卸载、handle 释放或提供方卸载都会汇合到同一个记忆化的完全停稳边界。提供方关闭会同时等待资源 teardown,以及已经观测到停用的公开 create/resume 包装层,因此依赖消失后,任何 continuation 都无法继续发布。 -每个 agent 与其会话共享一个由调用方选择的 `SessionId`,并假设它在全局唯一;意外的 UUID 冲突不属于受支持模型。两个使用同一 id 的并发操作都可以进行准备,但最终的 `enter()` 调用会裁决发布,所有失败方都会回滚各自的私有资源。每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。在同步创建通知期间请求的 detach 会等待该次分发退栈,从而保留 created/disposed 配对。Teardown 顺序为停止并 drain(包括尚未完成的空闲注入 flush)→ detach agent → detach 会话 → 撤销作用域;detach 完成后,即使私有作用域仍在完成清理,该 id 也可以复用。普通、不可 veto 的 `agent/*` 通知通过 `agentEvents(ctx, agent)` 发出;逐步骤组装通过 `assembleContextFor(agent)` 完成;轮次结束时的持久性检查点通过 `ctx.sessions.flush(session)` 完成。 +每个 agent 与其会话共享一个由调用方选择的 `SessionId`,并假设它在全局唯一;意外的 UUID 冲突不属于受支持模型。两个使用同一 id 的并发操作都可以进行准备,但最终的 `enter()` 调用会裁决发布,所有失败方都会回滚各自的私有资源。每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。在同步创建通知期间请求的 detach 会等待该次分发退栈,从而保留 created/disposed 配对。Teardown 顺序为停止并 drain → 撤销作用域 → detach agent → detach 会话;私有作用域清理完成后,该 id 即可复用。普通、不可 veto 的 `agent/*` 通知通过 `agentEvents(ctx, agent)` 发出;逐步骤组装通过 `assembleContextFor(agent)` 完成。 - `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): Agent`:在确切共享的 agent/会话 id 下同步创建,不运行 setup,并随调用 fiber 释放。声明式配置把 `agents[].id` 视为稳定 label,通常会先生成 `${label}-session-`,再调用此边界。应用也可以提供稳定且确切的 `sessionId`:首次使用时创建;重新挂载且持久化内容已存在时,则恢复已经实体化的历史。`resumeSessionId` 要求并加载现有的持久化 id,且与 `sessionId` 互斥。这样,默认的全新重启不会冲突,也无需保留第二个实时路由身份。 @@ -52,31 +52,31 @@ interface Config { ### 包内部实体驱动器 -实体 `ReactLoopAgent` 适配器、其 `Inbox`、`runLoop`,以及绑定实例的发布/启动控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent,而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。 +实体 `ReactLoopAgent`、其排队输入、outbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent,而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。 -`ReactLoopAgent.send()` 实现公开且完全解析的接纳路径。`followup()`/`queue()`/`steer()`/`inject()` 辅助方法会先解析每个可选字段,再委托给它;直接调用方通过 `ResolvedAgentInput` 提供必填的内容、来源、上下文、元数据、目标与唤醒事实。`followup()` 和 `queue()` 加入普通 FIFO,前者会唤醒空闲驱动器,后者则让其保持停驻。认领后的普通项是所属轮次的唯一消息;其上下文是提示词 waterfall(瀑布式事件)的默认附加上下文,只在通过接纳后实体化。缺少 placement 或 placement 为 `separate` 时,会追加一条独立注入的 `user/message`;placement 为 `prompt-prefix` 时,则把上下文、稳定的 `## My request:` 分隔符和有效请求写入同一条 `user/message`,其对模型隐藏的 envelope 保留显示内容和上下文描述符。waterfall 返回的允许决定具有权威性,因此,使用 `next()` 包装下游的监听器会保留下游 `content` 和 `additionalContexts`,除非它有意替换相应字段。后续普通项会等待前一普通轮次的检查点结算;取消、释放、提示词阻止或启动前失败则可能让上下文随消息一同丢弃。运行期间调用 `steer()`,或使用等效的 `send()` 路由,会在不分发 `agent/prompt-submit` 的情况下,把相同记录形态加入 steering FIFO;下一个检查点会对 `steering/message` 应用相同的独立或前缀 placement,但策略仍可以在另一步骤前停止。轮次及其检查点关闭后遗留的 steering 会连同上下文转为之后的排队输入,除非终止轮次策略、取消或释放将其丢弃。`inject()` 和不唤醒的下一步骤接纳要求上下文元组为空,绕过两个 FIFO 并直接追加持久上下文:轮次打开时,注入会在当前步骤执行 assistant 工具调用期间延后到一个 FIFO 中(成功批次把它放在所有结果之后,中断批次则在轮次关闭前 drain);空闲时,注入会包在一次性 `injection` 轮次中。每次 FIFO 入队都会发布 `agent/inbox/enqueue`;驱动器的认领会发布 `agent/inbox/dequeue`;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。格式错误的数据会在入队或追加前抛出。 +统一的 `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`。 -### 循环生命周期(`loop.ts`) +### 循环生命周期(`agent.ts`) 驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。包私有的编排入口点会恢复确切的 Agent,一次性派生 `agent.session`,并让操作局部的辅助函数捕获它,而不是通过浅层接口继续传递实体驱动器或每次操作的 `Session`。如果显式 `Session` 正是辅助函数的实际接口,该辅助函数会保留它;创建、持久化加载、未发布 setup、服务、worker、进程、持久化和 wire 协议则继续保留各自的显式身份。[agent 服务](../agent/README.md#initiating-agent-scope)规定传播、teardown 和分离工作规则。 -每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。成功的 `agent/step-result` 存储其转换后内容;被拒绝的结果会先记录空内容,再继续抛出原始失败。该锚点保留确切的 chunk 溯源(流没有 chunk 时为 `[]`),并在用量可用时保留用量;空内容不会进入派生消息历史。 +每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,保留确切的 chunk 溯源(流没有 chunk 时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。 在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度,并填入其配置默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。 -插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败,以及带内的终止错误或中止结束原因,才进入 `agent/request-error`;中间件、结果处理、工具和 `agent/post-step` 仍属于普通轮次失败。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实和不可变的先前失败。重试会在新的编号步骤中根据持久日志重建;成功会清除连续失败历史;耗尽后只在 `turn/end` 上记录一次结构化失败。AgentLoop 私下拥有一个取消持有者,其显式信号覆盖提示词策略、组装、每个步骤、模型与工具工作、恢复、continuation 和终止停止;它会在发布 `turn/end` 前立即退役该持有者,而驱动器可以在持久性 flush 期间继续保持 `running`。有效的 `cancel()` 会先发出仅存在于运行时的类型化 `user | parent` 原因,再清除待处理工作,并以协作方式中止该持有者;通知失败无法 veto 取消,通知观察方排队的工作会被清除,之后由中止观察方排队的工作属于下一轮次,空闲取消则不发出任何内容。持久 `turn/end` 仍使用粗粒度的 `aborted`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。释放会在终止分类中胜出;忽略信号的工作必须先结算,系统才能完全停稳。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。终止 continuation 的停止决定在轮次关闭和持久性 flush 期间始终具有权威性。 +插件失败会结束当前轮次,而不是结束循环。模型请求失败会先关闭其步骤,再带着确切的实时错误、规范化的提供方事实和轮次信号进入 `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)规定生命周期与竞态契约。 -在步骤内,独占调用形成屏障;并行安全调用使用有界滚动池,并在启动前重新分类。只有分发/主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用、drain 已启动的结果,然后在轮次通过普通中止路径关闭前,drain 已接纳的批次上下文。 +在步骤内,独占调用形成屏障;并行安全调用使用有界滚动池,并在启动前重新分类。只有分发/主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用,drain 已启动的结果,并保留其已定案的结果上下文,不区分取消原因。 ### 插件负责的内容 超出「调用模型、运行工具、重复」的所有内容,都属于监听事件分类体系的插件: - 钩子与策略:相关的 `agent/*` 检查点,加上受守卫保护的 `tools/pre-execute` → `tools/execute` → `tools/post-execute` → 定义拥有的 `finalizeContent` → `tools/result` 流水线;确切事件签名与 mode 位于生成的[事件目录](../../../docs/cordis-catalog/events.md) -- 压缩(compaction):在 `agent/post-step` 上观测压力;在 `agent/request-error` 上处理规范上下文溢出 -- 瞬时模型恢复:`dsh-llm-retry` 监听 `agent/request-error`,使用有限且针对错误码的预算,并发出不进入表层的 `llm/retry` 状态事件 +- 压缩(compaction):在 `agent/step` 上观测压力;在 `agent/request-error` 上修复规范溢出 +- 瞬时模型恢复:`dsh-llm-retry` 在 `agent/request-error` 上记录并等待其有限退避,然后返回重试动作 - 沙箱、权限、计划模式:使用 `tools/pre-execute` 提供可扩展的拒绝/询问,使用 `tools.guard()` 提供单调拥有方策略,使用 `tools/post-execute` 处理结果决定,并使用 `tools/result` 进行最终观测 - subagent:在循环外部实现为 `ctx.subagents` 提供方;进程内提供方使用 `ctx.agents.create()` 和拥有的 `AgentHandle` 进行 teardown,而通用的 [`ctx.tasks`](../../tasks/tasks/) 与 [`dsh-tool-subagent`](../../subagent/tool-subagent/) 负责后台收集。 -- 持久化:`session/event` + `session/flush` +- 持久化:从 `session/event` 立即后写;`session/flush` 是显式观测屏障 - UI:`session/event`(assistant token 流、边界、工具活动)+ `agent/*` 控制事件(`agent/status`、`agent/created`/`agent/disposed`) ## 模型体验 @@ -85,15 +85,15 @@ interface Config { #### 模型所见 -每个步骤中,循环会发送针对该 agent 呈现的系统提示词、可见工具 schema、冻结的会话前缀和会话派生消息。它提供 `model` 与 `cwd` 变量值,但不添加固定文案。 +每个步骤中,循环会发送针对该 agent 呈现的系统提示词、可见工具 schema 和会话派生消息。它提供 `provider`、`model` 与 `cwd` 变量值,但不添加固定文案。 #### Token 影响 -每个步骤都会再次计入系统文本、schema 与前缀。逐 agent 作用域决定初始贡献,而权威组装 waterfall 可以改变最终请求,并使其监听器负责保持协议连贯。 +每个步骤都会再次计入系统文本与 schema。逐 agent 作用域决定贡献,而权威组装 waterfall(瀑布式事件)可以改变最终请求,并使其监听器负责保持协议连贯。 #### KV Cache 影响 -只有在同一提供方和模型路由下,系统文本、schema、会话前缀与先前历史保持逐字节相同时,才保持仅追加。携带 token 的组装改写或组合变更可能从第一个改变的请求 token 起使复用失效。 +只有在同一提供方和模型路由下,系统文本、schema 与先前历史保持逐字节相同时,才保持仅追加。携带 token 的组装改写或组合变更可能从第一个改变的请求 token 起使复用失效。 ### 保留的消息历史 @@ -103,7 +103,7 @@ interface Config { #### Token 影响 -输入会随每条表层消息增长,直到压缩替换遮蔽较旧节点;包含多个步骤的工具轮次会在每个步骤重新发送累积的前缀与历史。 +输入会随每条表层消息增长,直到压缩替换遮蔽较旧节点;包含多个步骤的工具轮次会在每个步骤重新发送累积的历史。 #### KV Cache 影响 @@ -128,4 +128,4 @@ interface Config { - **分类是一元的**:安全性取决于比较同级调用或资源的调用必须保持独占(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md))。 - **配置 label 默认每次新建**:省略 `sessionId` 会在每次启动时创建全新的 `${id}-session-`;确切的恢复或创建行为要求显式提供稳定的 `sessionId`,而 `resumeSessionId` 要求已有持久化历史。 - **配置 agent 没有逐 agent persona 字段或 setup 钩子**:它们使用部署 persona;只有编程式 `ctx.agents.create()` / `resume()` 工厂选项支持带作用域的 persona/工具组合。 -- **没有内置轮次预算**:只要步骤包含工具调用或 steering,默认 continuation 就是 `continue`;限制失控轮次需要使用 `agent/turn-continuation` 强制停止插件。 +- **没有内置轮次预算**:工具调用或 steering 会让当前轮次继续;限制失控轮次的策略必须从既有生命周期 seam(如 `agent/turn-stopping`)执行取消。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index e09acfff40..2d25185733 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -1,536 +1,706 @@ /** - * The concrete Agent implementation: ReactLoopAgent plus its inbox. Everything - * observable happens through session events and the agent/* event taxonomy — - * plugins never need this class. + * Concrete Agent loop over two pending-input lists: queued prompts each open a + * turn that logs its admitted input after `turn/start` commits, while steering + * and injected context enter through the outbox at step boundaries. Every + * request is derived from the session log. * * @module dsh-agent-loop/agent */ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent' +import { AgentMessageId, 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 { + AgentMessage, Agent, - AgentCancelCause, + CancelOptions, + AgentInterruptReason, + InboxPlacement, AgentOptions, AgentStatus, - CancelOptions, - HookContext, - InjectOptions, - ResolvedAgentInput, + SettleReason, + PromptDecision, + RequestError, + RequestErrorAction, SendOptions, } from '@deepseek-ai/dsh-agent' -import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session' -import { DISPOSED_INTERRUPT_REASON, TurnCancellation } from './cancellation.ts' -import { Inbox, agentMessage, type InboxMessage } from './inbox.ts' -import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' +import { + BlockAssembler, LlmError, assertNever, deepFreeze, errorChain, isHarnessError, llmFailureOf, markAgentLoopRequest, +} from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm' +import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session' +import type { Session, SessionId, TurnEndReason, TurnTrigger, UserMessageData } from '@deepseek-ai/dsh-session' +import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import type {} from '@deepseek-ai/dsh-tools' +import { executeToolCalls } from './tool-calls.ts' -/** Sessions already claimed by a concrete driver construction. */ -const claimedDriverSessions = new WeakSet() - -/** Module-private driver entry: its symbol is absent from the package surface. */ -const startDriver = Symbol('dsh.agent-loop.start-driver') - -/** Module-private quiescent stop, valid both before and after driver start. */ -const stopDriver = Symbol('dsh.agent-loop.stop-driver') - -/** Module-private context binding for the mutually referential agent scope. */ -const bindContext = Symbol('dsh.agent-loop.bind-context') - -/** Module-private publication marker. */ -const publishAgent = Symbol('dsh.agent-loop.publish-agent') - -/** Factory-owned controls that can operate only on the agent created with them. */ -export interface PreparedReactLoopAgent { - /** The unpublished concrete agent. */ - agent: ReactLoopAgent - /** Mark the agent public so teardown emits its status lifecycle. */ - markPublished(): void - /** Stop the prepared instance even when publication has not started its loop. */ - dispose(): Promise | void - /** - * Start its driver after publication and session-start notification. - * The returned disposer reaches quiescence for both the loop and every - * fire-and-forget idle-injection flush the agent started. - */ - startDriver(): () => Promise | void -} +/** 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 } /** - * Construct an unpublished concrete agent with instance-bound lifecycle - * controls. Only those paired controls can publish or start this instance. - * @param ctx - the agent-loop service context used for driving and events. - * @param id - the concrete agent identity. - * @param options - loop options for the agent. - * @param session - the prepared session the agent will own. - * @param maxParallelToolCalls - resolved in-flight cap for this agent. - * @returns the agent and closures bound only to that exact instance. - */ -export function prepareReactLoopAgent( - ctx: Context, - id: SessionId, - options: AgentOptions, - session: Session, - maxParallelToolCalls: number, -): PreparedReactLoopAgent { - if (claimedDriverSessions.has(session)) { - throw new Error(`session "${session.id}" already has a concrete agent driver`) - } - const agent = new ReactLoopAgent(ctx, id, options, session, maxParallelToolCalls) - claimedDriverSessions.add(session) - const dispose = () => agent[stopDriver]() - return { - agent, - markPublished: () => { agent[publishAgent]() }, - dispose, - startDriver: () => { - agent[startDriver]() - return dispose - }, - } -} -/** - * Install the concrete agent's scope context exactly once. Construction and - * scope minting are mutually referential (the scope key is the agent), so the - * factory performs this one post-construction binding before setup receives - * the unpublished agent. The module-private binding rejects a second bind. - * @param agent - the unpublished concrete agent to bind. - * @param ctx - its fully extended agent scope context. - */ -export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context): void { - agent[bindContext](ctx) -} - -/** - * The concrete {@link Agent} implementation owned by the agent-loop plugin. - * - * Owns the inbox (queued + steering FIFOs), turn cancellation, and - * the loop driver. Everything observable happens through session events and - * the agent/* event taxonomy — plugins never need this class. + * The concrete {@link Agent}: each `run()` owns one turn and repeats model + * steps while tools or steering require another request. */ export class ReactLoopAgent implements Agent { - /** Queued + steering FIFOs; native-private so callers cannot bypass the public driving verbs. */ - readonly #inbox = new Inbox() + /** Prompts awaiting individual turns. */ + private queued: { message: AgentMessage; wakeup: boolean }[] = [] + /** Input taken into the session log at step boundaries. */ + private outbox: (UserMessageData | AgentMessage)[] = [] - /** - * The agent's scope context ({@link Agent.ctx}), wired by the factory right - * after the scope is minted — before the agent is registered, announced, or - * driven, so no consumer can observe it unset. Definite-assignment (`!`) - * expresses that two-phase construction: the agent object and its scope - * context are mutually referential (the scope is keyed BY this agent), so - * neither can exist strictly before the other. - */ - private boundContext: Context | undefined - - /** The agent's scoped composition context, bound once by its factory. */ - get ctx(): Context { - if (this.boundContext === undefined) throw new Error(`agent "${this.id}" context is not bound`) - return this.boundContext - } - - private _status: AgentStatus = 'idle' - /** Active turn owner from pre-running publication through durability settlement. */ - private turnCancellation: TurnCancellation | undefined - /** Whether runLoop has been installed into {@link done}. */ - private driverStarted = false - /** Whether registry publication began and status disposal is externally visible. */ - private published = false - /** Cause-less marker for queued work cancelled before the driver installs a turn owner. */ - private preRunCancelled = false - private disposed: Promise - private resolveDisposed!: () => void - /** Resolves when the driver loop has fully exited (tests/disposal). */ + /** 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() - /** - * Pending {@link whenIdle} waiters, resolved by {@link settleIdleWaiters} when - * the agent next settles out of `running`. Kept as internal agent state (NOT - * an effect-scoped `ctx.on` listener) so a concurrent fiber disposal — which - * runs the agent's own listeners' disposers — cannot drop the waiter before - * the `disposed` transition fires and leave the promise hanging. - */ - private idleWaiters: (() => void)[] = [] - /** Maximum parallel-safe calls allowed in one step. */ - private readonly maxParallelToolCalls: number - /** - * Durability checkpoints started by idle {@link inject} calls. `inject()` is - * synchronous, so it cannot await them itself; the driver disposer drains - * this set before the lifecycle unregisters the agent or detaches its session. - */ - private pendingIdleFlushes = new Set>() - /** Whether the current step is executing an assistant tool-call batch. */ - private toolBatchActive = false - /** Open-turn injections waiting for the active assistant tool-call batch to close. */ - private deferredInjections: HookContext[] = [] + + /** The agent-scoped registration boundary; the lifecycle owner unwinds it after {@link done}. */ + 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 constructor( private loopCtx: Context, public readonly id: SessionId, public readonly options: AgentOptions, public readonly session: Session, - maxParallelToolCalls: number, ) { - this.maxParallelToolCalls = maxParallelToolCalls - const { promise, resolve } = Promise.withResolvers() - this.disposed = promise - this.resolveDisposed = resolve + this.lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0 + this.scope = createScope(loopCtx, this) + this.ctx = this.scope.ctx.extend({ agent: this }) } + /** Last activity state published to observers. */ get status(): AgentStatus { - return this._status + return this.busy ? 'running' : 'idle' } - private setStatus(status: AgentStatus): void { - if (this._status === status || this._status === 'disposed') return - this._status = status - // Settle first so a throwing status listener cannot starve quiescence waiters. - if (status !== 'running') this.settleIdleWaiters() - agentEvents(this.loopCtx, this).emit('agent/status', status) - } - - /** - * Resolve and clear all pending {@link whenIdle} waiters. Called on a - * running→idle transition (from {@link setStatus}) and on disposal (from the - * internal driver disposer, which chains `done` for true loop-exit quiescence). - */ - private settleIdleWaiters(): void { - const waiters = this.idleWaiters - this.idleWaiters = [] - for (const resolve of waiters) resolve() - } - - /** - * Accept one public message payload as a detached record. Lossless-JSON - * materialization reads every nested field once; deep freeze prevents later - * caller mutation before an inbox or deferred-injection queue drains it. - */ - private snapshotMessage(id: AgentMessageId, input: ResolvedAgentInput): InboxMessage { - const { content, source, contexts, wakeup, meta } = input - const accepted = snapshotJsonValue({ - id, content, source, contexts, wakeup, - ...meta !== undefined ? { meta } : {}, - }) - if (accepted === undefined) { - throw new TypeError('agent message content, source, and contexts must be losslessly JSON-serializable') - } - return deepFreeze(accepted) - } - - /** Detach one context before it can outlive its caller in the active-batch FIFO. */ - private acceptContext(context: HookContext): HookContext { - const accepted = snapshotJsonValue(context) - if (accepted === undefined) { - throw new TypeError('agent context must be losslessly JSON-serializable') - } - return deepFreeze(accepted) - } - - /** Reject a driving operation once teardown has synchronously closed the agent. */ - private assertNotDisposed(): void { - if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) - } - - /** Accept one fully resolved agent input through the concrete driver's routing matrix. */ - send(input: ResolvedAgentInput): AgentMessageId { - this.assertNotDisposed() + /** Accept and route one unified send item. */ + send( + input: UserMessageData, + options: SendOptions, + ): AgentMessageId { + const { content, source } = deepFreeze(structuredClone(input)) + const { target, wakeup } = options const id = AgentMessageId(randomUUID()) - const { target, wakeup } = input - // next-step/no-wakeup is injection: durable context without running the model. - if (target === 'next-step' && !wakeup) { this.injectContext(input); return id } - // next-step/wakeup is steering into the running turn; idle falls back to a - // waking ordinary turn (there is no active turn to attach to). - const steering = target === 'next-step' && this._status === 'running' - const accepted = this.snapshotMessage(id, input) - if (steering) { - this.#inbox.steer(accepted) - } else { - this.#inbox.enqueue(accepted, wakeup) + if (target === 'next-step' && !wakeup) { + if (this.acceptsNextStep) { + this.outbox.push({ content, source }) + return id + } + this.session.append('user/message', { content, source }, { surfaceOp: 'append' }) + return id } - agentEvents(this.loopCtx, this).emit('agent/inbox/enqueue', agentMessage(accepted, steering)) + + const placement: InboxPlacement = target === 'next-step' && this.acceptsNextStep ? 'steering' : 'queued' + const message: AgentMessage = { + id, + content, + source, + } + deepFreeze(message) + if (placement === 'steering') { + this.outbox.push(message) + } 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) return id } - followup(content: ContentBlock[], options?: SendOptions): AgentMessageId { - return this.send({ - content, + /** Queue one ordinary prompt turn and wake the driver. */ + followup(input: UserMessageData): AgentMessageId { + return this.send(input, { target: 'next-turn', wakeup: true, - source: options?.source ?? { kind: 'user' }, - contexts: options?.contexts ?? [], - meta: options?.meta, }) } - queue(content: ContentBlock[], options?: SendOptions): AgentMessageId { - return this.send({ - content, - target: 'next-turn', - wakeup: false, - source: options?.source ?? { kind: 'user' }, - contexts: options?.contexts ?? [], - meta: options?.meta, - }) - } - - steer(content: ContentBlock[], options?: SendOptions): AgentMessageId { - return this.send({ - content, + /** Steer the open turn, falling back to a waking prompt while idle. */ + steer(input: UserMessageData): AgentMessageId { + return this.send(input, { target: 'next-step', wakeup: true, - source: options?.source ?? { kind: 'user' }, - contexts: options?.contexts ?? [], - meta: options?.meta, }) } - inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId { - return this.send({ - content, + /** Append model-facing context without waking the driver. */ + inject(input: UserMessageData): AgentMessageId { + return this.send(input, { target: 'next-step', wakeup: false, - source: options?.source ?? { kind: 'plugin', plugin: '' }, - contexts: [], - meta: options?.meta, }) } - /** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */ - private injectContext(input: Extract): void { - const { content, source, meta } = input - // Detach and validate the payload before any append, so malformed input - // cannot open a one-shot turn or otherwise mutate the session. - const accepted = this.acceptContext({ - content, - source, - ...meta !== undefined ? { meta } : {}, + /** + * 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(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) + } + if (!options.keepInbox) { + const discarded = this.queued.map(item => item.message) + for (const message of this.outbox) { + if ('id' in message) discarded.push(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.wakeScheduled || this.abort !== undefined || this.queued.some(item => item.wakeup)) { + await this.done.catch(() => undefined) + } + } + + /** Defer idle admission while keeping {@link done} as its quiescence owner. */ + private scheduleKick(): void { + if (this.abort !== undefined || this.wakeScheduled) return + this.wakeScheduled = true + const pending = Promise.withResolvers() + const scheduled = pending.promise + queueMicrotask(() => { + this.wakeScheduled = false + this.kick() + const activity = this.done + if (activity === scheduled) { + pending.resolve() + } else { + void activity.then( + () => { pending.resolve() }, + () => { pending.resolve() }, + ) + } }) - if (isTurnOpen(this.session)) { - // Provider protocols require every assistant tool-call batch to be - // followed only by its tool results. Historical interrupted batches do - // not own new context; only the currently executing batch may defer it. - if (this.toolBatchActive) { - this.deferredInjections.push(accepted) + this.done = scheduled + } + + /** 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') + } + // 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: UserMessageData[] | undefined + try { + signal.throwIfAborted() + const decision = await this.loopCtx.waterfall( + agentCarrier(this), 'agent/prompt-submit', this, message.content, message.source, signal, + () => Promise.resolve({ kind: 'allow' }), + ) + signal.throwIfAborted() + + if (decision.kind === 'allow') { + admitted = [{ content: decision.content ?? message.content, source: message.source }] + for (const context of decision.additionalContexts ?? []) { + admitted.push({ content: context.content, source: context.source }) + } + } + } 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 } - this.session.append('user/message', accepted, { surfaceOp: 'append' }) - return - } - // No turn open: wrap the injection in a one-shot turn so every event stays - // turn-enclosed (the durability/replay boundary is the turn). The payload is - // validated above, but `Session.append` can still reject a turn/start - // pre-commit (append re-entrancy from a session/event listener, or an - // internal-dispatch veto), so the finally owes a turn/end only when - // turn/start actually committed. - const turn = lastTurnNumber(this.session) + 1 - try { - this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - this.session.append('user/message', accepted, { surfaceOp: 'append' }) - } finally { - // Close the turn if turn/start made it into the log. A pre-commit veto - // must escape rather than being mistaken for a committed turn/end. - if (isTurnOpen(this.session)) { - this.session.append('turn/end', { turn, reason: { kind: 'completed' } }) - } - // Checkpoint only an accepted one-shot turn: a turn/start rejected - // pre-commit recorded nothing, so it owes no flush (and a spurious flush - // would emit a phantom-turn agent/error). The payload is validated up - // front, so a committed turn/start is always followed by its user/message. - const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn) - // Keep inject() synchronous: report checkpoint failures live instead of - // rejecting the caller, and track the task so disposal still drains it. - if (turnRecorded) { - // Through the store's flush (the carrier owner), never a raw parallel. - const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => { - const rendered = errorChain(error) - const err = error instanceof Error ? error : new Error(rendered) - this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`) - agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err) - }) - this.pendingIdleFlushes.add(flush) - // Retire on either settlement path. - const retire = (): void => { this.pendingIdleFlushes.delete(flush) } - void flush.then(retire, retire) - } - } - } - - /** Append deferred open-turn injections after the loop closes a tool-result batch. */ - private drainDeferredInjections(): void { - const pending = this.deferredInjections.splice(0) - for (const accepted of pending) { - this.session.append('user/message', accepted, { surfaceOp: 'append' }) - } - } - - /** - * Run one tool-call batch and drain its deferred context before settlement. - * The loop-owned acceptor remains valid after public disposal begins because - * the interrupted turn stays open until this batch settles. - */ - private async withToolBatch( - run: (acceptContext: (context: HookContext) => void) => Promise, - ): Promise { - this.toolBatchActive = true - const acceptContext = (context: HookContext): void => { - this.deferredInjections.push(this.acceptContext(context)) - } - try { - return await run(acceptContext) - } finally { - this.toolBatchActive = false - this.drainDeferredInjections() - } - } - - cancel(cause?: AgentCancelCause, options?: CancelOptions): void { - const resolvedCause = cause ?? { kind: 'user' } - const keepInbox = options?.keepInbox ?? false - const cancellation = this.turnCancellation - // keepInbox preserves pending work, so un-started items must not arm the - // pre-run cancel path that would otherwise drop the next queued turn. - const preRun = !keepInbox && cancellation === undefined - && (this.#inbox.hasQueued || this.#inbox.hasSteering) - if (cancellation !== undefined || preRun) { - if (preRun) this.preRunCancelled = true - // Coordination consumers must update their own state before this call - // clears the inbox or aborts the turn. Notification failures are - // contained by the fused dispatcher and cannot veto cancellation. - agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedCause) - } - if (!keepInbox) { - // Snapshot before clearing so the discard notification carries the exact - // dropped items; a replacement synchronously enqueued by an - // `agent/cancel-requested` observer belongs to the next turn, not here. - const discarded = this.#inbox.pending() - // Clear work already present before abort observers run. - this.#inbox.clear() - if (discarded.length > 0) { - const items = discarded.map(({ message, steering }) => agentMessage(message, steering)) - agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) - } - // No idle-waiter settle here: a `whenIdle` waiter exists only while the - // agent is `running` or a waking item is queued, and neither is left - // quiescent by clearing the inbox — a lone quiet item takes `whenIdle`'s - // fast path (no waiter), a waking item keeps the woken driver running, - // and a running agent owns its own idle transition (including the - // post-turn flush window). - } - cancellation?.request(resolvedCause) - } - - /** - * Resolve immediately when idle with no queued work, on the next quiescent - * idle transition otherwise, or after driver exit when already disposed. - * This observes quiescence; it does not own teardown. - */ - whenIdle(): Promise { - if (this._status === 'disposed') return this.done - // A lone quiet (`wakeup:false`) queued item leaves the agent quiescent — the - // driver stays parked — so gate on hasWakingQueued, not hasQueued. - if (this._status !== 'running' && !this.#inbox.hasWakingQueued) return Promise.resolve() - // Agent-owned waiters survive concurrent fiber disposal. - return new Promise((resolve) => { - this.idleWaiters.push(() => { - resolve(this._status === 'disposed' ? this.done : undefined) - }) + await this.run(trigger, admitted, inheritedOutboxLength) }) - } - - /** Bind the mutually referential scope context once. */ - private [bindContext](ctx: Context): void { - if (this.boundContext !== undefined) throw new Error(`agent "${this.id}" context is already bound`) - this.boundContext = ctx - } - - /** Mark that public lifecycle publication began. */ - private [publishAgent](): void { - this.published = true + // 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) } /** - * Start the driver loop. The prepared controller already owns its stable - * disposer, so teardown can mark the agent disposed even in the narrow - * publication window before this method runs. + * 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. */ - [startDriver](): void { - if (this._status === 'disposed') return - this.driverStarted = true - this.done = this.loopCtx.agents.withInitiator(this, () => runLoop(this.loopCtx, { - inbox: this.#inbox, - maxParallelToolCalls: this.maxParallelToolCalls, - setStatus: (status) => { this.setStatus(status) }, - installTurnCancellation: () => { - const cancellation = new TurnCancellation() - this.turnCancellation = cancellation - return cancellation - }, - clearTurnCancellation: (cancellation) => { - /* v8 ignore else -- the driver clears only the exact owner returned by its latest install. */ - if (this.turnCancellation === cancellation) this.turnCancellation = undefined - }, - disposed: this.disposed, - isDisposed: () => this._status === 'disposed', - isPreRunCancelled: () => this.preRunCancelled, - clearPreRunCancel: () => { this.preRunCancelled = false }, - withToolBatch: run => this.withToolBatch(run), - // Pre-run cancellation settles queued-work waiters before publishing idle. - settleIdle: () => { this.settleIdleWaiters() }, - })) - } + private async run( + trigger: TurnTrigger, + admitted: UserMessageData[] = [], + inheritedOutboxLength = 0, + ): 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 retry = false + const cancelRetry = (): void => { retry = false } + signal.addEventListener('abort', cancelRetry, { once: true }) - /** - * Quiescent stop shared by pre-start rollback and live teardown. It marks the - * agent disposed synchronously, contains an unexpected loop rejection, and - * drains every idle-injection flush before resolving. - */ - private [stopDriver](): Promise | void { - if (this._status !== 'disposed') { - // Snapshot any still-pending inbox items, then CLEAR and mark disposed - // BEFORE emitting the discard — mirroring cancel()'s snapshot→clear→emit - // order so a re-entrant followup()/cancel() from a discard listener throws - // `disposed` (or finds an empty inbox) instead of leaking or double- - // discarding an id. `followup()` emits enqueue unconditionally, so the discard - // is unconditional too (even on an unpublished rollback) to keep every - // enqueued id matched. - const discarded = this.#inbox.pending() - this.#inbox.clear() - this._status = 'disposed' - this.resolveDisposed() - if (discarded.length > 0) { - const items = discarded.map(({ message, steering }) => agentMessage(message, steering)) - agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) + 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' }) } - // Release whenIdle waiters BEFORE the (guarded) event emit — they are - // internal state that must settle even if a listener throws below. Each - // waiter chains `done`, so it resolves only once the loop actually exits. - this.settleIdleWaiters() - this.turnCancellation?.request(DISPOSED_INTERRUPT_REASON) - // An unpublished rollback has no public status lifecycle to announce. - // Once publication begins, disposed is part of the agent/status contract. - if (this.published) { - agentEvents(this.loopCtx, this).emit('agent/status', 'disposed') + signal.throwIfAborted() + + this.drainOutbox(turn) + + steps: while (true) { + step += 1 + const outcome = await this.step(turn, step, signal) + switch (outcome.kind) { + case 'completed': + 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 => 'id' in item)) 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, signal, + () => Promise.resolve(undefined), + ) + retry = action?.kind === 'retry' && !signal.aborted + } 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 + } + /* 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 + 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 }) + } + } catch (error: unknown) { + retry = false + this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(error)}`) + emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error) + } + // 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 (retry) { + await this.run({ kind: 'retry' }) + } 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() + } + } + + /** + * 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. + 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 { + for await (const chunk of stream) { + signal.throwIfAborted() + const chunkEvent = 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 } + } + 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 } + } + + // Truncated (max-tokens) output cannot owe tool calls. + const assembled = assembler.message() + const content = finish.kind === 'max-tokens' + ? assembled.content.filter(block => block.type !== 'tool-call') + : assembled.content + + session.append( + 'assistant/message', + { + turn, + step, + content, + provenance: { + provider: request.provider, + model: request.model, + ...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {}, + }, + ...assembler.usage === undefined ? {} : { usage: assembler.usage }, + }, + { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, + ) + + const toolCalls = content.filter(block => block.type === 'tool-call') + let concluded = false + if (toolCalls.length > 0) { + ({ concluded } = await executeToolCalls( + this.loopCtx, turn, step, toolCalls, signal, + context => this.outbox.push({ content: context.content, source: context.source }), + )) + } + + // 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', + } + } + + /** + * Compose one frozen request and bind it to the adapter registration that + * resolved its exact-model defaults. + */ + private async buildRequest( + turn: number, + step: number, + tools: GenerateOptions['tools'] & object, + system: string, + 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 route = { provider: this.options.provider ?? '', model: this.options.model ?? '' } + const reasoningEffort = persistedConfig?.provider === route.provider + && persistedConfig.model === route.model + ? persistedConfig.reasoningEffort + : undefined + const seedConfig = deepFreeze(structuredClone( + this.requestHeaderLogged + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- the instance logged the header it now folds + ? persistedConfig! + : { ...route, ...reasoningEffort === undefined ? {} : { reasoningEffort } }, + )) + const proposedConfig = await this.loopCtx.waterfall( + agentCarrier(this), 'agent/request', this, turn, step, signal, + () => Promise.resolve(seedConfig), + ) + signal.throwIfAborted() + if (!proposedConfig.provider || !proposedConfig.model) { + throw new Error(`agent "${this.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`) + } + let config: LlmCallConfig + let preparedCall: PreparedLlmCall | undefined + try { + preparedCall = await this.loopCtx.llm.prepareCall(proposedConfig, signal) + config = preparedCall.config + } catch (error: unknown) { + // A llm/stream listener may own and short-circuit a route with no + // adapter. Terminal dispatch still raises NO_ADAPTER when none does. + if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error + config = proposedConfig + } + signal.throwIfAborted() + + const header = canonicalHeader({ + config, + ...system ? { system } : {}, + ...tools.length > 0 ? { tools } : {}, + }) + const baseline = session.requestHeader() + if (!this.requestHeaderLogged) { + 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' }) + } + + const request = markAgentLoopRequest(deepFreeze({ + ...header.config, + messages: boundaryMessages, + ...header.system !== undefined ? { system: header.system } : {}, + ...header.tools !== undefined ? { tools: header.tools } : {}, + sessionId: 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 message of this.outbox.splice(0, limit)) { + if ('id' in message) { + steered = true + emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', message) + this.session.append( + 'steering/message', + { turn, content: message.content, source: message.source }, + { surfaceOp: 'append' }, + ) + } else { + this.session.append('user/message', message, { surfaceOp: 'append' }) } } - // Before runLoop starts there is normally nothing asynchronous to drain; - // keep publication rollback synchronous so create() cannot throw while its - // session/agent entries are still briefly live. A session-start listener - // may have called inject(), however, so preserve - // its durability checkpoint as a real quiescence boundary. - if (!this.driverStarted && this.pendingIdleFlushes.size === 0) return - return this.drainDriver() + return steered } - /** Await the loop (when started) and every outstanding idle flush. */ - private async drainDriver(): Promise { - // An unexpected driver rejection must not skip registry/session/scope - // cleanup. The normal loop contains turn failures itself; allSettled is the - // final lifecycle backstop for anything outside those boundaries. - await Promise.allSettled([this.done]) - // Repeat because settled flushes retire in adjacent promise reactions; - // allSettled keeps reporting failures from skipping ownership teardown. - while (this.pendingIdleFlushes.size > 0) { - await Promise.allSettled([...this.pendingIdleFlushes]) + /** + * 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(message => 'id' in message)) return + const contexts = this.outbox.splice(0) + for (let index = 0; index < contexts.length; index += 1) { + const context = contexts[index] + /* v8 ignore next 2 -- the steering precheck proves this batch is context-only */ + if (context === undefined || 'id' in context) throw new Error('rejected-admission context batch changed') + try { + this.session.append('user/message', context, { 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/cancellation.ts b/packages/core/agent-loop/src/cancellation.ts deleted file mode 100644 index c3f5430a20..0000000000 --- a/packages/core/agent-loop/src/cancellation.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** Turn-scoped cancellation ownership for the concrete AgentLoop driver. @module dsh-agent-loop/cancellation */ - -import type { AgentCancelCause } from '@deepseek-ai/dsh-agent' - -/** Stable runtime-only reason used when lifecycle teardown interrupts a turn. */ -export const DISPOSED_INTERRUPT_REASON = Object.freeze({ kind: 'disposed' } as const) - -/** - * Owns the single controller shared by every asynchronous boundary of one turn. - * The first request wins because a later caller must not rewrite the cause - * observed by earlier listeners. - */ -export class TurnCancellation { - readonly #controller = new AbortController() - - /** The explicit signal passed through this turn's execution boundaries. */ - get signal(): AbortSignal { - return this.#controller.signal - } - - /** - * Abort the turn once. - * @param reason - a typed caller cause or lifecycle disposal marker. - * @returns whether this request established the signal reason. - */ - request(reason: AgentCancelCause | typeof DISPOSED_INTERRUPT_REASON): boolean { - if (this.signal.aborted) return false - this.#controller.abort(Object.freeze({ kind: reason.kind })) - return true - } -} diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts deleted file mode 100644 index 0f93884515..0000000000 --- a/packages/core/agent-loop/src/inbox.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Per-agent message inbox: queued and steering FIFOs. Purely an in-memory - * mechanism of the loop driver — callers use `Agent`'s intent-named delivery - * methods instead. - * - * @module dsh-agent-loop/inbox - */ - -import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import type { JsonValue } from '@deepseek-ai/dsh-session' -import type { AgentMessage, AgentMessageId, HookContext } from '@deepseek-ai/dsh-agent' - -/** One message waiting in an agent's inbox; `id` is the value its accepting delivery method returned. */ -export interface InboxMessage { - id: AgentMessageId - content: ContentBlock[] - source: MessageSource - contexts: HookContext[] - /** Whether the item is marked to wake the driver or force a continuation. */ - wakeup: boolean - /** Opaque durable JSON state retained on the durable message but hidden from the model. */ - meta?: JsonValue -} - -/** - * Build the `agent/inbox/*` event payload for one inbox item. - * @param message - the accepted inbox record. - * @param steering - whether the item is in the steering FIFO (`next-step`). - * @returns the live-event message for enqueue/dequeue/discard. - */ -export function agentMessage(message: InboxMessage, steering: boolean): AgentMessage { - // Frozen: the fused emitter passes this exact object to every listener in - // turn, so one listener must not be able to mutate a field (`id`, `steering`, - // `content`, …) a later listener then observes. `message` is already a frozen - // inbox record, so its nested fields need no re-clone. - return Object.freeze({ - id: message.id, content: message.content, source: message.source, - contexts: message.contexts, steering, wakeup: message.wakeup, - }) -} - -/** - * Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO - * (drained between steps of a running turn). Purely an in-memory mechanism of - * the loop — the public surface is `Agent`'s intent-named delivery methods. - */ -export class Inbox { - private queuedMessages: InboxMessage[] = [] - private steeringMessages: InboxMessage[] = [] - private wakeup: (() => void) | undefined - - /** True while any queued message is pending — read by cancellation's discard snapshot and the turn-start dequeue guard. */ - get hasQueued(): boolean { - return this.queuedMessages.length > 0 - } - - /** - * True while a queued message wants to wake the driver — the "should the loop - * run" signal read by the idle wait's fast path, the loop's idle-publish - * check, and `whenIdle`. A `wakeup:false` (quiet) item alone leaves this - * false, so the driver stays parked until a waking follow-up (or a waking item - * ahead of it in FIFO order) drives the loop; the quiet item then rides along. - */ - get hasWakingQueued(): boolean { - return this.queuedMessages.some(message => message.wakeup) - } - - /** True while steering messages are pending — read by cancellation and the loop's stop-override check. */ - get hasSteering(): boolean { - return this.steeringMessages.length > 0 - } - - /** - * Add a message to the queued FIFO, waking a parked {@link waitForQueued} - * unless the item opted out. A non-waking item still runs once any woken - * item or later wakeup drives the parked loop. - * @param message - the message to queue for the next turn start. - * @param wake - whether to wake a parked idle wait (default true). - */ - enqueue(message: InboxMessage, wake = true): void { - this.queuedMessages.push(message) - if (wake) this.wakeup?.() - } - - /** - * Add a message to the steering FIFO. Deliberately no wakeup: steering is - * drained between steps of a running turn, never by the idle wait — - * `Agent.steer()` on an idle agent falls back to a waking ordinary turn instead. - * @param message - the message to inject between steps of the running turn. - */ - steer(message: InboxMessage): void { - this.steeringMessages.push(message) - } - - /** - * Remove the oldest queued message for one turn start. - * @returns the oldest message, or `undefined` when the queued FIFO is empty. - */ - dequeueQueued(): InboxMessage | undefined { - return this.queuedMessages.shift() - } - - /** - * Drain all steering messages (between steps). - * @returns the drained messages in arrival order; the steering FIFO is left empty. - */ - drainSteering(): InboxMessage[] { - return this.steeringMessages.splice(0) - } - - /** - * Snapshot the pending items (queued then steering, FIFO order) without - * removing them — the discard notification's payload source. - * @returns the pending items paired with whether each is steering. - */ - pending(): { message: InboxMessage; steering: boolean }[] { - return [ - ...this.queuedMessages.map(message => ({ message, steering: false })), - ...this.steeringMessages.map(message => ({ message, steering: true })), - ] - } - - /** - * Discard all pending messages (queued + steering) without delivering them — - * used by `cancel()`, which drops un-started work rather than draining it into - * a turn. Unlike `dequeueQueued`/`drainSteering`, the messages are thrown away. - */ - clear(): void { - this.queuedMessages.length = 0 - this.steeringMessages.length = 0 - } - - /** - * Wait until a queued message arrives or `cancel` resolves. - * @param cancel - a promise whose resolution abandons the wait without a - * message (the driver loop passes the agent's disposed promise so a parked - * loop can exit). - */ - waitForQueued(cancel: Promise): Promise { - if (this.hasWakingQueued) return Promise.resolve() - const { promise, resolve } = Promise.withResolvers() - this.wakeup = resolve - void cancel.then(resolve) - return promise.finally(() => { - if (this.wakeup === resolve) this.wakeup = undefined - }) - } -} diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index bdaa3f2401..28a482b3bb 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -8,9 +8,7 @@ import { Context, FiberState, Service } from 'cordis' import { randomUUID } from 'node:crypto' import z from 'schemastery' -import { createScope } from '@deepseek-ai/dsh-scope' -import type { Scope } from '@deepseek-ai/dsh-scope' -import { agentEvents } from '@deepseek-ai/dsh-agent' +import { emitAgentEvent } from '@deepseek-ai/dsh-agent' import type { Agent, AgentFactory, @@ -26,12 +24,7 @@ import type { Session, SessionHeader } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import { - bindReactLoopAgentContext, - prepareReactLoopAgent, - ReactLoopAgent, -} from './agent.ts' -import type { PreparedReactLoopAgent } from './agent.ts' +import { ReactLoopAgent } from './agent.ts' import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts' /** Fiber states that cannot own or serve a new lifecycle. */ @@ -41,31 +34,43 @@ const INACTIVE_STATES: ReadonlySet = new Set([ FiberState.FAILED, ]) -/** Factory-level ownership of every preparing or live transaction. */ +/** Factory-level ownership: live agent teardowns plus config startup work. */ class FactoryOwnership { private accepting = true + private readonly teardown = new AbortController() private readonly inactive = Promise.withResolvers() - private transactions = new Set() + private readonly liveAgents = new Set<() => Promise>() private startupTasks = new Set>() constructor(private readonly fiber: Context['fiber']) {} + /** Aborts (reason: `agent loop is not active` error) when factory teardown begins. */ + get signal(): AbortSignal { + return this.teardown.signal + } + isActive(): boolean { return this.accepting && !INACTIVE_STATES.has(this.fiber.state) } - track(transaction: AgentCreationTransaction): () => void { - this.transactions.add(transaction) - return () => { this.transactions.delete(transaction) } + /** Track one live agent's shared teardown until it has run. */ + track(dispose: () => Promise): () => void { + this.liveAgents.add(dispose) + return () => { this.liveAgents.delete(dispose) } } - /** Join config startup work that begins before an agent transaction exists. */ + /** Join config startup work that begins before an agent exists. */ trackStartup(task: Promise): void { this.startupTasks.add(task) const forget = () => { this.startupTasks.delete(task) } void task.then(forget, forget) } + /** Join one public create/resume continuation; factory dispose awaits its settlement. */ + trackWrapper(task: Promise): void { + this.trackStartup(task.then(() => undefined, () => undefined)) + } + /** Resolve `task`, or stop waiting when factory teardown begins. */ async waitWhileActive(task: Promise): Promise { await Promise.race([task, this.inactive.promise]) @@ -73,19 +78,29 @@ class FactoryOwnership { async dispose(): Promise { this.accepting = false + this.teardown.abort(new Error('agent loop is not active')) this.inactive.resolve() - const reason = new Error('agent loop is not active') await Promise.all([ - ...[...this.transactions].map(transaction => transaction.disposeForFactory(reason)), + ...[...this.liveAgents].map(dispose => dispose()), ...this.startupTasks, ]) } } -/** Build the public cancellation error while preserving a caller-supplied cause. */ -function signalAbortError(id: SessionId, signal: AbortSignal): Error { - if (signal.reason instanceof Error) return signal.reason - return new Error(`agent "${id}" creation aborted`, { cause: signal.reason }) +/** Await `operation`, or throw the signal's reason as soon as it aborts. */ +async function raceAbort(operation: PromiseLike | T, signal: AbortSignal, id: SessionId): Promise { + const toAbortError = (): Error => signal.reason instanceof Error + ? signal.reason + : new Error(`agent "${id}" creation aborted`, { cause: signal.reason }) + if (signal.aborted) throw toAbortError() + const aborted = Promise.withResolvers() + const listener = (): void => { aborted.reject(toAbortError()) } + signal.addEventListener('abort', listener, { once: true }) + try { + return await Promise.race([Promise.resolve(operation), aborted.promise]) + } finally { + signal.removeEventListener('abort', listener) + } } /** Resolve the deployment-wide scheduler cap at the owning config boundary. */ @@ -97,243 +112,15 @@ function resolveMaxParallelToolCalls(value: number | undefined): number { return maxParallelToolCalls } -/** - * Caller-owned create/resume transaction through rollback-covered publication - * and quiescent teardown. Resources remain private until the final registry - * entry arbitrates identity. - */ -class AgentCreationTransaction { - private active = true - private failure: Error | undefined - private readonly deactivation = Promise.withResolvers() - private readonly publication = Promise.withResolvers() - private readonly torndown = Promise.withResolvers() - private readonly wrapperCompletion = Promise.withResolvers() - private preparing: Promise | undefined - private driver: PreparedReactLoopAgent | undefined - private scope: Scope | undefined - private session: Session | undefined - private lifecycleDispose: (() => Promise | void) | undefined - private detachSession: (() => void) | undefined - private detachAgent: (() => void) | undefined - private publishing = false - private cleanupTask: Promise | undefined - private ownerFollowing = true - private readonly ownerDispose: () => Promise | void - private readonly untrackFactory: () => void - private readonly abortListener: (() => void) | undefined - readonly ownerAgent: Context['agent'] - readonly ownerFiber: Context['fiber'] - - constructor( - private readonly loopCtx: Context, - private readonly ownerCtx: Context, - private readonly ownership: FactoryOwnership, - readonly id: SessionId, - signal?: AbortSignal, - ) { - ownerCtx.fiber.assertActive() - this.ownerAgent = ownerCtx.agent - this.ownerFiber = ownerCtx.fiber - if (!ownership.isActive()) throw new Error('agent loop is not active') - this.ownerDispose = ownerCtx.effect(() => () => { - if (!this.ownerFollowing) return - return this.dispose(new Error(`agent "${id}" setup aborted: owner disposed during setup`)) - }, `agentLoop.owner(${id})`) - this.untrackFactory = ownership.track(this) - if (signal === undefined) { - this.abortListener = undefined - } else { - this.abortListener = () => { - /* v8 ignore next 3 -- transaction teardown contains callback/driver failures; rejection is a future-drift backstop. */ - void this.dispose(signalAbortError(id, signal)).catch((error: unknown) => { - this.loopCtx.logger.error(error) - }) - } - signal.addEventListener('abort', this.abortListener, { once: true }) - if (signal.aborted) this.deactivate(signalAbortError(id, signal)) - } - this.signal = signal - } - - private readonly signal: AbortSignal | undefined - - /** Whether caller, provider, and optional parent-agent ownership remain live. */ - isActive(): boolean { - return this.active - && this.ownership.isActive() - && this.ownerFiber.uid !== null - && !INACTIVE_STATES.has(this.ownerFiber.state) - && this.ownerAgent?.status !== 'disposed' - } - - /** Fail synchronously at every real lifecycle boundary after deactivation. */ - assertActive(): void { - if (this.isActive()) return - if (!this.ownership.isActive()) throw new Error('agent loop is not active') - throw this.failure ?? new Error(`agent "${this.id}" setup aborted: owner disposed during setup`) - } - - /** Race an external async operation against structural/signal deactivation. */ - async waitFor(operation: PromiseLike | T): Promise { - this.assertActive() - return await Promise.race([ - Promise.resolve(operation), - this.deactivation.promise.then(() => { - /* v8 ignore next -- deactivate() assigns failure before resolving deactivation. */ - throw this.failure ?? new Error(`agent "${this.id}" creation deactivated`) - }), - ]) - } - - /** Construct the driver and scope, then install their complete ordered lifecycle. */ - prepare(options: AgentOptions, session: Session, maxParallelToolCalls: number): ReactLoopAgent { - this.assertActive() - const gate = Promise.withResolvers() - this.preparing = gate.promise - try { - this.session = session - const driver = prepareReactLoopAgent(this.loopCtx, this.id, options, session, maxParallelToolCalls) - this.driver = driver - const agent = driver.agent - const scope = createScope(this.loopCtx, agent) - this.scope = scope - bindReactLoopAgentContext(agent, scope.ctx.extend({ agent })) - this.installLifecycle(scope, driver) - this.assertActive() - return agent - } catch (error: unknown) { - if (!this.isActive() && error instanceof Error && /inactive context/.test(error.message)) { - throw this.failure ?? this.disposalReason() - } - throw error - } finally { - gate.resolve() - this.preparing = undefined - } - } - - /** Register the exact scope disposer inside the ordered transaction effect. */ - private installLifecycle(scope: Scope, driver: PreparedReactLoopAgent): void { - this.lifecycleDispose = this.ownerCtx.effect(function* (this: AgentCreationTransaction) { - // First yielded, disposed last. - yield () => { this.finish() } - yield scope.rawDispose - yield () => { - this.detachSession?.() - this.detachSession = undefined - } - yield () => { - this.detachAgent?.() - this.detachAgent = undefined - } - // Last yielded, disposed first. - yield () => { - this.deactivate(this.disposalReason()) - if (this.publishing) { - return this.publication.promise.then(() => driver.dispose()) - } - return driver.dispose() - } - }.bind(this), `agentLoop.lifecycle(${this.id})`) - } - - /** Publish the exact prepared objects and start the driver. */ - publish(source: SessionStartSource): AgentHandle { - this.assertActive() - const driver = this.driver - /* v8 ignore next -- publish() is private and every caller invokes prepare() first. */ - if (driver === undefined) throw new Error(`agent "${this.id}" is not prepared`) - const agent = driver.agent - const session = this.session - /* v8 ignore next -- prepare() assigns the session before it can produce the driver above. */ - if (session === undefined) throw new Error(`agent "${this.id}" has no prepared session`) - this.publishing = true - try { - this.detachSession = agent.ctx.sessions.enter(session) - this.detachAgent = this.loopCtx.agents.enter(agent, this.ownerAgent) - - agent.ctx.sessions.announce(session) - this.assertActive() - this.loopCtx.agents.announce(agent) - this.assertActive() - - driver.markPublished() - agentEvents(this.loopCtx, agent).emit('agent/session-start', source) - this.assertActive() - driver.startDriver() - return { agent, dispose: () => this.dispose() } - } finally { - this.publishing = false - this.publication.resolve() - } - } - - /** Mark the transaction inactive exactly once and wake load/setup races. */ - private deactivate(reason: Error): void { - if (!this.active) return - this.active = false - this.failure = reason - this.deactivation.resolve() - } - - /** Choose the structural cause when an owner/factory effect starts teardown first. */ - private disposalReason(): Error { - if (this.failure !== undefined) return this.failure - if (!this.ownership.isActive()) return new Error('agent loop is not active') - if (this.ownerFiber.uid === null || INACTIVE_STATES.has(this.ownerFiber.state) || this.ownerAgent?.status === 'disposed') { - return new Error(`agent "${this.id}" setup aborted: owner disposed during setup`) - } - return new Error(`agent "${this.id}" lifecycle disposed`) - } - - /** Complete ownership bookkeeping after every resource reached quiescence. */ - private finish(): void { - this.untrackFactory() - this.ownerFollowing = false - void this.ownerDispose() - this.torndown.resolve() - } - - /** - * Deactivate and quiesce this transaction. The promise is memoized because - * Cordis effect disposers are single-shot while handles promise shared - * quiescence to every racing owner. - */ - dispose(reason = new Error(`agent "${this.id}" lifecycle disposed`)): Promise { - this.deactivate(reason) - return (this.cleanupTask ??= (async () => { - if (this.preparing !== undefined) await this.preparing - if (this.lifecycleDispose !== undefined) { - await this.lifecycleDispose() - await this.torndown.promise - return - } - try { - await this.driver?.dispose() - } finally { - try { - await this.scope?.dispose() - } finally { - this.finish() - } - } - })()) - } - - /** Mark the public create/resume continuation settled and detach its creation-only signal. */ - finishWrapper(): void { - if (this.signal !== undefined && this.abortListener !== undefined) { - this.signal.removeEventListener('abort', this.abortListener) - } - this.wrapperCompletion.resolve() - } - - /** Factory shutdown joins both resource teardown and the public wrapper's deactivation continuation. */ - async disposeForFactory(reason: Error): Promise { - await this.dispose(reason) - await this.wrapperCompletion.promise - } +/** Prepared-but-unpublished agent resources sharing one memoized teardown. */ +interface PreparedAgent { + agent: ReactLoopAgent + /** Aborts when the factory unloads, the caller cancels, or teardown begins — ends any setup await. */ + signal: AbortSignal + /** Enter registries, announce, notify session-start, and start the machine. */ + publish(source: SessionStartSource): AgentHandle + /** Reverse teardown: stop the machine, unregister, unwind the scope. Memoized. */ + dispose(): Promise } declare module 'cordis' { @@ -376,6 +163,9 @@ export interface Config { })[] } +/** Agent-loop configuration after defaults and load-time validation. */ +type ResolvedConfig = Config & { maxParallelToolCalls: number } + /** Reject self-contained identity conflicts before any configured agent starts. */ function validateConfiguredAgents(agents: Config['agents']): void { const exactIdentities = new Map() @@ -409,18 +199,21 @@ export class AgentLoop extends Service implements AgentFactory { cwd: z.string(), resumeSessionId: z.string(), })).default([]), - }) as unknown as z + }) as z + /** Validated configuration owned by the agent-loop service. */ + readonly config: ResolvedConfig private readonly ownership: FactoryOwnership - /** Resolved concurrency cap for every driver created by this factory. */ - private readonly maxParallelToolCalls: number /** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */ private readonly runtime: { ctx: Context } - constructor(ctx: Context, public config: Config) { + constructor(ctx: Context, config: Config) { super(ctx, 'agentLoop') - validateConfiguredAgents(config.agents) - this.maxParallelToolCalls = resolveMaxParallelToolCalls(config.maxParallelToolCalls) + this.config = { + ...config, + maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls), + } + validateConfiguredAgents(this.config.agents) this.ownership = new FactoryOwnership(ctx.fiber) this.runtime = { ctx } ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()') @@ -429,7 +222,7 @@ export class AgentLoop extends Service implements AgentFactory { ctx.systemPrompt.variable('model', context => context.agent?.options.model) ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd) - for (const { id, sessionId, cwd, resumeSessionId, ...options } of config.agents) { + for (const { id, sessionId, cwd, resumeSessionId, ...options } of this.config.agents) { const meta = cwd === undefined ? {} : { cwd } if (resumeSessionId === undefined || resumeSessionId === '') { const configuredId = sessionId ?? SessionId(`${id}-session-${randomUUID()}`) @@ -490,19 +283,25 @@ export class AgentLoop extends Service implements AgentFactory { ): Promise { await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId) if (!this.ownership.isActive()) return - const exists = (await persistence.list()).some(header => header.id === sessionId) - if (!this.ownership.isActive()) return - if (exists) { + try { await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions }) return + } catch (error: unknown) { + if (!this.ownership.isActive()) return + // A load is the per-id serialization barrier for eager write-behind and + // lifecycle retirement. Only a genuinely absent artifact falls back to + // first creation; corruption and backend failures stay loud. + const exists = (await persistence.list()).some(header => header.id === sessionId) + if (exists) throw error } this.create(sessionId, agentOptions, meta) } - /** Wait for an already-disposed same-id lifecycle to finish registry teardown. */ + /** Wait for a draining same-id lifecycle to finish registry teardown. */ private async waitForDrainingConfiguredIdentity(ownerCtx: Context, sessionId: SessionId): Promise { - const current = ownerCtx.agents.get(sessionId) - if (current?.status !== 'disposed') return + // Only an id still occupying a registry needs waiting for; a live healthy + // occupant is a collision the create/resume below will surface itself. + if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) return const released = Promise.withResolvers() const checkReleased = (): void => { @@ -521,6 +320,143 @@ export class AgentLoop extends Service implements AgentFactory { } } + /** + * Construct the driver, scope, and one memoized reverse teardown for a new + * agent. The teardown is registered with the factory and the owner fiber + * BEFORE publication, so a mid-setup unload rolls everything back; `signal` + * fuses caller cancellation with lifecycle teardown for setup awaits. + */ + private prepare(ownerCtx: Context, id: SessionId, options: AgentOptions, session: Session, callerSignal?: AbortSignal): PreparedAgent { + ownerCtx.fiber.assertActive() + // Every caller reaches prepare() synchronously from a service method + // whose Cordis dispatch already requires the live factory fiber, or + // re-checks ownership itself after its awaits (resume's load barrier). + /* v8 ignore next -- unreachable backstop, see above */ + if (!this.ownership.isActive()) throw new Error('agent loop is not active') + if (callerSignal?.aborted) { + throw callerSignal.reason instanceof Error + ? callerSignal.reason + : new Error(`agent "${id}" creation aborted`, { cause: callerSignal.reason }) + } + const loopCtx = this.runtime.ctx + + // Deactivation fuses three owners, each with its own reason: the caller's + // cancellation signal, the owner fiber's unload, and factory teardown. + // It is registered BEFORE any resource exists, over mutable slots, so an + // unload arriving while the scope is still minting finds a working + // disposer instead of a leak. + const abort = new AbortController() + const onCallerAbort = (): void => { + abort.abort(callerSignal?.reason instanceof Error + ? callerSignal.reason + : new Error(`agent "${id}" creation aborted`, { cause: callerSignal?.reason })) + } + const onFactoryTeardown = (): void => { abort.abort(this.ownership.signal.reason) } + callerSignal?.addEventListener('abort', onCallerAbort, { once: true }) + this.ownership.signal.addEventListener('abort', onFactoryTeardown, { once: true }) + + let machine: ReactLoopAgent | undefined + let detachSession: (() => void) | undefined + let detachAgent: (() => void) | undefined + let disposing: Promise | undefined + const machineReady = Promise.withResolvers() + // Reverse teardown, memoized so every racing owner awaits one quiescence: + // stop the machine, leave the registries, unwind the scope, release + // bookkeeping. + const dispose = (ownerTriggered = false): Promise => (disposing ??= (async () => { + abort.abort(new Error(`agent "${id}" lifecycle disposed`)) + callerSignal?.removeEventListener('abort', onCallerAbort) + this.ownership.signal.removeEventListener('abort', onFactoryTeardown) + try { + // Disposal IS a disposed-cause cancel followed by quiescence. New work + // sent after this point is the sender's bug — the registries are about + // to drop the agent, so nothing should still hold it. + 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.scope.dispose() + } + } finally { + try { + detachAgent?.() + detachSession?.() + } finally { + untrack() + if (!ownerTriggered) await unfollowOwner() + } + } + })()) + const untrack = this.ownership.track(dispose) + let unfollowOwner: () => Promise | void + try { + unfollowOwner = ownerCtx.effect(() => () => { + // Owner disposal owns the same quiescence boundary. Its teardown skips + // unregistering this already-running owner effect from inside itself. + if (disposing !== undefined) return + abort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`)) + return dispose(true) + }, `agentLoop.lifecycle(${id})`) + /* v8 ignore start -- ctx.effect throws only on an inactive fiber, which assertActive() above already rejected */ + } catch (error: unknown) { + untrack() + callerSignal?.removeEventListener('abort', onCallerAbort) + this.ownership.signal.removeEventListener('abort', onFactoryTeardown) + throw error + } + /* v8 ignore stop */ + + const assertLive = (): void => { + if (!abort.signal.aborted) return + // Every fused abort source carries an Error reason: onCallerAbort and + // raceAbort wrap non-Error caller reasons, and the factory/lifecycle + // owners abort with constructed Errors. + /* v8 ignore next -- unreachable String() arm, see above */ + throw abort.signal.reason instanceof Error ? abort.signal.reason : new Error(String(abort.signal.reason)) + } + try { + const agent = machine = new ReactLoopAgent(loopCtx, id, options, session) + machineReady.resolve() + assertLive() + + return { + agent, + signal: abort.signal, + publish: (source) => { + assertLive() + detachSession = agent.ctx.sessions.enter(session) + detachAgent = loopCtx.agents.enter(agent, ownerCtx.agent) + agent.ctx.sessions.announce(session) + assertLive() + loopCtx.agents.announce(agent) + assertLive() + // A synchronous announce/session-start listener may have started + // teardown; the machine is already live (send() works from the + // session-start seam), so only the liveness recheck is owed. + emitAgentEvent(loopCtx, agent, 'agent/session-start', source) + assertLive() + return { agent, dispose } + }, + dispose, + } + } catch (error: unknown) { + machineReady.resolve() + void dispose() + throw error + } + } + /** * Create an agent and session under one caller-supplied identity, owned by * the accessing fiber. Constructor-driven config calls mint a fresh combined @@ -531,51 +467,39 @@ export class AgentLoop extends Service implements AgentFactory { * @returns the published running agent. */ create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent { - const loopCtx = this.runtime.ctx - const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id) + const session = this.runtime.ctx.sessions.prepare(id, { meta }) + const prepared = this.prepare(this.ctx, id, options, session) try { - const session = loopCtx.sessions.prepare(id, { meta }) - const agent = transaction.prepare(options, session, this.maxParallelToolCalls) - transaction.publish('startup') - return agent + return prepared.publish('startup').agent } catch (error: unknown) { - void transaction.dispose(error instanceof Error ? error : new Error(String(error))) + void prepared.dispose() throw error - } finally { - transaction.finishWrapper() } } /** * Create an owned agent on a caller-supplied session id. - * @param ownerCtx - caller context that structurally owns the transaction. + * @param ownerCtx - caller context that structurally owns the lifecycle. * @param options - identities, session seed/metadata, loop options, setup, and cancellation. * @returns the published handle. */ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise { - const agentOptions = options.agentOptions ?? {} - const transaction = new AgentCreationTransaction( - this.runtime.ctx, - ownerCtx, - this.ownership, - options.sessionId, - options.signal, - ) - try { - const session = this.runtime.ctx.sessions.prepare(options.sessionId, { - ...options.seed === undefined ? {} : { seed: options.seed }, - ...options.meta === undefined ? {} : { meta: options.meta }, - }) - const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls) - await transaction.waitFor(options.setup?.(agent.ctx)) - transaction.assertActive() - return transaction.publish('startup') - } catch (error: unknown) { - await transaction.dispose(error instanceof Error ? error : new Error(String(error))) - throw error - } finally { - transaction.finishWrapper() - } + const session = this.runtime.ctx.sessions.prepare(options.sessionId, { + ...options.seed === undefined ? {} : { seed: options.seed }, + ...options.meta === undefined ? {} : { meta: options.meta }, + }) + const prepared = this.prepare(ownerCtx, options.sessionId, options.agentOptions ?? {}, session, options.signal) + const published = (async () => { + try { + await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId) + return prepared.publish('startup') + } catch (error: unknown) { + await prepared.dispose() + throw error + } + })() + this.ownership.trackWrapper(published) + return published } /** @@ -593,36 +517,48 @@ export class AgentLoop extends Service implements AgentFactory { } /** Resume through an explicit persistence handle used by the deferred config path. */ - private async resumeWith( + private resumeWith( ownerCtx: Context, persistence: SessionPersistence, options: ResumeAgentOptions, ): Promise { - const agentOptions = options.agentOptions ?? {} - const transaction = new AgentCreationTransaction( - this.runtime.ctx, - ownerCtx, - this.ownership, - options.resumeSessionId, - options.signal, - ) - try { - const loaded = await transaction.waitFor(persistence.load(options.resumeSessionId)) - transaction.assertActive() - const session = this.runtime.ctx.sessions.prepare(options.resumeSessionId, { + const id = options.resumeSessionId + const published = (async () => { + // The load may outlive its owner: race it against caller cancellation, + // owner-fiber unload, and factory teardown so a never-settling backend + // cannot pin the identity. + const ownerAbort = new AbortController() + const unfollowOwner = ownerCtx.effect(() => () => { + ownerAbort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`)) + }, `agentLoop.resume-load(${id})`) + const fused = AbortSignal.any([ + ...options.signal === undefined ? [] : [options.signal], + ownerAbort.signal, + this.ownership.signal, + ]) + let loaded: Awaited> + try { + loaded = await raceAbort(persistence.load(id), fused, id) + } finally { + await unfollowOwner() + } + ownerCtx.fiber.assertActive() + if (!this.ownership.isActive()) throw new Error('agent loop is not active') + const session = this.runtime.ctx.sessions.prepare(id, { seed: loaded.events, meta: loaded.meta, }) - const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls) - await transaction.waitFor(options.setup?.(agent.ctx)) - transaction.assertActive() - return transaction.publish('resume') - } catch (error: unknown) { - await transaction.dispose(error instanceof Error ? error : new Error(String(error))) - throw error - } finally { - transaction.finishWrapper() - } + const prepared = this.prepare(ownerCtx, id, options.agentOptions ?? {}, session, options.signal) + try { + await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id) + return prepared.publish('resume') + } catch (error: unknown) { + await prepared.dispose() + throw error + } + })() + this.ownership.trackWrapper(published) + return published } } diff --git a/packages/core/agent-loop/src/invariant.ts b/packages/core/agent-loop/src/invariant.ts index 0b67850015..5d96efc70c 100644 --- a/packages/core/agent-loop/src/invariant.ts +++ b/packages/core/agent-loop/src/invariant.ts @@ -48,7 +48,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant SessionId(`${String(session.id)}-invariant-rebuild`), structuredClone(events.slice(0, boundary)), ) - const expected = [...header.messagePrefix ?? [], ...rebuilt.deriveMessages()] + const expected = rebuilt.deriveMessages() if (JSON.stringify(options.messages) !== JSON.stringify(expected)) { fail(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`) } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts deleted file mode 100644 index 2484cd5793..0000000000 --- a/packages/core/agent-loop/src/loop.ts +++ /dev/null @@ -1,854 +0,0 @@ -/** - * Drives one agent across queued durable turns. Turn failures are contained so - * later work can run; the session log, not this driver, owns conversation state. - * See .agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md. - * @module dsh-agent-loop/loop - */ - -import { randomUUID } from 'node:crypto' -import type { Context } from 'cordis' -import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm' -import { isDeepStrictEqual } from 'node:util' -import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' -import { agentEvents, agentInterruptReasonOf, assembleContextFor, AgentMessageId } from '@deepseek-ai/dsh-agent' -import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' -import { canonicalHeader } from '@deepseek-ai/dsh-session' -import type { PromptMessageData, Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' -import { createTransmissionLog, recordRequestHeader } from './request-log.ts' -import type { TransmissionLog } from './request-log.ts' -import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' -import type {} from '@deepseek-ai/dsh-tools' -import { executeToolCalls } from './tool-calls.ts' -import { agentMessage, type Inbox, type InboxMessage } from './inbox.ts' -import type { TurnCancellation } from './cancellation.ts' - -/** Normalize thrown values while preserving an existing error code. */ -function toError(error: unknown): RequestError { - return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error }) -} - -/** Distinguishes final model-request failures from failures in later step processing. */ -class TerminalModelRequestFailure extends Error { - constructor( - readonly requestError: RequestError, - readonly failure: LlmFailure, - ) { - super(failure.message, { cause: requestError }) - this.name = 'TerminalModelRequestFailure' - } -} - -/** Convert terminal failure finishes into step errors; unknown extensible finishes remain successful. */ -function finishError(finish: FinishReason): { error: RequestError; failure: LlmFailure } | undefined { - switch (finish.kind) { - case 'error': - case 'aborted': { - const facts = finish.failure - const error = new LlmError(facts.message, facts.code, { - ...facts.status === undefined ? {} : { status: facts.status }, - ...facts.providerRetryAfterMs === undefined - ? {} - : { providerRetryAfterMs: facts.providerRetryAfterMs }, - ...facts.requestId === undefined ? {} : { requestId: facts.requestId }, - }) - return { error, failure: error.failure } - } - // stop / tool-calls / max-tokens / plugin-added kinds → not a failure. - default: - return undefined - } -} - -/** - * Build the `{ message, code? }` part of an error payload, omitting the - * `code` key entirely when absent (exactOptionalPropertyTypes-correct). - * The durable message renders the full cause chain: `turn/end` is the single - * durable record of an in-turn failure, so a wrapper message alone (e.g. - * `fetch failed`) would lose the diagnosis the session log exists to keep. - */ -function errorData(err: RequestError): { message: string; code?: string } { - return { message: errorChain(err), ...typeof err.code === 'string' ? { code: err.code } : {} } -} - -/** Preserve cause diagnostics, falling back to adapter-normalized prose for a hostile Error. */ -function durableFailure(err: RequestError, failure: LlmFailure): LlmFailure { - const message = errorChain(err) - return { ...failure, message: message === '' ? failure.message : message } -} - -/** Map a successful max-token finish onto the turn reason; other successful finishes add nothing. */ -function stepFinishReason(finish: FinishReason): TurnEndReason | undefined { - switch (finish.kind) { - case 'max-tokens': - return { kind: 'max-tokens' } - // stop / tool-calls / plugin-added kinds → no turn-end contribution - // beyond the default `completed`. FinishReason is merge-extensible, so a - // default (not assertNever) handles unknown kinds as ordinary success. - default: - return undefined - } -} - -/** Internal control-flow sentinel; durable classification comes only from the turn signal. */ -const TURN_INTERRUPTED = new Error('turn interrupted') - -const PROMPT_PREFIX_REQUEST_DELIMITER: ContentBlock = { - type: 'text', - text: '\n\n## My request:\n', -} - -interface PreparedPromptMessage { - data: PromptMessageData - separateContexts: HookContext[] -} - -/** Bake declared prefix contexts into one reconstructable prompt message. */ -function preparePromptMessage( - content: ContentBlock[], - source: PromptMessageData['source'], - contexts: readonly HookContext[], -): PreparedPromptMessage { - const prefixContexts = contexts.filter(context => context.placement === 'prompt-prefix') - const separateContexts = contexts.filter(context => context.placement !== 'prompt-prefix') - if (prefixContexts.length === 0) return { data: { content, source }, separateContexts } - return { - data: { - content: [ - ...prefixContexts.flatMap(context => context.content), - PROMPT_PREFIX_REQUEST_DELIMITER, - ...content, - ], - source, - envelope: { - displayContent: content, - prefixContexts: prefixContexts.map(context => ({ - source: context.source, - ...context.meta === undefined ? {} : { meta: context.meta }, - })), - }, - }, - separateContexts, - } -} - -/** Stop at an explicit cooperative boundary without stringifying the runtime reason. */ -function interruptionCheckpoint(signal: AbortSignal): void { - if (signal.aborted) throw TURN_INTERRUPTED -} - -/** Classify a supported turn interruption, with lifecycle disposal taking precedence. */ -function interruptionTurnEndReason(handle: LoopHandle, signal: AbortSignal): TurnEndReason | undefined { - if (handle.isDisposed()) return { kind: 'disposed' } - const reason = agentInterruptReasonOf(signal) - if (reason === undefined) return undefined - switch (reason.kind) { - case 'user': - case 'parent': - return { kind: 'aborted' } - /* v8 ignore next 2 -- the private holder requests disposed only after lifecycle state flips, which returns above. */ - case 'disposed': - return { kind: 'disposed' } - /* v8 ignore next 2 -- AgentInterruptReason is closed and the public helper filters unsupported reasons. */ - default: - return assertNever(reason, 'AgentInterruptReason') - } -} - -/** Mutable agent controls supplied to the loop driver. */ -export interface LoopHandle { - /** Native-private agent inbox handed to the driver only at internal startup. */ - readonly inbox: Inbox - /** Maximum parallel-safe calls allowed in one step. */ - readonly maxParallelToolCalls: number - setStatus(status: 'idle' | 'running'): void - /** Install a fresh active-turn owner before the running notification. */ - installTurnCancellation(): TurnCancellation - /** Clear only the exact owner whose turn reached its terminal event boundary. */ - clearTurnCancellation(cancellation: TurnCancellation): void - /** Resolves when the agent is disposed — unblocks the idle wait. */ - disposed: Promise - isDisposed(): boolean - /** Whether queued work was cancelled before an active turn owner existed. */ - isPreRunCancelled(): boolean - /** Clear the cause-less pre-run marker without affecting replacement work. */ - clearPreRunCancel(): void - /** Settle idle waiters before pre-running cancellation publishes idle. */ - settleIdle(): void - /** Run an active tool-call batch, accepting post-tool context into the FIFO drained before settlement. */ - readonly withToolBatch: (run: (acceptContext: (context: HookContext) => void) => Promise) => Promise -} - -/** - * Drive queued messages as independent durable turns until disposal. Plugin - * failures end the current turn without terminating the driver. The caller - * establishes the `ctx.agents.withInitiator()` boundary before entry; package-private - * orchestration recovers that exact Agent and captures its Session locally. - * @param ctx - the plugin context the loop reaches its initiating Agent, - * events (agent/…, session/flush), and services (systemPrompt, llm, tools) - * through. - * @param handle - the bridge to status, turn cancellation ownership, disposal, and pre-run cancellation state. - * @throws when no initiating Agent is active. - */ -export async function runLoop(ctx: Context, handle: LoopHandle): Promise { - const agent = ctx.agents.requireInitiator() - // Per-instance prefix and request-header state; conversation history remains in the session log. - const transmission = createTransmissionLog() - - const { session } = agent - // Fused subject and scope carrier for every agent event below. - const events = agentEvents(ctx, agent) - - while (!handle.isDisposed()) { - // An idle listener can enqueue and cancel replacement work before the next - // wait is installed. Consume that empty marker before parking the driver. - // A quiet (`wakeup:false`) item alone must not un-park the loop, so gate on - // hasWakingQueued, not hasQueued. - if (handle.isPreRunCancelled()) { - handle.clearPreRunCancel() - if (!handle.inbox.hasWakingQueued) { - handle.settleIdle() - handle.setStatus('idle') - continue - } - } - - await handle.inbox.waitForQueued(handle.disposed) - if (handle.isDisposed()) break - - // Cancellation between wake and `running` skips only the cancelled work; - // a replacement prompt still runs before the eventual idle transition. - if (handle.isPreRunCancelled()) { - handle.clearPreRunCancel() - if (!handle.inbox.hasWakingQueued) { - // Settle before publishing idle: the already-idle path has no status - // transition, while an idle listener can register waiters for new work. - handle.settleIdle() - handle.setStatus('idle') - continue - } - } - - let cancellation = handle.installTurnCancellation() - handle.setStatus('running') - if (handle.isDisposed()) { - handle.clearTurnCancellation(cancellation) - break - } - - // A synchronous `running` listener can cancel before `runTurn`; balance the - // status only when no waking replacement prompt was queued by that listener - // (a lone quiet item parks at idle rather than driving a turn). - if (cancellation.signal.aborted) { - handle.clearTurnCancellation(cancellation) - if (!handle.inbox.hasWakingQueued) { - handle.setStatus('idle') - continue - } - cancellation = handle.installTurnCancellation() - } - - // Idle injection can add a turn, so derive the next number from the log. - const turn = lastTurnNumber(session) + 1 - let terminalStopped = false - try { - terminalStopped = await runTurn(ctx, events, handle, turn, transmission, cancellation) - } catch (error: unknown) { - // Pre-turn failure has no durable boundary to close; report it without appending outside a turn. - const err = toError(error) - ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${errorChain(err)}`) - try { - events.emit('agent/error', turn, 0, err) - } catch { /* contained: a throwing agent/error listener must not kill the driver */ } - } finally { - handle.clearTurnCancellation(cancellation) - } - - // Late steering (arriving after runTurn returns, e.g. during the post-turn - // flush) becomes queued input — unless terminal policy stopped the turn, in - // which case it is dropped and must publish a discard so its enqueue is - // still matched (the invariant only catches a NEGATIVE count, not a leak). - const lateSteering = handle.inbox.drainSteering() - if (terminalStopped) { - if (lateSteering.length > 0) { - events.emit('agent/inbox/discard', lateSteering.map(message => agentMessage(message, true))) - } - } else { - for (const message of lateSteering) handle.inbox.enqueue(message) - } - - // Park at idle unless a waking item still wants the model to run; a lone - // quiet (`wakeup:false`) item stays queued but does not keep the loop busy. - if (!handle.inbox.hasWakingQueued) handle.setStatus('idle') - } -} - -async function runTurn( - ctx: Context, events: AgentEventDispatch, handle: LoopHandle, turn: number, transmission: TransmissionLog, - cancellation: TurnCancellation, -): Promise { - const agent = ctx.agents.requireInitiator() - const { session } = agent - const { signal } = cancellation - const drainSteering = (): boolean => { - const messages = handle.inbox.drainSteering() - for (const message of messages) { - events.emit('agent/inbox/dequeue', agentMessage(message, true)) - const prepared = preparePromptMessage(message.content, message.source, message.contexts) - session.append('steering/message', { - turn, ...prepared.data, - ...message.meta === undefined ? {} : { meta: message.meta }, - }, { surfaceOp: 'append' }) - for (const context of prepared.separateContexts) { - session.append('user/message', { - content: context.content, - source: context.source, - ...context.meta === undefined ? {} : { meta: context.meta }, - }, { surfaceOp: 'append' }) - } - } - return messages.length > 0 - } - - // Claim one queued message before opening its turn, but append it only after `turn/start`. - const message = handle.inbox.dequeueQueued() - /* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */ - if (!message) throw new Error('runTurn invariant violated: no queued message at turn start') - events.emit('agent/inbox/dequeue', agentMessage(message, false)) - const trigger: TurnTrigger = { kind: 'message', source: message.source } - - let reason: TurnEndReason = { kind: 'completed' } - let step = 0 - let requestFailureHistory: readonly LlmFailure[] = Object.freeze([]) - let stepOpen = false - let errorReported = false - let terminalStopped = false - - // Close the committed step once; pre-commit validation failure still escapes. - const closeStep = (): void => { - if (!stepOpen) return - session.append('step/end', { turn, step }) - stepOpen = false - } - - // Record the durable turn failure once and contain the live error notification. - const failTurn = (err: RequestError, failure?: LlmFailure): void => { - if (errorReported) return - errorReported = true - reason = failure === undefined - ? { kind: 'error', step, ...errorData(err) } - : { kind: 'error', step, failure: durableFailure(err, failure) } - try { - events.emit('agent/error', turn, step, err) - } catch { - // contained: the error is already captured on `reason`; a throwing - // agent/error listener must not prevent the turn from closing. - } - } - - // Retire cancellation authority before publishing the terminal event. The - // following durability flush is quiescent turn work, but no longer part of - // the cancellable turn lifetime. - const closeTurn = (): void => { - handle.clearTurnCancellation(cancellation) - session.append('turn/end', { turn, reason }) - } - - try { - // --- Turn boundary. Once turn/start is appended, a turn/end is owed no - // matter what throws below; the catch + closeTurn guarantee it. A pre-commit - // veto leaves no turn/start in the log and therefore owes no turn/end. - session.append('turn/start', { turn, trigger }) - interruptionCheckpoint(signal) - // The claimed message runs the `agent/prompt-submit` waterfall before it - // becomes a `user/message` — a hook can rewrite the prompt or block it. - // Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed; - // turn/end is now owed, so a throwing prompt-submit listener (the waterfall - // throws) is caught below and the turn still closes. - const promptDecision = await events.waterfall( - 'agent/prompt-submit', message.content, message.source, signal, - () => Promise.resolve({ - kind: 'allow', - ...message.contexts.length === 0 ? {} : { additionalContexts: message.contexts }, - }), - ) - interruptionCheckpoint(signal) - if (promptDecision.kind === 'block') { - session.append('prompt/blocked', { content: message.content, source: message.source, reason: promptDecision.reason }) - reason = { kind: 'rejected', reason: promptDecision.reason } - } else { - // `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them. - const content = promptDecision.content ?? message.content - const prepared = preparePromptMessage(content, message.source, promptDecision.additionalContexts ?? []) - session.append('user/message', { - ...prepared.data, - ...message.meta === undefined ? {} : { meta: message.meta }, - }, { surfaceOp: 'append' }) - // Separate contexts still enter THIS turn through inject(). Prefix - // contexts are already baked into the user/message with their durable - // display envelope, so appending them again would duplicate model input. - for (const context of prepared.separateContexts) { - agent.inject(context.content, { - source: context.source, - ...context.meta !== undefined ? { meta: context.meta } : {}, - }) - } - } - - while (true) { - // A blocked prompt closes its zero-step turn as rejected. - if (promptDecision.kind === 'block') break - step += 1 - - // Steering from the previous round's continuation listeners joins before - // the request. - drainSteering() - - // Assemble once before pre-step so listener work and the request share one prompt value. - const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent, signal)) - interruptionCheckpoint(signal) - const fullSystemPrompt = renderPrompt(assembly) - - // Compose the request-only prefix once per loop instance before the first - // request boundary. It precedes all derived history and is recorded only - // in the request header, not as session history. - if (transmission.sessionPrefix === undefined) { - const emptyPrefix: Message[] = deepFreeze([]) - const composed = await events.waterfall( - 'agent/session-prefix', emptyPrefix, signal, - () => Promise.resolve(emptyPrefix), - ) - // Never cache an interrupted composition; the next turn recomposes it. - interruptionCheckpoint(signal) - transmission.sessionPrefix = deepFreeze(structuredClone(composed)) - } - - // Await surface mutations outside the step before snapshotting history. - await events.serial('agent/pre-step', turn, step, signal) - interruptionCheckpoint(signal) - - // Snapshot the exact log prefix before step/start: the reconstruction - // boundary. Appends after this synchronous snapshot join the next request. - const boundaryMessages = session.deriveMessages() - - session.append('step/start', { turn, step }) - // Only a committed step/start creates a balancing obligation. A - // pre-commit veto throws before this assignment; post-commit observers - // are contained inside Session.append(). - stepOpen = true - - // A synchronous step/start observer can cancel after the step opened. - interruptionCheckpoint(signal) - - let stepOutcome: - | { hadToolCalls: boolean; finish: FinishReason } - | { requestError: RequestError; failure: LlmFailure } - | { error: RequestError } - try { - stepOutcome = await runStep( - ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, signal) - } catch (error: unknown) { - if (error instanceof TerminalModelRequestFailure) { - stepOutcome = { requestError: error.requestError, failure: error.failure } - } else { - stepOutcome = { error: toError(error) } - } - } - - if ('requestError' in stepOutcome) { - // Recovery observes a balanced failed step and the original provider - // error while the failed step's signal remains the active owner. - closeStep() - const interrupted = interruptionTurnEndReason(handle, signal) - if (interrupted !== undefined) { - reason = interrupted - break - } - - const defaultDecision: RequestErrorDecision = { action: 'fail' } - let recoveryDecision: RequestErrorDecision = defaultDecision - try { - recoveryDecision = await events.waterfall( - 'agent/request-error', turn, step, stepOutcome.requestError, - stepOutcome.failure, requestFailureHistory, signal, - () => Promise.resolve(defaultDecision), - ) - } catch (recoveryError: unknown) { - ctx.logger.warn( - `agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`, - ) - } - // Cancellation and disposal always win over either a recovery decision - // or a recovery-listener failure. - const recoveryInterrupted = interruptionTurnEndReason(handle, signal) - if (recoveryInterrupted !== undefined) { - reason = recoveryInterrupted - break - } - switch (recoveryDecision.action) { - case 'retry': - requestFailureHistory = Object.freeze([...requestFailureHistory, stepOutcome.failure]) - continue - case 'fail': - failTurn(stepOutcome.requestError, stepOutcome.failure) - break - /* v8 ignore next -- closed-union exhaustiveness guard */ - default: - assertNever(recoveryDecision, 'agent request-error decision') - } - break - } - - if ('error' in stepOutcome) { - // Steering that arrived during the failed step stays in the inbox — - // runLoop re-enqueues it as a queued message, so an abort-then-steer - // starts a fresh turn instead of being silently consumed. - closeStep() - const { error } = stepOutcome - const interrupted = interruptionTurnEndReason(handle, signal) - if (interrupted === undefined) failTurn(error) - else reason = interrupted - break - } - - requestFailureHistory = Object.freeze([]) - - // Preserve max-token completion unless a later disposal, abort, or error wins. - const stepReason = stepFinishReason(stepOutcome.finish) - if (stepReason) reason = stepReason - - // Steering that arrived during streaming/tool execution. - const steered = drainSteering() - - try { - await events.serial('agent/post-step', turn, step, signal) - } catch (error: unknown) { - stepOutcome = { error: toError(error) } - } - - if ('error' in stepOutcome) { - closeStep() - const interrupted = interruptionTurnEndReason(handle, signal) - if (interrupted === undefined) failTurn(stepOutcome.error) - else reason = interrupted - break - } - - const postStepInterrupted = interruptionTurnEndReason(handle, signal) - if (postStepInterrupted !== undefined) { - reason = postStepInterrupted - closeStep() - break - } - - closeStep() - - const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' } - let decision: ContinuationDecision - try { - decision = await events.waterfall( - 'agent/turn-continuation', turn, defaultDecision, signal, - () => Promise.resolve(defaultDecision), - ) - interruptionCheckpoint(signal) - } catch (error: unknown) { - const interrupted = interruptionTurnEndReason(handle, signal) - if (interrupted === undefined) failTurn(toError(error)) - else reason = interrupted - break - } - - // A continuation reason becomes next-step steering. Publish the same - // enqueue event a public steer would, so the inbox ledger stays balanced - // (every FIFO entry has a matching enqueue before its dequeue/discard). - if (decision.action === 'continue' && decision.reason) { - // Detach and freeze the listener-owned reason like a public steer, so an - // enqueue listener or the producer cannot mutate the durable/model-visible - // steering message before it drains. - const item: InboxMessage = deepFreeze({ - id: AgentMessageId(randomUUID()), - content: structuredClone(decision.reason.content), - source: structuredClone(decision.reason.source), - contexts: [], wakeup: true, - }) - handle.inbox.steer(item) - events.emit('agent/inbox/enqueue', agentMessage(item, true)) - } - let shouldContinue = decision.action === 'continue' - - // Pending steering overrides an ordinary stop. - if (!shouldContinue && handle.inbox.hasSteering) shouldContinue = true - - // Terminal policy is monotonic and runs after ordinary continuation folding. - let terminalStop = false - try { - const stop = await events.serial('agent/turn-stop', turn, signal) - interruptionCheckpoint(signal) - terminalStop = stop !== undefined - } catch (error: unknown) { - // A broken terminal policy is an ordinary continuation failure: fail - // this turn closed while leaving the driver alive for later turns. - const interrupted = interruptionTurnEndReason(handle, signal) - if (interrupted === undefined) failTurn(toError(error)) - else reason = interrupted - break - } - if (terminalStop) { - terminalStopped = true - // Terminal stop discards steering but preserves ordinary queued prompts. - // Publish a discard for every dropped steering item so the enqueue ⇒ - // dequeue-or-discard ledger stays balanced (the outstanding-count - // invariant and correlation consumers must not be left with dangling ids). - const dropped = handle.inbox.drainSteering() - if (dropped.length > 0) { - events.emit('agent/inbox/discard', dropped.map(item => agentMessage(item, true))) - } - shouldContinue = false - } - - if (!shouldContinue) break - } - - // Normal / inline-error loop exit: close the turn. - closeTurn() - } catch (error: unknown) { - // Close only a turn whose start committed to the log. - const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn) - if (!turnStartLogged) throw error - closeStep() - const interrupted = interruptionTurnEndReason(handle, signal) - if (interrupted === undefined) failTurn(toError(error)) - else reason = interrupted - closeTurn() - } - - // Flush through the store-owned durability checkpoint without killing the driver on failure. - try { - await ctx.sessions.flush(session) - } catch (error: unknown) { - // The turn is closed, so report the failed flush live rather than append outside a turn. - const err = toError(error) - ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${errorChain(err)}`) - try { - events.emit('agent/error', turn, step, err) - } catch { - // contained: a throwing agent/error listener must not escape the loop. - } - } - return terminalStopped -} - -/** - * Run one committed step: transform call config, log the request header, build - * the request from the cached prefix plus the step-boundary snapshot, stream and - * record the response, then execute tools. The caller has already assembled the - * prompt, run `agent/pre-step`, snapshotted history, and opened the step. - */ -async function runStep( - ctx: Context, - events: AgentEventDispatch, - handle: LoopHandle, - turn: number, - step: number, - assembly: PromptAssembly, - system: string, - boundaryMessages: Message[], - transmission: TransmissionLog, - signal: AbortSignal, -): Promise<{ hadToolCalls: boolean; finish: FinishReason }> { - const agent = ctx.agents.requireInitiator() - const { session, options } = agent - - // Seed the first request from agent options and later requests from the logged header; - // detach and freeze so listeners must return an attributable replacement. - const loggedConfig = session.requestHeader()?.config - const initialProvider = options.provider ?? '' - const initialModel = options.model ?? '' - const initialConfig: LlmCallConfig = { - provider: initialProvider, - model: initialModel, - ...loggedConfig?.provider === initialProvider - && loggedConfig.model === initialModel - && loggedConfig.reasoningEffort !== undefined - ? { reasoningEffort: loggedConfig.reasoningEffort } - : {}, - } - const seedConfig: LlmCallConfig = deepFreeze(structuredClone( - transmission.loggedHeader - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log - ? session.requestHeader()!.config - : initialConfig, - )) - - // Listener replacements are recorded in the request header before dispatch. - const proposedConfig = await events.waterfall( - 'agent/request', turn, step, seedConfig, signal, () => Promise.resolve(seedConfig), - ) - interruptionCheckpoint(signal) - if (!proposedConfig.provider || !proposedConfig.model) { - throw new Error(`agent "${agent.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`) - } - let config: LlmCallConfig - let preparedCall: PreparedLlmCall | undefined - try { - preparedCall = await ctx.llm.prepareCall(proposedConfig, signal) - config = preparedCall.config - } catch (error: unknown) { - // A waterfall listener may own and short-circuit a route with no adapter. - // Terminal dispatch still raises NO_ADAPTER when no listener handles it. - if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error - config = proposedConfig - } - interruptionCheckpoint(signal) - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call - const sessionPrefix = transmission.sessionPrefix! - - // Record the canonical header, including the otherwise-unlogged prefix, before dispatch. - const header = canonicalHeader({ - config, - ...system ? { system } : {}, - ...assembly.tools.length > 0 ? { tools: assembly.tools } : {}, - ...sessionPrefix.length > 0 ? { messagePrefix: sessionPrefix } : {}, - }) - recordRequestHeader(session, transmission, header) - - // Freeze the logged header plus boundary snapshot; the prefix precedes derived history. - const request: GenerateOptions = markAgentLoopRequest(deepFreeze({ - provider: header.config.provider, - model: header.config.model, - ...header.config.reasoningEffort !== undefined - ? { reasoningEffort: header.config.reasoningEffort } - : {}, - messages: [...header.messagePrefix ?? [], ...boundaryMessages], - ...header.system !== undefined ? { system: header.system } : {}, - ...header.tools !== undefined ? { tools: header.tools } : {}, - ...header.config.temperature !== undefined ? { temperature: header.config.temperature } : {}, - ...header.config.maxTokens !== undefined ? { maxTokens: header.config.maxTokens } : {}, - ...header.config.stop !== undefined ? { stop: header.config.stop } : {}, - sessionId: session.id, - signal, - })) - - // --- Model call (streaming-first; raw chunks are the replay record) --- - const assembler = new BlockAssembler() - const chunkSeqs: number[] = [] - const stream = preparedCall?.stream(request) ?? ctx.llm.stream(request) - try { - for await (const chunk of stream) { - interruptionCheckpoint(signal) - const chunkEvent = session.append('assistant/chunk', { turn, step, chunk }) - chunkSeqs.push(chunkEvent.seq) - assembler.push(chunk) - } - } catch (error: unknown) { - const failure = llmFailureOf(stream, error) - if (failure !== undefined && error instanceof Error) throw new TerminalModelRequestFailure(error, failure) - throw error - } - interruptionCheckpoint(signal) - - // Normalize failure finish chunks into the same path as thrown stream errors. - const stepError = finishError(assembler.finish) - if (stepError) throw new TerminalModelRequestFailure(stepError.error, stepError.failure) - - const recordAssistantMessage = ( - assembledContent: ContentBlock[], - message: Message, - preserveReplayState = true, - ): void => { - session.append( - 'assistant/message', - { - turn, - step, - content: message.content, - provenance: assistantProvenance( - header.config, - assembler.replayState, - preserveReplayState && isDeepStrictEqual(message.content, assembledContent), - ), - ...assembler.usage === undefined ? {} : { usage: assembler.usage }, - }, - { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, - ) - } - - // A rejected result still records the successful provider call without retaining rejected output. - const processStepResult = async (assembledContent: ContentBlock[], message: Message): Promise => { - try { - const processed = await events.waterfall( - 'agent/step-result', turn, step, message, signal, () => Promise.resolve(message), - ) - interruptionCheckpoint(signal) - return processed - } catch (error: unknown) { - recordAssistantMessage(assembledContent, { ...message, content: [] }, false) - throw error - } - } - - if (assembler.finish.kind === 'max-tokens') { - const assembled = assembler.message() - const assembledContent = structuredClone(assembled.content) - let message: Message = withoutToolCalls(assembled) - message = withoutToolCalls(await processStepResult(assembledContent, message)) - // Preserve usage even when max-token truncation produced no content. - recordAssistantMessage(assembledContent, message) - return { hadToolCalls: false, finish: assembler.finish } - } - - // Record the post-waterfall message that tool dispatch uses. - const assembled = assembler.message() - const assembledContent = structuredClone(assembled.content) - let message: Message = assembled - message = await processStepResult(assembledContent, message) - - // Every successful call records its completion anchor, including explicit - // empty chunk provenance for a contentless, usage-less provider response. - recordAssistantMessage(assembledContent, message) - - // Dispatch may overlap; policy, durable results, and result context stay model-ordered. - const toolCalls = message.content.filter(block => block.type === 'tool-call') - if (toolCalls.length === 0) return { hadToolCalls: false, finish: assembler.finish } - return handle.withToolBatch(async (acceptContext) => { - await executeToolCalls( - ctx, turn, step, toolCalls, signal, handle.maxParallelToolCalls, acceptContext, - ) - return { hadToolCalls: true, finish: assembler.finish } - }) -} - -/** Build durable assistant provenance, dropping replay state after any content rewrite. */ -function assistantProvenance(config: LlmCallConfig, replayState: unknown, contentUnchanged: boolean): NonNullable { - return { - provider: config.provider, - model: config.model, - ...contentUnchanged && replayState !== undefined ? { replayState } : {}, - } -} - -function withoutToolCalls(message: Message): Message { - return { ...message, content: message.content.filter(block => block.type !== 'tool-call') } -} - -/** - * The last turn number in a (possibly seeded) session log, or 0. - * @param session - the session whose log is scanned for the latest `turn/start`. - * @returns the latest `turn/start`'s turn number, or 0 when the log has none (the next turn is this plus one). - */ -export function lastTurnNumber(session: Session): number { - const lastStart = session.events.findLast(event => event.type === 'turn/start') - return lastStart?.data.turn ?? 0 -} - -/** - * Whether the session log has an unmatched `turn/start`. Agent status is not - * sufficient during pre-start and post-end windows. - * @param session - the session whose log is inspected. - * @returns true when the log's last turn boundary is a `turn/start` with no matching `turn/end` yet. - */ -export function isTurnOpen(session: Session): boolean { - const last = session.events.findLast(e => e.type === 'turn/start' || e.type === 'turn/end') - return last?.type === 'turn/start' -} diff --git a/packages/core/agent-loop/src/request-log.ts b/packages/core/agent-loop/src/request-log.ts deleted file mode 100644 index ea6141fea9..0000000000 --- a/packages/core/agent-loop/src/request-log.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Per-loop-instance request-header bookkeeping for reconstructability. The - * comparison baseline is folded from the session log; a fresh instance anchors - * it with an initial/resume snapshot and later logs full changed snapshots. - * - * @module dsh-agent-loop/request-log - */ - -import { headerEquals } from '@deepseek-ai/dsh-session' -import type { EpochHeader, Session } from '@deepseek-ai/dsh-session' -import type { Message } from '@deepseek-ai/dsh-llm' - -/** Per-loop-instance bookkeeping: whether THIS instance has logged a header yet. */ -export interface TransmissionLog { - /** True once this loop instance appended its anchoring `request/header` snapshot. */ - loggedHeader: boolean - /** - * The instance's composed session prefix (the `agent/session-prefix` - * waterfall's deep-frozen product), cached on the instance's first - * request-building step and reused verbatim for every request it sends — - * the structural guarantee that the prefix never changes mid-session. - * `undefined` until composed. - */ - sessionPrefix?: Message[] -} - -/** - * Fresh bookkeeping for a newly-started loop instance. - * @returns state with `loggedHeader` false, so the instance's first request appends an anchoring snapshot. - */ -export function createTransmissionLog(): TransmissionLog { - return { loggedHeader: false } -} - -/** - * Append the full header snapshot owed by this request: initial/resume for the - * instance's first request, nothing when unchanged, or change otherwise. - * - * @param session - the session whose log explains the request. - * @param state - this loop instance's bookkeeping (mutated on first log). - * @param header - the canonical header the request will ACTUALLY use - * (post-`agent/request`). - */ -export function recordRequestHeader(session: Session, state: TransmissionLog, header: EpochHeader): void { - if (!state.loggedHeader) { - session.append('request/header', { header, reason: session.requestHeader() === undefined ? 'initial' : 'resume' }) - state.loggedHeader = true - return - } - // This instance logged a snapshot, so the fold is necessarily defined. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const baseline = session.requestHeader()! - if (headerEquals(baseline, header)) return - session.append('request/header', { header, reason: 'change' }) -} diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index b6e83d7b23..0ae7b5c3a1 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -11,8 +11,7 @@ import type { Context } from 'cordis' import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm' -import type { HookContext } from '@deepseek-ai/dsh-agent' -import type { Session } from '@deepseek-ai/dsh-session' +import type { Session, UserMessageData } from '@deepseek-ai/dsh-session' import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools' /** One tool call after argument parsing, ready to schedule. */ @@ -32,13 +31,16 @@ interface Slot { interface GroupOutcome { consumed: number aborted: boolean + /** Whether any committed result carried {@link ToolExecutionResult.concludesTurn}. */ + concluded: boolean } /** * Schedule one assistant step's tool calls by their live concurrency mode. * Started calls receive ordered results. Abort drains them, records synthetic * results for unstarted calls, and returns with the signal still aborted after - * accepting started-call context into the batch FIFO owned by the caller. + * accepting started-call context through the caller-supplied acceptor (the + * machine stages it on its outbox for the next step boundary). * The committed step's AgentLoop driver boundary supplies the initiating Agent * that becomes each explicit {@link ToolExecutionInput.agent}. * @@ -47,8 +49,7 @@ interface GroupOutcome { * @param step - current step number. * @param toolCalls - assistant calls in model order. * @param signal - abort signal shared by the step. - * @param maxParallel - validated in-flight cap. - * @param acceptContext - accepts committed result context into the active batch. + * @param acceptContext - accepts committed result context for the next step boundary. */ export async function executeToolCalls( ctx: Context, @@ -56,9 +57,8 @@ export async function executeToolCalls( step: number, toolCalls: ToolCallBlock[], signal: AbortSignal, - maxParallel: number, - acceptContext: (context: HookContext) => void, -): Promise { + acceptContext: (context: UserMessageData) => void, +): Promise<{ concluded: boolean }> { const agent = ctx.agents.requireInitiator() const { session } = agent @@ -75,6 +75,7 @@ export async function executeToolCalls( })) let next = 0 + let concluded = false while (next < planned.length) { // Commit before classifying again so registry changes affect unstarted calls. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition @@ -82,14 +83,16 @@ export async function executeToolCalls( const mode = ctx.tools.executionMode(first.exec).kind const group = mode === 'parallel' ? planned.slice(next) : [first] const outcome = await runGroup( - ctx, turn, step, group, mode, signal, maxParallel, acceptContext, + ctx, turn, step, group, mode, signal, acceptContext, ) next += outcome.consumed + concluded ||= outcome.concluded if (outcome.aborted) { for (const call of planned.slice(next)) appendSkippedToolCall(session, turn, step, call.block) - return + return { concluded } } } + return { concluded } } /** Parse model arguments, preserving invalid JSON as text and mapping empty input to `{}`. */ @@ -116,10 +119,10 @@ async function runGroup( group: PlannedCall[], mode: ToolExecutionMode['kind'], signal: AbortSignal, - maxParallel: number, - acceptContext: (context: HookContext) => void, + acceptContext: (context: UserMessageData) => void, ): Promise { const { session } = ctx.agents.requireInitiator() + const { maxParallelToolCalls } = ctx.agentLoop.config const slots: (Slot | undefined)[] = group.map(() => undefined) // Started slots retain their tool/call seq for result provenance. const callSeqs: number[] = group.map(() => -1) @@ -127,6 +130,7 @@ async function runGroup( let committed = 0 let started = 0 let aborted: boolean = signal.aborted + let concluded = false // `committed` advances only across contiguous model-order slots. const commitReady = async (): Promise => { @@ -140,6 +144,7 @@ async function runGroup( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!) for (const context of result.additionalContexts ?? []) acceptContext(context) + concluded ||= result.concludesTurn === true committed++ } } @@ -174,7 +179,7 @@ async function runGroup( } const fillPool = async (): Promise => { - while (!aborted && nextToStart < group.length && inFlight.size < maxParallel) { + while (!aborted && nextToStart < group.length && inFlight.size < maxParallelToolCalls) { // Re-read later modes after ordered commits so registry changes can create a barrier. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition const nextCall = group[nextToStart]! @@ -206,11 +211,11 @@ async function runGroup( // Started calls and accepted context settle first; every remaining model // call then receives an ordered synthetic result before the turn aborts. for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block) - return { consumed: group.length, aborted: true } + return { consumed: group.length, aborted: true, concluded } } /* v8 ignore next -- unreachable: a non-aborted group commits every started call */ if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls') - return { consumed: started, aborted: false } + return { consumed: started, aborted: false, concluded } } /** Append the durable call/result pair for a model call skipped after cancellation. */ diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index a2359562a8..e99971aa95 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function send(agent: Agent, text: string): void { - agent.followup([{ type: 'text', text }]) + agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } }) } /** Adapter that holds both drivers at the same awaited continuation. */ @@ -153,6 +153,7 @@ describe('AgentLoop initiator scope', () => { const { ctx } = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('signal-owner'), { provider: 'mock', model: 'mock' }) let signals: AbortSignal[] = [] + let admissionSignals: AbortSignal[] = [] const capture = (signal: AbortSignal | undefined): void => { if (signal === undefined) throw new Error('turn seam omitted its explicit signal') expect(ctx.agents.requireInitiator()).toBe(agent) @@ -164,29 +165,20 @@ describe('AgentLoop initiator scope', () => { return next() }) ctx.on('agent/prompt-submit', async (subject, _content, _source, signal, next) => { + if (subject === agent) { + expect(ctx.agents.requireInitiator()).toBe(agent) + admissionSignals.push(signal) + } + return next() + }) + ctx.on('agent/step', (subject, _turn, _step, signal) => { + if (subject === agent) capture(signal) + }) + ctx.on('agent/request', async (subject, _turn, _step, signal, next) => { if (subject === agent) capture(signal) return next() }) - ctx.on('agent/session-prefix', async (subject, _prefix, signal, next) => { - if (subject === agent) capture(signal) - return next() - }) - ctx.on('agent/pre-step', (subject, _turn, _step, signal) => { - if (subject === agent) capture(signal) - }) - ctx.on('agent/request', async (subject, _turn, _step, _config, signal, next) => { - if (subject === agent) capture(signal) - return next() - }) - ctx.on('agent/step-result', async (subject, _turn, _step, _message, signal, next) => { - if (subject === agent) capture(signal) - return next() - }) - ctx.on('agent/turn-continuation', async (subject, _turn, _decision, signal, next) => { - if (subject === agent) capture(signal) - return next() - }) - ctx.on('agent/turn-stop', (subject, _turn, signal) => { + ctx.on('agent/turn-stopping', (subject, _turn, signal) => { if (subject === agent) capture(signal) }) ctx.tools.register(defineContentToolFixture({ @@ -205,14 +197,19 @@ describe('AgentLoop initiator scope', () => { const firstSignal = signals[0] expect(firstSignal).toBeDefined() expect(new Set([...signals, ...adapter.requests.slice(0, 2).map(request => request.signal!)])).toEqual(new Set([firstSignal])) + expect(admissionSignals).toHaveLength(1) + expect(admissionSignals[0]).not.toBe(firstSignal) signals = [] + admissionSignals = [] const secondIdle = waitForIdle(ctx, agent) send(agent, 'second') await secondIdle const secondSignal = signals[0] expect(secondSignal).toBeDefined() expect(new Set([...signals, adapter.requests[2]!.signal!])).toEqual(new Set([secondSignal])) + expect(admissionSignals).toHaveLength(1) + expect(admissionSignals[0]).not.toBe(secondSignal) expect(secondSignal).not.toBe(firstSignal) expect(ctx.agents.currentInitiator()).toBeUndefined() await ctx.fiber.dispose() @@ -343,7 +340,6 @@ describe('AgentLoop initiator scope', () => { expect(captured).toBe(handle.agent) await handle.dispose() - expect(captured?.status).toBe('disposed') expect(ctx.agents.currentInitiator()).toBeUndefined() await ctx.fiber.dispose() }) @@ -365,7 +361,6 @@ describe('AgentLoop initiator scope', () => { await loopFiber.await() expect(adapter.firstAgentDuringAbort?.id).toBe(oldAgent.id) expect(adapter.firstAgentDuringAbort?.session).toBe(oldAgent.session) - expect(oldAgent.status).toBe('disposed') expect(() => oldService.currentInitiator()).toThrow('agent initiator scope is disposed') expect(ctx.agents).not.toBe(oldService) adapter.agents = ctx.agents @@ -406,7 +401,6 @@ describe('AgentLoop initiator scope', () => { await ctx.fiber.dispose() expect(adapter.firstAgentDuringAbort?.id).toBe(agent.id) expect(adapter.firstAgentDuringAbort?.session).toBe(agent.session) - expect(agent.status).toBe('disposed') expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed') }) }) diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 4663a63e16..e0aa4467dc 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -1,19 +1,14 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' -import { bindReactLoopAgentContext, prepareReactLoopAgent, type ReactLoopAgent } from '../src/agent.ts' import { MockAdapter, textResponse } from './mock-adapter.ts' -function driverDone(agent: Agent): Promise { - return (agent as Agent & { done: Promise }).done -} - -async function harness(adapter: MockAdapter) { +async function harness(adapter: MockAdapter): Promise { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) @@ -25,403 +20,85 @@ async function harness(adapter: MockAdapter) { return ctx } -function waitForIdle(ctx: Context, agent: Agent): Promise { - return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { - if (subject === agent && status === 'idle') { - dispose() - resolve() - } - }) - }) -} - -function waitForStatus(ctx: Context, agent: Agent, expected: Agent['status']): Promise { - return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { - if (subject === agent && status === expected) { - dispose() - resolve() - } - }) - }) -} - -function send(agent: Agent, text: string) { - agent.followup([{ type: 'text', text }]) +function send(agent: Agent, text: string): void { + agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } }) } describe('Agent', () => { - it('rejects access before context binding and a second driver for one session', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.create(SessionId('exclusive-driver')) - const prepared = prepareReactLoopAgent( - ctx, SessionId('first-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, - ) - - expect(() => prepared.agent.ctx).toThrow('context is not bound') - expect(() => prepareReactLoopAgent( - ctx, SessionId('second-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, - )) - .toThrow('already has a concrete agent driver') - - await prepared.dispose() - await ctx.fiber.dispose() - }) - - it('borrows caller options and binds its scoped context exactly once', async () => { - const ctx = await harness(new MockAdapter([textResponse('unused')])) - const options = { provider: 'mock', model: 'mock' } - const agent = ctx.agentLoop.create(SessionId('owned-bindings'), options) - - expect(agent.options).toBe(options) - expect(agent.id).toBe('owned-bindings') - expect(agent.session.id).toBe(agent.id) - expect(() => { bindReactLoopAgentContext(agent as ReactLoopAgent, new Context()) }).toThrow(/context is already bound/) - - await ctx.fiber.dispose() - }) - - it('send exposes the fully resolved delivery path without applying helper defaults', async () => { - const adapter = new MockAdapter([textResponse('accepted')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - const enqueued = Promise.withResolvers<{ id: string; source: unknown; wakeup: boolean }>() - ctx.on('agent/inbox/enqueue', (subject, message) => { - if (subject === agent) enqueued.resolve(message) - }) - - const id = agent.send({ - content: [{ type: 'text', text: 'advanced input' }], - source: { kind: 'plugin', plugin: 'advanced-caller' }, - contexts: [], - meta: { caller: 'advanced' }, - target: 'next-turn', - wakeup: true, - }) - await waitForIdle(ctx, agent) - - expect(await enqueued.promise).toMatchObject({ - id, - source: { kind: 'plugin', plugin: 'advanced-caller' }, - wakeup: true, - }) - expect(agent.session.events.find(event => event.type === 'user/message')) - .toMatchObject({ - data: { - source: { kind: 'plugin', plugin: 'advanced-caller' }, - meta: { caller: 'advanced' }, - }, - }) - await ctx.fiber.dispose() - }) - - it('followup() throws after disposal', async () => { - const adapter = new MockAdapter(['hang']) - const ctx = await harness(adapter) - let agent!: Agent - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) - }, { inject: ['agentLoop'] })) - send(agent, 'go') - await new Promise(r => setTimeout(r, 30)) - await fiber.dispose() - await driverDone(agent) - - expect(() => { agent.followup([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') - }) - - it('disposal discards still-pending inbox items so every id gets a terminal event', async () => { - const adapter = new MockAdapter(['hang']) - const ctx = await harness(adapter) - let agent!: Agent - const discarded: string[] = [] - ctx.on('agent/inbox/discard', (subject, messages) => { - if (subject === agent) discarded.push(...messages.map(m => m.id)) - }) - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) - }, { inject: ['agentLoop'] })) - - // A quiet (non-waking) item stays parked in the inbox; disposal must drop it - // WITH a discard so its enqueued id is not left dangling forever. - const id = agent.queue([{ type: 'text', text: 'never runs' }]) - await fiber.dispose() - await driverDone(agent) - - expect(discarded).toEqual([id]) - }) - - it('steer() throws after disposal', async () => { - const adapter = new MockAdapter(['hang']) - const ctx = await harness(adapter) - let agent!: Agent - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) - }, { inject: ['agentLoop'] })) - send(agent, 'go') - await new Promise(r => setTimeout(r, 30)) - await fiber.dispose() - await driverDone(agent) - - expect(() => { agent.steer([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') - }) - - it('inject() throws after disposal', async () => { - const adapter = new MockAdapter(['hang']) - const ctx = await harness(adapter) - let agent!: Agent - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) - }, { inject: ['agentLoop'] })) - send(agent, 'go') - await new Promise(r => setTimeout(r, 30)) - await fiber.dispose() - await driverDone(agent) - - expect(() => { agent.inject([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') - }) - - it('inject() decides enclosure from the LOG (open turn), not agent status', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - // Status is idle while the log has an open turn; enclosure must follow the log. - agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - agent.inject([{ type: 'text', text: 'mid' }], { source: { kind: 'plugin', plugin: 'p' } }) - expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1) - expect(agent.session.events.at(-1)!.type).toBe('user/message') - - agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - agent.inject([{ type: 'text', text: 'after' }], { source: { kind: 'plugin', plugin: 'p' } }) - const starts = agent.session.events.filter(e => e.type === 'turn/start') - expect(starts).toHaveLength(2) - const last = starts[1]! - expect(last.type === 'turn/start' && last.data.trigger.kind).toBe('injection') - expect(agent.session.events.at(-1)!.type).toBe('turn/end') // turn-enclosed - }) - - it('inject() defaults its source to an empty plugin, never user', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - agent.inject([{ type: 'text', text: 'no explicit source' }]) - const injected = agent.session.events.at(-1)! - expect(injected.type === 'user/message' && injected.data.source).toEqual({ kind: 'plugin', plugin: '' }) - }) - - it('idle inject() contains a failing flush (logs, does not throw into the caller)', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - // A persistence-like listener whose flush rejects. - ctx.on('session/flush', () => { throw new Error('disk gone') }) - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - // inject() is synchronous and fires a fire-and-forget flush; a rejecting - // flush must be contained (logged), never thrown into the caller. - expect(() => { agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } }) }).not.toThrow() - await new Promise(r => setTimeout(r, 20)) // let the contained flush settle - expect(warn).toHaveBeenCalledWith(expect.stringContaining('flush after idle injection failed')) - warn.mockRestore() - }) - - it('idle inject() validates its payload BEFORE opening a turn, so invalid input appends nothing', async () => { + it('idle inject() appends context without opening a turn or requesting a flush', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let flushes = 0 ctx.on('session/flush', () => { flushes += 1 }) - // Non-serializable injected content is rejected by the up-front snapshot - // BEFORE any append (the unified send contract: invalid input throws before - // mutating the log). No one-shot turn opens and no durability checkpoint fires. + agent.inject({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'p' } }) + + expect(agent.session.events.map(event => event.type)).toEqual(['user/message']) + expect(agent.status).toBe('idle') + expect(adapter.requests).toHaveLength(0) + await agent.whenIdle() + expect(flushes).toBe(0) + }) + + it('inject() preserves an explicitly empty plugin source', async () => { + const ctx = await harness(new MockAdapter([textResponse('ok')])) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + agent.inject({ content: [{ type: 'text', text: 'empty plugin source' }], source: { kind: 'plugin', plugin: '' } }) + + const injected = agent.session.events.at(-1) + expect(injected?.type === 'user/message' && injected.data.source) + .toEqual({ kind: 'plugin', plugin: '' }) + }) + + it('idle inject() rejects invalid input before append', async () => { + const ctx = await harness(new MockAdapter([textResponse('ok')])) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + expect(() => { - agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } }) - }).toThrow(/losslessly JSON-serializable/) - expect(agent.session.events).toHaveLength(0) - await new Promise(r => setTimeout(r, 10)) // give any (erroneous) flush a chance - expect(flushes).toBe(0) // nothing was appended, so no checkpoint - }) - - it('idle inject() re-entered from a session/event listener is rejected pre-commit and opens no turn', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - let flushes = 0 - ctx.on('session/flush', () => { flushes += 1 }) - // Injecting from inside a session/event listener re-enters Session.append, - // which rejects pre-commit — so turn/start never commits. The finally sees - // no open turn (closes nothing) and no recorded turn (no checkpoint), and - // the reentrant throw is contained by Session's post-commit dispatch. - // Fire on turn/end: at that instant the outer one-shot turn is closed (no - // turn open), so the reentrant inject takes the idle one-shot-turn path and - // its turn/start append re-enters Session and is rejected pre-commit. - let reentered = false - ctx.on('session/event', (_s, event) => { - if (!reentered && event.type === 'turn/end') { - reentered = true - agent.inject([{ type: 'text', text: 'reentrant' }], { source: { kind: 'plugin', plugin: 'p' } }) - } - }) - - agent.inject([{ type: 'text', text: 'outer' }], { source: { kind: 'plugin', plugin: 'p' } }) - // The outer injection's own one-shot turn is balanced; the reentrant one - // opened no turn (its turn/start was rejected pre-commit). - const turnStarts = agent.session.events.filter(e => e.type === 'turn/start') - expect(turnStarts).toHaveLength(1) - const injected = agent.session.events.filter(e => e.type === 'user/message') - expect(injected).toHaveLength(1) // the reentrant user/message never committed - await new Promise(r => setTimeout(r, 10)) - expect(flushes).toBe(1) // only the outer accepted turn checkpointed - }) - - it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - let flushes = 0 - ctx.on('session/flush', () => { flushes += 1 }) - // Session contains a throwing post-commit turn/end observer. The accepted - // boundary still triggers the idle injection's durability checkpoint. - let threw = false - ctx.on('session/event', (_s, event) => { - if (!threw && event.type === 'turn/end') { threw = true; throw new Error('boom turn/end') } - }) - - expect(() => { agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } }) }).not.toThrow() - const types = agent.session.events.map(e => e.type) - expect(types).toEqual(['turn/start', 'user/message', 'turn/end']) // balanced - await new Promise(r => setTimeout(r, 10)) - expect(flushes).toBe(1) // checkpoint fired despite the throwing turn/end listener - }) - - it('idle inject() reports a failing flush via agent/error (step 0) AND the logger', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - // A non-Error rejection exercises the String() normalization branch. - ctx.on('session/flush', () => { throw 'disk gone' }) - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - const errors: { turn: number; step: number; message: string }[] = [] - ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message })) - - agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } }) - await new Promise(r => setTimeout(r, 20)) // let the contained flush settle - - // Reported via agent/error (step 0 — the idle-injection convention) so - // plugins monitoring agent/error see idle-injection persistence failures, - // mirroring the loop's post-turn/end flush path. A non-Error throw is - // normalized to an Error. - expect(errors).toEqual([{ turn: 1, step: 0, message: 'disk gone' }]) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('flush after idle injection failed')) - warn.mockRestore() - }) - - it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - // A non-serializable source is rejected by the up-front snapshot BEFORE any - // append, so NO turn opens and the log stays empty. - expect(() => { - agent.inject([{ type: 'text', text: 'x' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never }) - }).toThrow(/losslessly JSON-serializable/) + agent.inject({ content: [{ type: 'text', text: 'x', bad: 1n } as never], source: { kind: 'plugin', plugin: 'p' } }) + }).toThrow(/non-JSON-serializable/) expect(agent.session.events).toHaveLength(0) }) - it('steer() when idle falls through to send() and starts a turn', async () => { + it('steer() while idle becomes a woken prompt turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } }) - await waitForIdle(ctx, agent) + agent.steer({ content: [{ type: 'text', text: 'steer idle' }], source: { kind: 'plugin', plugin: 'test' } }) + await agent.whenIdle() - expect(agent.session.events.some(e => e.type === 'user/message')).toBe(true) + expect(agent.session.events.some(event => event.type === 'user/message')).toBe(true) expect(adapter.requests).toHaveLength(1) }) - it('disposer is idempotent (double-stop)', async () => { - // Create a bare Agent and start it through the package-internal - // test seam. Then call its disposer twice — the second call hits the - // early-return branch. - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - const session = ctx.sessions.create(SessionId('test')) - const prepared = prepareReactLoopAgent( - ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, - ) - const { agent } = prepared - - // Start the loop to get the disposer; the agent waits for messages - // (idle, never-resolving cancel), so it will stay idle. - prepared.markPublished() - const dispose = prepared.startDriver() - - const firstDisposal = dispose() - expect(agent.status).toBe('disposed') - await firstDisposal - - await expect(dispose()).resolves.toBeUndefined() - expect(agent.status).toBe('disposed') - }) - - it('a pre-start disposal makes a later driver-start attempt inert', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.create(SessionId('pre-start-dispose')) - const prepared = prepareReactLoopAgent( - ctx, SessionId('pre-start-dispose'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, - ) - - await prepared.dispose() - expect(prepared.agent.status).toBe('disposed') - const dispose = prepared.startDriver() - await dispose() - await expect(prepared.agent.done).resolves.toBeUndefined() - expect(prepared.agent.session.events).toEqual([]) - await ctx.fiber.dispose() - }) - - it('setting the same status does not emit agent/status again', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) + it('emits one running and idle transition for one completed turn', async () => { + const ctx = await harness(new MockAdapter([textResponse('ok')])) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - const statuses: string[] = [] ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) }) send(agent, 'hi') - await waitForIdle(ctx, agent) + await agent.whenIdle() - // After the turn, agent is idle. Send again to trigger another attempt - // to go idle — but it's already idle, so no emission. - const idleTransitionCount = statuses.filter(s => s === 'idle').length - expect(idleTransitionCount).toBe(1) // only the final transition from running + expect(statuses).toEqual(['running', 'idle']) }) - it('whenIdle() resolves immediately when the agent is not running', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) + it('whenIdle() resolves immediately without active work', async () => { + const ctx = await harness(new MockAdapter([textResponse('ok')])) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - // Fresh agent is idle — whenIdle() takes the not-running fast path and - // resolves without subscribing. await must not hang. await agent.whenIdle() - expect(agent.status).not.toBe('running') + + expect(agent.status).toBe('idle') }) - it('whenIdle() waits for queued work that has not flipped status yet', async () => { - const adapter = new MockAdapter(['hang']) - const ctx = await harness(adapter) + it('whenIdle() waits for active work until explicit cancellation', async () => { + const ctx = await harness(new MockAdapter(['hang'])) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'queued') @@ -430,150 +107,25 @@ describe('Agent', () => { await Promise.resolve() expect(settled).toBe(false) - await waitForStatus(ctx, agent, 'running') agent.cancel({ kind: 'user' }) await idle - expect(settled).toBe(true) expect(agent.status).toBe('idle') }) - it('whenIdle() awaits the running→idle transition, ignoring other subjects/running events', async () => { - const adapter = new MockAdapter([textResponse('ok'), textResponse('ok')]) - const ctx = await harness(adapter) + it('contains a throwing status listener on both transitions', async () => { + const ctx = await harness(new MockAdapter([textResponse('ok')])) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' }) - - // Drive `agent` into `running`, then await whenIdle() — it subscribes to - // agent/status and resolves on the first transition out of running. - const running = new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { - if (subject === agent && status === 'running') { dispose(); resolve() } - }) + ctx.on('agent/status', (_subject, status) => { + throw new Error(`bad ${status} listener`) }) + send(agent, 'go') - await running - expect(agent.status).toBe('running') - - // While `agent`'s whenIdle is pending, churn `other` through running→idle: - // every status event it emits hits whenIdle's guard with `subject !== this`, - // so the wait must ignore them and only resolve on `agent`'s own idle. - send(other, 'go') - await agent.whenIdle() - expect(agent.status).toBe('idle') - }) - it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => { - // Covers the waiter's disposed arm: whenIdle() queues an internal waiter - // while running (not the fast path), then the disposer settles it and chains - // `done` (loop exit), not an eager resolve. A bare Agent + direct - // internal driver disposer keeps the emit synchronous. - const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) - const adapter = new MockAdapter(['hang']) - ctx.llm.registerAdapter(['mock'], adapter) - const session = ctx.sessions.create(SessionId('bare')) - const prepared = prepareReactLoopAgent( - ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + expect(agent.status).toBe('idle') + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('agent event "agent/status" listener threw'), ) - const { agent } = prepared - prepared.markPublished() - const dispose = prepared.startDriver() - agent.followup([{ type: 'text', text: 'go' }]) - await new Promise(r => setTimeout(r, 30)) - expect(agent.status).toBe('running') - - const idle = agent.whenIdle() // queues an internal waiter (running) - const disposal = dispose() // settles the waiter synchronously; whenIdle chains done - await idle - expect(agent.status).toBe('disposed') - await disposal - }) - - it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => { - // The waiter is internal agent state, NOT an effect-scoped ctx.on listener: - // disposing the OWNING fiber runs the agent's listener disposers, which would - // have dropped a ctx.on-based waiter before the 'disposed' transition and - // hung the promise. With internal waiters, the fiber disposer still settles it. - const adapter = new MockAdapter(['hang']) - const ctx = await harness(adapter) - let agent!: Agent - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) - }, { inject: ['agentLoop'] })) - send(agent, 'go') - await new Promise(r => setTimeout(r, 30)) - expect(agent.status).toBe('running') - - const idle = agent.whenIdle() // queued while running - await fiber.dispose() // tears the fiber down (drops agent listeners) - await idle // must resolve, not hang - expect(agent.status).toBe('disposed') - }) - - it('whenIdle() on a disposed agent awaits the loop exit (done), not just the status flip', async () => { - // The disposer emits agent/status('disposed') BEFORE the driver loop - // unwinds, so whenIdle() must chain `done` (true quiescence) on the - // disposed path. Dispose a running agent, then assert whenIdle() resolves - // only after `done` — i.e. the loop has actually exited. - const adapter = new MockAdapter(['hang']) - const ctx = await harness(adapter) - let agent!: Agent - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) - }, { inject: ['agentLoop'] })) - send(agent, 'go') - await new Promise(r => setTimeout(r, 30)) - - let doneResolved = false - void driverDone(agent).then(() => { doneResolved = true }) - await fiber.dispose() // sets status disposed, aborts, drains the loop - expect(agent.status).toBe('disposed') - - // whenIdle() must not resolve before `done` has — chaining `done` is the - // quiescence guarantee. By here dispose() awaited the loop, so done is - // settled; whenIdle resolves and done is observed resolved. - await agent.whenIdle() - expect(doneResolved).toBe(true) - }) - - it('contains a throwing agent/status listener on the running transition', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/status', (_subject, status) => { - if (status === 'running') throw new Error('bad running listener') - }) - - send(agent, 'go') - await agent.whenIdle() - - expect(adapter.requests).toHaveLength(1) - expect(agent.status).toBe('idle') - expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent event "agent/status" listener threw')) - warn.mockRestore() - }) - - it('contains a throwing agent/status listener on the idle transition', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/status', (_subject, status) => { - if (status === 'idle') throw new Error('bad idle listener') - }) - - send(agent, 'go') - await agent.whenIdle() - - expect(adapter.requests).toHaveLength(1) - expect(agent.status).toBe('idle') - expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent event "agent/status" listener threw')) - warn.mockRestore() }) }) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 218d416ea5..94d3db0e55 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -8,7 +8,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import LlmService, { type Message } from '@deepseek-ai/dsh-llm' +import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' @@ -33,7 +33,7 @@ async function harness(adapter: MockAdapter) { } function send(agent: Agent, text: string) { - agent.followup([{ type: 'text', text }]) + agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } }) } /** Resolve on the agent's next idle transition (event-based, not status poll). */ @@ -63,7 +63,7 @@ describe('Agent.cancel()', () => { ctx.on('agent/cancel-requested', (subject, cause) => { if (subject !== agent) return seen.push(`first:${cause.kind}`) - subject.followup([{ type: 'text', text: 'queued by cancel observer' }]) + subject.followup({ content: [{ type: 'text', text: 'queued by cancel observer' }], source: { kind: 'user' } }) throw new Error('observer failed') }) ctx.on('agent/cancel-requested', (subject, cause) => { @@ -71,7 +71,7 @@ describe('Agent.cancel()', () => { }) send(agent, 'drop me') - agent.cancel() + agent.cancel({ kind: 'user' }) await new Promise(resolve => setTimeout(resolve, 30)) agent.cancel({ kind: 'parent' }) @@ -104,12 +104,17 @@ describe('Agent.cancel()', () => { 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) }) // Queue a turn WITHOUT waking the driver, so it sits in the inbox. - agent.queue([{ type: 'text', text: 'preserved' }]) - // keepInbox cancel: no active turn, work preserved, no discard event. + agent.send({ 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.cancel({ kind: 'user' }, { keepInbox: true }) expect(discards).toEqual([]) + expect(cancelRequests).toEqual([]) // The preserved item still runs once the driver is woken by a later send. send(agent, 'wake it') @@ -117,14 +122,14 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['preserved', 'wake it']) }) - it('a lone queued message leaves the agent parked at idle', async () => { + 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.queue([{ type: 'text', text: 'quiet' }]) + agent.send({ 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) @@ -140,7 +145,7 @@ describe('Agent.cancel()', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.queue([{ type: 'text', text: 'quiet' }]) + agent.send({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }, { target: 'next-turn', wakeup: false }) const idle = agent.whenIdle() agent.cancel({ kind: 'user' }) await idle @@ -190,7 +195,7 @@ describe('Agent.cancel()', () => { await disposalDone await driverDone(agent) - expect(agent.status).toBe('disposed') + expect(agent.status).toBe('idle') expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false) expect(userTexts(agent)).toEqual([]) expect(adapter.requests).toHaveLength(0) @@ -215,101 +220,6 @@ describe('Agent.cancel()', () => { expect(agent.status).toBe('idle') }) - it('cancel() between consecutive turns restores idle and leaves idle steer usable', async () => { - const adapter = new MockAdapter([textResponse('first reply'), textResponse('steer reply')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('between-turn-cancel'), { provider: 'mock', model: 'mock' }) - - let rejectFirstFlush = true - ctx.on('session/flush', (session) => { - if (session !== agent.session || !rejectFirstFlush) return - rejectFirstFlush = false - throw new Error('first flush failed') - }) - - const cancelled = Promise.withResolvers() - ctx.on('agent/error', (subject, _turn, _step, error) => { - if (subject !== agent || error.message !== 'first flush failed') return - // The first hop runs before runLoop resumes from runTurn; the second lands - // before its resolved waitForQueued continuation checks cancellation. - queueMicrotask(() => { - queueMicrotask(() => { - agent.cancel({ kind: 'user' }) - cancelled.resolve(undefined) - }) - }) - }) - - const statuses: string[] = [] - ctx.on('agent/status', (subject, status) => { - if (subject === agent) statuses.push(status) - }) - - send(agent, 'first') - send(agent, 'queued tail') - await cancelled.promise - - expect(agent.status).toBe('idle') - expect(statuses).toEqual(['running', 'idle']) - expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - expect(userTexts(agent)).toEqual(['first']) - - let idleResolved = false - void agent.whenIdle().then(() => { idleResolved = true }) - await Promise.resolve() - expect(idleResolved).toBe(true) - - const idle = waitForIdle(ctx, agent) - agent.steer([{ type: 'text', text: 'idle steer' }]) - await idle - - expect(statuses).toEqual(['running', 'idle', 'running', 'idle']) - expect(adapter.requests).toHaveLength(2) - expect(userTexts(agent)).toEqual(['first', 'idle steer']) - }) - - it('an idle-listener replacement keeps whenIdle pending until the replacement turn finishes', async () => { - const adapter = new MockAdapter([textResponse('first reply'), textResponse('replacement reply')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('between-turn-idle-listener'), { provider: 'mock', model: 'mock' }) - - let rejectFirstFlush = true - ctx.on('session/flush', (session) => { - if (session !== agent.session || !rejectFirstFlush) return - rejectFirstFlush = false - throw new Error('first flush failed') - }) - - ctx.on('agent/error', (subject, _turn, _step, error) => { - if (subject !== agent || error.message !== 'first flush failed') return - queueMicrotask(() => { - queueMicrotask(() => { agent.cancel({ kind: 'user' }) }) - }) - }) - - const replacementRegistered = Promise.withResolvers() - let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined - ctx.on('agent/status', (subject, status) => { - if (subject !== agent || status !== 'idle' || replacementObservation !== undefined) return - send(agent, 'replacement') - replacementObservation = agent.whenIdle().then(() => ({ - status: agent.status, - requests: adapter.requests.length, - turns: agent.session.events.filter(event => event.type === 'turn/start').length, - })) - replacementRegistered.resolve(undefined) - }) - - send(agent, 'first') - send(agent, 'cancelled tail') - await replacementRegistered.promise - if (replacementObservation === undefined) throw new Error('idle listener did not register replacement work') - - await expect(replacementObservation).resolves.toEqual({ status: 'idle', requests: 2, turns: 2 }) - expect(userTexts(agent)).toEqual(['first', 'replacement']) - }) - it('idle-listener cancellation settles its waiter without cancelling later work', async () => { const adapter = new MockAdapter([textResponse('first reply'), textResponse('later reply')]) const ctx = await harness(adapter) @@ -391,22 +301,6 @@ describe('Agent.cancel()', () => { expect(adapter.requests).toHaveLength(1) }) - it('cancel() with no cause defaults to user when aborting an active turn', async () => { - const adapter = new MockAdapter(['hang']) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - const reasons: TurnEndReason[] = [] - ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) - - send(agent, 'go') - await new Promise(r => setTimeout(r, 30)) - agent.cancel() - await waitForIdle(ctx, agent) - - expect(reasons).toEqual([{ kind: 'aborted' }]) - }) - it('cancel from an assistant/message observer skips execution but balances replay', async () => { const adapter = new MockAdapter([ toolCallResponse('c1', 'danger', {}), @@ -482,98 +376,6 @@ describe('Agent.cancel()', () => { expect(reasons.length).toBe(2) }) - it('cancel from inside the agent/session-prefix waterfall drops the step (prefix-composition window)', async () => { - const adapter = new MockAdapter([textResponse('should not stream')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - // Prefix composition runs before the pre-step seam on the instance's first - // step; a cancel landing inside it must drop the about-to-start step - // without running the seam or the model. - let streamed = false - ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true }) - ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => { - agent.cancel({ kind: 'user' }) - return next() - }) - - const reasons: TurnEndReason[] = [] - ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - expect(streamed).toBe(false) - expect(reasons).toEqual([{ kind: 'aborted' }]) - }) - - it('disposal from inside the agent/session-prefix waterfall ends the turn disposed (prefix-composition window)', async () => { - const adapter = new MockAdapter([textResponse('should not stream')]) - const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [] }) - ctx.llm.registerAdapter(['mock'], adapter) - - const handle = await ctx.agents.create({ - sessionId: SessionId('dispose-prefix-session'), - agentOptions: { provider: 'mock', model: 'mock' }, - }) - const agent = handle.agent - - let disposalDone: Promise | undefined - let streamed = false - ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true }) - ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => { - disposalDone = handle.dispose() - return next() - }) - - send(agent, 'go') - await new Promise(resolve => setTimeout(resolve, 0)) - await disposalDone - await driverDone(agent) - - // No step opened, no model call ran, and the turn closed disposed. - expect(streamed).toBe(false) - expect(adapter.requests).toHaveLength(0) - const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) - }) - - it('a cancel-interrupted prefix composition is discarded: the next send recomposes and ships the fresh prefix (stale-cache guard)', async () => { - const adapter = new MockAdapter([textResponse('reply')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - // The interrupted first composition must not cache its degraded empty value; - // the next prompt recomposes and logs/sends the fresh prefix. - const opener: Message = { role: 'user', content: [{ type: 'text', text: 'fresh opener' }] } - let compositions = 0 - ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise => { - compositions += 1 - if (compositions === 1) { - agent.cancel({ kind: 'user' }) - return next() - } - return [opener, ...await next()] - }) - - send(agent, 'dropped') - await waitForIdle(ctx, agent) - send(agent, 'real prompt') - await waitForIdle(ctx, agent) - - expect(compositions).toBe(2) - expect(adapter.requests).toHaveLength(1) - expect(adapter.requests[0]?.messages[0]).toEqual(opener) - const headerEvent = agent.session.events.find(e => e.type === 'request/header') - expect(headerEvent?.type === 'request/header' && headerEvent.data.header.messagePrefix).toEqual([opener]) - }) - it('cancel from a synchronous turn/start session-event listener drops the step (step-start window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) @@ -667,11 +469,7 @@ describe('Agent.cancel()', () => { expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length) }) - it('cancel during the continuation window ends the turn aborted and runs no further step', async () => { - // A continuation-waterfall listener cancels DURING the continuation decision - // (the finished step's AbortController is already cleared), and votes to - // continue — but the turn-scoped marker checked right after must end the turn - // `aborted` and run NO second step. + it('cancel during the stopping window ends the turn aborted and runs no further step', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -683,20 +481,18 @@ describe('Agent.cancel()', () => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) - let continued = false - ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => { - if (subject === agent && !continued) { - continued = true + let cancelled = false + ctx.on('agent/turn-stopping', (subject) => { + if (subject === agent && !cancelled) { + cancelled = true agent.cancel({ kind: 'user' }) - return { action: 'continue' as const } } - return next() }) send(agent, 'go') await waitForIdle(ctx, agent) - // Only ONE step ran (the second was cancelled in the continuation window), + // Only ONE step ran (the second was cancelled in the stopping window), // and the shared turn signal classified the durable outcome as aborted. expect(steps).toBe(1) expect(reasons).toEqual([{ kind: 'aborted' }]) @@ -782,7 +578,7 @@ describe('Agent.cancel()', () => { expect(agent.status).toBe('running') // Steer (joins the running turn's steering FIFO), then cancel: the steering // must be dropped, NOT re-enqueued as a new queued turn. - agent.steer([{ type: 'text', text: 'steer text' }]) + agent.steer({ content: [{ type: 'text', text: 'steer text' }], source: { kind: 'user' } }) agent.cancel({ kind: 'user' }) await waitForIdle(ctx, agent) @@ -855,51 +651,7 @@ describe('Agent.cancel()', () => { expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) }) - it('retires turn cancellation before terminal publication and a blocked durability flush', async () => { - const adapter = new MockAdapter([textResponse('done')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('terminal-cancellation-authority'), { provider: 'mock', model: 'mock' }) - const flushStarted = Promise.withResolvers() - const releaseFlush = Promise.withResolvers() - let abortedDuringTurnEnd: boolean | undefined - let cancelNotifications = 0 - - ctx.on('agent/cancel-requested', (subject) => { - if (subject === agent) cancelNotifications += 1 - }) - ctx.on('session/event', (session, event) => { - if (session !== agent.session || event.type !== 'turn/end') return - const signal = adapter.requests[0]?.signal - if (signal === undefined) throw new Error('model request omitted its turn signal') - agent.cancel({ kind: 'user' }) - abortedDuringTurnEnd = signal.aborted - }) - ctx.on('session/flush', async (session) => { - if (session !== agent.session) return - flushStarted.resolve(undefined) - await releaseFlush.promise - }) - - send(agent, 'finish before persistence drains') - await flushStarted.promise - const signal = adapter.requests[0]?.signal - if (signal === undefined) throw new Error('model request omitted its turn signal') - const idle = agent.whenIdle() - agent.cancel({ kind: 'user' }) - - expect(abortedDuringTurnEnd).toBe(false) - expect(signal.aborted).toBe(false) - expect(cancelNotifications).toBe(0) - expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({ - data: { reason: { kind: 'completed' } }, - }) - - releaseFlush.resolve(undefined) - await idle - expect(agent.status).toBe('idle') - }) - - it('records disposed when lifecycle teardown races an already-requested cancel', async () => { + it('preserves the first user cancellation when lifecycle teardown races it', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) const handle = await ctx.agents.create({ @@ -914,19 +666,15 @@ describe('Agent.cancel()', () => { await handle.dispose() const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) }) it.each([ 'prompt-submit', 'system-prompt', - 'session-prefix', - 'pre-step', + 'step', 'request', - 'step-result', - 'post-step', - 'turn-continuation', - 'turn-stop', + 'stopping', 'tool', ] as const)('lets a cooperative %s boundary settle from the explicit turn signal', async (stage) => { const adapter = new MockAdapter(stage === 'tool' @@ -959,44 +707,19 @@ describe('Agent.cancel()', () => { return next() }) break - case 'session-prefix': - ctx.on('agent/session-prefix', async (subject, _prefix, signal, next) => { - if (subject === agent) await blockUntilAbort(signal) - return next() - }) - break - case 'pre-step': - ctx.on('agent/pre-step', async (subject, _turn, _step, signal) => { + case 'step': + ctx.on('agent/step', async (subject, _turn, _step, signal) => { if (subject === agent) await blockUntilAbort(signal) }) break case 'request': - ctx.on('agent/request', async (subject, _turn, _step, _config, signal, next) => { + ctx.on('agent/request', async (subject, _turn, _step, signal, next) => { if (subject === agent) await blockUntilAbort(signal) return next() }) break - case 'step-result': - ctx.on('agent/step-result', async (subject, _turn, _step, _message, signal, next) => { - if (subject === agent) await blockUntilAbort(signal) - return next() - }) - break - case 'post-step': - ctx.on('agent/post-step', async (subject, _turn, _step, signal) => { - if (subject !== agent) return - await blockUntilAbort(signal) - throw new Error('post-step failed after cancellation') - }) - break - case 'turn-continuation': - ctx.on('agent/turn-continuation', async (subject, _turn, _decision, signal, next) => { - if (subject === agent) await blockUntilAbort(signal) - return next() - }) - break - case 'turn-stop': - ctx.on('agent/turn-stop', async (subject, _turn, signal) => { + case 'stopping': + ctx.on('agent/turn-stopping', async (subject, _turn, signal) => { if (subject === agent) await blockUntilAbort(signal) }) break @@ -1016,11 +739,15 @@ describe('Agent.cancel()', () => { send(agent, 'go') await started.promise - const idle = waitForIdle(ctx, agent) + const idle = agent.whenIdle() agent.cancel({ kind: 'user' }) await idle const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) + if (stage === 'prompt-submit') { + expect(turnEnd).toBeUndefined() + } else { + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) + } await ctx.fiber.dispose() }) }) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 0b6ad2b2ec..f585a82b48 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -89,7 +89,7 @@ describe('config-driven session id', () => { const ctx = await makeCoreContext() await ctx.plugin(SessionPersistenceJsonl, { root }) ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first'), textResponse('second')])) - const config = { agents: [{ id: 'main', sessionId: SessionId('config-exact-reload'), model: 'mock' }] } + const config = { agents: [{ id: 'main', sessionId: SessionId('config-exact-reload'), provider: 'mock', model: 'mock' }] } const firstLoop = await ctx.plugin(AgentLoop, config) let first: Agent | undefined @@ -98,7 +98,7 @@ describe('config-driven session id', () => { first = ctx.agents.get(SessionId('config-exact-reload')) } expect(first).toBeDefined() - first!.followup([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) + first!.followup({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } }) await waitForIdle(ctx, first!) await firstLoop.dispose() @@ -110,7 +110,7 @@ describe('config-driven session id', () => { } expect(second).toBeDefined() expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me') - second!.followup([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } }) + second!.followup({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }) await waitForIdle(ctx, second!) await ctx.sessions.flush(second!.session) const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload')) @@ -131,20 +131,20 @@ describe('config-driven session id', () => { await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() const first = ctx.agents.get(sessionId) as Agent - const flushGate = Promise.withResolvers() - let flushStarted = false - ctx.on('session/flush', (session) => { - if (session !== first.session) return - flushStarted = true - return flushGate.promise + const cleanupGate = Promise.withResolvers() + const cleanupStarted = Promise.withResolvers() + first.ctx.effect(() => async () => { + cleanupStarted.resolve(undefined) + await cleanupGate.promise }) - first.inject([{ type: 'text', text: 'persist before replacement' }], { - source: { kind: 'plugin', plugin: 'test' }, - }) - expect(flushStarted).toBe(true) + first.inject({ content: [{ type: 'text', text: 'persist before replacement' }], source: { kind: 'plugin', plugin: 'test' } }) + await ctx.sessions.flush(first.session) + expect(JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events)) + .toContain('persist before replacement') const firstDisposal = firstLoop.dispose() - await expect.poll(() => first.status).toBe('disposed') + await cleanupStarted.promise + expect(first.status).toBe('idle') const failures: unknown[] = [] ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) const secondLoop = await ctx.plugin(AgentLoop, config) @@ -152,7 +152,7 @@ describe('config-driven session id', () => { expect(ctx.agents.get(sessionId)).toBe(first) expect(failures).toEqual([]) - flushGate.resolve(undefined) + cleanupGate.resolve(undefined) await firstDisposal await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() const second = ctx.agents.get(sessionId) as Agent @@ -175,21 +175,25 @@ describe('config-driven session id', () => { await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() const first = ctx.agents.get(sessionId) as Agent - const flushGate = Promise.withResolvers() - ctx.on('session/flush', (session) => { - if (session === first.session) return flushGate.promise - }) - first.inject([{ type: 'text', text: 'persist before cancellation' }], { - source: { kind: 'plugin', plugin: 'test' }, + const cleanupGate = Promise.withResolvers() + const cleanupStarted = Promise.withResolvers() + first.ctx.effect(() => async () => { + cleanupStarted.resolve(undefined) + await cleanupGate.promise }) + first.inject({ content: [{ type: 'text', text: 'persist before cancellation' }], source: { kind: 'plugin', plugin: 'test' } }) + await ctx.sessions.flush(first.session) + expect(JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events)) + .toContain('persist before cancellation') const firstDisposal = firstLoop.dispose() - await expect.poll(() => first.status).toBe('disposed') + await cleanupStarted.promise + expect(first.status).toBe('idle') const secondLoop = await ctx.plugin(AgentLoop, config) await secondLoop.dispose() expect(ctx.agents.get(sessionId)).toBe(first) - flushGate.resolve(undefined) + cleanupGate.resolve(undefined) await firstDisposal expect(ctx.agents.get(sessionId)).toBeUndefined() await ctx.fiber.dispose() @@ -268,14 +272,14 @@ describe('config-driven session id', () => { }) it.each(['resolve', 'reject'] as const)( - 'joins an exact-id persistence lookup that will %s before AgentLoop disposal completes', + 'abandons an exact-id persistence lookup that later %s when AgentLoop disposal starts', async (outcome) => { const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-dispose-')) dirs.push(root) const ctx = await makeCoreContext() await ctx.plugin(SessionPersistenceJsonl, { root }) - const listing = Promise.withResolvers>>() - vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(listing.promise) + const loading = Promise.withResolvers>>() + vi.spyOn(ctx.sessionPersistence, 'load').mockReturnValue(loading.promise) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const failures: unknown[] = [] ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) @@ -283,14 +287,20 @@ describe('config-driven session id', () => { const loop = await ctx.plugin(AgentLoop, { agents: [{ id: 'main', sessionId: SessionId('config-exact-dispose'), model: 'mock' }], }) - let disposed = false - const disposal = loop.dispose().then(() => { disposed = true }) + await loop.dispose() + if (outcome === 'resolve') { + loading.resolve({ + meta: { + id: SessionId('config-exact-dispose'), + version: 0, + createdAt: Date.now(), + }, + events: [], + }) + } else { + loading.reject(new Error('startup cancelled by teardown')) + } await Promise.resolve() - expect(disposed).toBe(false) - - if (outcome === 'resolve') listing.resolve([]) - else listing.reject(new Error('startup cancelled by teardown')) - await disposal expect(ctx.agents.get(SessionId('config-exact-dispose'))).toBeUndefined() expect(failures).toEqual([]) expect(warn).not.toHaveBeenCalled() @@ -335,7 +345,7 @@ describe('config-driven session id', () => { expect(a1.id).toBe(a1.session.id) expect(a1.session.id).toMatch(idPattern) expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined() - a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + a1.followup({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -354,7 +364,7 @@ describe('config-driven session id', () => { expect(a2.id).toBe(a2.session.id) expect(a2.session.id).toMatch(idPattern) expect(a2.session.id).not.toBe(a1.session.id) - a2.followup([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } }) + a2.followup({ content: [{ type: 'text', text: 'q2' }], source: { kind: 'user' } }) await waitForIdle(ctx2, a2) await ctx2.fiber.dispose() }) @@ -375,7 +385,7 @@ describe('config-driven session id', () => { await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')])) const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent - a1.followup([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) + a1.followup({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -431,3 +441,36 @@ describe('config-driven session id', () => { await ctx.fiber.dispose() }) }) + +describe('startup reporting after factory teardown', () => { + it('suppresses the configured-restore failure report once the loop is disposed', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-disposed-report-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('x')])) + + // A restore lookup that hangs until after the loop is gone: the eventual + // failure lands with ownership inactive and must be silently dropped. + const gate = Promise.withResolvers() + // The teardown path may drop the pending lookup without awaiting it. + gate.promise.catch(() => undefined) + vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(gate.promise) + const failures: unknown[] = [] + ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + + const loop = await ctx.plugin(AgentLoop, { + agents: [{ id: 'main', sessionId: SessionId('config-disposed-report'), model: 'mock' }], + }) + const disposal = loop.dispose() + gate.reject(new Error('backend failed after teardown began')) + await disposal + + await new Promise(r => setTimeout(r, 20)) + expect(failures).toEqual([]) + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('config-driven restore')) + warn.mockRestore() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 4385733ae7..c725b8d55a 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -1,17 +1,17 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { CallId, ContentBlock, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { type Agent, type ContinuationDecision, type HookContext } from '@deepseek-ai/dsh-agent' -import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' -import { prepareReactLoopAgent } from '../src/agent.ts' +import ToolRegistry, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools' +import AgentRegistry, { type Agent, type InboxPlacement } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { ReactLoopAgent } from '../src/agent.ts' import InvariantService from '@deepseek-ai/dsh-invariants' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' -import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' +import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' async function mountInvariants(ctx: Context): Promise { await ctx.plugin(InvariantService) @@ -50,62 +50,11 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function send(agent: Agent, text: string) { - agent.followup([{ type: 'text', text }]) + agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } }) } -describe('session log records what agent/step-result actually produced', () => { - it('a step-result rewrite is what the log, derived history, and tool dispatch all see', async () => { - const original = textResponse('original') - original[original.length - 1] = { type: 'finish', reason: { kind: 'stop' }, replayState: { private: 'original-state' } } - const adapter = new MockAdapter([original, textResponse('done')]) - const ctx = await harness(adapter) - const executed: string[] = [] - ctx.tools.register(defineContentToolFixture({ - name: 'injected-tool', - description: '', - parameters: {}, - async execute() { - executed.push('injected-tool') - return [{ type: 'text', text: 'ran' }] - }, - })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - // Plugin rewrites the message: replaces the text AND adds a tool call. - let rewritten = false - ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _signal, next) => { - if (rewritten) return next() - rewritten = true - return { - role: 'assistant' as const, - content: [ - { type: 'text' as const, text: 'rewritten' }, - { type: 'tool-call' as const, id: CallId('c-injected'), name: 'injected-tool', arguments: '{}' }, - ], - } - }) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - // the injected tool call was dispatched… - expect(executed).toEqual(['injected-tool']) - // …and the session log recorded the REWRITTEN message, not the original - const recorded = agent.session.events.find(e => e.type === 'assistant/message')! - expect(JSON.stringify(recorded.data)).toContain('rewritten') - expect(JSON.stringify(recorded.data)).not.toContain('original') - expect(recorded.type === 'assistant/message' && recorded.data.provenance.replayState).toBeUndefined() - // tool/call + tool/result correlate with the injected call id - const callEvent = agent.session.events.find(e => e.type === 'tool/call')! - if (callEvent.type !== 'tool/call') throw new Error('wrong event type') - expect(callEvent.data.callId).toBe('c-injected') - // derived history shows the rewritten message (replay-correct) - const derived = agent.session.deriveMessages() - expect(JSON.stringify(derived)).toContain('rewritten') - expect(JSON.stringify(derived)).not.toContain('original') - }) - - it('records adapter replay state when step-result preserves the assembled content', async () => { +describe('assistant replay provenance', () => { + it('records adapter replay state with the assembled assistant content', async () => { const response = textResponse('unchanged') const replayState = { private: 'state' } response[response.length - 1] = { type: 'finish', reason: { kind: 'stop' }, replayState } @@ -116,7 +65,7 @@ describe('session log records what agent/step-result actually produced', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const recorded = agent.session.events.find(e => e.type === 'assistant/message') + const recorded = agent.session.events.find(event => event.type === 'assistant/message') expect(recorded?.type === 'assistant/message' && recorded.data.provenance).toEqual({ provider: 'mock', model: 'next-model', replayState, }) @@ -124,212 +73,9 @@ describe('session log records what agent/step-result actually produced', () => { provider: 'mock', model: 'next-model', replayState, }) }) - - it('drops adapter replay state when step-result mutates assembled content in place', async () => { - const response = textResponse('original') - response[response.length - 1] = { type: 'finish', reason: { kind: 'stop' }, replayState: { private: 'state' } } - const adapter = new MockAdapter([response]) - const ctx = await harness(adapter) - ctx.on('agent/step-result', async (_agent, _turn, _step, message) => { - const block = message.content[0] - if (block?.type === 'text') block.text = 'mutated' - return message - }) - const agent = ctx.agentLoop.create(SessionId('mutated-replay-state'), { provider: 'mock', model: 'next-model' }) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - const recorded = agent.session.events.find(event => event.type === 'assistant/message') - expect(recorded?.type === 'assistant/message' && recorded.data.content).toEqual([{ type: 'text', text: 'mutated' }]) - expect(recorded?.type === 'assistant/message' && recorded.data.provenance.replayState).toBeUndefined() - }) -}) - -describe('successful provider completion survives agent/step-result failure', () => { - async function expectContentlessCompletionAnchor( - response: StreamChunk[], - id: string, - providerText: string, - ): Promise { - const adapter = new MockAdapter([response]) - const ctx = await harness(adapter) - await mountInvariants(ctx) - const agent = ctx.agentLoop.create(SessionId(id), { provider: 'mock', model: 'mock' }) - const failure = new Error(`${id} result processing failed`) - const reported: Error[] = [] - - ctx.on('agent/step-result', async () => { - throw failure - }) - ctx.on('agent/error', (subject, _turn, _step, error) => { - if (subject === agent) reported.push(error) - }) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - const events = [...agent.session.events] - const chunks = events.filter(event => event.type === 'assistant/chunk') - const completions = events.filter(event => event.type === 'assistant/message') - expect(completions).toHaveLength(1) - expect(completions[0]?.type === 'assistant/message' && completions[0].data).toEqual({ - turn: 1, - step: 1, - content: [], - provenance: { provider: 'mock', model: 'mock' }, - usage: { inputTokens: 10, outputTokens: providerText.length }, - }) - expect(completions[0]?.sourceEventSeqs).toEqual(chunks.map(event => event.seq)) - expect(agent.session.deriveMessages()).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'go' }] }, - ]) - expect(reported).toHaveLength(1) - expect(reported[0]).toBe(failure) - const turnEnd = events.findLast(event => event.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ - kind: 'error', - step: 1, - message: failure.message, - }) - } - - it('records one content-less anchor when ordinary stop result processing rejects', async () => { - const providerText = 'ordinary provider output' - await expectContentlessCompletionAnchor( - textResponse(providerText), - 'a-step-result-stop-failure', - providerText, - ) - }) - - it('records one content-less anchor when max-token result processing rejects', async () => { - const providerText = 'truncated provider output' - await expectContentlessCompletionAnchor( - maxTokensResponse(providerText), - 'a-step-result-max-token-failure', - providerText, - ) - }) }) describe('abort during tool execution ends the turn', () => { - it('balances a cancelled tool batch through context and post-step before closing', async () => { - const adapter = new MockAdapter([ - // model asks for two tool calls in one step - [ - { type: 'block-start', index: 0, blockType: 'tool-call' }, - { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'aborter', arguments: '{}' } }, - { type: 'block-start', index: 1, blockType: 'tool-call' }, - { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'second', arguments: '{}' } }, - { type: 'finish', reason: { kind: 'tool-calls' } }, - ] satisfies StreamChunk[], - textResponse('should never be requested'), - ]) - const ctx = await harness(adapter) - const executed: string[] = [] - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineContentToolFixture({ - name: 'aborter', - description: '', - parameters: {}, - async execute(_args, exec) { - executed.push('aborter') - exec.agent?.steer( - [{ type: 'text', text: 'steering before abort' }], - { source: { kind: 'plugin', plugin: 'abort-test' } }, - ) - agent.cancel({ kind: 'user' }) - return [{ type: 'text', text: 'done' }] - }, - })) - ctx.on('tools/post-execute', async exec => ({ - kind: 'accept', - additionalContexts: [{ - content: [{ type: 'text', text: `context for ${exec.callId}` }], - source: { kind: 'plugin', plugin: 'abort-test' }, - }], - })) - ctx.tools.register(defineContentToolFixture({ - name: 'second', - description: '', - parameters: {}, - async execute() { - executed.push('second') - return [{ type: 'text', text: 'done' }] - }, - })) - - const reasons: TurnEndReason[] = [] - const order: string[] = [] - ctx.on('session/event', (session, event) => { - if (session !== agent.session) return - switch (event.type) { - case 'assistant/message': order.push('assistant/message'); break - case 'tool/call': order.push(`tool/call:${event.data.callId}`); break - case 'tool/result': { - const outcome = event.data.error?.code === TOOL_ABORTED - || event.data.error?.code === TOOL_ABORTED_BEFORE_DISPATCH - ? 'aborted' - : 'completed' - order.push(`tool/result:${event.data.callId}:${outcome}`) - break - } - // Injected context is a plugin-sourced user/message; the direct human - // prompt (user source) is not tracked in this ordering. - case 'user/message': if (event.data.source.kind !== 'user') order.push('context/message'); break - case 'steering/message': order.push('steering/message'); break - case 'step/end': order.push('step/end'); break - case 'turn/end': { - reasons.push(event.data.reason) - order.push(`turn/end:${event.data.reason.kind}`) - break - } - } - }) - let postSteps = 0 - ctx.on('agent/post-step', (subject, turn, step, signal) => { - if (subject !== agent) return - postSteps += 1 - expect({ turn, step, aborted: signal.aborted }).toEqual({ turn: 1, step: 1, aborted: true }) - order.push('agent/post-step') - }) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - expect(executed).toEqual(['aborter']) - expect(adapter.requests).toHaveLength(1) - expect(postSteps).toBe(1) - expect(order).toEqual([ - 'assistant/message', - 'tool/call:c1', - 'tool/result:c1:aborted', - 'tool/call:c2', - 'tool/result:c2:aborted', - 'context/message', - 'agent/post-step', - 'step/end', - 'turn/end:aborted', - ]) - expect(reasons).toEqual([{ kind: 'aborted' }]) - const calls = agent.session.events.filter(event => event.type === 'tool/call') - const results = agent.session.events.filter(event => event.type === 'tool/result') - expect(calls.map(event => event.data.callId)).toEqual([CallId('c1'), CallId('c2')]) - expect(results).toHaveLength(2) - expect(results[0]!.data).toMatchObject({ - callId: CallId('c1'), - content: [{ type: 'text', text: 'Error: tool call aborted' }], - isError: true, - error: { name: 'AbortError', code: TOOL_ABORTED }, - }) - expect(results[1]!.data).toMatchObject({ - callId: CallId('c2'), - isError: true, - error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, - }) - }) - it('records context accepted before a tool-step abort in the same turn', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'aborter', {})]) const ctx = await harness(adapter) @@ -339,7 +85,7 @@ describe('abort during tool execution ends the turn', () => { description: '', parameters: {}, async execute() { - agent.inject([{ type: 'text', text: 'accepted before abort' }], { source: { kind: 'plugin', plugin: 'test' } }) + agent.inject({ content: [{ type: 'text', text: 'accepted before abort' }], source: { kind: 'plugin', plugin: 'test' } }) agent.cancel({ kind: 'user' }) return [{ type: 'text', text: 'done' }] }, @@ -356,17 +102,17 @@ describe('abort during tool execution ends the turn', () => { await waitForIdle(ctx, agent) const events = [...agent.session.events] - const isInjected = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user' expect(events - .filter(event => event.type === 'tool/result' || isInjected(event) + .filter(event => event.type === 'tool/result' + || (event.type === 'user/message' && event.data.source.kind === 'plugin') || event.type === 'step/end' || event.type === 'turn/end') - .map(event => isInjected(event) ? 'context/message' : event.type)) - .toEqual(['tool/result', 'context/message', 'context/message', 'step/end', 'turn/end']) + .map(event => event.type)) + .toEqual(['tool/result', 'user/message', 'step/end', 'turn/end']) expect(events - .filter(isInjected) - .map(event => event.data.content)) + .flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' + ? [event.data.content] + : [])) .toEqual([ - [{ type: 'text', text: 'accepted before abort' }], [{ type: 'text', text: 'accepted result context after abort' }], ]) }) @@ -413,17 +159,20 @@ describe('abort during tool execution ends the turn', () => { await waitForIdle(ctx, agent) const events = [...agent.session.events] - const isInjected = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user' expect(events - .filter(event => event.type === 'tool/result' || isInjected(event) + .filter(event => event.type === 'tool/result' + || (event.type === 'user/message' && event.data.source.kind === 'plugin') || event.type === 'step/end' || event.type === 'turn/end') - .map(event => isInjected(event) ? 'context/message' : event.type)) - .toEqual(['tool/result', 'tool/result', 'context/message', 'step/end', 'turn/end']) - expect(events.find(isInjected)?.data.content) - .toEqual([{ type: 'text', text: 'accepted after first result' }]) + .map(event => event.type)) + .toEqual(['tool/result', 'tool/result', 'step/end', 'turn/end']) + expect(events.flatMap(event => + event.type === 'user/message' && event.data.source.kind === 'plugin' + ? [event.data.content] + : [])[0]) + .toBeUndefined() }) - it('drains deferred context before disposal reaches quiescence', async () => { + it('records result context finalized after disposal cancellation', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'waiter', {})]) const ctx = await harness(adapter) const started = Promise.withResolvers() @@ -436,7 +185,7 @@ describe('abort during tool execution ends the turn', () => { description: '', parameters: {}, async execute(_args, exec) { - agent.inject([{ type: 'text', text: 'accepted before disposal' }], { source: { kind: 'plugin', plugin: 'test' } }) + agent.inject({ content: [{ type: 'text', text: 'accepted before disposal' }], source: { kind: 'plugin', plugin: 'test' } }) started.resolve(undefined) const signal = exec.signal if (!signal) throw new Error('tool execution signal is missing') @@ -460,10 +209,10 @@ describe('abort during tool execution ends the turn', () => { await fiber.dispose() expect(agent.session.events - .filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user') - .map(event => event.data.content)) + .flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' + ? [event.data.content] + : [])) .toEqual([ - [{ type: 'text', text: 'accepted before disposal' }], [{ type: 'text', text: 'accepted result context during disposal' }], ]) expect(agent.session.events.find(event => event.type === 'turn/end')?.data.reason) @@ -503,22 +252,25 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'leave an unmatched historical call') await waitForIdle(ctx, agent) - ctx.on('agent/pre-step', (subject, turn) => { + ctx.on('agent/step', (subject, turn) => { if (subject === agent && turn === 2) { - agent.inject([{ type: 'text', text: 'new turn context' }], { source: { kind: 'plugin', plugin: 'test' } }) + agent.inject({ content: [{ type: 'text', text: 'new turn context' }], source: { kind: 'plugin', plugin: 'test' } }) } }) send(agent, 'start a text-only turn') await waitForIdle(ctx, agent) - expect(agent.session.events.find((event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user')?.data.content) + expect(agent.session.events.flatMap(event => + event.type === 'user/message' && event.data.source.kind === 'plugin' + ? [event.data.content] + : [])[0]) .toEqual([{ type: 'text', text: 'new turn context' }]) expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('new turn context') }) }) describe('steering from late extension points is never stranded', () => { - it('steer() from an agent/turn-continuation listener overrides a stop decision', async () => { + it('steer() from an agent/turn-stopping listener continues the same turn', async () => { const adapter = new MockAdapter([ textResponse('no tools, would stop here'), textResponse('continued because of steering'), @@ -527,12 +279,11 @@ describe('steering from late extension points is never stranded', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let steeredOnce = false - ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, _signal, next) => { + ctx.on('agent/turn-stopping', () => { if (!steeredOnce) { steeredOnce = true - agent.steer([{ type: 'text', text: 'one more thing' }]) + agent.steer({ content: [{ type: 'text', text: 'one more thing' }], source: { kind: 'user' } }) } - return next() }) send(agent, 'go') @@ -556,7 +307,7 @@ describe('steering from late extension points is never stranded', () => { ctx.on('session/event', (subject, event) => { if (subject !== agent.session || event.type !== 'step/end' || steeredOnce) return steeredOnce = true - agent.steer([{ type: 'text', text: 'goal reminder from step/end' }]) + agent.steer({ content: [{ type: 'text', text: 'goal reminder from step/end' }], source: { kind: 'user' } }) }) send(agent, 'go') @@ -587,7 +338,8 @@ describe('steering from late extension points is never stranded', () => { if (event.type === 'turn/start') turns.push(event.data.turn) if (event.type === 'turn/end' && !steeredOnce) { steeredOnce = true - agent.steer([{ type: 'text', text: 'too late for this turn' }]) + expect(agent.acceptsNextStep).toBe(false) + agent.steer({ content: [{ type: 'text', text: 'too late for this turn' }], source: { kind: 'user' } }) } }) @@ -604,22 +356,23 @@ describe('steering from late extension points is never stranded', () => { }) describe('plugin exceptions are contained', () => { - it('a throwing agent/turn-continuation listener ends the turn with an error, loop survives', async () => { + it('a throwing agent/turn-stopping listener ends the turn with an error, loop survives', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threwOnce = false - ctx.on('agent/turn-continuation', async (): Promise => { + ctx.on('agent/turn-stopping', async () => { if (!threwOnce) { threwOnce = true throw new Error('broken continuation plugin') } - return { action: 'stop' } }) const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) + ctx.on('agent/error', (_agent, _turn, _step, error) => { + if (error instanceof Error) errors.push(error) + }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -632,45 +385,9 @@ describe('plugin exceptions are contained', () => { expect(agent.status).toBe('idle') }) - it('a rejecting first-turn flush settles before the queued tail starts', async () => { - const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - const firstFlush = Promise.withResolvers() - const releaseFirstFlush = Promise.withResolvers() - let flushes = 0 - ctx.on('session/flush', async (session) => { - if (session !== agent.session) return - flushes += 1 - if (flushes === 1) { - firstFlush.resolve(undefined) - await releaseFirstFlush.promise - throw new Error('disk full') - } - }) - - const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) - - const idle = waitForIdle(ctx, agent) - send(agent, 'first') - send(agent, 'second') - - await firstFlush.promise - expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - - releaseFirstFlush.resolve(undefined) - await idle - - expect(errors.map(e => e.message)).toEqual(['disk full']) - expect(adapter.requests).toHaveLength(2) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) - }) }) -describe('disposed status is part of the agent/status contract', () => { +describe('disposal leaves the two-state status contract balanced', () => { it('disposing the fiber ends the active turn and never starts its queued tail', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) @@ -691,7 +408,7 @@ describe('disposed status is part of the agent/status contract', () => { await fiber.dispose() await driverDone(agent) - expect(statuses).toEqual(['running', 'disposed']) + expect(statuses).toEqual(['running', 'idle']) expect(reasons).toEqual([{ kind: 'disposed' }]) expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) const messages = agent.session.events @@ -712,7 +429,7 @@ describe('disposed status is part of the agent/status contract', () => { }, { inject: ['agentLoop'] })) ctx.on('agent/status', (_agent, status) => { - if (status === 'disposed') throw new Error('broken status listener') + if (status === 'idle') throw new Error('broken status listener') }) send(agent, 'go') @@ -720,8 +437,7 @@ describe('disposed status is part of the agent/status contract', () => { await fiber.dispose() await driverDone(agent) // must not hang - expect(agent.status).toBe('disposed') - expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() // unregistered despite the throw + expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() }) }) @@ -743,7 +459,9 @@ describe('adapter registration, routing, and accepted-input ownership', () => { const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) + ctx.on('agent/error', (_agent, _turn, _step, error) => { + if (error instanceof Error) errors.push(error) + }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -757,8 +475,8 @@ describe('adapter registration, routing, and accepted-input ownership', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides - ctx.on('agent/request', async (_agent, _turn, _step, config, _signal) => { - return { ...config, provider: 'mock', model: 'mock' } + ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + return { ...await next(), provider: 'mock', model: 'mock' } }) send(agent, 'go') @@ -767,7 +485,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }]) }) - it('agent/inbox/enqueue carries the resolved source; steering/message records its source', async () => { + it('agent/inbox/enqueue carries the exact message; steering/message records its source', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -776,190 +494,38 @@ describe('adapter registration, routing, and accepted-input ownership', () => { description: '', parameters: {}, async execute() { - agent.steer([{ type: 'text', text: 's' }], { source: { kind: 'plugin', plugin: 'goal' } }) + agent.steer({ content: [{ type: 'text', text: 's' }], source: { kind: 'plugin', plugin: 'goal' } }) return [] }, })) - const queuedSources: { source: MessageSource; contexts: HookContext[]; steering: boolean }[] = [] - ctx.on('agent/inbox/enqueue', (_agent, info) => void queuedSources.push({ source: info.source, contexts: info.contexts, steering: info.steering })) + const queuedSources: MessageSource[] = [] + const queuedShapes: string[][] = [] + const placements: InboxPlacement[] = [] + ctx.on('agent/inbox/enqueue', (_agent, message, placement) => { + queuedSources.push(message.source) + queuedShapes.push(Object.keys(message).sort()) + placements.push(placement) + }) send(agent, 'go') // no explicit source → default {kind:'user'} must be visible await waitForIdle(ctx, agent) - expect(queuedSources[0]).toEqual({ source: { kind: 'user' }, contexts: [], steering: false }) - expect(queuedSources[1]).toEqual({ source: { kind: 'plugin', plugin: 'goal' }, contexts: [], steering: true }) + expect(queuedSources).toEqual([ + { kind: 'user' }, + { kind: 'plugin', plugin: 'goal' }, + ]) + expect(queuedShapes).toEqual([ + ['content', 'id', 'source'], + ['content', 'id', 'source'], + ]) + expect(placements).toEqual(['queued', 'steering']) // The drain appends the durable steering/message with the caller's source // intact — the log, not a transient emit, is where consumers read it. const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.source] : []) expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }]) }) - it('send() owns content and source before notification and delivery', async () => { - const adapter = new MockAdapter([textResponse('done')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('owned-send'), { provider: 'mock', model: 'mock' }) - const content = [{ type: 'text' as const, text: 'accepted-send' }] - const source = { kind: 'plugin' as const, plugin: 'accepted-source' } - let notifiedContent: ContentBlock[] | undefined - let notifiedSource: MessageSource | undefined - let notifiedContexts: HookContext[] | undefined - ctx.on('agent/inbox/enqueue', (subject, info) => { - if (subject !== agent || info.steering) return - // Retain the exact notification references: cloning here would test the - // listener's copy rather than the event/inbox ownership boundary. - notifiedContent = info.content - notifiedSource = info.source - notifiedContexts = info.contexts - }) - - const contexts: HookContext[] = [{ - content: [{ type: 'text', text: 'accepted-context' }], - source: { kind: 'plugin', plugin: 'context-source' }, - meta: { version: 1 }, - }] - agent.followup(content, { source, contexts }) - content[0]!.text = 'caller-mutated-send' - source.plugin = 'caller-mutated-source' - contexts[0]!.content[0] = { type: 'text', text: 'caller-mutated-context' } - await waitForIdle(ctx, agent) - - expect(notifiedContent).toEqual([{ type: 'text', text: 'accepted-send' }]) - expect(notifiedSource).toEqual({ kind: 'plugin', plugin: 'accepted-source' }) - expect(notifiedContexts).toEqual([{ - content: [{ type: 'text', text: 'accepted-context' }], - source: { kind: 'plugin', plugin: 'context-source' }, - meta: { version: 1 }, - }]) - expect(Object.isFrozen(notifiedContent)).toBe(true) - expect(Object.isFrozen(notifiedContent?.[0])).toBe(true) - expect(Object.isFrozen(notifiedSource)).toBe(true) - expect(Object.isFrozen(notifiedContexts)).toBe(true) - expect(Object.isFrozen(notifiedContexts?.[0]?.content)).toBe(true) - const recorded = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : []) - expect(recorded).toContainEqual({ - content: [{ type: 'text', text: 'accepted-send' }], - source: { kind: 'plugin', plugin: 'accepted-source' }, - }) - const request = JSON.stringify(adapter.requests[0]!.messages) - expect(request).toContain('accepted-send') - expect(request).toContain('accepted-context') - expect(request).not.toContain('caller-mutated-send') - expect(request).not.toContain('caller-mutated-context') - }) - - it('running steer() owns content and source before notification and delivery', async () => { - const adapter = new MockAdapter([toolCallResponse('c1', 'gate', {}), textResponse('done')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('owned-steer'), { provider: 'mock', model: 'mock' }) - const entered = Promise.withResolvers() - const release = Promise.withResolvers() - ctx.tools.register(defineContentToolFixture({ - name: 'gate', - description: '', - parameters: {}, - async execute() { - entered.resolve(undefined) - await release.promise - return [{ type: 'text', text: 'tool done' }] - }, - })) - let notifiedContent: ContentBlock[] | undefined - let notifiedSource: MessageSource | undefined - let notifiedContexts: HookContext[] | undefined - ctx.on('agent/inbox/enqueue', (subject, info) => { - if (subject !== agent || !info.steering) return - notifiedContent = info.content - notifiedSource = info.source - notifiedContexts = info.contexts - }) - - agent.followup([{ type: 'text', text: 'start' }]) - await entered.promise - expect(agent.status).toBe('running') - const content = [{ type: 'text' as const, text: 'accepted-steer' }] - const source = { kind: 'plugin' as const, plugin: 'accepted-source' } - const contexts: HookContext[] = [ - { - content: [{ type: 'text', text: 'accepted-steering-prefix' }], - source: { kind: 'plugin', plugin: 'steering-prefix' }, - placement: 'prompt-prefix', - }, - { - content: [{ type: 'text', text: 'accepted-steering-context' }], - source: { kind: 'plugin', plugin: 'steering-context' }, - meta: { kind: 'separate-card' }, - }, - { - content: [{ type: 'text', text: 'accepted-steering-context-without-meta' }], - source: { kind: 'plugin', plugin: 'steering-context-without-meta' }, - }, - ] - agent.steer(content, { source, contexts }) - content[0]!.text = 'caller-mutated-steer' - source.plugin = 'caller-mutated-source' - contexts[0]!.content[0] = { type: 'text', text: 'caller-mutated-steering-prefix' } - contexts[0]!.placement = 'separate' - contexts[1]!.content[0] = { type: 'text', text: 'caller-mutated-steering-context' } - contexts[2]!.content[0] = { type: 'text', text: 'caller-mutated-steering-context-without-meta' } - const idle = waitForIdle(ctx, agent) - release.resolve(undefined) - await idle - - expect(notifiedContent).toEqual([{ type: 'text', text: 'accepted-steer' }]) - expect(notifiedSource).toEqual({ kind: 'plugin', plugin: 'accepted-source' }) - expect(notifiedContexts).toEqual([ - { - content: [{ type: 'text', text: 'accepted-steering-prefix' }], - source: { kind: 'plugin', plugin: 'steering-prefix' }, - placement: 'prompt-prefix', - }, - { - content: [{ type: 'text', text: 'accepted-steering-context' }], - source: { kind: 'plugin', plugin: 'steering-context' }, - meta: { kind: 'separate-card' }, - }, - { - content: [{ type: 'text', text: 'accepted-steering-context-without-meta' }], - source: { kind: 'plugin', plugin: 'steering-context-without-meta' }, - }, - ]) - expect(Object.isFrozen(notifiedContent)).toBe(true) - expect(Object.isFrozen(notifiedContent?.[0])).toBe(true) - expect(Object.isFrozen(notifiedSource)).toBe(true) - expect(Object.isFrozen(notifiedContexts)).toBe(true) - const recorded = agent.session.events.flatMap(event => event.type === 'steering/message' ? [event.data] : []) - expect(recorded).toContainEqual({ - turn: 1, - content: [ - { type: 'text', text: 'accepted-steering-prefix' }, - { type: 'text', text: '\n\n## My request:\n' }, - { type: 'text', text: 'accepted-steer' }, - ], - source: { kind: 'plugin', plugin: 'accepted-source' }, - envelope: { - displayContent: [{ type: 'text', text: 'accepted-steer' }], - prefixContexts: [{ - source: { kind: 'plugin', plugin: 'steering-prefix' }, - }], - }, - }) - const request = JSON.stringify(adapter.requests[1]!.messages) - expect(request).toContain('accepted-steer') - expect(request).toContain('accepted-steering-prefix') - expect(request).toContain('accepted-steering-context') - expect(request).toContain('accepted-steering-context-without-meta') - expect(request).not.toContain('caller-mutated-steer') - expect(request).not.toContain('caller-mutated-steering-prefix') - expect(request).not.toContain('caller-mutated-steering-context') - expect(request).not.toContain('caller-mutated-steering-context-without-meta') - - const steeringIndex = agent.session.events.findIndex(event => event.type === 'steering/message') - const contextIndex = agent.session.events.findIndex(event => event.type === 'user/message' - && event.data.source.kind === 'plugin' && event.data.source.plugin === 'steering-context') - expect(steeringIndex).toBeGreaterThanOrEqual(0) - expect(contextIndex).toBe(steeringIndex + 1) - }) }) describe('turn numbering continues across seeded sessions', () => { @@ -982,16 +548,13 @@ describe('turn numbering continues across seeded sessions', () => { ctx2.llm.registerAdapter(['mock'], second) const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] }) - const prepared = prepareReactLoopAgent( - ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + const forked = new ReactLoopAgent( + ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded, ) - const forked = prepared.agent - prepared.markPublished() - ctx2.effect(() => prepared.startDriver()) const turns: number[] = [] ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) - forked.followup([{ type: 'text', text: 'continue' }]) + forked.followup({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }) await new Promise((resolve) => { ctx2.on('agent/status', (subject, status) => { if (subject === forked && status === 'idle') resolve() @@ -1157,7 +720,9 @@ describe('turn and step boundary recovery', () => { if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') } }) const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) + ctx.on('agent/error', (_a, _t, _s, error) => { + if (error instanceof Error) errors.push(error) + }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -1174,6 +739,46 @@ describe('turn and step boundary recovery', () => { expect(stepEndIdx).toBeLessThan(turnEndIdx) }) + it('a pre-commit turn/start rejection leaves no turn state for the next prompt', async () => { + const adapter = new MockAdapter([textResponse('after recovery')]) + const ctx = await balancedHarness(adapter) + const agent = ctx.agentLoop.create(SessionId('a-turnstart-veto'), { provider: 'mock', model: 'mock' }) + let rejected = false + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const event = args[1] as SessionEvent + if (event.type === 'turn/start' && !rejected) { + rejected = true + throw new Error('reject turn-start before commit') + } + }) + const errors: Error[] = [] + ctx.on('agent/error', (_agent, _turn, _step, error) => { + if (error instanceof Error) errors.push(error) + }) + + send(agent, 'rejected') + await waitForIdle(ctx, agent) + + // The rejected turn left nothing behind: no events, no admitted prompt. + expect(agent.session.events).toEqual([]) + expect(errors.map(error => error.message)).toEqual(['reject turn-start before commit']) + + // The next prompt reuses the never-committed turn number and carries only + // its own admitted content — invariants (mounted) accept the log. + send(agent, 'go') + await waitForIdle(ctx, agent) + + expect(boundaryCounts(agent)).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1 }) + const turnStart = agent.session.events.find(event => event.type === 'turn/start') + expect(turnStart?.type === 'turn/start' && turnStart.data.turn).toBe(1) + const prompts = agent.session.events.filter(event => event.type === 'user/message') + expect(prompts.map(event => event.type === 'user/message' && event.data.content)).toEqual([ + [{ type: 'text', text: 'go' }], + ]) + expect(adapter.requests).toHaveLength(1) + }) + it('a pre-commit step/start validation failure does not invent a step boundary', async () => { const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) @@ -1188,7 +793,9 @@ describe('turn and step boundary recovery', () => { } }) const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) }) + ctx.on('agent/error', (_agent, _turn, _step, error) => { + if (error instanceof Error) errors.push(error) + }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -1204,41 +811,6 @@ describe('turn and step boundary recovery', () => { expect(errors.map(error => error.message)).toEqual(['reject step-start before commit']) }) - it('a one-shot turn/end validation failure preserves the earlier turn error on retry', async () => { - const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider failed', code: 'UNKNOWN' } } }] - const adapter = new MockAdapter([errorStream]) - const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(SessionId('a-turnend-veto'), { provider: 'mock', model: 'mock' }) - let rejected = false - ctx.on('internal/dispatch', (_mode, name, args) => { - if (name !== 'session/event') return - const event = args[1] as SessionEvent - if (event.type === 'turn/end' && !rejected) { - rejected = true - throw new Error('reject first turn-end') - } - }) - const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) }) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - expect(errors.map(error => error.message)).toEqual(['provider failed']) - expect(boundaryCounts(agent)).toMatchObject({ - turnStart: 1, - turnEnd: 1, - stepStart: 1, - stepEnd: 1, - errors: 1, - }) - const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ - kind: 'error', - failure: { message: 'provider failed', code: 'UNKNOWN' }, - }) - }) - it('a one-shot step/end validation failure keeps the step open until retry succeeds', async () => { const adapter = new MockAdapter([textResponse('completed before close validation')]) const ctx = await balancedHarness(adapter) @@ -1253,7 +825,9 @@ describe('turn and step boundary recovery', () => { } }) const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) }) + ctx.on('agent/error', (_agent, _turn, _step, error) => { + if (error instanceof Error) errors.push(error) + }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -1342,17 +916,19 @@ describe('turn and step boundary recovery', () => { }, { inject: ['agentLoop'] })) let threw = false - ctx.on('agent/pre-step', () => { + ctx.on('agent/step', () => { if (threw) return threw = true void fiber.dispose() throw new Error('boom pre-step during disposal') }) const errorEmits: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => void errorEmits.push(error)) + ctx.on('agent/error', (_a, _t, _s, error) => { + if (error instanceof Error) errorEmits.push(error) + }) send(agent, 'go') - await driverDone(agent) + await agent.whenIdle() const e = [...agent.session.events] // Balanced: one turn/start, one turn/end carrying disposed (NOT error). @@ -1376,7 +952,9 @@ describe('turn and step boundary recovery', () => { if (!threw && event.type === 'turn/start') { threw = true; throw new Error('boom turn/start append') } }) const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) + ctx.on('agent/error', (_a, _t, _s, error) => { + if (error instanceof Error) errors.push(error) + }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -1407,7 +985,9 @@ describe('turn and step boundary recovery', () => { if (event.type === 'step/end' && !threw) { threw = true; throw new Error('boom step-end') } }) const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) + ctx.on('agent/error', (_a, _t, _s, error) => { + if (error instanceof Error) errors.push(error) + }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -1446,7 +1026,9 @@ describe('turn and step boundary recovery', () => { if (!threw && event.type === 'step/end') { threw = true; throw new Error('boom step/end listener') } }) const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) + ctx.on('agent/error', (_a, _t, _s, error) => { + if (error instanceof Error) errors.push(error) + }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -1539,34 +1121,6 @@ describe('tool result call identity', () => { }) }) -describe('surface: assistant/message records exact empty provenance when no chunks streamed', () => { - it('a step-result listener injecting content over an empty stream records sourceEventSeqs []', async () => { - // The explicit empty source set distinguishes a known empty provider - // stream from legacy events whose provenance was not recorded. - const adapter = new MockAdapter([[]]) - const ctx = await harness(adapter) - await mountInvariants(ctx) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _signal) => ({ - role: 'assistant' as const, - content: [{ type: 'text' as const, text: 'injected' }], - })) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - const recorded = agent.session.events.find(e => e.type === 'assistant/message')! - expect(recorded.type).toBe('assistant/message') - expect(recorded.surfaceOp).toBe('append') - expect(recorded.sourceEventSeqs).toEqual([]) - // The injected content reaches derived history. - expect(JSON.stringify(agent.session.deriveMessages())).toContain('injected') - }) -}) - - - describe('disposal and cancellation during pre-step assembly', () => { it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => { // Start disposal, then release assembly. Do not await disposal first: it @@ -1671,7 +1225,7 @@ describe('disposal and cancellation during pre-step assembly', () => { expect(reasons).toEqual([{ kind: 'aborted' }]) }) - it('disposal during agent/pre-step seam ends the turn disposed', { timeout: 15000 }, async () => { + it('disposal during agent/step listeners ends the turn disposed', { timeout: 15000 }, async () => { // Start disposal, then release pre-step; awaiting disposal first would // deadlock on the blocked driver. const adapter = new MockAdapter(['hang']) @@ -1688,7 +1242,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) - ctx.on('agent/pre-step', async () => { + ctx.on('agent/step', async () => { await blocker }) @@ -1708,13 +1262,13 @@ describe('disposal and cancellation during pre-step assembly', () => { await disposalDone await driverDone(agent) - // After the pre-step seam finishes, the post-seam cancel/dispose check + // After the agent/step listeners finish, the post-listener cancel/dispose check // catches disposal. The step was never opened, no LLM call was made. const e = [...agent.session.events] expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) const turnEnd = e.findLast(x => x.type === 'turn/end') - // Disposal wins the post-seam check — reason is `disposed`. + // Disposal wins the post-listener check — reason is `disposed`. expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) expect(e.some(x => x.type === 'step/start')).toBe(false) expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) @@ -1722,8 +1276,8 @@ describe('disposal and cancellation during pre-step assembly', () => { // (turn boundaries have no agent/* mirror). }) - it('cancel during agent/pre-step seam ends the turn aborted', { timeout: 15000 }, async () => { - // Release pre-step after cancellation to exercise the post-seam check. + it('cancel during agent/step listeners ends the turn aborted', { timeout: 15000 }, async () => { + // Release agent/step after cancellation to exercise the post-listener check. const adapter = new MockAdapter(['hang']) let releasePreStep!: () => void const blocker = new Promise(r => void (releasePreStep = r)) @@ -1738,7 +1292,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) - ctx.on('agent/pre-step', async () => { + ctx.on('agent/step', async () => { await blocker }) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index f8ff5a74b9..f9176be9b1 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, LlmError, StreamChunk, errorChain } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -38,33 +38,9 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function send(agent: Agent, text: string) { - agent.followup([{ type: 'text', text }]) + agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } }) } -describe('inbox acceptance', () => { - it('rejects non-serializable content or source synchronously before notification or enqueue', async () => { - const adapter = new MockAdapter([textResponse('turn 1')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - let queued = 0 - ctx.on('agent/inbox/enqueue', () => { queued += 1 }) - - expect(() => { - agent.followup([{ type: 'text', text: 'first', bad: 1n } as never]) - }).toThrow(/losslessly JSON-serializable/) - expect(() => { - agent.followup([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never }) - }).toThrow(/losslessly JSON-serializable/) - expect(queued).toBe(0) - expect(agent.session.events).toHaveLength(0) - - // The rejected value never woke or poisoned the loop; a valid message runs. - send(agent, 'second') - await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(1) - }) -}) - describe('tool JSON parse', () => { it('passes through non-JSON arguments string without crashing', async () => { const adapter = new MockAdapter([ @@ -127,8 +103,8 @@ describe('tool JSON parse', () => { }) }) -describe('toError normalization', () => { - it('normalizes non-Error throws from pre-commit dispatch validation via the runLoop backstop', async () => { +describe('thrown-value propagation', () => { + it('preserves non-Error throws from pre-commit dispatch validation', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -139,23 +115,25 @@ describe('toError normalization', () => { const event = args[1] as SessionEvent if (event.type === 'turn/start' && !threwOnce) { threwOnce = true - throw 'naked string error' // non-Error throw, normalized via toError + throw 'naked string error' } }) - const errors: Error[] = [] + const errors: unknown[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) send(agent, 'fails before turn start') send(agent, 'survives as the next item') await waitForIdle(ctx, agent) expect(errors).toHaveLength(1) - expect(errors[0]).toMatchObject({ message: 'naked string error', code: 'UNKNOWN' }) + expect(errors[0]).toBe('naked string error') expect(adapter.requests).toHaveLength(1) const starts = agent.session.events.filter(event => event.type === 'turn/start') const ends = agent.session.events.filter(event => event.type === 'turn/end') const messages = agent.session.events.filter(event => event.type === 'user/message') expect(starts).toHaveLength(1) + // The rejected turn/start committed nothing, so the survivor reuses turn 1 + // and the rejected prompt does not leak into it. expect(starts[0]?.type === 'turn/start' && starts[0].data.turn).toBe(1) expect(ends).toHaveLength(1) expect(messages).toHaveLength(1) @@ -164,32 +142,31 @@ describe('toError normalization', () => { ]) }) - it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => { + it('preserves non-Error throws from the agent/request waterfall', async () => { const adapter = new MockAdapter([textResponse('irrelevant')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threwOnce = false - ctx.on('agent/request', async (_agent, _turn, _step, _options, _signal, _next) => { + ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { if (!threwOnce) { threwOnce = true - throw { code: 500 } // non-Error throw, goes through runStep catch + throw { code: 500 } } - return _next() + return next() }) - const errors: Error[] = [] + const errors: unknown[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) send(agent, 'go') await waitForIdle(ctx, agent) expect(errors).toHaveLength(1) - // String() of { code: 500 } is '[object Object]' - expect(errors[0]!.message).toBe('[object Object]') + expect(errors[0]).toEqual({ code: 500 }) const turnEnd = agent.session.events.find(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' && ('failure' in turnEnd.data.reason ? turnEnd.data.reason.failure.code : turnEnd.data.reason.code)) - .toBe('UNKNOWN') + .toBeUndefined() }) }) @@ -200,7 +177,7 @@ describe('coded error data emission', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threwOnce = false - ctx.on('agent/request', async (_agent, _turn, _step, _options, _signal, next) => { + ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { if (!threwOnce) { threwOnce = true throw new LlmError('server overloaded', 'RATE_LIMIT') @@ -208,13 +185,13 @@ describe('coded error data emission', () => { return next() }) - const errors: Error[] = [] + const errors: unknown[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) send(agent, 'go') await waitForIdle(ctx, agent) expect(errors).toHaveLength(1) - expect(errors[0]!.message).toBe('server overloaded') + expect(errorChain(errors[0])).toBe('server overloaded') // turn-end error reason includes the code const turnEnd = agent.session.events.find(e => e.type === 'turn/end') @@ -277,3 +254,295 @@ describe('structured tool error propagation (the runtime-validation Agent Note, .toEqual({ name: 'HarnessError', code: 'BOOM' }) }) }) + +describe('request-error action edges', () => { + it('ignores a retry action returned after the turn was aborted', async () => { + const { LlmError } = await import('@deepseek-ai/dsh-llm') + const adapter = new MockAdapter([ + () => { throw new LlmError('busy', 'RATE_LIMIT') }, + textResponse('never used'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('retry-after-cancel'), { provider: 'mock', model: 'mock' }) + ctx.on('agent/request-error', async (subject) => { + subject.cancel({ kind: 'user' }) + return { kind: 'retry' } + }) + + send(agent, 'go') + await agent.whenIdle() + + // One failed request, no retry turn. + expect(adapter.requests).toHaveLength(1) + const ends = agent.session.events.filter(e => e.type === 'turn/end') + expect(ends).toHaveLength(1) + }) + + it('completed recovery does not retry when cancellation raced the waterfall', async () => { + const { LlmError } = await import('@deepseek-ai/dsh-llm') + const adapter = new MockAdapter([ + () => { throw new LlmError('busy', 'RATE_LIMIT') }, + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('retry-raced'), { provider: 'mock', model: 'mock' }) + ctx.on('agent/request-error', async (subject, _turn, _step, _error, _failure, signal, next) => { + await next() + subject.cancel({ kind: 'user' }) + expect(signal.aborted).toBe(true) + return { kind: 'retry' } + }) + + send(agent, 'go') + await agent.whenIdle() + + expect(adapter.requests).toHaveLength(1) + const end = agent.session.events.findLast(e => e.type === 'turn/end') + expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('aborted') + }) +}) + +describe('stream failure edges', () => { + it('rethrows a mid-stream throw that carries no adapter failure facts', async () => { + const adapter = new MockAdapter([textResponse('will be vetoed')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('stream-no-facts'), { provider: 'mock', model: 'mock' }) + let recoveries = 0 + ctx.on('agent/request-error', async () => { recoveries += 1 }) + // A pre-commit chunk veto throws INSIDE the stream-consumption try, but it + // is not an adapter-boundary failure, so llmFailureOf yields no facts. + let vetoed = false + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const event = args[1] as SessionEvent + if (event.type === 'assistant/chunk' && !vetoed) { + vetoed = true + throw new Error('reject the first chunk') + } + }) + + send(agent, 'go') + await agent.whenIdle() + + // No facts -> not offered to recovery; the turn fails through settle(). + expect(recoveries).toBe(0) + const end = agent.session.events.findLast(e => e.type === 'turn/end') + expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error') + }) +}) + +describe('post-turn continuation edges', () => { + it('whenIdle resolves for a waiter whose awaited run fails', async () => { + const adapter = new MockAdapter([textResponse('unused')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('whenidle-reject'), { provider: 'mock', model: 'mock' }) + let rejected = false + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const event = args[1] as SessionEvent + if (event.type === 'turn/start' && !rejected) { + rejected = true + throw new Error('veto turn start while a waiter is pending') + } + }) + + send(agent, 'go') + await expect(agent.whenIdle()).resolves.toBeUndefined() + expect(agent.status).toBe('idle') + }) +}) + +describe('persistent step-close rejection', () => { + it('still publishes the terminal status when both step-close attempts are vetoed', async () => { + const adapter = new MockAdapter([textResponse('will not close')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('stepend-double-veto'), { provider: 'mock', model: 'mock' }) + // Persistently reject step/end: the catch's own close attempt fails too, + // and the contained failure must not strand status at running. + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const event = args[1] as SessionEvent + if (event.type === 'step/end') throw new Error('step close permanently rejected') + }) + const statuses: string[] = [] + ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) }) + + send(agent, 'go') + await agent.whenIdle() + + expect(agent.status).toBe('idle') + expect(statuses).toEqual(['running', 'idle']) + }) +}) + +describe('tool result meta persistence', () => { + it('records a presentationMeta payload on the tool/result event', async () => { + const { defineTool } = await import('@deepseek-ai/dsh-tools') + const adapter = new MockAdapter([ + toolCallResponse('c1', 'meta-tool', {}), + textResponse('done'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('tool-meta'), { provider: 'mock', model: 'mock' }) + ctx.tools.register(defineTool({ + name: 'meta-tool', + description: 'carries presentation meta', + parameters: {}, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + presentationMeta: () => ({ presentation: 'diff-card' }), + }, + async execute() { + return 'ran' + }, + })) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + const result = agent.session.events.find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.meta).toEqual({ presentation: 'diff-card' }) + }) +}) + +describe('turn close failure containment', () => { + it('a rejected turn/end append is contained: warn + agent/error, no retry', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('turnend-veto'), { provider: 'mock', model: 'mock' }) + let vetoed = false + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const event = args[1] as SessionEvent + if (event.type === 'turn/end' && !vetoed) { + vetoed = true + throw new Error('reject turn end') + } + }) + const errors: unknown[] = [] + ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) }) + + send(agent, 'go') + await agent.whenIdle() + + // The close failure is reported live; the machine still reaches idle. + expect(errors.map(e => e instanceof Error && e.message)).toContain('reject turn end') + expect(agent.status).toBe('idle') + expect(adapter.requests).toHaveLength(1) + }) +}) + +describe('recovery without a retry action', () => { + it('a completed recovery that returns no action leaves the failed turn terminal', async () => { + const { LlmError } = await import('@deepseek-ai/dsh-llm') + const adapter = new MockAdapter([ + () => { throw new LlmError('down', 'SERVICE_UNAVAILABLE') }, + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('recovery-no-retry'), { provider: 'mock', model: 'mock' }) + let recoveries = 0 + ctx.on('agent/request-error', async () => { recoveries += 1 }) + + send(agent, 'go') + await agent.whenIdle() + + expect(recoveries).toBe(1) + expect(adapter.requests).toHaveLength(1) + const end = agent.session.events.findLast(e => e.type === 'turn/end') + expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error') + }) +}) + +describe('unrenderable failure settlement', () => { + it('drops the rendered message when the error chain cannot be rendered', async () => { + const { LlmError } = await import('@deepseek-ai/dsh-llm') + const adapter = new MockAdapter([ + () => { + const error = new LlmError('will become hostile', 'SERVER') + // A hostile message getter makes errorChain collapse to its sentinel; + // settle() must then fall back to the failure facts alone. + Object.defineProperty(error, 'message', { + get() { throw new Error('hostile accessor') }, + }) + throw error + }, + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('unrenderable'), { provider: 'mock', model: 'mock' }) + + send(agent, 'go') + await agent.whenIdle() + + const end = agent.session.events.findLast(e => e.type === 'turn/end') + expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error') + if (end?.type === 'turn/end' && end.data.reason.kind === 'error') { + // The durable failure keeps the adapter facts' message, not the + // unrenderable chain. + expect(end.data.reason.failure?.message).not.toBe('') + } + }) +}) + +describe('driver bookkeeping edges', () => { + it('a deferred wake settles when replacement activity rejects', async () => { + const adapter = new MockAdapter([]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('rejected-deferred-wake'), { + provider: 'mock', + model: 'mock', + }) + ctx.on('agent/inbox/enqueue', (subject) => { + if (subject !== agent) return + subject.cancel({ kind: 'user' }) + const mutable = subject as Agent & { done: Promise } + mutable.done = Promise.reject(new Error('replacement rejected')) + }) + + send(agent, 'cancel before wake') + + await expect(agent.whenIdle()).resolves.toBeUndefined() + 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 + // already appended step/end, so the request-failed branch's own + // step-close guard must see stepOpen === false and skip the append. + const adapter = new MockAdapter([ + [ + { type: 'usage' as const, usage: { inputTokens: 1, outputTokens: 0 } }, + { type: 'finish' as const, reason: { kind: 'error' as const, failure: { message: 'empty', code: 'EMPTY_RESPONSE' } } }, + ] satisfies StreamChunk[], + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('finish-after-close'), { provider: 'mock', model: 'mock' }) + void LlmError + + send(agent, 'go') + await agent.whenIdle() + + const types = agent.session.events.map(e => e.type) + expect(types.filter(t => t === 'step/end')).toHaveLength(1) + const end = agent.session.events.findLast(e => e.type === 'turn/end') + expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error') + }) +}) diff --git a/packages/core/agent-loop/tests/inbox-invariant.spec.ts b/packages/core/agent-loop/tests/inbox-invariant.spec.ts deleted file mode 100644 index 0d907f26d7..0000000000 --- a/packages/core/agent-loop/tests/inbox-invariant.spec.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Regression: the dsh-agent FIFO-conservation invariant must stay balanced on - * the loop-authored continuation-reason steering path. A continue-with-reason - * decision enters the steering FIFO and later drains (or is discarded by - * cancel); both must be matched by an enqueue event so the invariant's - * outstanding count never goes negative. - * @module dsh-agent-loop/tests/inbox-invariant - */ - -import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' -import InvariantService from '@deepseek-ai/dsh-invariants' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { MockAdapter, textResponse } from './mock-adapter.ts' - -async function harness(adapter: MockAdapter) { - const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) - await ctx.plugin(InvariantService) - await ctx.plugin(AgentInvariant) - await ctx.plugin(AgentLoop, { agents: [] }) - ctx.llm.registerAdapter(['mock'], adapter) - return ctx -} - -function waitForIdle(ctx: Context, agent: Agent): Promise { - return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { - if (subject === agent && status === 'idle') { dispose(); resolve() } - }) - }) -} - -describe('inbox FIFO-conservation invariant', () => { - it('stays balanced when a continuation reason enters and drains the steering FIFO', async () => { - const adapter = new MockAdapter([textResponse('step 1'), textResponse('step 2')]) - const ctx = await harness(adapter) - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - let forced = false - ctx.on('agent/turn-continuation', async (_agent, _turn, _default, _signal, next) => { - if (forced) return next() - forced = true - return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } } - }) - - agent.followup([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - - expect(adapter.requests).toHaveLength(2) - // The continuation reason drained as a steering/message on the second step. - expect(agent.session.events.some(e => e.type === 'steering/message')).toBe(true) - // No invariant violation was logged. - expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) - expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false) - }) - - it('stays balanced when cancel discards a pending continuation reason', async () => { - const adapter = new MockAdapter([textResponse('only step')]) - const ctx = await harness(adapter) - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - // Force a continuation reason, then cancel from the same checkpoint so the - // reason sits in the steering FIFO when the inbox is discarded. - ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => { - if (subject !== agent) return next() - queueMicrotask(() => { agent.cancel({ kind: 'user' }) }) - return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } } - }) - - agent.followup([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - - expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) - expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false) - }) - - it('stays balanced when a terminal stop discards pending steering', async () => { - const adapter = new MockAdapter([textResponse('done')]) - const ctx = await harness(adapter) - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - const discards: number[] = [] - ctx.on('agent/inbox/discard', (subject, messages) => { if (subject === agent) discards.push(messages.length) }) - - // A continuation reason enqueues a steering item; a terminal stop then drops - // it. The drop must emit a discard so the enqueue ⇒ dequeue-or-discard - // ledger stays balanced (no dangling outstanding id). - ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => { - if (subject !== agent) return next() - return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } } - }) - let stopped = false - ctx.on('agent/turn-stop', (subject) => { - if (subject !== agent || stopped) return undefined - stopped = true - return { action: 'stop' as const } - }) - - agent.followup([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - - expect(discards).toEqual([1]) // the dropped steering item was reported - expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) - expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false) - }) - - it('stays balanced when late steering lands after a terminal stop (post-turn flush window)', async () => { - const adapter = new MockAdapter([textResponse('done')]) - const ctx = await harness(adapter) - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - let enqueues = 0 - const discards: number[] = [] - ctx.on('agent/inbox/enqueue', (subject) => { if (subject === agent) enqueues += 1 }) - ctx.on('agent/inbox/discard', (subject, messages) => { if (subject === agent) discards.push(messages.length) }) - - // Terminal-stop the turn, then steer during the post-turn flush window - // (status is still running). That late steer is drained by runLoop and - // dropped because the turn terminally stopped; it must still be discarded so - // its enqueue is matched (the drain sits on a different code path than the - // in-turn terminal-stop drop). - ctx.on('agent/turn-stop', subject => (subject === agent ? { action: 'stop' as const } : undefined)) - let steered = false - ctx.on('session/flush', (session) => { - if (session !== agent.session || steered) return - steered = true - agent.steer([{ type: 'text', text: 'late' }], { source: { kind: 'plugin', plugin: 'late' } }) - }) - - agent.followup([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - - // The prompt plus the late steer both enqueued; both are matched (the prompt - // dequeued, the late steer discarded) so no id is left outstanding. - expect(enqueues).toBe(2) - expect(discards).toEqual([1]) - expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) - expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false) - }) -}) diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts deleted file mode 100644 index 289cd2f6d5..0000000000 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { AgentMessageId } from '@deepseek-ai/dsh-agent' -import { Inbox, agentMessage } from '../src/inbox.ts' - -function message(text: string) { - return { id: AgentMessageId(text), content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [], wakeup: true } -} - -describe('agentMessage', () => { - it('returns a frozen payload so a listener cannot mutate it for later listeners', () => { - const payload = agentMessage(message('m'), false) - expect(Object.isFrozen(payload)).toBe(true) - expect(() => { (payload as { id: string }).id = 'mutated' }).toThrow() - expect(payload.id).toBe(AgentMessageId('m')) - }) -}) - -function resolverPair() { - let r!: () => void - const p = new Promise((resolve) => { r = resolve }) - return { promise: p, resolve: r } -} - -describe('Inbox', () => { - it('dequeues one queued message at a time in FIFO order', () => { - const inbox = new Inbox() - inbox.enqueue(message('first')) - inbox.enqueue(message('second')) - expect(inbox.hasQueued).toBe(true) - - expect(inbox.dequeueQueued()?.content[0]).toMatchObject({ text: 'first' }) - expect(inbox.hasQueued).toBe(true) - expect(inbox.dequeueQueued()?.content[0]).toMatchObject({ text: 'second' }) - expect(inbox.hasQueued).toBe(false) - expect(inbox.dequeueQueued()).toBeUndefined() - }) - - it('enqueue(msg, false) queues without waking a parked waiter', async () => { - const inbox = new Inbox() - let woke = false - const waiter = inbox.waitForQueued(new Promise(() => {})).then(() => { woke = true }) - inbox.enqueue(message('quiet'), false) - // The item is queued, but the parked waiter was not resolved by it. - expect(inbox.hasQueued).toBe(true) - await Promise.resolve() - expect(woke).toBe(false) - // A later waking enqueue resolves the same waiter. - inbox.enqueue(message('loud')) - await waiter - expect(woke).toBe(true) - }) - - it('pending() snapshots queued then steering without removing them', () => { - const inbox = new Inbox() - inbox.enqueue(message('q')) - inbox.steer(message('s')) - const pending = inbox.pending() - expect(pending.map(p => p.steering)).toEqual([false, true]) - // Snapshot does not drain the FIFOs. - expect(inbox.hasQueued).toBe(true) - expect(inbox.hasSteering).toBe(true) - }) - - it('pushes and drains steering messages separately from queued', () => { - const inbox = new Inbox() - inbox.steer(message('steer')) - expect(inbox.hasQueued).toBe(false) - expect(inbox.hasSteering).toBe(true) - - const steering = inbox.drainSteering() - expect(steering).toHaveLength(1) - expect(inbox.hasSteering).toBe(false) - }) - - it('waitForQueued returns immediately when a queued message is already present', async () => { - const inbox = new Inbox() - inbox.enqueue(message('ready')) - - const started = Date.now() - await inbox.waitForQueued(new Promise(() => {})) // never-resolving cancel - expect(Date.now() - started).toBeLessThan(50) - }) - - it('waitForQueued resolves when a message is enqueued', async () => { - const inbox = new Inbox() - const waiter = inbox.waitForQueued(new Promise(() => {})) // never-resolving cancel - // enqueue after starting the wait - setTimeout(() => { inbox.enqueue(message('wake')) }, 5) - await waiter - }) - - it('waitForQueued resolves when the cancel promise resolves', async () => { - const inbox = new Inbox() - const { promise, resolve } = resolverPair() - const waiter = inbox.waitForQueued(promise) - resolve() - await waiter - }) - - it('waitForQueued overwrites the previous wakeup callback (only the latest waiter is notified)', async () => { - const inbox = new Inbox() - const { promise: p1, resolve: r1 } = resolverPair() - - void inbox.waitForQueued(new Promise(() => {})) // first call, never resolved - void inbox.waitForQueued(p1) // second call overwrites wakeup - - // Cancelling the latest waiter clears the shared callback; enqueue must neither - // wake the stale waiter nor fail on the cleared callback. - r1() - await p1 - - inbox.enqueue(message('hey')) - }) - - it('clears wakeup in finally handler when enqueue resolves', async () => { - const inbox = new Inbox() - void inbox.waitForQueued(new Promise(() => {})) // never-resolving cancel - // The wakeup is set. Now trigger it via enqueue → wakeup() calls resolve, - // promise resolves, finally clears wakeup because wakeup === resolve. - inbox.enqueue(message('wake')) - // No explicit await needed — enqueue is synchronous, and the microtask - // (finally) runs. The key coverage hit is finally with wakeup === resolve. - }) - - it('finally handler does not clear wakeup when a different waiter overwrote it', async () => { - // A stale waiter's finally must not clear the replacement waiter. - const inbox = new Inbox() - const { promise: c1, resolve: r1 } = resolverPair() - - void inbox.waitForQueued(c1) // wakeup = resolve1, c1.then(resolve1) - void inbox.waitForQueued(new Promise(() => {})) // wakeup = resolve2, cancel never resolves - - r1() - await c1 - - // The replacement remains registered and is resolved by enqueue. - inbox.enqueue(message('hey')) - }) -}) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index de721b1653..2c057b8a70 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -1,18 +1,29 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import LlmService, { CallId } from '@deepseek-ai/dsh-llm' +import SessionStore, { + SessionId, + type SessionEvent, + type TurnEndReason, + type UserMessageData, +} from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { type Agent, type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { + type Agent, + type AgentMessage, + type InboxPlacement, + type PromptDecision, + type SessionStartSource, +} from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' /** * The interception seams introduced by the hooks taxonomy: `agent/prompt-submit`, - * `agent/session-start`, the reshaped `agent/turn-continuation` - * ({@link ContinuationDecision}), and the `tools/pre-execute` / `tools/post-execute` + * `agent/session-start`, `agent/turn-stopping`, and the + * `tools/pre-execute` / `tools/post-execute` * split with `additionalContexts` buffering. These verify the canonical event * surface a hook bridge (or a native plugin) programs against, WITHOUT any * external protocol — a native plugin uses the typed decisions directly. @@ -42,7 +53,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function send(agent: Agent, text: string) { - agent.followup([{ type: 'text', text }]) + agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } }) } function events(agent: Agent): SessionEvent[] { @@ -69,6 +80,57 @@ describe('agent/prompt-submit', () => { expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'hello' }]) }) + it('snapshots and freezes input before publishing or awaiting admission', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('owned-input'), { provider: 'mock', model: 'mock' }) + const entered = Promise.withResolvers() + const decision = Promise.withResolvers() + const observed: AgentMessage[] = [] + ctx.on('agent/inbox/enqueue', (subject, message) => { + if (subject !== agent) return + expect(Object.isFrozen(message)).toBe(true) + expect(Object.isFrozen(message.content)).toBe(true) + expect(Object.isFrozen(message.content[0])).toBe(true) + expect(Object.isFrozen(message.source)).toBe(true) + expect(() => { + const block = message.content[0] + if (block?.type === 'text') block.text = 'listener mutation' + }).toThrow() + }) + ctx.on('agent/inbox/enqueue', (subject, message) => { + if (subject === agent) observed.push(message) + }) + ctx.on('agent/prompt-submit', async () => { + entered.resolve(undefined) + return decision.promise + }) + const input: UserMessageData = { + content: [{ type: 'text', text: 'accepted text' }], + source: { kind: 'plugin', plugin: 'accepted source' }, + } + + const idle = waitForIdle(ctx, agent) + agent.followup(input) + await entered.promise + const block = input.content[0] + if (block?.type === 'text') block.text = 'caller mutation' + if (input.source.kind === 'plugin') input.source.plugin = 'caller mutation' + decision.resolve({ kind: 'allow' }) + await idle + + expect(observed).toHaveLength(1) + expect(observed[0]).toMatchObject({ + content: [{ type: 'text', text: 'accepted text' }], + source: { kind: 'plugin', plugin: 'accepted source' }, + }) + const userMsg = events(agent).find(event => event.type === 'user/message') + expect(userMsg?.type === 'user/message' && userMsg.data).toEqual({ + content: [{ type: 'text', text: 'accepted text' }], + source: { kind: 'plugin', plugin: 'accepted source' }, + }) + }) + it('allow with content REWRITES the prompt before it is recorded', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) @@ -92,14 +154,12 @@ describe('agent/prompt-submit', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - const meta = { kind: 'prompt-context', version: 1 } ctx.on('agent/prompt-submit', async (): Promise => ({ kind: 'allow', additionalContexts: [{ content: [{ type: 'text', text: 'extra ctx' }], source: { kind: 'plugin', plugin: 'test' }, - meta, }], })) @@ -112,60 +172,10 @@ describe('agent/prompt-submit', () => { expect(userMsg).toBeDefined() expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }]) expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) - expect(ctxMsg?.type === 'user/message' && ctxMsg.data.meta).toEqual(meta) const sent = JSON.stringify(adapter.requests[0]!.messages) expect(sent).toContain('extra ctx') }) - it('bakes prompt-prefix contexts and a request delimiter into one durable user message', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('prefixed'), { provider: 'mock', model: 'mock' }) - - ctx.on('agent/prompt-submit', async (_agent, _content, _source, _signal, next): Promise => { - const downstream = await next() - return downstream.kind === 'block' - ? downstream - : { ...downstream, content: [{ type: 'text', text: 'rewritten request' }] } - }) - agent.followup([{ type: 'text', text: 'original request' }], { - contexts: [{ - content: [{ type: 'text', text: 'untrusted prefix' }], - source: { kind: 'plugin', plugin: 'prefix' }, - placement: 'prompt-prefix', - meta: { kind: 'prefix-card' }, - }], - }) - await waitForIdle(ctx, agent) - - const log = events(agent) - const user = log.find(event => event.type === 'user/message') - expect(user?.type === 'user/message' && user.data).toEqual({ - content: [ - { type: 'text', text: 'untrusted prefix' }, - { type: 'text', text: '\n\n## My request:\n' }, - { type: 'text', text: 'rewritten request' }, - ], - source: { kind: 'user' }, - envelope: { - displayContent: [{ type: 'text', text: 'rewritten request' }], - prefixContexts: [{ - source: { kind: 'plugin', plugin: 'prefix' }, - meta: { kind: 'prefix-card' }, - }], - }, - }) - expect(log.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false) - expect(adapter.requests[0]?.messages.at(-1)).toEqual({ - role: 'user', - content: [ - { type: 'text', text: 'untrusted prefix' }, - { type: 'text', text: '\n\n## My request:\n' }, - { type: 'text', text: 'rewritten request' }, - ], - }) - }) - it('runs pre-step after prompt rewrites and injected context become durable', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) @@ -179,7 +189,7 @@ describe('agent/prompt-submit', () => { })) let preStepDerived: string | undefined - ctx.on('agent/pre-step', (subject, _turn, step) => { + ctx.on('agent/step', (subject, _turn, step) => { if (subject === agent && step === 1) preStepDerived = JSON.stringify(subject.session.deriveMessages()) }) @@ -192,7 +202,7 @@ describe('agent/prompt-submit', () => { expect(preStepDerived).not.toContain('ORIGINAL prompt') }) - it('block drops the (only) prompt → zero-step turn ends rejected, model never called', async () => { + it('block drops the claimed prompt before any turn or model call', 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' }) @@ -203,29 +213,228 @@ describe('agent/prompt-submit', () => { const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) - agent.followup([{ type: 'text', text: 'do something' }], { - contexts: [{ content: [{ type: 'text', text: 'must be dropped' }], source: { kind: 'plugin', plugin: 'test' } }], - }) - await waitForIdle(ctx, agent) + agent.followup({ content: [{ type: 'text', text: 'do something' }], source: { kind: 'user' } }) + await agent.whenIdle() // the model was never called expect(adapter.requests).toHaveLength(0) - // the turn opened and closed balanced, with no user/message and no step const log = events(agent) - expect(log.some(e => e.type === 'turn/start')).toBe(true) - expect(log.some(e => e.type === 'turn/end')).toBe(true) + expect(log.some(e => e.type === 'turn/start')).toBe(false) + expect(log.some(e => e.type === 'turn/end')).toBe(false) expect(log.some(e => e.type === 'user/message')).toBe(false) expect(log.some(e => e.type === 'step/start')).toBe(false) - // the veto is recorded durably as a prompt/blocked in the open turn - const blocked = log.find(e => e.type === 'prompt/blocked') - expect(blocked?.type === 'prompt/blocked' && blocked.data).toMatchObject({ - content: [{ type: 'text', text: 'do something' }], - reason: 'blocked by policy', + expect(reasons).toEqual([]) + }) + + it('stages inject and steer during admission for the admitted turn', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('admission-outbox'), { provider: 'mock', model: 'mock' }) + const entered = Promise.withResolvers() + const decision = Promise.withResolvers() + const placements: InboxPlacement[] = [] + ctx.on('agent/prompt-submit', async () => { + entered.resolve(undefined) + return decision.promise }) - // ended rejected with the block reason - expect(reasons).toEqual([{ kind: 'rejected', reason: 'blocked by policy' }]) - const turnEnd = log.findLast(e => e.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'rejected', reason: 'blocked by policy' }) + ctx.on('agent/inbox/enqueue', (subject, _message, placement) => { + if (subject === agent) placements.push(placement) + }) + + const idle = waitForIdle(ctx, agent) + send(agent, 'admitted prompt') + await entered.promise + expect(agent.status).toBe('running') + expect(agent.acceptsNextStep).toBe(true) + expect(events(agent).some(event => event.type === 'turn/start')).toBe(false) + + agent.inject({ + content: [{ type: 'text', text: 'attached context' }], + source: { kind: 'plugin', plugin: 'test' }, + }) + agent.steer({ content: [{ type: 'text', text: 'admission steering' }], source: { kind: 'user' } }) + expect(events(agent).some(event => event.type === 'user/message')).toBe(false) + expect(placements).toEqual(['queued', 'steering']) + + decision.resolve({ kind: 'allow' }) + await idle + expect(agent.acceptsNextStep).toBe(false) + + const staged = events(agent).filter(event => + event.type === 'turn/start' || event.type === 'user/message' || event.type === 'steering/message') + expect(staged.map(event => event.type)).toEqual([ + 'turn/start', + 'user/message', + 'user/message', + 'steering/message', + ]) + expect(staged[1]?.type === 'user/message' && staged[1].data.content) + .toEqual([{ type: 'text', text: 'admitted prompt' }]) + expect(staged[2]?.type === 'user/message' && staged[2].data.content) + .toEqual([{ type: 'text', text: 'attached context' }]) + expect(staged[3]?.type === 'steering/message' && staged[3].data.content) + .toEqual([{ type: 'text', text: 'admission steering' }]) + const request = JSON.stringify(adapter.requests[0]?.messages) + expect(request).toContain('admitted prompt') + expect(request).toContain('attached context') + expect(request).toContain('admission steering') + }) + + it('keeps admission-time outbox input staged when admission is blocked', async () => { + const adapter = new MockAdapter([textResponse('retried')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('blocked-admission-outbox'), { provider: 'mock', model: 'mock' }) + const entered = Promise.withResolvers() + const decision = Promise.withResolvers() + const disposeBlock = ctx.on('agent/prompt-submit', async () => { + entered.resolve(undefined) + return decision.promise + }) + + const blockedIdle = waitForIdle(ctx, agent) + send(agent, 'blocked prompt') + await entered.promise + expect(agent.acceptsNextStep).toBe(true) + agent.inject({ + content: [{ type: 'text', text: 'staged context' }], + source: { kind: 'plugin', plugin: 'test' }, + }) + agent.steer({ content: [{ type: 'text', text: 'staged steering' }], source: { kind: 'user' } }) + decision.resolve({ kind: 'block', reason: 'policy' }) + await blockedIdle + + expect(agent.acceptsNextStep).toBe(false) + expect(events(agent)).toEqual([]) + expect(adapter.requests).toEqual([]) + + disposeBlock() + send(agent, 'resume') + await waitForIdle(ctx, agent) + + const staged = events(agent).filter(event => + event.type === 'user/message' || event.type === 'steering/message') + expect(staged.map(event => event.type)).toEqual([ + 'user/message', + 'steering/message', + 'user/message', + ]) + expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('blocked prompt') + expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged context') + expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged steering') + }) + + it('orders rejected-admission outbox input before a later admitted prompt', async () => { + const adapter = new MockAdapter([textResponse('continued')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('rejected-admission-order'), { + provider: 'mock', + model: 'mock', + }) + ctx.on('agent/prompt-submit', async (_agent, content, _source, _signal, next) => { + const decision = await next() + return content.some(block => block.type === 'text' && block.text === 'blocked prompt') + ? { kind: 'block', reason: 'policy' } + : decision + }) + ctx.on('agent/prompt-submit', async (subject, content, _source, _signal, next) => { + if (content.some(block => block.type === 'text' && block.text === 'blocked prompt')) { + subject.inject({ + content: [{ type: 'text', text: 'earlier state change' }], + source: { kind: 'plugin', plugin: 'test' }, + }) + subject.steer({ + content: [{ type: 'text', text: 'earlier steering' }], + source: { kind: 'user' }, + }) + } + return next() + }) + + const idle = waitForIdle(ctx, agent) + send(agent, 'blocked prompt') + send(agent, 'later prompt') + await idle + + const staged = events(agent).filter(event => + event.type === 'turn/start' || event.type === 'user/message' || event.type === 'steering/message') + expect(staged.map(event => event.type)).toEqual([ + 'turn/start', + 'user/message', + 'steering/message', + 'user/message', + ]) + expect(staged[1]?.type === 'user/message' && staged[1].data.content) + .toEqual([{ type: 'text', text: 'earlier state change' }]) + expect(staged[2]?.type === 'steering/message' && staged[2].data.content) + .toEqual([{ type: 'text', text: 'earlier steering' }]) + expect(staged[3]?.type === 'user/message' && staged[3].data.content) + .toEqual([{ type: 'text', text: 'later prompt' }]) + }) + + it('commits context-only injection when admission closes without a turn', async () => { + const adapter = new MockAdapter([]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('blocked-admission-context'), { provider: 'mock', model: 'mock' }) + const entered = Promise.withResolvers() + const decision = Promise.withResolvers() + ctx.on('agent/prompt-submit', async () => { + entered.resolve(undefined) + return decision.promise + }) + + const idle = waitForIdle(ctx, agent) + send(agent, 'blocked prompt') + await entered.promise + agent.inject({ + content: [{ type: 'text', text: 'independent context' }], + source: { kind: 'plugin', plugin: 'test' }, + }) + decision.resolve({ kind: 'block', reason: 'policy' }) + await idle + + const log = events(agent) + expect(log.map(event => event.type)).toEqual(['user/message']) + expect(log[0]?.type === 'user/message' && log[0].data.content) + .toEqual([{ type: 'text', text: 'independent context' }]) + expect(adapter.requests).toEqual([]) + }) + + it('retains rejected-admission context when its idle append fails', async () => { + const adapter = new MockAdapter([textResponse('retried')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('blocked-admission-append-failure'), { + provider: 'mock', + model: 'mock', + }) + const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + vi.spyOn(agent.session, 'append').mockImplementationOnce(() => { + throw new Error('append unavailable') + }) + const entered = Promise.withResolvers() + const decision = Promise.withResolvers() + const disposeBlock = ctx.on('agent/prompt-submit', async () => { + entered.resolve(undefined) + return decision.promise + }) + + agent.followup({ content: [{ type: 'text', text: 'blocked prompt' }], source: { kind: 'user' } }) + await entered.promise + agent.inject({ + content: [{ type: 'text', text: 'retained context' }], + source: { kind: 'plugin', plugin: 'test' }, + }) + decision.resolve({ kind: 'block', reason: 'policy' }) + await agent.whenIdle() + + expect(events(agent)).toEqual([]) + expect(warned).toHaveBeenCalledWith(expect.stringContaining('append unavailable')) + + disposeBlock() + send(agent, 'resume') + await waitForIdle(ctx, agent) + + expect(events(agent).some(event => event.type === 'user/message' + && JSON.stringify(event.data.content).includes('retained context'))).toBe(true) }) it('adjacent blocked and allowed prompts keep independent turn outcomes', async () => { @@ -241,7 +450,7 @@ describe('agent/prompt-submit', () => { const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) - // Both sends land before the driver wakes, but each remains its own turn. + // The rejected admission is dropped; the allowed prompt owns the only turn. send(agent, 'secret') send(agent, 'safe') await waitForIdle(ctx, agent) @@ -252,21 +461,11 @@ describe('agent/prompt-submit', () => { expect(userMsgs).toHaveLength(1) expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }]) expect(adapter.requests.length).toBeGreaterThanOrEqual(1) - // the blocked prompt is durably recorded, with its content + reason - const blocked = log.filter(e => e.type === 'prompt/blocked') - expect(blocked).toHaveLength(1) - expect(blocked[0]?.type === 'prompt/blocked' && blocked[0].data).toMatchObject({ - content: [{ type: 'text', text: 'secret' }], - reason: 'policy: no secrets', - }) - expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2) - expect(reasons).toEqual([ - { kind: 'rejected', reason: 'policy: no secrets' }, - { kind: 'completed' }, - ]) + expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1) + expect(reasons).toEqual([{ kind: 'completed' }]) }) - it('a throwing prompt-submit listener ends its turn balanced while an adjacent message survives', async () => { + it('a throwing prompt-submit listener drops that admission while an adjacent message survives', async () => { const adapter = new MockAdapter([textResponse('after')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -279,7 +478,9 @@ describe('agent/prompt-submit', () => { const errors: Error[] = [] const reasons: TurnEndReason[] = [] const statuses: string[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) + ctx.on('agent/error', (_a, _t, _s, error) => { + if (error instanceof Error) errors.push(error) + }) ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) }) ctx.on('session/event', (session, event) => { if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason) @@ -289,16 +490,11 @@ describe('agent/prompt-submit', () => { send(agent, 'first') send(agent, 'second') await idle - expect(errors.map(e => e.message)).toEqual(['prompt hook broke']) - // The failed prompt forms one balanced error turn; the adjacent prompt forms - // the following normal turn without an intermediate idle transition. + expect(errors).toEqual([]) const log = events(agent) - expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2) - expect(log.filter(e => e.type === 'turn/end')).toHaveLength(2) - expect(reasons).toEqual([ - { kind: 'error', step: 0, message: 'prompt hook broke' }, - { kind: 'completed' }, - ]) + expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1) + expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1) + expect(reasons).toEqual([{ kind: 'completed' }]) expect(statuses).toEqual(['running', 'idle']) expect(adapter.requests).toHaveLength(1) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('second') @@ -329,7 +525,7 @@ describe('agent/session-start', () => { const ctx = await harness(adapter) ctx.on('agent/session-start', (agent) => { - agent.inject([{ type: 'text', text: 'session preamble' }], { source: { kind: 'plugin', plugin: 'test' } }) + agent.inject({ content: [{ type: 'text', text: 'session preamble' }], source: { kind: 'plugin', plugin: 'test' } }) }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -360,236 +556,6 @@ describe('agent/session-start', () => { }) }) -describe('agent/session-prefix', () => { - it('dispatches to global and matching agent-scope listeners only', async () => { - const adapter = new MockAdapter([textResponse('a done'), textResponse('b done')]) - const ctx = await harness(adapter) - const agentA = ctx.agentLoop.create(SessionId('prefix-a'), { provider: 'mock', model: 'mock' }) - const agentB = ctx.agentLoop.create(SessionId('prefix-b'), { provider: 'mock', model: 'mock' }) - const seen: string[] = [] - ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => { - seen.push(`global:${agent.id}`) - return next() - }) - agentA.ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => { - seen.push(`a:${agent.id}`) - return next() - }) - agentB.ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => { - seen.push(`b:${agent.id}`) - return next() - }) - - send(agentA, 'run a') - await waitForIdle(ctx, agentA) - send(agentB, 'run b') - await waitForIdle(ctx, agentB) - - expect(seen).toEqual([ - 'global:prefix-a', 'a:prefix-a', - 'global:prefix-b', 'b:prefix-b', - ]) - }) - - it('composes once per loop instance and fronts every request; the header records it; history stays untouched', async () => { - const adapter = new MockAdapter([ - toolCallResponse('c1', 'echo', { text: 'ping' }), - textResponse('done'), - textResponse('again'), - ]) - const ctx = await harness(adapter) - ctx.tools.register(defineContentToolFixture({ - name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, - async execute(args) { return [{ type: 'text', text: String(args.text) }] }, - })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - const reminder: Message = { role: 'user', content: [{ type: 'text', text: 'catalog' }] } - let composed = 0 - ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise => { - composed += 1 - return [...await next(), reminder] - }) - - send(agent, 'go') - await waitForIdle(ctx, agent) - send(agent, 'next turn') - await waitForIdle(ctx, agent) - - // Three requests (two turns), ONE composition: the frozen product is - // reused verbatim, so the prefix cannot drift mid-session. - expect(adapter.requests).toHaveLength(3) - expect(composed).toBe(1) - for (const request of adapter.requests) { - expect(request.messages[0]).toEqual(reminder) - } - // The anchoring snapshot is the prefix's durable record — and the ONLY - // header event: reuse means no changed snapshot ever. - const headerEvents = events(agent).filter(e => e.type === 'request/header') - expect(headerEvents).toHaveLength(1) - expect(headerEvents[0]?.type === 'request/header' && headerEvents[0].data.header.messagePrefix).toEqual([reminder]) - // Never session history: the derivation starts at the real user prompt. - expect(agent.session.deriveMessages()[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'go' }] }) - }) - - it('composes before the first pre-step and records the prefix on the request header', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - const reminder: Message = { role: 'user', content: [{ type: 'text', text: 'opener' }] } - const order: string[] = [] - ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise => { - order.push('compose') - return [reminder, ...await next()] - }) - ctx.on('agent/pre-step', () => { - order.push('pre-step') - }) - - send(agent, 'hi') - await waitForIdle(ctx, agent) - - expect(order).toEqual(['compose', 'pre-step']) - expect(agent.session.requestHeader()?.messagePrefix).toEqual([reminder]) - }) - - it('the canonical prepend pattern composes contributions in registration order', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - // Both listeners use the canonical `[mine, ...await next()]` prepend: the - // waterfall unwinds innermost-first (the second listener's array is built - // first), so prepending puts the FIRST-registered contribution first. - ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise => { - return [{ role: 'user', content: [{ type: 'text', text: 'first' }] }, ...await next()] - }) - ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise => { - return [{ role: 'user', content: [{ type: 'text', text: 'second' }] }, ...await next()] - }) - - send(agent, 'hi') - await waitForIdle(ctx, agent) - - const texts = adapter.requests[0]!.messages.map(m => m.content[0]?.type === 'text' ? m.content[0].text : '') - expect(texts).toEqual(['first', 'second', 'hi']) - }) - - it('with no contributions the header omits messagePrefix and the request is the bare derivation', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - // A listener that delegates without contributing — the canonical no-op. - ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => next()) - - send(agent, 'hi') - await waitForIdle(ctx, agent) - - const headerEvent = events(agent).find(e => e.type === 'request/header') - expect(headerEvent?.type === 'request/header' && 'messagePrefix' in headerEvent.data.header).toBe(false) - expect(adapter.requests[0]!.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]) - }) - - it('the frozen seed rejects in-place mutation — a contribution is a returned extension', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - let mutationError: unknown - ctx.on('agent/session-prefix', async (_agent, prefix, _signal, next): Promise => { - try { - prefix.push({ role: 'user', content: [{ type: 'text', text: 'smuggled' }] }) - } catch (error: unknown) { - mutationError = error - } - return next() - }) - - send(agent, 'hi') - await waitForIdle(ctx, agent) - - expect(mutationError).toBeInstanceOf(TypeError) - expect(adapter.requests[0]!.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]) - }) - - it('mutating a listener-held reference after composition cannot alter later requests (the cache is a frozen clone)', async () => { - const adapter = new MockAdapter([ - toolCallResponse('c1', 'echo', { text: 'ping' }), - textResponse('done'), - ]) - const ctx = await harness(adapter) - ctx.tools.register(defineContentToolFixture({ - name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, - async execute(args) { return [{ type: 'text', text: String(args.text) }] }, - })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - const held: Message = { role: 'user', content: [{ type: 'text', text: 'v1' }] } - ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise => [...await next(), held]) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - // The listener mutates the object it contributed AFTER composition; the - // cached prefix is a deep-frozen clone, so step 2's request is unchanged. - held.content = [{ type: 'text', text: 'v2' }] - expect(adapter.requests[1]!.messages[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'v1' }] }) - expect(events(agent).filter(e => e.type === 'request/header')).toHaveLength(1) - }) -}) - - -describe('agent/turn-continuation (ContinuationDecision)', () => { - it('a continue decision with a reason records next-step steering in the same turn', async () => { - const adapter = new MockAdapter([textResponse('step 1 no tools'), textResponse('step 2')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - let forced = false - ctx.on('agent/turn-continuation', async (_agent, _turn, _default, _signal, next): Promise => { - if (!forced) { - forced = true - return { action: 'continue', reason: { content: [{ type: 'text', text: 'keep going on the goal' }], source: { kind: 'plugin', plugin: 'goal' } } } - } - return next() - }) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - const log = events(agent) - // The continuation stays in the turn, is logged with provenance before step 2, - // and reaches that step's request. - expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1) - expect(log.filter(e => e.type === 'step/start')).toHaveLength(2) - const steering = log.find(e => e.type === 'steering/message') - expect(steering?.type === 'steering/message' && steering.data.content).toEqual([{ type: 'text', text: 'keep going on the goal' }]) - expect(steering?.type === 'steering/message' && steering.data.source).toEqual({ kind: 'plugin', plugin: 'goal' }) - expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going on the goal') - }) - - it('a stop decision ends the turn even when the step had tool calls', async () => { - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' })]) - const ctx = await harness(adapter) - ctx.tools.register(defineContentToolFixture({ - name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, - async execute(args) { return [{ type: 'text', text: String(args.text) }] }, - })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - ctx.on('agent/turn-continuation', async (): Promise => ({ action: 'stop' })) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - // default would have continued (had tool calls), but the stop decision wins - expect(adapter.requests).toHaveLength(1) - expect(events(agent).some(e => e.type === 'tool/result')).toBe(true) - }) -}) - describe('tool additionalContexts buffering across a step', () => { it('appends each call\'s contexts only AFTER all tool/results, preserving adjacency', async () => { // One assistant step with TWO tool calls; the second model response stops. @@ -616,7 +582,6 @@ describe('tool additionalContexts buffering across a step', () => { additionalContexts: [{ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' }, - meta: { callId: exec.callId }, }], })) @@ -638,7 +603,6 @@ describe('tool additionalContexts buffering across a step', () => { .flatMap(e => (e.type === 'user/message' ? e.data.content : [])) .map(b => (b.type === 'text' ? b.text : '')) expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2']) - expect(injected.map(e => e.type === 'user/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }]) }) it('appends multiple contexts deferred by one composite tool after its outer result', async () => { @@ -647,8 +611,8 @@ describe('tool additionalContexts buffering across a step', () => { ctx.tools.register(defineContentToolFixture({ name: 'composite', description: 'composite', parameters: {}, async execute(_args, exec) { - exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' }, meta: { order: 1 } }) - exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' }, meta: { order: 2 } }) + exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' } }) + exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' } }) return [{ type: 'text', text: 'outer result' }] }, })) @@ -666,7 +630,6 @@ describe('tool additionalContexts buffering across a step', () => { { kind: 'plugin', plugin: 'a' }, { kind: 'plugin', plugin: 'b' }, ]) - expect(contextEvents.map(event => event.type === 'user/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }]) }) }) @@ -706,10 +669,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se apply(ctx: Context) { // 1. SessionStart: seed a standing instruction. ctx.on('agent/session-start', (agent, source) => { - agent.inject( - [{ type: 'text', text: `policy active (started: ${source})` }], - { source: { kind: 'plugin', plugin: 'native-guard' } }, - ) + agent.inject({ content: [{ type: 'text', text: `policy active (started: ${source})` }], source: { kind: 'plugin', plugin: 'native-guard' } }) }) // 2. PromptSubmit: block a forbidden prompt, annotate the rest. ctx.on('agent/prompt-submit', async (_agent, content, _source, _signal, next): Promise => { @@ -760,7 +720,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se expect(log.some(e => e.type.startsWith('hook/'))).toBe(false) }) - it('the same plugin blocks a destructive prompt → rejected turn, model never called', async () => { + it('the same plugin blocks a destructive prompt before a turn or model call', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) await ctx.plugin(NativeGuard) @@ -770,10 +730,10 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'run rm -rf /') - await waitForIdle(ctx, agent) + await agent.whenIdle() expect(adapter.requests).toHaveLength(0) - expect(reasons).toEqual([{ kind: 'rejected', reason: 'destructive prompt blocked' }]) + expect(reasons).toEqual([]) }) it('HMR-safety: disposing the plugin fiber removes all four listeners', async () => { diff --git a/packages/core/agent-loop/tests/invariant.spec.ts b/packages/core/agent-loop/tests/invariant.spec.ts index cb0dcd2384..0c439bc7e2 100644 --- a/packages/core/agent-loop/tests/invariant.spec.ts +++ b/packages/core/agent-loop/tests/invariant.spec.ts @@ -47,15 +47,14 @@ describe('request-reconstruction invariant', () => { expect(() => { dispatch(ctx, options) }).not.toThrow() }) - it('requires the folded session prefix ahead of derived history', async () => { + it('requires the messages to equal the boundary derivation exactly (no unlogged prefix)', async () => { const { ctx, session, boundary } = await requestSetup() - const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: 'catalog' }] } - session.append('request/header', { header: { config: { provider: 'mock', model: 'm' }, messagePrefix: [prefix] }, reason: 'change' }) - expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id })) }) - .not.toThrow() + const extra = { role: 'user' as const, content: [{ type: 'text' as const, text: 'catalog' }] } expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })) }) + .not.toThrow() + expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([extra, ...boundary]), sessionId: session.id })) }) .toThrow(/diverges from the boundary derivation/) - expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id })) }) + expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary, extra]), sessionId: session.id })) }) .toThrow(/diverges from the boundary derivation/) }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 8875b51d67..0adaf3d15b 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId, TurnEndReason, type JsonValue } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function send(agent: Agent, text: string) { - agent.followup([{ type: 'text', text }]) + agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } }) } describe('agent loop', () => { @@ -118,37 +118,6 @@ describe('agent loop', () => { const types = agent.session.events.map(e => e.type) expect(types).toContain('tool/call') expect(types).toContain('tool/result') - const durableResult = agent.session.events.find(event => event.type === 'tool/result') - expect(durableResult?.type === 'tool/result' && 'value' in durableResult.data).toBe(false) - }) - - it('persists presentation metadata projected from the canonical value', async () => { - const adapter = new MockAdapter([ - toolCallResponse('c1', 'writer', { path: 'a.txt' }, 'writing'), - textResponse('done'), - ]) - const ctx = await harness(adapter) - ctx.tools.register(defineTool({ - name: 'writer', - description: 'writes a file', - parameters: { path: { type: 'string' } }, - output: { - schema: { type: 'string' }, - render: () => [{ type: 'text', text: 'ok' }], - presentationMeta: (_args, value) => ({ diffs: [{ path: value, oldText: null, newText: 'x' }] }), - }, - async execute() { - return 'a.txt' - }, - })) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - send(agent, 'use the tool') - await waitForIdle(ctx, agent) - - const toolResult = agent.session.events.find(e => e.type === 'tool/result') - expect(toolResult?.type === 'tool/result' && toolResult.data.meta) - .toEqual({ diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] }) }) it('renders harness identity, then the persona, then tool guidance — with {{variables}} resolved', async () => { @@ -196,7 +165,9 @@ describe('agent loop', () => { const adapter = new MockAdapter([textResponse('ok after rescue')]) const ctx = await harness(adapter, 'In {{cwd}}.') const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) + ctx.on('agent/error', (_agent, _turn, _step, error) => { + if (error instanceof Error) errors.push(error) + }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'hi') @@ -236,7 +207,8 @@ describe('agent loop', () => { assembly.variables['model'] = 'mock' return next() }) - ctx.on('agent/request', async (_agent, _turn, _step, config, _signal, _next) => { + ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + const config = await next() return { ...config, provider: 'mock', model: 'mock' } }) const agent = ctx.agentLoop.create(SessionId('a-late-model'), {}) @@ -249,50 +221,6 @@ describe('agent loop', () => { expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou run on mock.') }) - it.each([ - ['BigInt', { n: 1n }], - ['Map', new Map([['key', 'value']])], - ['class instance', new (class ResultMeta { x = 1 })()], - ])('rejects non-JSON presentation metadata (%s) before the durable result commit', async (_kind, meta) => { - const adapter = new MockAdapter([ - toolCallResponse('bad-meta-call', 'bad-meta', {}, 'calling'), - textResponse('recovered'), - ]) - const ctx = await harness(adapter) - ctx.tools.register(defineTool({ - name: 'bad-meta', - description: 'returns invalid durable metadata', - parameters: {}, - output: { - schema: { type: 'string' }, - render: (_args, value) => [{ type: 'text', text: value }], - presentationMeta: () => meta as unknown as JsonValue, - }, - execute: () => Promise.resolve('apparent success'), - })) - const agent = ctx.agentLoop.create(SessionId('bad-meta-agent'), { provider: 'mock', model: 'mock' }) - - send(agent, 'use the tool') - await waitForIdle(ctx, agent) - - const result = agent.session.events.find(event => event.type === 'tool/result') - expect(result?.type).toBe('tool/result') - if (result?.type === 'tool/result') { - expect(result.data.callId).toBe('bad-meta-call') - expect(result.data.isError).toBe(true) - expect(result.data.meta).toBeUndefined() - expect(result.data.error).toEqual({ name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' }) - expect(result.data.content).toEqual([{ - type: 'text', - text: 'Error: tool "bad-meta" returned invalid output: output.presentationMeta returned non-lossless JSON', - }]) - } - // The normalized failure was durably logged and fed back to the model; the - // turn continued normally instead of failing after an apparent success. - expect(adapter.requests).toHaveLength(2) - expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('output.presentationMeta returned non-lossless JSON') - }) - it('omits the system field when a system-prompt/assemble veto empties the assembly', async () => { // The documented escape valve: a deployment that must drop the harness // openers short-circuits the assemble waterfall; the request then carries @@ -343,7 +271,7 @@ describe('agent loop', () => { parameters: {}, async execute() { // steer while the turn is running (during tool execution) - agent.steer([{ type: 'text', text: 'change of plans' }]) + agent.steer({ content: [{ type: 'text', text: 'change of plans' }], source: { kind: 'user' } }) return [{ type: 'text', text: 'tool done' }] }, })) @@ -365,14 +293,14 @@ describe('agent loop', () => { expect(flat).toContain('change of plans') }) - it('same-tick idle steering inherits one-send-one-turn FIFO behavior', async () => { + it('same-tick idle steering preserves one turn per send', async () => { const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const idle = waitForIdle(ctx, agent) - agent.steer([{ type: 'text', text: 'first idle steer' }]) - agent.steer([{ type: 'text', text: 'second idle steer' }]) + agent.steer({ content: [{ type: 'text', text: 'first idle steer' }], source: { kind: 'user' } }) + agent.steer({ content: [{ type: 'text', text: 'second idle steer' }], source: { kind: 'user' } }) await idle expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) @@ -382,53 +310,75 @@ describe('agent loop', () => { [{ type: 'text', text: 'first idle steer' }], [{ type: 'text', text: 'second idle steer' }], ]) + expect(agent.session.events.filter(event => event.type === 'steering/message')).toEqual([]) expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('first idle steer') + expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('second idle steer') + expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('second idle steer') }) - it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => { + it('keeps steering staged after a failed step until the next admitted turn', async () => { + const adapter = new MockAdapter([textResponse('recovered')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' }) + let fail = true + ctx.on('agent/step', (subject) => { + if (subject !== agent || !fail) return + fail = false + subject.steer({ content: [{ type: 'text', text: 'pending steering' }], source: { kind: 'user' } }) + throw new Error('step failed') + }) + + send(agent, 'prompt') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(0) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false) + + send(agent, 'resume') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(true) + expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering') + }) + + it('inject() while idle appends context without opening a turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } }) - // The idle inject records a self-contained turn (turn/start → user/message - // → turn/end) so the event stays turn-enclosed, but does NOT run the model. - await new Promise(r => setTimeout(r, 20)) + agent.inject({ content: [{ type: 'text', text: 'file changed: a.ts' }], source: { kind: 'plugin', plugin: 'watcher' } }) expect(agent.status).toBe('idle') expect(adapter.requests).toHaveLength(0) - const injectedTurn = agent.session.events.filter(e => e.type === 'turn/start') - expect(injectedTurn).toHaveLength(1) - const it0 = injectedTurn[0]! - expect(it0.type === 'turn/start' && it0.data.trigger.kind).toBe('injection') - expect(agent.session.events.at(-1)!.type).toBe('turn/end') // turn-enclosed + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(0) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'user/message', + data: { source: { kind: 'plugin', plugin: 'watcher' } }, + }) send(agent, 'go') await waitForIdle(ctx, agent) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) const flat = JSON.stringify(adapter.requests[0]!.messages) expect(flat).toContain('file changed: a.ts') expect(flat).not.toContain('