Merge origin/master into feat/tui-master-port
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 呈现为已提交历史相比,这是更好的选择;快照固定这一转换。
|
||||
|
||||
@@ -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-06-30-bash-stdin-env-trusted-plugin-surface.md: 284cd45a66294dbc9e8207a1e00e9642d32d4e58
|
||||
2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 9486f8c35c5060150b072fb673acca5d4167ec1a
|
||||
2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 556d5dd86dfcc92c4628e68c19390f0033560d25
|
||||
2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 9d67797f86903e70e7bdcd6f80f19d17c41ac18e
|
||||
|
||||
@@ -18,7 +18,7 @@ Three deliberate choices:
|
||||
|
||||
1. **The model-facing tool omits `stdin` and `env`.** Shell syntax already covers those needs, so duplicate parameters would add surface without authority separation. The tool builds requests only from declared model arguments, signal, and owner; trusted in-process callers may set the seam fields directly. Harness-owned variables use the separate `dshEnv` channel from the [managed environment decision](../feature/2026-07-10-agent-session-identity-and-log-location.md), so ordinary `env` cannot replace them.
|
||||
|
||||
2. **`env` merges AFTER the credential scrub, so an explicit caller entry wins even on a credential-shaped name.** The later managed-namespace decision reserves `DSH_*`: ambient entries are removed, ordinary `env` cannot set them, and trusted `dshEnv` merges last. The complete order is `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → ordinary `env` → `dshEnv`.
|
||||
2. **`env` merges AFTER the credential scrub, so an explicit caller entry wins even on a credential-shaped name.** The later managed-namespace decision manages `DSH_*`: ambient entries are removed, and trusted `dshEnv` merges last, so an ordinary `env` entry can never displace a managed value. The complete order is `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → ordinary `env` → `dshEnv`.
|
||||
|
||||
3. **`stdin`/`env` are required-absent-OK (plain optional) on the resolved spec, NOT required-but-nullable like `owner`.** `owner` is required-but-nullable because a *silently* missing owner yields an unowned, cross-session-readable task — a security footgun that a visible `undefined` guards against. `stdin`/`env` have no such hazard: a missing one means "no stdin / no extra env", which is the safe, ordinary case (every model-driven call). So they stay plain optionals, matching `signal`.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Status: implemented
|
||||
|
||||
1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置 seam 字段。harness 自有变量使用[托管环境决策](../feature/2026-07-10-agent-session-identity-and-log-location.md)规定的独立 `dshEnv` 通道,因此普通 `env` 无法替换它们。
|
||||
|
||||
2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目即使具有凭证形态的名称也会胜出。** 后续的托管命名空间决策保留 `DSH_*`:环境条目会被移除,普通 `env` 无法设置它们,受信的 `dshEnv` 最后合并。完整顺序为 `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → 普通 `env` → `dshEnv`。
|
||||
2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目即使具有凭证形态的名称也会胜出。** 后续的托管命名空间决策托管 `DSH_*`:环境条目会被移除,受信的 `dshEnv` 最后合并,因此普通 `env` 条目永远无法顶掉托管值。完整顺序为 `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → 普通 `env` → `dshEnv`。
|
||||
|
||||
3. **`stdin`/`env` 在已解析 spec 上是 required-absent-OK(普通 optional),而非像 `owner` 那样 required-but-nullable。** `owner` 之所以是 required-but-nullable,是因为*静默*缺失的 owner 会产生一个无主、跨会话可读的任务——一个安全隐患,显式的 `undefined` 可以防范。`stdin`/`env` 没有这种风险:缺失意味着「无 stdin / 无额外 env」,这是安全的常规情况(所有模型驱动的调用都如此)。因此它们保持普通 optional,与 `signal` 一致。
|
||||
|
||||
|
||||
@@ -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-06-timeout-deadline-library.md: 11d4b8cd48dd345d2324b63e01bd726f12d846b4
|
||||
2026-07-06-timeout-deadline-library.zh.md: 334914c689adf54a654c5907395c29ceeeb50891
|
||||
2026-07-06-timeout-deadline-library.md: 63463a76a65743436d4e78479800c19e257a42de
|
||||
2026-07-06-timeout-deadline-library.zh.md: c3d3cdf1c63813fc24c10727e42d326142f3f4de
|
||||
|
||||
@@ -8,7 +8,7 @@ English | [中文](2026-07-06-timeout-deadline-library.zh.md)
|
||||
|
||||
Timeout handling was drifting apart across the tool-bearing capabilities, and the divergence was not superficial — it was the same logic re-implemented three ways, each with its own subtle correctness burden.
|
||||
|
||||
- **bash** ([packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts)) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently.
|
||||
- **bash** (then in the bash-local implementation's `run.ts`) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. After this consolidation, the plumbing — today [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts) — only reacts to aborts; [packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification.
|
||||
- **web_fetch** ([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts)) had a correct but *hand-rolled* timeout: it constructed an `AbortController`, wired `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`, manually added and removed the upstream-signal listener, cleared the timer in a `finally`, and recovered the timeout reason from `signal.reason` in a `translateAbortOrNetwork` helper because the reader surfaces a bare `AbortError`.
|
||||
- **web_search** ([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts)) had **no timeout at all**: `WebSearchRequest` ([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts)) carries no `timeoutMs` field, and each provider's `search()` only forwards `exec.signal`. (web_search stays untimed here — see Consequences.)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Status: implemented
|
||||
|
||||
超时处理在各个承载工具的能力之间逐渐分化,而且这种分化并非表面的:同一套逻辑被以三种方式重新实现,各自带有微妙的正确性负担。
|
||||
|
||||
- **bash**([packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。
|
||||
- **bash**(当时位于 bash-local 实现的 `run.ts`)在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。经此次整合之后,这套管道——今天位于 [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts)——只响应中止;[packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) 拥有融合的 deadline 以及 `timedOut`/`aborted` 分类。
|
||||
- **web_fetch**([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts))有一套正确但*手写*的超时:构造一个 `AbortController`,连接 `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因(因为 reader 只抛出裸 `AbortError`)。
|
||||
- **web_search**([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts))**完全没有超时**:`WebSearchRequest`([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本次设计中保持无超时——见「后果」。)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 证明、上限、委托与辅助调用路由。真实循环测试覆盖抛出式和带内溢出,并验证剪枝或摘要压缩后的重试请求从替换表层重建。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 保持现有协议。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)——公开辅助方法以及接受完全解析输入的接口。
|
||||
|
||||
@@ -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.
|
||||
@@ -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。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-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
|
||||
@@ -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.
|
||||
@@ -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()` 会留下缺少预期直接提示词的上下文,除非调用方提供领域专用的准入归属。
|
||||
- 公共投递契约和收件箱记录保持精简:没有上下文附件、上下文放置元数据、提示词封套或重复的持久事件类型。
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 小件
|
||||
|
||||
@@ -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
|
||||
2026-07-26-subprocess-consumer-migration.md: b31f69f1251a7219e168ed7800ba16ff7d6b328c
|
||||
2026-07-26-subprocess-consumer-migration.zh.md: 47e7519a72f5482f255d15d87b483e9295f6cd2c
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note: The subprocess seam goes Node-shaped and every eligible spawner rides it
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-26-subprocess-consumer-migration.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [subprocess seam](2026-07-26-subprocess-seam.md) shipped shaped for exactly one consumer family: batch-collected stdout/stderr, batch stdin, a single escalating `kill()`. That was deliberate scope control, and its own note records "migrate the other spawn sites" as rejected-for-now. Review on the introducing PR reversed that deferral: the stacked follow-up should reshape the interface toward Node's API and move the remaining process-running places onto the service. The remaining spawners each carried a private copy of some slice of the same mechanics — lsp-local had its own detached-tree signalling (POSIX group + Windows taskkill + liveness polling), subagent-subprocess had the dispose ladder and its own scrub, mcp-client and pty-local and the SDK helper each had a third/fourth/fifth copy of the credential scrub — and none of it was swappable or centrally testable.
|
||||
|
||||
## Decision
|
||||
|
||||
The seam's vocabulary is now Node-shaped, and every spawner that can ride the service does:
|
||||
|
||||
- **Per-stream stdio dispositions** on `SubprocessSpawnSpec`: `'pipe'` (the raw `Readable`/`Writable`, for consumer-owned protocol framing), `'inherit'` (diagnostics to the parent's stream), and collect mode `{ maxBytes, spill? }` — the original bounded tail-keep shape, with the spill file now optional so a diagnostic tail (a language server's stderr) buffers without touching disk. stdin is `'ignore'`, `'pipe'`, or `{ data }` (write-and-close batch).
|
||||
- **`SubprocessOutcome` carries exit facts only** (Node's close-event vocabulary); collected output stays readable through `handle.collected` after settlement (spill fds seal at the settle boundary), so batch and streaming callers share one access path and nothing is copied into the outcome.
|
||||
- **Tree-scoped termination behind one verb**: `terminate()` owns the SIGTERM→grace→SIGKILL escalation (serves the spec's abort signal too, and is a no-op once the tree is gone) — the handle exposes no single-signal `kill(signal?)`, so a consumer cannot skip the grace window; `waitForExit()` polls tree liveness (POSIX group probe; direct-child boundary on Windows); Windows tree termination (`taskkill /T`, injectable) moved in from lsp-local, so tree semantics are platform-correct for every consumer. (The stdin-EOF-first dispose ladder initially absorbed from `subagent-subprocess` later moved back out to its one consumer — see the [ladder-ownership Agent Note](2026-07-27-dispose-ladder-to-consumer.md).)
|
||||
- **One scrub definition**: `scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` live on the seam. Spawners that cannot route the spawn itself through the service — pty-local (node-pty owns the fork) and mcp-client (the MCP SDK owns the transport spawn) — import the function, so environment policy is single-sourced even where process ownership is not; the SDK helper's `scrubEnvironment()` defaults through it as well.
|
||||
|
||||
Migrations landed with the reshape: **bash-local/bash-sandbox** (collect modes + batch stdin; the bash `kill()` maps to `terminate()` so `task_kill` keeps escalation semantics), **lsp-local** (piped protocol streams + a no-spill collected stderr tail; `LspConnection` takes the seam's spawn function; its private tree-op helpers deleted), **subagent-acp** (piped ndjson streams + inherited stderr; spawn failure surfaces through `done` rejection into the same startup race; disposal is the backend-owned `disposeAcpChild` ladder over the seam's verbs, with the plugin's configured graces). **`dsh-subagent-subprocess` is deleted** — the dispose ladder and scrub are the seam's; the unused isolated-config-dir helper died with it (no consumer existed).
|
||||
|
||||
Compositions mounting lsp-local or subagent-acp now load `dsh-subprocess-local` (the plugins inject `'subprocess'`); the acp/lsp test fixtures gained the row.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the batch-only seam and let stream consumers stay bespoke.** The introducing note's position, rejected by review: it leaves three private copies of tree signalling and five of the scrub, and any future runner (containerized executor, remote process host) would have to pick which private copy to fork. The Node-shaped dispositions cover all three observed stream shapes without widening the outcome type or buffering piped streams.
|
||||
|
||||
**A single `stdio: 'pipe' | 'inherit' | 'collect'` mode for all three streams at once.** Rejected: real consumers mix modes per stream (lsp: pipe/pipe/collect; acp: pipe/pipe/inherit; bash: data/collect/collect). Per-stream dispositions are exactly Node's shape and avoid a second spawn call for the mixed cases.
|
||||
|
||||
**Migrate pty-local and mcp-client spawns too.** Rejected on ownership grounds, not scope: node-pty's `fork()` allocates the terminal itself, and the MCP SDK's `StdioClientTransport` spawns internally — neither call site is ours to route. They adopt the shared scrub (the part that is policy), and their READMEs say why the spawn stays put.
|
||||
|
||||
**Migrate the test-support launchers (acp-snapshot, loader-smoke) and the SDK package-manager runner.** Rejected: the support packages are deliberately dependency-light test infrastructure that must not depend on product seams, and the SDK wizard's `stdio: 'inherit'`-with-redirect semantics plus its out-of-composition lifecycle (no cordis context at all) make the service a poor fit; it shares the scrub instead.
|
||||
|
||||
## Consequences
|
||||
|
||||
Bought: one implementation of tree signalling, escalation, bounded collection, and the scrub, tested once in `dsh-subprocess-local`'s suites (including injected-platform Windows coverage that lsp-local's private copy never had); lsp-local and subagent-acp shed their process plumbing and their children now survive plugin reloads and die with composition teardown like bash's; a whole package (`dsh-subagent-subprocess`) is gone. The seam README's "one consumer family" limitation is retired.
|
||||
|
||||
Cost: the seam is wider — three stdio modes and the terminate/waitForExit/dispose lifecycle surface instead of one mode and one verb — so a future backend implements more surface; the compositions for lsp-local/subagent-acp each carry the subprocess row now; and `SubprocessOutcome` no longer carries output, a breaking shape change inside the still-unreleased stack (the PR2 layer was updated in place rather than shimmed, per the pre-release stance). pty-local/mcp-client/SDK/test-support spawns remain outside the service by ownership, with the scrub as the shared floor.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note: 进程 seam 转向 Node 形状,所有具备条件的 spawn 调用点一并迁入
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-26-subprocess-consumer-migration.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[进程 seam](2026-07-26-subprocess-seam.md) 交付时恰好只为一个消费方家族塑形:批量收集的 stdout/stderr、批量 stdin、单一的升级式 `kill()`。那是有意的范围控制,其自身的 Agent Note 也把「迁移其余 spawn 调用点」记为暂缓否决项。引入该 seam 的 PR(Pull Request)上的评审推翻了这一暂缓决定:堆叠其上的后续变更应当把接口向 Node 的 API 方向重塑,并把其余运行进程之处迁到该服务上。其余各 spawn 调用点此前各自持有同一套机制中某个切片的私有副本——lsp-local 自带 detached 进程树信号发送(POSIX 进程组 + Windows taskkill + 存活轮询),subagent-subprocess 自带 dispose(资源释放)阶梯和自己的凭据清除,mcp-client、pty-local 与 SDK helper 则各自持有凭据清除的第三、第四、第五份副本——而这一切既不可替换,也无法集中测试。
|
||||
|
||||
## 决策
|
||||
|
||||
这道 seam 的词汇如今已是 Node 形状,凡能接入该服务的 spawn 调用点均已迁入:
|
||||
|
||||
- **按流划分的 stdio 处置方式(disposition)**,位于 `SubprocessSpawnSpec` 上:`'pipe'`(原始的 `Readable`/`Writable`,供消费方自有的协议分帧使用)、`'inherit'`(诊断输出直通父进程的流),以及收集模式(collect)`{ maxBytes, spill? }`——即最初的有界尾部保留形状,只是 spill 文件改为可选,使诊断尾部(例如语言服务器的 stderr)无需落盘即可缓冲。stdin 则为 `'ignore'`、`'pipe'` 或 `{ data }`(写完即关闭的批量形式)。
|
||||
- **`SubprocessOutcome` 只承载退出事实**(Node close 事件的词汇);收集到的输出在结算后仍可经 `handle.collected` 读取(spill 文件描述符在结算边界封存),因此批量与流式调用方共用一条访问路径,也没有任何内容被复制进这份结果。
|
||||
- **以进程树为范围的终止,集中在一个动词后面**:`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(也承接 spec 的 abort 信号,进程树消亡后为空操作)——句柄不暴露单信号的 `kill(signal?)`,因此消费方无法跳过宽限窗口;`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界)。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。(最初从 `subagent-subprocess` 吸收的以 stdin EOF 打头的 dispose 阶梯,后来又移回其唯一消费方——见[阶梯归属 Agent Note](2026-07-27-dispose-ladder-to-consumer.md)。)
|
||||
- **凭据清除只有一份定义**:`scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` 定义在 seam 上。无法把 spawn 本身路由到该服务的调用点——pty-local(node-pty 拥有 fork)与 mcp-client(MCP SDK 拥有传输层的 spawn)——改为导入该函数,因此即便进程所有权无法统一,环境策略仍是单一来源;SDK helper 的 `scrubEnvironment()` 默认同样委托给它。
|
||||
|
||||
各项迁移随这次重塑一并落地:**bash-local/bash-sandbox**(收集模式 + 批量 stdin;bash 的 `kill()` 映射到 `terminate()`,因此 `task_kill` 保有升级语义),**lsp-local**(管道化的协议流 + 无 spill 的 stderr 收集尾部;`LspConnection` 改为接收 seam 的 spawn 函数;其私有的进程树操作辅助函数已删除),**subagent-acp**(管道化的 ndjson 流 + inherit 的 stderr;spawn 失败经 `done` 的 reject 汇入同一个启动竞态;dispose 是后端自有的 `disposeAcpChild` 阶梯,经由 seam 的动词运行,携带插件所配置的宽限期)。**`dsh-subagent-subprocess` 已删除**——dispose 阶梯与凭据清除归 seam 所有;无人使用的隔离配置目录辅助函数随之消亡(其消费方本就不存在)。
|
||||
|
||||
挂载 lsp-local 或 subagent-acp 的组合如今都加载 `dsh-subprocess-local`(这两个插件注入 `'subprocess'`);acp/lsp 测试 fixture(测试前置数据)补上了这一行组合配置。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**保持只支持批量的 seam,让流式消费方继续各自为政。**这正是引入该 seam 的 Agent Note 当初的立场,评审将其否决:这样会留下三份进程树信号发送的私有副本和五份凭据清除的私有副本,而未来任何运行器(容器化执行器、远程进程宿主)都得挑选去 fork 哪一份私有副本。Node 形状的处置方式覆盖已观察到的全部三种流形状,既不拓宽结果类型,也不缓冲管道化的流。
|
||||
|
||||
**用单个 `stdio: 'pipe' | 'inherit' | 'collect'` 模式一次性统辖全部三条流。**否决:真实消费方按流混用模式(lsp:pipe/pipe/collect;acp:pipe/pipe/inherit;bash:data/collect/collect)。按流划分的处置方式恰好就是 Node 的形状,也免去了混用场景的第二个 spawn 调用。
|
||||
|
||||
**把 pty-local 与 mcp-client 的 spawn 也一并迁移。**基于所有权而非范围否决:node-pty 的 `fork()` 自行分配终端,MCP SDK 的 `StdioClientTransport` 在内部完成 spawn——这两处调用点都不归我们路由。它们采纳共享的凭据清除(那正是属于策略的部分),并在各自的 README 中说明 spawn 为何留在原地。
|
||||
|
||||
**迁移 test-support 启动器(acp-snapshot、loader-smoke)与 SDK package-manager 运行器。**否决:support 各包(package)是刻意保持轻依赖的测试基础设施,不得依赖产品 seam;而 SDK 向导那套附带重定向的 `stdio: 'inherit'` 语义,加上其完全脱离组合的生命周期(根本没有 cordis 上下文),使该服务并不合用;它改为共享凭据清除。
|
||||
|
||||
## 后果
|
||||
|
||||
换来的是:进程树信号发送、升级、有界收集与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local 与 subagent-acp 卸下了自己的进程管道,其子进程如今像 bash 的一样,在插件重载后存活、随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。
|
||||
|
||||
代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词换成 terminate/waitForExit/dispose 这组生命周期表面),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。
|
||||
@@ -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-24-intent-named-agent-delivery.md: 32b0502350063610efff746cbef779e8225055eb
|
||||
2026-07-24-intent-named-agent-delivery.zh.md: ce8860b397497f4de587a9373d1cd300cf7dab29
|
||||
2026-07-26-subprocess-seam.md: ad2f8522be51ba16b0df155aeb334a88f493890f
|
||||
2026-07-26-subprocess-seam.zh.md: d9a0fb56b57b545dd1f94fde0cfb436d58fe00d4
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note: The subprocess service is its own seam under the bash executors (`dsh-subprocess` / `dsh-subprocess-local`)
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-26-subprocess-seam.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-bash-local` bundled two capabilities that change for different reasons: *running a bash command* (command defaulting, timeout classification, model-friendly terminal environment, the stdout/stderr merge the bash tool renders) and *running and managing a child process* (detached process groups, bounded tail-keep output with spill files, the credential scrub and `DSH_*` merge order, SIGTERM→grace→SIGKILL escalation, kill-and-join disposal). The process half — `run.ts`, roughly half the package — had no seam of its own: a future non-shell runner (a direct-argv executor, a worker supervisor) would have to re-implement or reach into bash internals, and the shared `DSH_*`/`CollectedOutput` vocabulary lived in a package whose name promises shell semantics. The bundling also tied background-process lifetime to the executor's fiber: reloading the bash executor killed every live background process, unlike the sibling [task registry](2026-07-26-task-registry-seam.md), whose registrations deliberately outlive producer fibers.
|
||||
|
||||
## Decision
|
||||
|
||||
A new `subprocess/` capability family owns "run and manage a process"; the bash family keeps "run a bash command" and consumes it:
|
||||
|
||||
- **`@deepseek-ai/dsh-subprocess` (interface)** — the abstract `SubprocessService` owning `ctx.subprocess` with one method, `spawn(spec): SubprocessHandle`, and the shared vocabulary: the fully-explicit `SubprocessSpawnSpec` (argv, cwd, per-stream stdio dispositions, grace — no defaults; deployment-varying knobs stay with the calling seam's config, per the `dsh-bash` request/spec template and the no-hidden-defaults rule), `SubprocessHandle` with non-consuming offset-based readers, `SubprocessOutcome` with deliberately no timeout/cancel classification, and the shared scrub plus `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` types. `argv` is never shell-interpreted. (The [consumer-migration Agent Note](2026-07-26-subprocess-consumer-migration.md) later widened the stdio and termination vocabulary Node-ward.)
|
||||
- **`@deepseek-ai/dsh-subprocess-local` (implementation)** — `LocalSubprocessService` over the former `run.ts` plumbing (`spawn.ts`): detached groups, tail-keep truncation with private bounded spill files, credential scrub with the explicit-env merge after it, group kill escalation, and disposal that kills and joins every still-running managed process. It has no config; every limit arrives on the spec. The terminal `ENV_OVERRIDES` (`TERM=dumb` etc.) did NOT move — that is bash-tool presentation policy and stays in `dsh-bash-local`, merged through the ordinary env channel.
|
||||
- **`dsh-bash-local` (consumer)** — `inject: ['subprocess']`; maps each resolved `BashExecSpec` onto a `SubprocessSpawnSpec` (`['bash', '-c', command]`), keeps its config, `resolve()` defaulting, fused-deadline `timedOut`/`aborted` classification, the `[stderr]`-marked background read merge with its consuming cursor, and the `onProcessDone` subclass hook. `dsh-bash-sandbox` is unchanged apart from redeclaring the inherited inject; it still wraps at the command-string level and re-enters the inherited spawn path.
|
||||
- **`dsh-bash` (seam)** — re-exports the moved vocabulary from `dsh-subprocess`, so no bash consumer changes an import; `BashExecRequest`/`BashExecSpec`/`BashProcess` and the sandbox facts remain bash-owned.
|
||||
|
||||
Every composition that loads a bash executor now also loads `@deepseek-ai/dsh-subprocess-local` (CLI, examples, python bundled runtime, create-sdk's bash feature resources, inline test configs).
|
||||
|
||||
Background-process lifetime moved from the executor to the subprocess service: the executor no longer retains a live-process set, so an executor reload leaves background work running and readable, and composition teardown (the service's disposal) remains the kill-and-join boundary. One behavioral seam shifted with it: a background spawn failure can no longer be buffered as fake stderr inside the plumbing (the service rejects `done` and buffers nothing for a process that never ran), so the executor injects the `spawn failed: …` note into exactly one `readOutput()` delta.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Leave the process plumbing inside `dsh-bash-local` (status quo).** Rejected for the same reason the [task registry split](2026-07-26-task-registry-seam.md) landed: the boundary is stable and already documented in-code (`run.ts`'s module doc said "this layer reacts to an abort signal; the executor owns deadlines and classifies causes"), and keeping it private makes every future non-shell runner either fork the mechanics or depend on a bash-named package for non-bash work. The user-visible driver for this stack was exactly this split.
|
||||
|
||||
**Migrate the repo's other spawn sites (lsp-local, pty-local, subagent-subprocess, sdk package-manager, test-support launchers) onto `ctx.subprocess` in the same change.** Rejected as scope creep with real design risk at this PR's scale: those sites have materially different stream and lifecycle needs — node-pty ownership (pty), LSP framing over long-lived stdio with tree-kill fallbacks (lsp), stdin-EOF-first disposal ladders and no output buffering (subagent transports) — and forcing them under a handle shaped for bounded batch output would either bloat the seam or misfit the consumers. The seam shipped proven against its one real consumer family, per the shape-interfaces-around-current-consumers rule. Review then asked for exactly that follow-up as a stacked PR; the [consumer-migration Agent Note](2026-07-26-subprocess-consumer-migration.md) records the Node-ward reshape and which sites moved (and which stayed, by ownership).
|
||||
|
||||
**Put `run_in_background`/task semantics into the process seam instead.** Rejected: that boundary already exists — `ctx.tasks` owns ids, ownership, and notices, and the bash tool adapts a `BashProcess` into task hooks. The process seam sits *below* the bash executor, not beside the task registry.
|
||||
|
||||
**Move `ENV_OVERRIDES` (TERM=dumb, PAGER=cat …) into the subprocess service.** Rejected: a generic subprocess service must not impose terminal presentation policy on non-terminal consumers; the ambient scrub (credential-shaped and `DSH_*` names) is a security/identity invariant and stays, but terminal friendliness is the bash tool's choice, expressed through the spec's explicit env where a caller's own entry still wins.
|
||||
|
||||
## Consequences
|
||||
|
||||
Bought: "run and manage a process" is a swappable capability with the standard three-package shape (consumer count starts at two: `bash-local`, `bash-sandbox`); a containerized or remote process backend slots in without touching bash semantics; the shared `DSH_*`/output vocabulary has a non-shell home; and background processes survive executor reloads, matching the task registry's lifetime model. The spawn plumbing suite moved wholesale to `dsh-subprocess-local` (argv-based, plus argv-validation and service lifecycle/disposal suites); the executor suite now pins the bash-owned layers (classification, merge, spawn-failure note, service-owned lifetime) against the real service.
|
||||
|
||||
Cost: one more package pair and one more composition row everywhere a bash executor loads — a boot that loads an executor without the subprocess service leaves `ctx.bash` pending on `ctx.subprocess` (standard missing-service behavior). The moved-vocabulary re-exports keep `dsh-bash` imports working but mean two packages now name the same types; the subprocess seam is the owner and the bash seam documents the re-export. The spawn-failure note became single-delivery through the read path where the old plumbing retained it in the stderr buffer for repeated `readFrom(0)` reads — acceptable because the bash background read path was already a consuming cursor, and the note reaches the one reader that exists.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note: 进程管理器是 bash 执行器之下的独立 seam(`dsh-subprocess` / `dsh-subprocess-local`)
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-26-subprocess-seam.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`dsh-bash-local` 原先把两项因不同原因而变化的能力捆绑在一起:*运行一条 bash 命令*(命令默认值补全、超时分类、对模型友好的终端环境、bash 工具所渲染的 stdout/stderr 合并)与*运行并管理一个子进程*(detached 进程组、附带 spill 文件的有界尾部保留输出、凭据清除与 `DSH_*` 合并次序、SIGTERM→宽限期→SIGKILL 升级、先终止再等待退出的 dispose(资源释放))。进程这一半(`run.ts`)约占整个包(package)的一半,却没有属于自己的 seam:未来的非 shell 运行器(直接执行 argv 的执行器、worker supervisor)将不得不重新实现这套机制,或者探入 bash 内部;而共享的 `DSH_*`/`CollectedOutput` 词汇则存放在一个名字承诺 shell 语义的包里。这种捆绑还把后台进程的存续期系在执行器的 fiber 上:重载 bash 执行器会杀死每一个存活的后台进程。这一点不同于兄弟的[任务注册表](2026-07-26-task-registry-seam.md):后者的注册存续期刻意长于生产方 fiber。
|
||||
|
||||
## 决策
|
||||
|
||||
新的 `subprocess/` 能力家族拥有「运行并管理一个进程」;bash 家族保留「运行一条 bash 命令」,并成为前者的消费方:
|
||||
|
||||
- **`@deepseek-ai/dsh-subprocess`(接口)**——拥有 `ctx.subprocess` 的抽象 `SubprocessService`(仅一个方法:`spawn(spec): SubprocessHandle`),以及共享词汇:完全显式的 `SubprocessSpawnSpec`(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期,一律不设默认值;随部署变化的旋钮依照 `dsh-bash` 的 request/spec 模板与无隐藏默认值规则,留在调用方 seam 的配置里)、携带基于偏移量的非消费式读取器的 `SubprocessHandle`、刻意不含超时/取消分类的 `SubprocessOutcome`,以及共享的凭据清除与 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` 类型。`argv` 绝不经过 shell 解释。([消费方迁移 Agent Note](2026-07-26-subprocess-consumer-migration.md) 其后将 stdio 与终止词汇拓宽为 Node 形状。)
|
||||
- **`@deepseek-ai/dsh-subprocess-local`(实现)**——`LocalSubprocessService`,构建在原 `run.ts` 管道(现为 `spawn.ts`)之上:detached 进程组、带私有有界 spill 文件的尾部保留截断、清除之后合并显式 env 的凭据清除、进程组 kill 升级,以及会终止每个仍在运行的受管进程并等待其退出的 dispose。该实现没有任何配置;每项限制都随 spec 到达。终端相关的 `ENV_OVERRIDES`(`TERM=dumb` 等)并未迁移:那是 bash 工具的呈现策略,留在 `dsh-bash-local` 里,经普通 env 通道合并。
|
||||
- **`dsh-bash-local`(消费方)**——`inject: ['subprocess']`;把每个解析后的 `BashExecSpec` 映射为一个 `SubprocessSpawnSpec`(`['bash', '-c', command]`),并保留自身配置、`resolve()` 默认值补全、基于融合 deadline 的 `timedOut`/`aborted` 分类、带 `[stderr]` 标记的后台读取合并及其消费游标,以及 `onProcessDone` 子类钩子。`dsh-bash-sandbox` 除了重新声明继承来的 inject 之外没有变化;它仍在命令字符串层面做包装,并重新进入继承的 spawn 路径。
|
||||
- **`dsh-bash`(seam)**——把迁走的词汇从 `dsh-subprocess` 重导出,因此没有任何 bash 消费方需要改动导入;`BashExecRequest`/`BashExecSpec`/`BashProcess` 与沙箱事实仍归 bash 所有。
|
||||
|
||||
如今,每个加载 bash 执行器的组合都同时加载 `@deepseek-ai/dsh-subprocess-local`:CLI(命令行界面)、各示例、Python 捆绑运行时、create-sdk 的 bash 功能资源,以及各内联测试配置。
|
||||
|
||||
后台进程的存续期从执行器移到了管理器:执行器不再保有存活进程集合,于是重载执行器后,后台工作会继续运行且仍可读取,而组合拆除(管理器的 dispose)仍是先终止再等待退出的边界。一条行为 seam 随之挪动:后台 spawn 失败不再能在管道内部被缓冲成伪造的 stderr(对一个从未真正运行的进程,管理器会 reject `done`,且不缓冲任何内容),因此执行器把 `spawn failed: …` 提示注入恰好一个 `readOutput()` 增量。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**把进程管道留在 `dsh-bash-local` 里(维持现状)。**否决的理由与[任务注册表拆分](2026-07-26-task-registry-seam.md)得以落地的理由相同:这条边界既稳定,也早已记录在代码里(`run.ts` 的模块文档曾写明「this layer reacts to an abort signal; the executor owns deadlines and classifies causes」),而若继续将它保持私有,未来每个非 shell 运行器就只能要么 fork 这套机制,要么为非 bash 工作去依赖一个以 bash 命名的包。这组堆叠变更对用户可见的动因正是这一拆分。
|
||||
|
||||
**在同一变更中把仓库其余 spawn 调用点(lsp-local、pty-local、subagent-subprocess、sdk package-manager、test-support 各启动器)迁到 `ctx.subprocess` 上。**在本 PR(Pull Request)的规模下,作为带有真实设计风险的范围蔓延否决。这些调用点在流与生命周期上的需求存在实质差异:node-pty 所有权(pty)、长生命周期 stdio 上的 LSP 分帧加进程树终止回退(lsp)、以 stdin EOF 打头的 dispose 阶梯和完全不缓冲输出(subagent 传输层)。把它们强行纳入一个按有界批量输出塑形的句柄之下,要么会让这道 seam 膨胀,要么会让句柄与消费方错配。依照「接口围绕当前消费方塑形」的规则,该 seam 当时在其唯一真实的消费方家族上得到验证后交付。评审随后恰恰要求以堆叠 PR 的形式完成这项后续工作;[消费方迁移 Agent Note](2026-07-26-subprocess-consumer-migration.md) 记录了向 Node 形状的重塑,以及哪些调用点迁入(哪些因所有权归属而留在原地)。
|
||||
|
||||
**改把 `run_in_background`/任务语义放进进程 seam。**否决:那条边界已经存在。`ctx.tasks` 拥有 id、所有权与通知,bash 工具则把 `BashProcess` 适配成任务钩子。进程 seam 位于 bash 执行器*之下*,而不是与任务注册表并列。
|
||||
|
||||
**把 `ENV_OVERRIDES`(TERM=dumb、PAGER=cat 等)移入管理器。**否决:通用进程管理器不得把终端呈现策略强加给非终端消费方;对环境中凭据形态名称与 `DSH_*` 名称的清除是安全与身份不变式,予以保留,但终端友好性是 bash 工具自己的选择,经 spec 的显式 env 表达,而调用方自己的条目依旧优先。
|
||||
|
||||
## 后果
|
||||
|
||||
换来的是:「运行并管理一个进程」成为一项具备标准三包形态的可替换能力(消费方起步就有两个:`bash-local`、`bash-sandbox`);容器化或远程进程后端可以直接接入,而不触碰 bash 语义;共享的 `DSH_*`/输出词汇有了一个不带 shell 含义的归属;后台进程也能在执行器重载后存活,与任务注册表的存续期模型一致。spawn 管道测试套件整体迁至 `dsh-subprocess-local`(现以 argv 为基础,外加 argv 校验与管理器生命周期/dispose 套件);执行器测试套件如今对着真实管理器固定 bash 所有的各层(分类、合并、spawn 失败提示、归管理器所有的存续期)。
|
||||
|
||||
代价是:多出一对包,而且凡加载 bash 执行器之处都多一行组合配置。若某次启动加载了执行器却没有加载管理器,`ctx.bash` 会因等待 `ctx.subprocess` 而保持挂起(标准的服务缺失行为)。迁移词汇的重导出让 `dsh-bash` 的导入继续可用,但也意味着两个包如今命名同一批类型;进程 seam 是所有者,bash seam 则记录这层重导出。spawn 失败提示经由读取路径变为单次交付,而旧管道曾把它保留在 stderr 缓冲区里,供重复的 `readFrom(0)` 读取;这一点可以接受,因为 bash 的后台读取路径本就是消费游标,该提示能到达唯一存在的那个读取方。
|
||||
@@ -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
|
||||
2026-07-27-dispose-ladder-to-consumer.md: 97b551ff509e3b424f6bf5725939cf54acc961a7
|
||||
2026-07-27-dispose-ladder-to-consumer.zh.md: b6849ad393737f2fef06e2007991583b12a04d7a
|
||||
@@ -0,0 +1,23 @@
|
||||
# Agent Note: The dispose ladder belongs to its consumer, not the subprocess seam
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-27-dispose-ladder-to-consumer.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`SubprocessHandle.dispose(graces)` and `SubprocessDisposeGraces` put a full teardown *policy* — stdin-EOF wait, then SIGTERM, then SIGKILL, each tier bounded by a caller-supplied window — on a seam whose other verbs are single mechanisms. Only one consumer ever called it (the ACP subagent backend); bash rides `terminate()` and service teardown, and the LSP host runs its own protocol-first shutdown. Every future backend nonetheless had to implement the ladder to satisfy the interface, and the implementation carried a `dsh-timeout` dependency solely for the ladder's tier bounds.
|
||||
|
||||
## Decision
|
||||
|
||||
The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs, graceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then `terminate()` (whose SIGTERM→spec-grace→SIGKILL escalation already encodes the signal tiers), then a final bounded whole-tree wait that throws if survivors remain. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold each tier on real tree exit. `dsh-subprocess-local` drops its `dsh-timeout` dependency; the seam's handle loses one method and one exported interface.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the ladder on the handle as a convenience.** Rejected: a seam method every implementation must provide is not a convenience, it is contract surface — and this one encodes one consumer's cooperation shape (stdin-EOF-first) as if it were process vocabulary. The seam's own README already had to caveat that children quiescing on other signals need "their own tier-1", which is the admission that the ladder is policy.
|
||||
|
||||
**Move the ladder to a shared helper package.** Rejected: one consumer. A second out-of-process backend with the same stdin-EOF cooperation shape can lift `disposeAcpChild` to shared code when it exists; extracting now would recreate `dsh-subagent-subprocess`, the single-purpose library this stack just deleted.
|
||||
|
||||
## Consequences
|
||||
|
||||
Bought: the seam is one method and one type smaller; implementations owe four verbs and no teardown policy; `dsh-subprocess-local` loses a dependency; the ladder's tier windows live beside the config fields that tune them. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier-tier tests moved from the seam suite to the ACP suite, and the seam suite pins the verbs the ladder composes (bounded `waitForExit` false-then-true across an escalation) instead of the composed policy.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Agent Note: dispose 阶梯归其消费方所有,而非 subprocess seam
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-27-dispose-ladder-to-consumer.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`SubprocessHandle.dispose(graces)` 与 `SubprocessDisposeGraces` 把一整套拆卸*策略*——等待 stdin EOF、再 SIGTERM、再 SIGKILL,每一层由调用方提供的时间窗约束——放在了一个其余动词均为单一机制的 seam 上。它始终只有一个调用方(ACP subagent 后端);bash 走 `terminate()` 与服务拆卸,LSP 主机运行自己的协议优先关闭流程。然而每个未来后端都必须实现该阶梯才能满足接口,实现包也仅为阶梯的层级时限背上了 `dsh-timeout` 依赖。
|
||||
|
||||
## 决策
|
||||
|
||||
阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs, graceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后 `terminate()`(其 SIGTERM→spec 宽限期→SIGKILL 升级已编码了信号层级),最后进行有界的整树等待,若仍有存活进程则抛出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在每一层确认进程树真正退出所需的停稳探针。`dsh-subprocess-local` 卸下 `dsh-timeout` 依赖;seam 的句柄少了一个方法和一个导出接口。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**把阶梯作为便利方法留在句柄上。**否决:一个每个实现都必须提供的 seam 方法不是便利,而是契约表面——而这一个把某一消费方的配合形状(stdin EOF 打头)当作进程词汇来编码。seam 自己的 README 早已不得不加注「依赖其他信号停稳的子进程需要自己的第一阶」,这本身就是承认该阶梯是策略。
|
||||
|
||||
**把阶梯移到共享辅助包。**否决:只有一个消费方。当第二个具有相同 stdin EOF 配合形状的进程外后端出现时,可以再把 `disposeAcpChild` 提升为共享代码;现在抽取只会重造 `dsh-subagent-subprocess`——这组堆叠变更刚刚删掉的那个单一用途库。
|
||||
|
||||
## 后果
|
||||
|
||||
买到的:seam 少了一个方法和一个类型;实现只欠四个动词,不欠拆卸策略;`dsh-subprocess-local` 少了一个依赖;阶梯的层级时间窗与调节它们的配置字段住在一起。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试从 seam 套件移入 ACP 套件,seam 套件转而钉住阶梯所组合的动词(升级前后有界 `waitForExit` 先假后真),而非组合后的策略。
|
||||
Reference in New Issue
Block a user