diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
index 28de5eb97c..b8d06ebf6d 100644
--- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
+++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
@@ -4,9 +4,9 @@ Status: implemented
## 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, calls `agent.retry()`, and stops waterfall delegation.
-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.
@@ -48,7 +48,7 @@ The initial shared transient-code set is intentionally small: the adapters' exis
`@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/idle`. 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:
@@ -66,13 +66,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 calls `agent.retry()` without delegating. Turn cancellation and plugin disposal end the wait without requesting 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
@@ -90,7 +90,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.
@@ -118,9 +118,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.
@@ -130,7 +130,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason`
## Consequences
-- Every transient recovery attempt is visible as a closed step plus `llm/retry`, and the bounded policy prevents hidden SDK retries from multiplying cost. A retry can still duplicate provider billing even when no chunk arrived; the finite attempt budget limits but cannot remove that risk.
+- Every transient recovery attempt is visible as a closed failed turn plus `llm/retry`, and the bounded policy prevents hidden SDK retries from multiplying cost. A retry can still duplicate provider billing even when no chunk arrived; the finite attempt budget limits but cannot remove that risk.
- Provider SDKs may hide status or retry headers. Those adapters retain the stable facts they expose and otherwise use a coarse code rather than letting recovery policy parse fragile text.
- Durable retry events expand the session protocol and UI state machine. Shipping the event and its consumer together prevents an unused telemetry vocabulary, but later schema changes still require persistence and replay work.
- Clearing a failed step's live chunks can visibly retract output. That is preferable to presenting discarded text or partial tool JSON as committed history, and snapshots pin the transition.
diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml
index b25f335819..fdd7444512 100644
--- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-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
+2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 83fe62276b235d5961ed0fee61a4a1603c383c56
+2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: fef9f48cbcfda42a13a88737f10b446ba317d5de
diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md
index b934f7fd70..83fe62276b 100644
--- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md
+++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md
@@ -22,9 +22,9 @@ The loop fires awaited serial `agent/post-step(agent, turn, step, signal)` after
### Request recovery is limited to the final model boundary
-`RequestError`, `RequestErrorDecision`, and the `agent/request-error` waterfall represent failures after the final adapter has been selected. Each returned stream handle owns a private failure set that preserves the original thrown error identity across dispatch, iterator construction, and iteration without leaking nested-call provenance into an outer call. Terminal in-band `error` or `aborted` finishes enter the same path. Prompt assembly, request middleware, request logging, result processing, tools, post-step listeners, and cleanup remain ordinary failures.
+`RequestError` and the `agent/request-error` waterfall represent failures after the final adapter has been selected. Each returned stream handle owns a private failure set that preserves the original thrown error identity across dispatch, iterator construction, and iteration without leaking nested-call provenance into an outer call. Terminal in-band `error` or `aborted` finishes enter the same path. Prompt assembly, request middleware, request logging, result processing, tools, step listeners, and cleanup remain ordinary failures.
-The failed step closes before recovery runs. A retry opens the next numbered step and rebuilds the request from the durable log; consecutive recovery attempts reset only after a successful provider request. Both DeepSeek adapters normalize recognized provider context-limit failures to `CONTEXT_WINDOW_EXCEEDED`.
+The failed step closes before recovery runs. A handling listener repairs durable state, calls `agent.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/idle`. Both DeepSeek adapters normalize recognized provider context-limit failures to `CONTEXT_WINDOW_EXCEEDED`.
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 calls `agent.retry()` whenever pruning or summarization increases it. This remains true when pruning lands before later summary work throws; cancellation still wins. A backend returning a result without replacement cannot authorize retry, while pruning-only progress can authorize a retry without a `CompactionResult`.
`maxOverflowRetries` is optional and defaults to `1`; `0` disables overflow recovery without disabling pressure. `auto: false` registers neither automatic listener. Noncanonical errors, exhausted attempts, an already-aborted signal, a missing routed model, no safe range, no generation change, and recovery throws before any replacement all delegate to the next listener. With no later recovery, the loop reports the original provider error object and code. A recovery throw after generation advances authorizes retry from durable progress; cancellation or disposal remains authoritative even if recovery work completes concurrently.
@@ -42,7 +42,7 @@ The default summarizer resolves explicit configuration, then the latest logged r
## Testing
-Unit tests cover final-adapter failure provenance and identity, closed-step retry numbering and reset, cancellation and disposal, post-step ordering, routed-envelope pressure, pressure-gated pruning, pruning-only relief, pruned-input summarization, balanced overflow reduction, durable prune progress before later failure, generation proof, caps, delegation, and auxiliary-call routing. Real-loop tests cover thrown and in-band overflow through pruning or summary compaction to a reconstructed retry request.
+Unit tests cover final-adapter failure provenance and identity, closed-turn retry numbering and reset, cancellation and disposal, step-boundary ordering, routed-envelope pressure, pressure-gated pruning, pruning-only relief, pruned-input summarization, balanced overflow reduction, durable prune progress before later failure, generation proof, caps, delegation, and auxiliary-call routing. Real-loop tests cover thrown and in-band overflow through pruning or summary compaction to a reconstructed retry request.
## Alternatives considered
diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md
index 3b5b60a95b..fef9f48cbc 100644
--- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md
@@ -22,9 +22,9 @@ Status: implemented
### 请求恢复只覆盖最终模型边界
-`RequestError`、`RequestErrorDecision` 与 `agent/request-error` waterfall 表示最终适配器已经选定之后的失败。每个返回的流句柄都绑定一个私有失败集合;该集合在分发、异步迭代器构造与迭代过程中保留原始抛出错误的身份,同时防止把嵌套调用的错误来源误归到外层调用。终止性的带内 `error` 或 `aborted` finish 进入同一路径。提示词装配、请求中间件、请求日志、结果处理、工具、post-step 监听器与清理仍属于普通失败。
+`RequestError` 与 `agent/request-error` waterfall 表示最终适配器已经选定之后的失败。每个返回的流句柄都绑定一个私有失败集合;该集合在分发、异步迭代器构造与迭代过程中保留原始抛出错误的身份,同时防止把嵌套调用的错误来源误归到外层调用。终止性的带内 `error` 或 `aborted` finish 进入同一路径。提示词装配、请求中间件、请求日志、结果处理、工具、step 监听器与清理仍属于普通失败。
-恢复运行前,失败 step 已经关闭。重试会打开下一个编号 step,并从持久日志重建请求;连续恢复尝试计数只在提供方请求成功后重置。两个 DeepSeek 适配器都把识别出的提供方上下文限制错误规范化为 `CONTEXT_WINDOW_EXCEEDED`。
+恢复运行前,失败 step 已经关闭。负责处理的监听器修复持久状态、调用 `agent.retry()`,并停止 waterfall 委托。循环随后关闭失败 turn,并从持久日志开启一个重试 turn,中间不发布空闲通知。重试策略与尝试计数由插件自己拥有;compact-basic 在链路到达终态 `agent/idle` 时清除对应 agent 的溢出计数。两个 DeepSeek 适配器都把识别出的提供方上下文限制错误规范化为 `CONTEXT_WINDOW_EXCEEDED`。
如果取消发生在 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 增加时就调用 `agent.retry()`。即使剪枝先落盘而后续摘要工作抛错,这条规则仍然成立;取消依然优先。后端若只返回结果但没有替换表层,不能授权重试;只有剪枝取得进展时,即使没有 `CompactionResult` 也可以授权重试。
`maxOverflowRetries` 可选且默认为 `1`;`0` 只禁用溢出恢复,不会禁用压力检查。`auto: false` 不注册任何自动监听器。非规范化错误、尝试耗尽、已经中止的 signal、缺失路由模型、没有安全范围、generation 未变化,以及在任何替换之前恢复抛错,都会委托给下一个监听器。若没有后续恢复,循环报告原始提供方错误对象与代码。generation 增加后的恢复抛错会基于持久进展授权重试;即使恢复工作并发完成,取消或销毁仍具有最终优先级。
@@ -42,7 +42,7 @@ Status: implemented
## 测试
-单元测试覆盖最终适配器失败的来源与身份、已关闭 step 的重试编号与重置、取消与销毁、post-step 顺序、已路由信封压力、压力门控剪枝、剪枝独立解除压力、从已剪枝输入生成摘要、平衡溢出缩减、后续失败前已落盘的剪枝进展、generation 证明、上限、委托与辅助调用路由。真实循环测试覆盖抛出式和带内溢出,并验证剪枝或摘要压缩后的重试请求从替换表层重建。
+单元测试覆盖最终适配器失败的来源与身份、已关闭 turn 的重试编号与重置、取消与销毁、step 边界顺序、已路由信封压力、压力门控剪枝、剪枝独立解除压力、从已剪枝输入生成摘要、平衡溢出缩减、后续失败前已落盘的剪枝进展、generation 证明、上限、委托与辅助调用路由。真实循环测试覆盖抛出式和带内溢出,并验证剪枝或摘要压缩后的重试请求从替换表层重建。
## 考虑过的替代方案
diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md
index f5beab3eb4..7c9751ce24 100644
--- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md
+++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md
@@ -37,7 +37,7 @@ An earlier draft put the full algorithm (the retention walk, token-summing, text
Successful-call pressure cannot run at pre-step because final `agent/request` routing, provider output, tool results, buffered context, and steering do not exist there. Serial `agent/post-step(agent, turn, step, signal)` fires after those facts are durable and before `step/end`. `dsh-compact-basic` measures the canonical logged request through `ctx.tokenMeter`, so the next request sees any replacement without a speculative envelope override. Once pressure qualifies, optional `ctx.toolResultPrune` rewriting runs before summary selection; compact-basic remeasures the durable surface and skips summarization if pruning restores safe pressure.
-Canonical provider context overflow takes a separate path. The failed step closes, `agent/request-error` receives the original request error and consecutive retry count, and compact-basic prunes before forcing one useful balanced reduction. It returns retry only if `session.surface.replaceGeneration` increases, including pruning-only progress when no summary range exists; the loop then opens a new numbered step and reconstructs its request from the durable log. No replacement, a recovery failure before any replacement, cancellation, an exhausted cap, or an unrelated error preserves the original provider failure. If pruning already advanced the generation before later summary work fails, recovery retries from that durable pruned surface unless cancellation or disposal wins. The complete lifecycle decision is in the [after-call recovery Agent Note](../architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md).
+Canonical provider context overflow takes a separate path. The failed step closes and `agent/request-error` receives the original request error. Compact-basic owns its per-agent overflow count, prunes before forcing one useful balanced reduction, and calls `agent.retry()` only if `session.surface.replaceGeneration` increases, including pruning-only progress when no summary range exists. The loop then closes the failed turn, opens a new numbered retry turn, and reconstructs its request from the durable log. No replacement, a recovery failure before any replacement, cancellation, an exhausted cap, or an unrelated error preserves the original provider failure. If pruning already advanced the generation before later summary work fails, recovery retries from that durable pruned surface unless cancellation or disposal wins. The complete lifecycle decision is in the [after-call recovery Agent Note](../architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md).
```
assistant/message → tool/result/context/steering
diff --git a/.agents/notes/implemented/feature/2026-07-07-session-prefix.md b/.agents/notes/implemented/feature/2026-07-07-session-prefix.md
index 7faf80cf80..a8783d0b8f 100644
--- a/.agents/notes/implemented/feature/2026-07-07-session-prefix.md
+++ b/.agents/notes/implemented/feature/2026-07-07-session-prefix.md
@@ -10,7 +10,7 @@ The obvious third option — let a plugin edit the request's `messages` on the w
## Decision
-`agent/session-prefix` is a waterfall on the agent event map ([`packages/core/agent/src/types.ts`](../../../../packages/core/agent/src/types.ts)): listeners receive a frozen empty seed and return an extension (the canonical contribution is a prepend, `[mine, ...await next()]`, which yields registration order on the wire). The loop ([`packages/core/agent-loop/src/loop.ts`](../../../../packages/core/agent-loop/src/loop.ts)) fires it once per loop instance, lazily before the instance's first `agent/pre-step`; the composed list is deep-cloned, deep-frozen, cached on the instance, and placed in front of the ENTIRE derived history — directly after the provider's system slot — on every request the instance sends ([wire order](../../../../docs/core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header)).
+`agent/session-prefix` is a waterfall on the agent event map ([`packages/core/agent/src/types.ts`](../../../../packages/core/agent/src/types.ts)): listeners receive a frozen empty seed and return an extension (the canonical contribution is a prepend, `[mine, ...await next()]`, which yields registration order on the wire). The loop ([agent-loop source](../../../../packages/core/agent-loop/src/)) fires it once per loop instance, lazily before the instance's first `agent/pre-step`; the composed list is deep-cloned, deep-frozen, cached on the instance, and placed in front of the ENTIRE derived history — directly after the provider's system slot — on every request the instance sends ([wire order](../../../../docs/core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header)).
Three properties carry the design:
diff --git a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md
index b482a444b5..3f938f99c4 100644
--- a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md
+++ b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md
@@ -10,7 +10,7 @@ Status: implemented
- `streamBlocks()` — a "convenience view" that runs the chunks through a `BlockAssembler` and yields completed `ContentBlock`s in stream order ([index.ts:137-144](../../../../packages/llm/llm/src/index.ts)).
- `generate()` — one fully-assembled `GenerateResult`, dispatched through a second `llm/generate` waterfall ([index.ts:151-157](../../../../packages/llm/llm/src/index.ts)).
-The only production consumer of the LLM service is the agent loop, and it uses `stream()` exclusively — feeding raw chunks through its own `BlockAssembler` so it can log chunks for replay fidelity while assembling in parallel ([packages/core/agent-loop/src/loop.ts](../../../../packages/core/agent-loop/src/loop.ts), the `ctx.llm.stream(req)` step). Grepping `streamBlocks` and `ctx.llm.generate` across `packages/*/src` and `examples/*/src` finds no production callers. The references are the service methods, docs, and tests; adapter tests use `generate()` as a convenient driver, but they can hand-drain `stream()` through the same assembler helper without preserving a public production API.
+The only production consumer of the LLM service is the agent loop, and it uses `stream()` exclusively — feeding raw chunks through its own `BlockAssembler` so it can log chunks for replay fidelity while assembling in parallel ([packages/core/agent-loop/src/agent.ts](../../../../packages/core/agent-loop/src/agent.ts), the `ctx.llm.stream(req)` step). Grepping `streamBlocks` and `ctx.llm.generate` across `packages/*/src` and `examples/*/src` finds no production callers. The references are the service methods, docs, and tests; adapter tests use `generate()` as a convenient driver, but they can hand-drain `stream()` through the same assembler helper without preserving a public production API.
This is the [drop-mutable-session-summary](2026-06-19-drop-mutable-session-summary.md) pattern: assembled-view APIs with tested contracts, consumed by tests rather than production. They were built speculatively for consumers that do not care about token-level deltas, but the one real consumer cares about deltas precisely so it can persist high-fidelity replay data.
diff --git a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md
index c79202b95a..7de730edd7 100644
--- a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md
+++ b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md
@@ -4,7 +4,7 @@ Status: implemented
## Problem
-The loop records every model token delta as a durable `assistant/chunk` session event AND emitted a parallel live `agent/stream-chunk` Cordis event carrying the identical data. In `packages/core/agent-loop/src/loop.ts` the two sat one line apart:
+The loop records every model token delta as a durable `assistant/chunk` session event AND emitted a parallel live `agent/stream-chunk` Cordis event carrying the identical data. In `packages/core/agent-loop/src/agent.ts` the two sat one line apart:
```ts ignore-check
const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
diff --git a/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md b/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md
index ebfc774792..97c399f50e 100644
--- a/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md
+++ b/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md
@@ -4,7 +4,7 @@ Status: implemented
## Problem
-`agent/steering` was the last remaining transient mirror of a durable session event. The loop's steering drain appends the durable `steering/message { turn, content, source }` and, on the very next line, emitted `agent/steering(agent, turn, content, source)` — the identical fact as a fire-and-forget event (`packages/core/agent-loop/src/loop.ts`, `drainSteering`). It had zero production listeners: the only subscriber anywhere was a loop regression test asserting the emit carried `source` — the same fact the durable event already records one line above.
+`agent/steering` was the last remaining transient mirror of a durable session event. The loop's steering drain appends the durable `steering/message { turn, content, source }` and, on the very next line, emitted `agent/steering(agent, turn, content, source)` — the identical fact as a fire-and-forget event (`packages/core/agent-loop/src/agent.ts`, `drainOutbox`). It had zero production listeners: the only subscriber anywhere was a loop regression test asserting the emit carried `source` — the same fact the durable event already records one line above.
`agent/steering` duplicated the immediately preceding durable `steering/message` with the same payload. `agent/queued` remains the live-only signal because it fires before persistence and covers work that may be cancelled before entering the log.
@@ -12,7 +12,7 @@ Steering carries real production traffic — the hook bridges' turn-continuation
## Decision
-`agent/steering` is removed from the agent event taxonomy: the declaration in `packages/core/agent/src/types.ts` (and its mention in the live-events JSDoc list there), the emit in `drainSteering` (whose then-unused `ctx` parameter went with it), the row in `packages/core/agent/README.md`, and the emit line in the loop-pseudocode blocks (the `packages/core/agent-loop/src/loop.ts` module doc and [architecture.md](../../../../docs/architecture.md)); the cordis catalog is regenerated without it. The one regression test pins source preservation on the durable `steering/message` event — the fact it pins lives on the log.
+`agent/steering` is removed from the agent event taxonomy: the declaration in `packages/core/agent/src/types.ts` (and its mention in the live-events JSDoc list there), the emit in `drainOutbox`, the row in `packages/core/agent/README.md`, and the emit line in the loop-pseudocode blocks (the `packages/core/agent-loop/src/agent.ts` module doc and [architecture.md](../../../../docs/architecture.md)); the cordis catalog is regenerated without it. The one regression test pins source preservation on the durable `steering/message` event — the fact it pins lives on the log.
Three implemented Agent Notes stated the retention, and each is amended per [implemented/AGENTS.md](../AGENTS.md) to point here as the record of the removal: the [boundary Agent Note](2026-06-20-remove-agent-boundary-mirror-events.md)'s retained-list entry, the [stream-chunk Agent Note](2026-07-02-remove-stream-chunk-mirror.md)'s scope clause, and the [event-domain-semantics Agent Note](../architecture/2026-06-30-event-domain-semantics.md)'s transient-emit enumeration.
diff --git a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.i18n.yaml
new file mode 100644
index 0000000000..1aa37f9255
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-24-agent-loop-observable-state-machine.md: 7a3607c5bcf6edece07433acd766a6dece551d1c
+2026-07-24-agent-loop-observable-state-machine.zh.md: 7b53eb6087e99ce4c84caae7b94d275c0b6a10db
diff --git a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md
new file mode 100644
index 0000000000..7a3607c5bc
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md
@@ -0,0 +1,58 @@
+# Agent Note: Collapse agent-loop events around the observable state machine
+
+Status: implemented
+
+English | [中文](2026-07-24-agent-loop-observable-state-machine.zh.md)
+
+## Problem
+
+The agent loop exposed its control flow as a large set of Cordis events. Separate `pre-step` and `post-step` checkpoints bracketed a step, `session-prefix` and `step-result` transformed request and response messages, `request-error` decided whether a failed request retried inside its turn, and `turn-continuation` plus `turn-stop` composed competing continuation decisions.
+
+Those events made internal phases public even when the durable session log already owned the corresponding turn and step facts. They also mixed two extension models: some listeners observed a boundary and issued an agent command, while others returned control decisions that the loop interpreted. Understanding the public machine therefore required reconstructing event order, waterfall precedence, and special terminal overrides together.
+
+Agent lifetime, whole-agent activity, inbox-item progress, and per-turn settlement are independent state dimensions. Treating them as one status or one linear callback sequence makes ordinary questions ambiguous: an agent can remain `running` across several turns, an accepted item can be discarded without opening a turn, and one turn can settle while later work keeps the agent active.
+
+## Decision
+
+The public contract exposes four orthogonal state dimensions:
+
+- Registration lifetime is the `agent/created` to `agent/disposed` interval. Disposal is the terminal registry edge, not an `AgentStatus`.
+- Whole-agent activity is `AgentStatus = 'idle' | 'running'`. Consecutive turns may share one `running` interval.
+- A FIFO-backed message progresses from `agent/inbox/enqueue` to exactly one `agent/inbox/dequeue` or `agent/inbox/discard`, correlated by `AgentMessageId`. The inbox events describe acceptance, claim, and removal rather than turn completion.
+- A claimed turn passes through prompt admission and zero or more request steps. An automatic retry closes the failed turn and immediately opens another; `agent/idle` reports only the terminal turn in that chain and remains distinct from the whole-agent transition to `status === 'idle'`.
+
+The loop keeps five machine extension events. `agent/prompt-submit` admits, rewrites, or blocks a claimed prompt. `agent/step` is the single awaited between-steps checkpoint and runs before every request is derived. `agent/request` is the waterfall for the frozen call configuration; the configuration comes only from `await next()`, not from a duplicate positional argument. `agent/request-error` serializes ownership of awaited model-request recovery. `agent/stopping` runs when the turn otherwise has no work left; a listener that needs another step records real steering with `agent.steer()`, and the loop decides from that data after all listeners settle.
+
+Continuation and termination are data rather than returned control enums. Tool calls and accepted steering require another step. A tool result carrying `concludesTurn` ends the tool loop at its step. The loop does not expose general `ContinuationDecision` or terminal-stop return channels.
+
+A model-request failure closes its step, then enters `agent/request-error` with the exact error, normalized `LlmFailure`, and live turn signal. A listener that owns recovery repairs state, calls `agent.retry()`, and returns without delegating. The loop closes the failed turn and opens one retry turn over that state without an intervening idle notification; retry is not another step inside the failed turn. `agent/idle` reports the terminal outcome, and `agent/error` remains the live error notification for consumers that report failures independently of turn settlement.
+
+The event taxonomy removes `agent/pre-step`, `agent/post-step`, `agent/session-prefix`, `agent/step-result`, `agent/turn-continuation`, and `agent/turn-stop`. Durable turn and step boundaries remain session events. Model-facing additions use logged message channels, request configuration uses `agent/request`, response content is recorded as assembled, failed-request recovery uses `agent/request-error` plus `agent.retry()`, and end-of-turn continuation uses `agent/stopping` plus steering.
+
+## Alternatives considered
+
+**Keep the fine-grained event sequence.** This preserves a dedicated interception point for every internal phase, including request-only prefixes, assistant-message rewriting, post-step work, in-turn request recovery, and terminal stop overrides. It also makes the loop's private sequencing a permanent public contract and lets overlapping seams express conflicting decisions. The decision accepts the lost interception points in exchange for one boundary per supported extension responsibility.
+
+**Represent disposal as a third `AgentStatus`.** This gives retained handles a terminal status value but duplicates the registry lifecycle already expressed by `agent/disposed`. The decision keeps `AgentStatus` about live activity and makes registration lifetime a separate dimension.
+
+**Return a retry decision from `agent/request-error`.** A returned instruction duplicates the existing `agent.retry()` command and requires the loop to carry policy history across attempts. The waterfall remains useful for ordered ownership: an unhandled listener delegates, while a handling listener performs its awaited repair, calls `agent.retry()`, and stops delegation.
+
+**Mirror durable turn and step boundaries as agent events.** This gives live consumers a second event stream for the same facts. The decision keeps the session log as the source of truth and exposes only extension checkpoints or live-only facts that the durable stream cannot carry.
+
+## Consequences
+
+The observable machine is smaller and compositional: registration lifetime, activity, item progress, and terminal settlement can be followed independently. In particular, `agent/idle` does not imply `agent.status === 'idle'`; it reports the terminal turn of one drain chain, while `agent/status` reports whether the whole agent is active.
+
+Plugins no longer rewrite every phase of the loop. There is no request-only message prefix, assistant-message transform, post-step checkpoint, generic continuation enum, generic terminal-stop result, or in-turn request retry. Extensions use the remaining owned channels instead of recreating those phases.
+
+Continuation plugins publish durable steering rather than returning an unlogged reason. Recovery plugins act after the failed step and explicitly schedule another turn through `agent.retry()`. This makes every attempt a complete turn while keeping asynchronous repair and policy ownership at one narrow waterfall boundary.
+
+The inbox lifecycle complements, rather than replaces, the durable session log. `AgentMessageId` correlates acceptance with claim or discard; turn and step numbers, messages, tool activity, and terminal reasons remain session facts.
+
+## Related
+
+- [Unify agent delivery on send(target × wakeup) and coalesce injected context into user/message](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md)
+- [Remove implicit batching from ordinary sends](2026-07-17-one-send-one-turn.md)
+- [Microkernel event taxonomy](../architecture/2026-06-11-microkernel-event-taxonomy.md)
+- [Bounded LLM request recovery](../architecture/2026-06-21-bounded-llm-request-recovery.md)
+- [Reconstructable requests](../architecture/2026-07-05-reconstructable-requests.md)
diff --git a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.zh.md b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.zh.md
new file mode 100644
index 0000000000..7b53eb6087
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.zh.md
@@ -0,0 +1,58 @@
+# Agent Note: 围绕可观察状态机收拢 agent loop(智能体循环)事件
+
+Status: implemented
+
+[English](2026-07-24-agent-loop-observable-state-machine.md) | 中文
+
+## 问题
+
+agent loop 曾将其控制流暴露为大量 Cordis 事件。`pre-step` 和 `post-step` 两个独立检查点分列步骤前后,`session-prefix` 和 `step-result` 分别变换请求消息与响应消息,`request-error` 决定失败的请求是否在当前轮次内重试,`turn-continuation` 与 `turn-stop` 则组合相互竞争的继续执行决策。
+
+即使持久会话日志已经记录了对应的轮次与步骤事实,这些事件仍会将内部阶段公开。它们还混用了两种扩展模型:部分监听器观察边界并发出 agent 命令,另一些监听器则返回由循环解释的控制决策。因此,要理解公开状态机,必须同时还原事件顺序、waterfall(瀑布式事件)优先级和特殊的终止覆盖规则。
+
+agent 生命周期、agent 整体活动状态、收件箱条目的进度以及每轮次的结算,是彼此独立的状态维度。若将它们视为一个状态或一条线性回调序列,常见问题就会产生歧义:agent 可以在多个轮次之间持续保持 `running`;已接受的条目可以不启动轮次就被丢弃;一个轮次可以完成结算,而后续工作仍让 agent 保持活动。
+
+## 决策
+
+公开契约暴露四个正交的状态维度:
+
+- 注册生命周期是从 `agent/created` 到 `agent/disposed` 的区间。dispose(资源释放)是注册表的终止边界,而不是一种 `AgentStatus`。
+- agent 整体活动状态为 `AgentStatus = 'idle' | 'running'`。连续多个轮次可以共用同一个 `running` 区间。
+- 由 FIFO 支撑的消息从 `agent/inbox/enqueue` 开始,最终必然进入 `agent/inbox/dequeue` 或 `agent/inbox/discard` 二者之一,并通过 `AgentMessageId` 关联。收件箱事件描述接受、领取和移除,而不是轮次完成。
+- 已领取的轮次经过提示词准入和零个或多个请求步骤。自动重试会关闭失败轮次并立即开启另一个轮次;`agent/idle` 只报告该重试链的终态轮次,且仍不同于 agent 整体转换到 `status === 'idle'`。
+
+循环保留五个状态机扩展事件。`agent/prompt-submit` 对已领取的提示词执行准入、改写或阻断。`agent/step` 是步骤之间唯一需要等待的检查点,在每次派生请求前运行。`agent/request` 是冻结调用配置所用的 waterfall;配置只能来自 `await next()`,不再通过重复的位置参数提供。`agent/request-error` 串行确定需要等待的模型请求恢复由谁负责。当轮次原本已经没有剩余工作时,`agent/stopping` 运行;需要再执行一个步骤的监听器使用 `agent.steer()` 记录真实的 steering(中途引导),循环在所有监听器完成后根据这份数据作出决定。
+
+是否继续和终止执行由数据表达,不再由返回的控制枚举表达。工具调用和已接受的 steering 要求再执行一个步骤。携带 `concludesTurn` 的工具结果会在其所属步骤终止工具循环。循环不再暴露通用的 `ContinuationDecision` 或终止停止返回通道。
+
+模型请求失败会先关闭当前步骤,再携带准确错误、标准化 `LlmFailure` 和仍有效的轮次信号进入 `agent/request-error`。负责恢复的监听器修复状态、调用 `agent.retry()`,并停止继续委托。循环会关闭失败轮次,并基于该状态开启一个重试轮次,中间不发布空闲通知;重试不是失败轮次内的另一个步骤。`agent/idle` 报告终态结果;对于需要脱离轮次结算单独报告失败的消费方,`agent/error` 仍作为实时错误通知保留。
+
+事件分类体系移除了 `agent/pre-step`、`agent/post-step`、`agent/session-prefix`、`agent/step-result`、`agent/turn-continuation` 和 `agent/turn-stop`。持久的轮次与步骤边界仍由会话事件记录。面向模型的新增内容使用有日志记录的消息通道,请求配置使用 `agent/request`,响应内容按组装后的原样记录,失败请求恢复使用 `agent/request-error` 加 `agent.retry()`,轮次结束时是否继续则使用 `agent/stopping` 加 steering 表达。
+
+## 考虑过的替代方案
+
+**保留细粒度事件序列。** 这样可以为每个内部阶段保留专用拦截点,包括仅用于请求的前缀、助手消息改写、步骤后处理、轮次内请求恢复以及终止停止覆盖。但这也会使循环的私有执行顺序成为永久的公开契约,并允许相互重叠的 seam 表达彼此冲突的决策。当前决策接受这些拦截点的缺失,以换取每项受支持的扩展职责仅对应一个边界。
+
+**将 dispose 表示为第三种 `AgentStatus`。** 这样会让仍被持有的句柄得到一个终止状态值,但也会重复表达 `agent/disposed` 已经体现的注册表生命周期。当前决策让 `AgentStatus` 只表示活动中 agent 的状态,并将注册生命周期作为独立维度。
+
+**让 `agent/request-error` 返回重试决策。** 返回指令会与现有的 `agent.retry()` 命令重复,还要求循环跨尝试携带策略历史。Waterfall 只需保留有序归属能力:未处理的监听器继续委托,负责处理的监听器完成可等待的修复、调用 `agent.retry()`,然后停止委托。
+
+**将持久的轮次与步骤边界映射为 agent 事件。** 这样会为同一事实向实时消费方提供第二条事件流。当前决策将会话日志保留为真源,仅暴露扩展检查点或持久事件流无法承载的纯实时事实。
+
+## 影响
+
+可观察状态机更小,也更容易组合:注册生命周期、活动状态、条目进度和终态结算可以分别追踪。尤其是,`agent/idle` 并不意味着 `agent.status === 'idle'`;前者报告一次排空链的终态轮次,`agent/status` 则报告整个 agent 是否处于活动状态。
+
+插件不再能够改写循环的每个阶段。不再提供仅用于请求的消息前缀、助手消息变换、步骤后检查点、通用的继续执行枚举、通用的终止停止结果或轮次内请求重试。扩展改用剩余的归属明确的通道,而不是重新构造这些阶段。
+
+负责继续执行的插件发布可持久化的 steering,而不是返回未记录到日志中的原因。恢复插件在失败步骤结束后处理错误,并通过 `agent.retry()` 显式安排另一个轮次。这样,每次尝试都会成为完整轮次,同时异步修复和策略归属集中在一个狭窄的 waterfall 边界。
+
+收件箱生命周期用于补充持久会话日志,而非取代它。`AgentMessageId` 将接受操作与领取或丢弃操作关联起来;轮次编号与步骤编号、消息、工具活动和终止原因仍属于会话事实。
+
+## 相关内容
+
+- [统一通过 send(target × wakeup) 交付 agent 消息,并将注入上下文合并到 user/message](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md)
+- [移除普通发送中的隐式批处理](2026-07-17-one-send-one-turn.md)
+- [微内核事件分类体系](../architecture/2026-06-11-microkernel-event-taxonomy.md)
+- [有界 LLM 请求恢复](../architecture/2026-06-21-bounded-llm-request-recovery.md)
+- [可重建的请求](../architecture/2026-07-05-reconstructable-requests.md)
diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md
index 5f1e43eb17..d0eedfb4c8 100644
--- a/docs/agent-lifecycle.md
+++ b/docs/agent-lifecycle.md
@@ -15,7 +15,6 @@ sequenceDiagram
participant LLM as ctx.llm
participant Tools as ctx.tools
participant Session
- participant Persistence
participant SDK as UI or SDK listener
User->>Agent: followup(content)
Agent-->>SDK: agent/inbox/enqueue
@@ -26,7 +25,7 @@ sequenceDiagram
Hooks-->>Driver: authoritative allow, block, or add context
Driver->>Session: user/message or rejected turn/end
Driver->>Prompt: system-prompt/assemble waterfall
- Driver-->>Driver: agent/pre-step serial checkpoint
+ Driver-->>Driver: agent/step serial checkpoint
Driver->>Session: step/start
Driver->>LLM: agent/request waterfall, then llm/stream waterfall
LLM-->>Driver: StreamChunk*
@@ -35,9 +34,8 @@ sequenceDiagram
alt final adapter or terminal in-band request failure
Driver->>Session: step/end
Driver->>Hooks: agent/request-error waterfall
- Hooks-->>Driver: retry in a new step or preserve the original error
+ Hooks-->>Driver: call agent.retry() or preserve the original error
else model request succeeded
- Driver->>Hooks: agent/step-result waterfall
Driver->>Session: assistant/message
Driver->>Tools: classify pending call by executionMode
loop barriers and bounded rolling pool, reclassify before start
@@ -52,19 +50,16 @@ sequenceDiagram
end
end
Driver->>Session: post-tool context and steering (no prompt-submit)
- Driver->>Hooks: agent/post-step serial checkpoint
Driver->>Session: step/end
- Driver->>Hooks: agent/turn-continuation waterfall
- Driver->>Hooks: agent/turn-stop serial terminal checkpoint
+ Driver->>Hooks: agent/stopping serial terminal checkpoint
end
Driver->>Session: turn/end
- Driver->>Persistence: session/flush parallel checkpoint
Driver-->>SDK: agent/status idle
```
The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set.
-`dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and a fresh retry step, and returns retry only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.
+`dsh-compact-basic` uses `agent/step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.
The returned `agent/prompt-submit` allow is authoritative; listeners wrapping `next()` preserve downstream content and additional contexts unless replacement is intentional. Steering bypasses that waterfall and joins at its durable checkpoint.
diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml
index 8568b125fa..fdc8f4197d 100644
--- a/docs/architecture.i18n.yaml
+++ b/docs/architecture.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-architecture.md: c8f328560600d8a7e1f1ab659fab39c13c18dee3
-architecture.zh.md: 61fba67a8585220e1ef59f02844271fee8a46401
+architecture.md: 1bf151c0d7db88b102dd49fa8f21ece02beec9d2
+architecture.zh.md: 43d1b6ff0a2123f4a48ded99d938e70ebf93f084
diff --git a/docs/architecture.md b/docs/architecture.md
index c8f3285606..1bf151c0d7 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -110,23 +110,23 @@ idle inject:
do not open a turn or run the model
```
-Each step assembles ordered prompt sections, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona, while the loop supplies `model` and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)).
+Each step assembles ordered prompt sections, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona, while the loop supplies `provider`, `model`, and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)).
-Tool-time context—including active-turn `inject()` and post-tool `additionalContexts`—settles after results. Accepted steering drains from the same pending item at that boundary and requests another step. Idle `inject()` instead appends context immediately without changing turn numbering; persistence owns its eager drain.
+Tool-time context—including active-turn `inject()` and post-tool `additionalContexts`—settles after results. Steering drains at that boundary and requests another step. Idle `inject()` appends context immediately without changing turn numbering; persistence drains it eagerly.
-Pruning precedes summaries; overflow retries require durable progress. Bounded transient retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)).
+Pruning precedes summaries; overflow retries require durable progress. Recovery runs through `agent/request-error` between the failed step and turn closes. A handling policy calls `agent.retry()` to schedule one retry turn; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)).
### Failure Boundaries
-Adapter failures close the step before `agent/request-error` with exact `Error`, `LlmFailure`, and history. Retry opens another step; success clears history; exhaustion stores failure on `turn/end`. Failed chunks commit no message/tool.
+Adapter failures close the step before `agent/request-error` receives the exact `Error`, normalized `LlmFailure`, and turn signal. A handling listener calls `agent.retry()`; the loop closes the failed turn and opens another from durable history without an idle notification. Exhaustion leaves the failed `turn/end` terminal. Failed chunks commit no message or tool call.
-Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched tool calls get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The turn signal retires before `turn/end`. Effective `cancel()` emits its typed cause before clearing queues and aborting; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)).
+Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. Effective `cancel(cause)` emits its cause before clearing queues and aborting; observers cannot veto and idle calls emit nothing. Durability records `aborted` for user or parent cancellation and `disposed` for teardown, which awaits quiescence. The cause changes reporting, not late result-context handling ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)).
-Turn and step execution events are turn-enclosed; an idle injected `user/message` may sit between turns. Reload closes an interrupted turn tail with a synthetic `interrupted` turn end. Post-close failures report only through `agent/error`; no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants.
+Turn and step events are turn-enclosed; idle injected `user/message` events may sit between turns. Reload closes an interrupted tail with a synthetic turn end. Post-close failures use only `agent/error`. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap).
### Agent Handles
-`ctx.agents` owns live agents and returns `AgentHandle { agent, dispose() }`. Plugins use `send(content, completeOptions)` when routing must be explicit, or the `followup()`, `steer()`, and `inject()` presets; `cancel()` and `whenIdle()` control lifecycle. The caller fiber, factory provider, and consumer handle co-own teardown through one awaited disposer.
+`ctx.agents` owns live agents and returns `AgentHandle { agent, dispose() }`. Plugins use complete `send()` options or the `followup()`, `steer()`, and `inject()` presets; `cancel()` and `whenIdle()` control lifecycle. One awaited disposer coordinates teardown ownership.
### Agent Scope
@@ -138,9 +138,9 @@ Each agent owns a scoped `agent.ctx`; shared storage overlays global tool, promp
The session log is authoritative. `deriveMessages()` projects model history; raw `assistant/chunk` events remain for replay and UI fidelity. Fork, resume, transcript rendering, telemetry, and persistence derive from the same stream.
-**Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` fronted by the header's session prefix, and headers by folding `request/header` — and the package-owned `dsh-agent-loop/invariant` can assert it through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)).
+**Model-visible ⟺ logged**: the log reconstructs every request from the messages at `step/start` and the folded `request/header`; the package-owned `dsh-agent-loop/invariant` can assert it through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)).
-Durability is a plugin concern. Backends buffer synchronous `session/event` notifications. The semantic checkpoint policy drains requests before adapter dispatch, recorded top-level calls before tool dispatch, and complete response/result batches at `agent/post-step`; the loop retains the final turn-end checkpoint. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, with SQLite under one contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)).
+Durability is a plugin concern. Backends eagerly drain synchronous `session/event` notifications. The semantic checkpoint policy uses `session/flush` as an observation barrier before adapter dispatch, before top-level tool dispatch, and at `agent/step` before the next request. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, with SQLite under one contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)).
`ctx.sessions.appendOutOfBand()` joins plugin-owned log-only events to an open turn or creates a balanced, flushed zero-step turn. `session/title` folds latest-wins with source seqs and provenance; its immediate fallback and sole optional async provider never delay the agent response. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)).
@@ -148,7 +148,7 @@ Durability is a plugin concern. Backends buffer synchronous `session/event` noti
Messages use typed blocks from merge-extensible `ContentBlockMap`; the same pattern types `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`. New blocks coordinate adapters, UI, compaction, token metering, and persistence; replay measurements live in [token-meter.md](core-data-structures/token-meter.md).
-Streaming uses raw chunks and `BlockAssembler`. Each `LlmAdapter.stream()` is one provider attempt; adapters report facts and `agent/request-error` owns recovery. The loop logs chunks and successful provenance/replay state. Remote adapters use per-read idle watchdogs. Replay state crosses routes only when they share an adapter instance ([contract](core-data-structures/llm-streaming.md)).
+Streaming uses raw chunks and `BlockAssembler`. Each `LlmAdapter.stream()` is one provider attempt; adapters report normalized failure facts and a handling `agent/request-error` plugin calls `agent.retry()`. The loop logs chunks and successful provenance/replay state. Remote adapters use per-read idle watchdogs. Replay state crosses routes only when they share an adapter instance ([contract](core-data-structures/llm-streaming.md)).
## Extension And Composition
@@ -158,7 +158,7 @@ A swappable capability usually splits into **interface / implementation / consum
Exceptions combine layers: LLM interface/consumer; filesystem policy; web registries; named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)).
-`dsh-workspace-context` composes baselines on `agent/session-prefix` and appends `ctx.fs`-discovered nested changes on `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths.
+`dsh-workspace-context` injects the baseline at the first `agent/step` and appends `ctx.fs`-discovered changes through `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths.
### Bundles And Apps
diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md
index 61fba67a85..43d1b6ff0a 100644
--- a/docs/architecture.zh.md
+++ b/docs/architecture.zh.md
@@ -110,23 +110,23 @@ idle inject:
do not open a turn or run the model
```
-每个步骤都会组装有序提示词片段、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定,循环则提供 `model` 和 `cwd`([提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。
+每个步骤都会组装有序提示词片段、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定,循环则提供 `provider`、`model` 和 `cwd`([提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。
-工具执行阶段的上下文,包括活跃轮次内的 `inject()` 和工具执行后的 `additionalContexts`,会在结果记录完毕后落定。已接受的 steering(中途引导)会在同一边界从同一个待处理项排空,并请求再执行一个步骤。空闲状态下的 `inject()` 则会立即追加上下文,且不改变轮次编号;持久化层独立负责由此产生的即时排空。
+工具执行阶段的上下文,包括活跃轮次内的 `inject()` 和工具执行后的 `additionalContexts`,会在结果记录完毕后落定。Steering 会在同一边界排空并请求再执行一个步骤。空闲状态下的 `inject()` 会立即追加上下文,且不改变轮次编号;持久化层会尽快排空。
-裁剪先于摘要;溢出重试必须取得持久进展。有界的瞬态重试在 `agent/request-error` 上组合;取消优先([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md))。
+裁剪先于摘要;溢出重试必须取得持久进展。恢复会在失败步骤关闭后、轮次关闭前通过 `agent/request-error` 运行。负责处理的策略调用 `agent.retry()` 安排一个重试轮次;取消优先([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md))。
### 失败边界
-适配器故障会先关闭步骤,再进入 `agent/request-error`;该事件会收到准确的 `Error`、`LlmFailure` 和历史记录。重试会开启另一个步骤;成功会清除历史记录;重试耗尽后,故障存入 `turn/end`。失败分片不会提交消息或工具。
+适配器故障会先关闭步骤,再由 `agent/request-error` 接收准确的 `Error`、标准化的 `LlmFailure` 和轮次信号。负责处理的监听器调用 `agent.retry()`;循环关闭失败轮次,并从持久历史开启另一个轮次,中间不发出空闲通知。重试耗尽后,失败的 `turn/end` 即为终态记录。失败分片不会提交消息或工具调用。
-其他故障使用 `agent/error`。取消和资源释放均优先于恢复;尚未分派的工具调用会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。轮次信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会在清空队列和中止前发出类型化原因;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose 会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。
+其他故障使用 `agent/error`。取消和资源释放优先于恢复;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 在清空队列和中止前发出原因;观察方不能否决,空闲调用不发事件。用户或父级取消持久记录为 `aborted`,等待停稳的资源释放记录为 `disposed`。原因只改变报告方式,不改变延迟完成的结果上下文处理([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。
-轮次和步骤的执行事件均位于轮次边界内;空闲时注入的 `user/message` 可以位于两个轮次之间。重新加载会用合成的 `interrupted` 轮次结束事件闭合中断轮次的日志尾部。关闭后的故障只通过 `agent/error` 报告;此时已没有安全的轮次内位置。每个轮次有一个 `TurnEndReason`;各变体由 [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) 统一定义。
+轮次和步骤事件均位于轮次边界内;空闲时注入的 `user/message` 可以位于两个轮次之间。重新加载会用合成的轮次结束事件闭合中断尾部。关闭后的故障只使用 `agent/error`。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。
### Agent 句柄
-`ctx.agents` 拥有活跃 agent,并返回 `AgentHandle { agent, dispose() }`。插件需要显式路由时使用 `send(content, completeOptions)`,否则使用 `followup()`、`steer()` 和 `inject()` 预设;`cancel()` 与 `whenIdle()` 控制生命周期。调用方 fiber、工厂提供方和消费方句柄通过同一个需等待完成的 disposer 共同拥有拆卸过程。
+`ctx.agents` 拥有活跃 agent,并返回 `AgentHandle { agent, dispose() }`。插件使用完整的 `send()` 选项,或使用 `followup()`、`steer()` 和 `inject()` 预设;`cancel()` 与 `whenIdle()` 控制生命周期。一个需等待完成的 disposer 协调拆卸归属。
### Agent 作用域
@@ -138,9 +138,9 @@ idle inject:
会话日志是权威依据。`deriveMessages()` 投影出模型历史;原始 `assistant/chunk` 事件留在日志中,以保证回放和 UI 保真。fork、恢复、transcript(文本记录)渲染、遥测和持久化均派生自同一个事件流。
-**模型可见 ⟺ 已记录**:日志可以重建每个请求,包括由请求头会话前缀置于开头的 `step/start` 时消息,以及通过折叠 `request/header` 得到的请求头;开发期不变量会断言这一点([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。
+**模型可见 ⟺ 已记录**:日志可以根据 `step/start` 时的消息和折叠后的 `request/header` 重建每个请求;由该包提供的 `dsh-agent-loop/invariant` 可通过 `ctx.invariants` 断言这一点([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。
-持久性由插件负责。后端会缓冲同步的 `session/event` 通知。语义检查点策略会在适配器分发前刷写请求,在工具分发前刷写已记录的顶层调用,并在 `agent/post-step` 刷写完整的响应与结果批次;循环仍保留最终的轮次结束检查点。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。
+持久性由插件负责。后端会尽快排空同步的 `session/event` 通知。语义检查点策略使用 `session/flush` 作为观察屏障:分别位于适配器分发前、顶层工具分发前,以及下一次请求之前的 `agent/step`。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。
`ctx.sessions.appendOutOfBand()` 会把插件所属的纯日志事件加入开放轮次,或创建一个平衡且已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq 和来源信息;其即时回退标题和唯一可选异步提供方都不会延迟 agent 响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。
@@ -148,7 +148,7 @@ idle inject:
消息使用从可合并扩展的 `ContentBlockMap` 派生的类型化块;`MessageSource`、`FinishReason`、`TurnTrigger` 和 `TurnEndReason` 也采用同一模式定义类型。新增块会协调适配器、UI、压缩、token 计量和持久化;回放计量见 [token-meter.md](core-data-structures/token-meter.md)。
-流式输出使用原始分片和 `BlockAssembler`。每次 `LlmAdapter.stream()` 调用代表一次提供方尝试;适配器报告事实,`agent/request-error` 负责恢复。循环会记录分片及成功结果的来源信息和回放状态。远程适配器使用逐次读取空闲看门狗。只有当路由共用同一个适配器实例时,回放状态才会跨路由传递([契约](core-data-structures/llm-streaming.md))。
+流式输出使用原始分片和 `BlockAssembler`。每次 `LlmAdapter.stream()` 调用代表一次提供方尝试;适配器报告标准化的故障事实,负责处理的 `agent/request-error` 插件会调用 `agent.retry()`。循环会记录分片及成功结果的来源信息和回放状态。远程适配器使用逐次读取空闲看门狗。只有当路由共用同一个适配器实例时,回放状态才会跨路由传递([契约](core-data-structures/llm-streaming.md))。
## 扩展与组合
@@ -158,7 +158,7 @@ idle inject:
例外情况会合并不同层次:LLM(大语言模型)合并接口和消费方,文件系统整合策略,web 使用注册表,skill 和 subagent 使用具名提供方。subagent 可以通过 spawn 创建全新实例、fork 一个已完成轮次的前缀,或使用 ACP(Agent Client Protocol)子 agent([subagent.md](core-data-structures/subagent.md))。
-`dsh-workspace-context` 在 `agent/session-prefix` 上组合基线,并在通过 `ctx.fs` 发现嵌套变更后,于 `tools/post-execute` 追加这些变更;其[决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)记录了隔离方式。`dsh-paths` 负责共享路径。
+`dsh-workspace-context` 在第一次 `agent/step` 注入基线,并通过 `tools/post-execute` 追加 `ctx.fs` 发现的变更;其[决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)记录了隔离方式。`dsh-paths` 负责共享路径。
### 组合包与应用
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 49a0a1510f..6600d6a172 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -27,7 +27,7 @@ export interface AcpConfig {
Depends on: `Stream` (`@agentclientprotocol/sdk`)
-Source: [`packages/ui/acp/src/index.ts:285`](../packages/ui/acp/src/index.ts)
+Source: [`packages/ui/acp/src/index.ts:286`](../packages/ui/acp/src/index.ts)
## `@deepseek-ai/dsh-acp-demo`
@@ -113,7 +113,7 @@ export interface Config {
Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core-data-structures/core.md)
-Source: [`packages/core/agent-loop/src/index.ts:360`](../packages/core/agent-loop/src/index.ts)
+Source: [`packages/core/agent-loop/src/index.ts:147`](../packages/core/agent-loop/src/index.ts)
## `@deepseek-ai/dsh-agent-spine-demo`
@@ -318,7 +318,7 @@ Requires: `llm` · `tokenMeter`
export interface BasicCompactConfig extends CompactPolicyConfig {
/** Exact provider/model overrides; duplicate targets fail plugin load. */
modelPolicies?: ModelCompactPolicyConfig[]
- /** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */
+ /** Enable automatic step-boundary pressure and overflow-recovery listeners. Defaults to `true`. */
auto?: boolean
}
@@ -411,7 +411,7 @@ export interface Config {
}
```
-Source: [`packages/goal/goal/src/index.ts:56`](../packages/goal/goal/src/index.ts)
+Source: [`packages/goal/goal/src/index.ts:55`](../packages/goal/goal/src/index.ts)
## `@deepseek-ai/dsh-hooks-claude`
@@ -1063,7 +1063,7 @@ export interface Config {
}
```
-Source: [`packages/session-title/session-title/src/index.ts:70`](../packages/session-title/session-title/src/index.ts)
+Source: [`packages/session-title/session-title/src/index.ts:69`](../packages/session-title/session-title/src/index.ts)
## `@deepseek-ai/dsh-session-title-all-messages-llm`
@@ -1367,7 +1367,7 @@ export interface Config {
}
```
-Source: [`packages/goal/tool-goal/src/index.ts:27`](../packages/goal/tool-goal/src/index.ts)
+Source: [`packages/goal/tool-goal/src/index.ts:25`](../packages/goal/tool-goal/src/index.ts)
## `@deepseek-ai/dsh-tool-lsp`
@@ -1435,7 +1435,7 @@ export interface Config {
}
```
-Source: [`packages/skill/tool-skill/src/index.ts:19`](../packages/skill/tool-skill/src/index.ts)
+Source: [`packages/skill/tool-skill/src/index.ts:20`](../packages/skill/tool-skill/src/index.ts)
## `@deepseek-ai/dsh-tool-subagent`
@@ -1567,7 +1567,7 @@ export interface Config {
export type ToolPresentationMode = 'native' | 'code' | 'both'
```
-Source: [`packages/core/tools/src/index.ts:529`](../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:534`](../packages/core/tools/src/index.ts)
## `@deepseek-ai/dsh-tui`
@@ -1631,7 +1631,7 @@ export interface TuiConfig {
}
```
-Source: [`packages/ui/tui/src/index.ts:270`](../packages/ui/tui/src/index.ts)
+Source: [`packages/ui/tui/src/index.ts:268`](../packages/ui/tui/src/index.ts)
## `@deepseek-ai/dsh-tui-demo`
diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml
index 03d868a264..4c9900a606 100644
--- a/docs/cookbook/extension-cookbook.i18n.yaml
+++ b/docs/cookbook/extension-cookbook.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-extension-cookbook.md: c13b46e06a3b34512cd371e6a4868a6e932a575f
-extension-cookbook.zh.md: aeb5f905278c07344c68d80da05dc5daf299b4f6
+extension-cookbook.md: 723c169e8e39e13be23db95c8152d067058a1a24
+extension-cookbook.zh.md: a02928a94a5ffba3608de822d5afbf920d0f16bf
diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md
index c13b46e06a..723c169e8e 100644
--- a/docs/cookbook/extension-cookbook.md
+++ b/docs/cookbook/extension-cookbook.md
@@ -102,7 +102,7 @@ Every product feature maps to a listener on a documented extension seam — the
| `/loop` | on the `turn/end` session event, `followup()` the next iteration; or force-continue |
| Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and terminal `agent/turn-stop` |
| Queued + steering messages | core `Agent.followup()` / `Agent.steer()` |
-| Context compaction (auto + manual) | the `ctx.compact` seam + `dsh-compact-basic`; automatic pressure runs on serial `agent/post-step`, canonical overflow recovery runs on `agent/request-error`, and manual callers use the same compact service ([compaction Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) |
+| Context compaction (auto + manual) | the `ctx.compact` seam + `dsh-compact-basic`; automatic pressure runs on serial `agent/step`, canonical overflow recovery runs on `agent/request-error`, and manual callers use the same compact service ([compaction Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) |
| System prompt configurability | `ctx.systemPrompt.section()` with ordering and scope-local shadowing |
| AGENTS.md (root) | a section provider reading the file |
| AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener |
diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md
index aeb5f90527..a02928a94a 100644
--- a/docs/cookbook/extension-cookbook.zh.md
+++ b/docs/cookbook/extension-cookbook.zh.md
@@ -102,7 +102,7 @@ export function apply(ctx: Context) {
| `/loop` | 在 `turn/end` 会话事件上 `followup()` 下一次迭代;或强制继续 |
| 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和终端 `agent/turn-stop` 来强制输出 |
| 排队消息 + steering(中途引导) | 核心 `Agent.followup()` / `Agent.steer()` |
-| 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + `dsh-compact-basic`;自动压力检查运行在串行 `agent/post-step`,规范化溢出恢复运行在 `agent/request-error`,手动调用方使用同一个压缩服务([压缩 Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) |
+| 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + `dsh-compact-basic`;自动压力检查运行在串行 `agent/step`,规范化溢出恢复运行在 `agent/request-error`,手动调用方使用同一个压缩服务([压缩 Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) |
| 系统提示词可配置性 | `ctx.systemPrompt.section()`,支持排序与作用域局部覆盖 |
| AGENTS.md(根目录) | 一个读取该文件的 section provider |
| AGENTS.md(子目录,按需触发)+ 文件变更通知 | 从 watcher / tool-result 监听器调用 `agent.inject()` |
diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md
index 3c7a22351d..942ccb7ba2 100644
--- a/docs/cordis-catalog/events.md
+++ b/docs/cordis-catalog/events.md
@@ -15,15 +15,15 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n
### `agent/cancel-requested` — emit
-Effective broad cancellation was requested, before queued/steering work is cleared or the active turn is aborted. This observe-only notification cannot veto cancellation; listener failures are contained.
+Effective broad cancellation was requested, before queued/outbox work is cleared or the active turn is aborted. This observe-only notification cannot veto cancellation; listener failures are contained.
```ts cordis-catalog
/**
- * Effective broad cancellation was requested, before queued/steering work
+ * Effective broad cancellation was requested, before queued/outbox work
* is cleared or the active turn is aborted. This observe-only notification
* cannot veto cancellation; listener failures are contained.
* @param agent - the agent whose current work is being cancelled.
- * @param cause - resolved typed cancellation cause, including the default.
+ * @param cause - the explicit typed cancellation cause.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
@@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/steering work is clear
Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:359`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:328`](../../packages/core/agent/src/types.ts)
### `agent/created` — emit
@@ -54,16 +54,16 @@ A fully configured agent and live session were published. Setup is composition-o
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:270`](../../packages/core/agent/src/types.ts)
### `agent/disposed` — emit
-An agent left the registry; AgentLoop emits this after driver quiescence but before session detachment and scoped-registration unwind. Custom registry users own their driver-ordering contract.
+An agent left the registry; AgentLoop emits this after driver quiescence and scoped-registration unwind, but before session detachment. Custom registry users own their driver-ordering contract.
```ts cordis-catalog
/**
* An agent left the registry; AgentLoop emits this after driver quiescence
- * but before session detachment and scoped-registration unwind. Custom
+ * and scoped-registration unwind, but before session detachment. Custom
* registry users own their driver-ordering contract.
* @param agent - the exact agent removed from the registry.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
@@ -74,16 +74,16 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:279`](../../packages/core/agent/src/types.ts)
### `agent/error` — emit
-A step or turn errored. The loop reports a failure here (plus the logger) even when the error has no in-turn position for a session `error` event.
+A step or turn errored. The machine reports a failure here (plus the logger) even when the error has no in-turn position for a durable record.
```ts cordis-catalog
/**
- * A step or turn errored. The loop reports a failure here (plus the logger)
- * even when the error has no in-turn position for a session `error` event.
+ * A step or turn errored. The machine reports a failure here (plus the
+ * logger) even when the error has no in-turn position for a durable record.
* @param agent - the agent whose turn errored.
* @param turn - the turn in which the failure surfaced.
* @param step - the step at which the failure surfaced.
@@ -96,7 +96,30 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:507`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:436`](../../packages/core/agent/src/types.ts)
+
+### `agent/idle` — emit
+
+One drain chain reached its terminal turn: that turn's `turn/end` is already committed. Automatically recovered failed turns do not emit this notification. `reason` says why; model-request recovery is exhausted when an error reaches it.
+
+```ts cordis-catalog
+/**
+ * One drain chain reached its terminal turn: that turn's `turn/end` is
+ * already committed. Automatically recovered failed turns do not emit this
+ * notification. `reason` says why; model-request recovery is exhausted when
+ * an error reaches it.
+ * @param agent - the agent whose turn closed.
+ * @param turn - the terminal turn number.
+ * @param reason - why the terminal turn ended, with live error facts when it failed.
+ * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
+ * @mode emit
+ */
+'agent/idle'(this: Scoped, agent: Agent, turn: number, reason: IdleReason): void
+```
+
+Types: [Agent](../core-data-structures/core.md) · [IdleReason](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
+
+Source: [`packages/core/agent/src/types.ts:423`](../../packages/core/agent/src/types.ts)
### `agent/inbox/dequeue` — emit
@@ -117,21 +140,19 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary,
Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:335`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:306`](../../packages/core/agent/src/types.ts)
### `agent/inbox/discard` — emit
-Pending inbox items were dropped without delivering them, so every enqueued id receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop` dropping pending steering (in-turn and on the post-turn late-steering drain); and disposal of any still-pending items (before `agent/status('disposed')`). Fires once per drop with every dropped item.
+Pending inbox items were dropped without delivering them, so every enqueued id receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal, emits this after `agent/cancel-requested` when applicable and before aborting the active work. Fires once per drop with every dropped item.
```ts cordis-catalog
/**
* Pending inbox items were dropped without delivering them, so every
* enqueued id receives exactly one terminal `agent/inbox/dequeue` OR
- * `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after
- * `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop`
- * dropping pending steering (in-turn and on the post-turn late-steering
- * drain); and disposal of any still-pending items (before
- * `agent/status('disposed')`). Fires once per drop with every dropped item.
+ * `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal,
+ * emits this after `agent/cancel-requested` when applicable and before
+ * aborting the active work. Fires once per drop with every dropped item.
* @param agent - the agent whose inbox items were dropped.
* @param messages - the discarded messages in FIFO order (queued then steering); never empty.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
@@ -142,21 +163,17 @@ Pending inbox items were dropped without delivering them, so every enqueued id r
Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:349`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:318`](../../packages/core/agent/src/types.ts)
### `agent/inbox/enqueue` — emit
-A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `message` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.
+An item entered the queued or steering inbox.
```ts cordis-catalog
/**
- * A detached, frozen item entered the agent's inbox (queued or steering
- * FIFO). Source defaults are already applied, so `message` holds the exact
- * accepted values. This is the enqueue-time live signal; the durable record
- * is the eventual `user/message`/`steering/message`. Injection
- * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.
- * @param agent - the agent whose inbox received the item.
- * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).
+ * An item entered the queued or steering inbox.
+ * @param agent - the owning agent.
+ * @param message - accepted content, source, and correlation identity.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
@@ -165,67 +182,17 @@ A detached, frozen item entered the agent's inbox (queued or steering FIFO). Sou
Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:325`](../../packages/core/agent/src/types.ts)
-
-### `agent/post-step` — serial
-
-Awaited serial checkpoint after the response, real or synthetic tool results, injected context, and steering are durable but before `step/end`. A cancelled tool batch reaches this checkpoint with an aborted signal.
-
-```ts cordis-catalog
-/**
- * Awaited serial checkpoint after the response, real or synthetic tool
- * results, injected context, and steering are durable but before `step/end`.
- * A cancelled tool batch reaches this checkpoint with an aborted signal.
- * @param agent - the agent whose step is settling.
- * @param turn - the open turn number.
- * @param step - the open step number.
- * @param signal - the turn abort signal.
- * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
- * @mode serial
- */
-'agent/post-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void
-```
-
-Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-
-Source: [`packages/core/agent/src/types.ts:457`](../../packages/core/agent/src/types.ts)
-
-### `agent/pre-step` — serial
-
-Awaited serial checkpoint before `step/start`; appends land outside the pending step and are included when the loop derives request history. `signal` cancels listener work. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
-
-```ts cordis-catalog
-/**
- * Awaited serial checkpoint before `step/start`; appends land outside the
- * pending step and are included when the loop derives request history.
- * `signal` cancels listener work.
- * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
- * @param agent - the agent opening the step.
- * @param turn - the open turn number.
- * @param step - the pending step number.
- * @param signal - the turn abort signal.
- * @mode serial
- */
-'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void
-```
-
-Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-
-Source: [`packages/core/agent/src/types.ts:388`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:296`](../../packages/core/agent/src/types.ts)
### `agent/prompt-submit` — waterfall
-Allow, rewrite, or block one claimed prompt before it becomes a user message. Call `next()` for the unchanged default. A listener wrapping a downstream `allow` must preserve its `content` and `additionalContexts` unless it intentionally replaces them. The signal controls only this turn; listeners may cooperate with it but must not retain it to control another turn. Steering messages do not dispatch this event; they join an open turn at a steering checkpoint.
+Allow, rewrite, or block one claimed prompt before it becomes a user message. Call `next()` for the unchanged default. The signal controls only this turn; listeners may cooperate with it but must not retain it for another turn.
```ts cordis-catalog
/**
* Allow, rewrite, or block one claimed prompt before it becomes a user
- * message. Call `next()` for the unchanged default. A listener wrapping a
- * downstream `allow` must preserve its `content` and `additionalContexts`
- * unless it intentionally replaces them. The signal controls only this turn;
- * listeners may cooperate with it but must not retain it to control another
- * turn. Steering messages do not dispatch this event; they join an open turn
- * at a steering checkpoint.
+ * message. Call `next()` for the unchanged default. The signal controls only this turn;
+ * listeners may cooperate with it but must not retain it for another turn.
* @param agent - the agent whose turn claimed the message.
* @param content - the claimed message's blocks, as queued.
* @param source - the message's resolved source.
@@ -238,84 +205,57 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:404`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:355`](../../packages/core/agent/src/types.ts)
### `agent/request` — waterfall
-Replace the frozen call configuration. Model-visible content must use logged channels; this seam cannot mutate messages. Injection here joins the next request because the current step boundary is already fixed.
+Replace the frozen call configuration. `await next()` yields the config the machine would use (agent options on the first request, the logged header afterwards); return a replacement to switch. Model-visible content must use logged channels; this seam cannot mutate messages.
```ts cordis-catalog
/**
- * Replace the frozen call configuration. Model-visible content must use
- * logged channels; this seam cannot mutate messages. Injection here joins
- * the next request because the current step boundary is already fixed.
+ * Replace the frozen call configuration. `await next()` yields the config
+ * the machine would use (agent options on the first request, the logged
+ * header afterwards); return a replacement to switch. Model-visible
+ * content must use logged channels; this seam cannot mutate messages.
* @param agent - the agent making the model call.
* @param turn - the open turn number.
* @param step - the step whose request this is.
- * @param config - the config the loop would use (frozen); return a replacement to switch.
- * @param signal - the current turn's explicit abort signal; ambient
- * initiator identity does not imply liveness or cancellation authority.
+ * @param signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
- */
-'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, signal: AbortSignal, next: () => Promise): Promise
+*/
+'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise): Promise
```
Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:418`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:381`](../../packages/core/agent/src/types.ts)
### `agent/request-error` — waterfall
-Recover a model-request failure after its failed step has closed. `retry` opens a new numbered step; `fail` preserves the original request error. Call `next()` to delegate to the next recovery listener or the default.
+Handle a model-request failure after its failed step has closed but before the failed turn closes. A listener calls Agent.retry to schedule one retry turn, returns without `next()` when it owns the error, or calls `next()` to delegate. The default leaves the failure terminal.
```ts cordis-catalog
/**
- * Recover a model-request failure after its failed step has closed. `retry`
- * opens a new numbered step; `fail` preserves the original request error.
- * Call `next()` to delegate to the next recovery listener or the default.
+ * Handle a model-request failure after its failed step has closed but
+ * before the failed turn closes. A listener calls {@link Agent.retry} to
+ * schedule one retry turn, returns without `next()` when it owns the error,
+ * or calls `next()` to delegate. The default leaves the failure terminal.
* @param agent - the agent whose request failed.
* @param turn - the open turn number.
* @param step - the failed step number.
* @param error - the original model-request failure.
* @param failure - serializable facts normalized at the final adapter boundary.
- * @param priorFailures - immutable failures that already authorized another request in this consecutive sequence.
* @param signal - the turn abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
-'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise): Promise
+'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, signal: AbortSignal, next: () => Promise): Promise
```
-Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
+Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:472`](../../packages/core/agent/src/types.ts)
-
-### `agent/session-prefix` — waterfall
-
-Compose request-only messages placed before derived history. The frozen result is computed once per loop instance, logged on its anchoring request header, and reused so the provider prefix remains stable. Interrupted composition is discarded. Composition precedes the first `agent/pre-step` and request boundary, so listener appends join the current request. Changing context belongs in history; contributors should prepend to `await next()` to preserve registration order. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
-
-```ts cordis-catalog
-/**
- * Compose request-only messages placed before derived history. The frozen
- * result is computed once per loop instance, logged on its anchoring request
- * header, and reused so the provider prefix remains stable. Interrupted
- * composition is discarded. Composition precedes the first `agent/pre-step`
- * and request boundary, so listener appends join the current request.
- * Changing context belongs in history; contributors should prepend to
- * `await next()` to preserve registration order.
- * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
- * @param agent - the agent whose session prefix is being composed.
- * @param prefix - the frozen seed; return an extended replacement.
- * @param signal - the current turn's explicit abort signal.
- * @mode waterfall
- */
-'agent/session-prefix'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise
-```
-
-Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-
-Source: [`packages/core/agent/src/types.ts:433`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:396`](../../packages/core/agent/src/types.ts)
### `agent/session-start` — emit
@@ -337,16 +277,16 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md)
-Source: [`packages/core/agent/src/types.ts:372`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:341`](../../packages/core/agent/src/types.ts)
### `agent/status` — emit
-Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does not enter `running` synchronously; drive lifecycle from this event.
+Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` synchronously; drive lifecycle from this event.
```ts cordis-catalog
/**
- * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does
- * not enter `running` synchronously; drive lifecycle from this event.
+ * Agent status changed (`idle` ⇄ `running`). `send()` does not enter
+ * `running` synchronously; drive lifecycle from this event.
* @param agent - the agent whose status flipped.
* @param status - the status just entered (the transition's destination).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
@@ -357,74 +297,57 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no
Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:313`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:288`](../../packages/core/agent/src/types.ts)
-### `agent/step-result` — waterfall
+### `agent/step` — serial
-Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …).
+Awaited serial checkpoint before EVERY request of a turn is built (the first as well as each post-tools continuation). The single "between steps" seam: inject context, steer, or edit the session log here — the request's history derives from the log right after this settles.
```ts cordis-catalog
/**
- * Waterfall: post-process the assembled assistant {@link Message} before
- * tool dispatch (validation, content rewriting, …).
- * @param agent - the agent that received the step's response.
+ * Awaited serial checkpoint before EVERY request of a turn is built (the
+ * first as well as each post-tools continuation). The single "between
+ * steps" seam: inject context, steer, or edit the session log here — the
+ * request's history derives from the log right after this settles.
+ * @param agent - the agent about to send a request.
* @param turn - the open turn number.
- * @param step - the step that produced the message.
- * @param message - the assistant message as assembled from the stream.
- * @param signal - the current turn's explicit abort signal.
+ * @param step - the step number about to open.
+ * @param signal - the turn abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
- * @mode waterfall
+ * @mode serial
*/
-'agent/step-result'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, signal: AbortSignal, next: () => Promise): Promise
+'agent/step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void
```
-Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
+Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:445`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:368`](../../packages/core/agent/src/types.ts)
-### `agent/turn-continuation` — waterfall
+### `agent/stopping` — serial
-Override whether the turn continues. The default continues after tool calls or steering and stops otherwise; a continue reason becomes steering.
+The turn is about to close: the model owes no response (no live tool calls, no fresh steering). Awaited before the boundary commits — a listener that objects steers (`agent.steer(...)`) and the machine re-reads its inbox: fresh steering runs another step, none closes the turn. Data decides, so listener order cannot change the outcome. The inverse control (stop a tool loop early) is data too: a tool result carrying `concludesTurn` ends the turn at its step.
```ts cordis-catalog
/**
- * Override whether the turn continues. The default continues after tool
- * calls or steering and stops otherwise; a continue reason becomes steering.
- * @param agent - the agent deciding whether to run another step.
- * @param turn - the turn being continued or stopped.
- * @param defaultDecision - what the loop would do absent an override.
- * @param signal - the current turn's explicit abort signal.
- * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
- * @mode waterfall
- */
-'agent/turn-continuation'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, signal: AbortSignal, next: () => Promise): Promise
-```
-
-Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-
-Source: [`packages/core/agent/src/types.ts:483`](../../packages/core/agent/src/types.ts)
-
-### `agent/turn-stop` — serial
-
-Monotonic terminal-stop checkpoint after continuation and steering are folded; a stop remains authoritative through turn close and flush: steering queued in that window is discarded, while ordinary sends survive.
-
-```ts cordis-catalog
-/**
- * Monotonic terminal-stop checkpoint after continuation and steering are
- * folded; a stop remains authoritative through turn close and flush:
- * steering queued in that window is discarded, while ordinary sends survive.
- * @param agent - the agent whose composed continuation outcome may be stopped.
- * @param turn - the turn at its terminal-stop checkpoint.
+ * The turn is about to close: the model owes no response (no live tool
+ * calls, no fresh steering). Awaited before the boundary commits — a
+ * listener that objects steers (`agent.steer(...)`) and the machine
+ * re-reads its inbox: fresh steering runs another step, none closes the
+ * turn. Data decides, so listener order cannot change the outcome. The
+ * inverse control (stop a tool loop early) is data too: a tool result
+ * carrying `concludesTurn` ends the turn at its step.
+ * @param agent - the agent whose turn is at its stop boundary.
+ * @param turn - the turn about to close.
* @param signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode serial
*/
-'agent/turn-stop'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | ContinuationStop | undefined
+'agent/stopping'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | void
```
-Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
+Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:494`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:411`](../../packages/core/agent/src/types.ts)
## `agent-loop/*`
@@ -447,7 +370,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers
Types: [SessionId](../core-data-structures/core.md)
-Source: [`packages/core/agent-loop/src/index.ts:353`](../../packages/core/agent-loop/src/index.ts)
+Source: [`packages/core/agent-loop/src/index.ts:140`](../../packages/core/agent-loop/src/index.ts)
## `approval/*`
@@ -570,7 +493,7 @@ Goal mutation accepted by one live agent. The matching context event is already
Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/goal/goal/src/types.ts:167`](../../packages/goal/goal/src/types.ts)
+Source: [`packages/goal/goal/src/types.ts:169`](../../packages/goal/goal/src/types.ts)
## `llm/*`
@@ -620,7 +543,7 @@ Creation announcement during session publication. A synchronous throw vetoes and
Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md)
-Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:70`](../../packages/core/session/src/index.ts)
### `session/disposed` — emit
@@ -641,7 +564,7 @@ Emitted once when an announced session leaves the store, including publication r
Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md)
-Source: [`packages/core/session/src/index.ts:89`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:80`](../../packages/core/session/src/index.ts)
### `session/event` — emit
@@ -664,7 +587,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before
Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md)
-Source: [`packages/core/session/src/index.ts:101`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:92`](../../packages/core/session/src/index.ts)
### `session/flush` — parallel
@@ -685,7 +608,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await
Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md)
-Source: [`packages/core/session/src/index.ts:111`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:102`](../../packages/core/session/src/index.ts)
## `subagent/*`
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 2d1917934f..23fac905ba 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -27,7 +27,7 @@ create(id: SessionId, options: AgentOptions = {}, meta: Pick
```
@@ -1246,7 +1246,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md)
-Source: [`packages/core/session/src/index.ts:604`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:593`](../../packages/core/session/src/index.ts)
## `ctx.sessionTitle` — `SessionTitleService`
@@ -1280,7 +1280,7 @@ register(provider: SessionTitleProvider): () => Promise
Types: [Session](../core-data-structures/session.md) · [SessionTitleProvider](../core-data-structures/session-title.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md)
-Source: [`packages/session-title/session-title/src/index.ts:284`](../../packages/session-title/session-title/src/index.ts)
+Source: [`packages/session-title/session-title/src/index.ts:283`](../../packages/session-title/session-title/src/index.ts)
## `ctx.skills` — `SkillService`
@@ -1686,7 +1686,7 @@ async execute(exec: ToolExecutionInput): Promise
Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md)
-Source: [`packages/core/tools/src/index.ts:634`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:639`](../../packages/core/tools/src/index.ts)
## `ctx.tui` — `TuiExtensionService` (abstract seam)
@@ -1709,7 +1709,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi
abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
```
-Source: [`packages/ui/tui/src/index.ts:150`](../../packages/ui/tui/src/index.ts)
+Source: [`packages/ui/tui/src/index.ts:148`](../../packages/ui/tui/src/index.ts)
## `ctx.userInteraction` — `UserInteractionService`
diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md
index 6fa281a96c..96324e3e85 100644
--- a/docs/core-data-structures/compaction.md
+++ b/docs/core-data-structures/compaction.md
@@ -60,7 +60,7 @@ type CompactionTrigger = 'pressure' | 'context-overflow'
`CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, returning `null` when no safe work exists, and `compactRegion(...)` for an explicit inclusive surface range. Every backend marks its replacement `user/message` with the package-exported `COMPACT_CHECKPOINT_SOURCE`; consumers call `isCompactCheckpointSource()` instead of coupling checkpoint recognition to one backend. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration.
-Pressure compaction runs at serial `agent/post-step`, after successful assistant output, tool results, buffered context, and steering are durable but before `step/end`. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and authorizes a fresh numbered-step retry only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling.
+Pressure compaction runs at serial `agent/step` before request derivation. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and calls `agent.retry()` only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling.
The seam exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for those edge checks. Both validate current surface membership and reject missing seqs and orphan results; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) owns their cache semantics.
diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml
index 4d2dc00fca..403b25986e 100644
--- a/docs/core-data-structures/core.i18n.yaml
+++ b/docs/core-data-structures/core.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-core.md: 1d7a06405bbda523f37389e1b09c62549ece6750
-core.zh.md: 2048a59451c3064eefdfe4225cc186c77921a9c0
+core.md: 882352e7dc814443468195f57b9c3f7c9401b170
+core.zh.md: e686216530f827ad8b94b42a43e3fa66a2d3a36e
diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md
index 1d7a06405b..882352e7dc 100644
--- a/docs/core-data-structures/core.md
+++ b/docs/core-data-structures/core.md
@@ -454,13 +454,7 @@ type AgentCancelCause =
`Agent` is an abstract class: concrete drivers implement the abstract members, while `followup`/`steer`/`inject` are shared concrete delegates to the single abstract `send` over the (`target` × `wakeup`) matrix.
```ts type-equiv
-/**
- * Public agent handle; its concrete implementation is internal to
- * `@deepseek-ai/dsh-agent-loop`. An abstract class rather than an interface so
- * the fixed-preset aliases ({@link Agent.followup}, {@link Agent.steer},
- * {@link Agent.inject}) are shared concrete delegates over the single abstract
- * {@link Agent.send} primitive; concrete drivers implement `send` once.
- */
+/** Public live-agent handle with aliases over the unified delivery primitive. */
abstract class Agent {
/** The single identity shared with {@link session}. */
abstract readonly id: SessionId
@@ -475,7 +469,7 @@ abstract class Agent {
/**
* The unified delivery primitive over the (`target` × `wakeup`) matrix.
- * Detaches, validates, and freezes one lossless-JSON item, then routes it:
+ * It routes the caller's typed content and source as follows:
*
* - `next-turn` queues an item that becomes the sole ordinary message of its
* own FIFO-ordered turn; `wakeup:true` wakes a
@@ -483,12 +477,9 @@ abstract class Agent {
* - `next-step` with `wakeup:true` submits steering into the active turn
* (idle falls back to a woken `next-turn`).
* - `next-step` with `wakeup:false` injects durable model-facing context
- * without running the model: an open turn joins at the current log position
- * (deferred behind an executing tool batch until it settles), and an idle
- * inject records a one-shot turn with its own durability checkpoint.
- *
- * Attached contexts share the same snapshot and ownership boundary. Invalid
- * input throws synchronously before any notification, enqueue, or append.
+ * without running the model: an open turn stages it for the next safe log
+ * position, while an idle injection appends it immediately without opening
+ * a turn.
* @param content - the model-facing content blocks to deliver.
* @param options - target queue, wakeup decision, and source.
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
@@ -500,8 +491,7 @@ abstract class Agent {
* turn. An effective call first emits `agent/cancel-requested` with the
* resolved typed cause. The first cause wins for the active turn, and
* `whenIdle()` resolves after cancellation reaches quiescence. Idle
- * cancellation is a no-op and does not arm later work. The active turn
- * snapshots and freezes the required cause.
+ * cancellation is a no-op and does not arm later work.
* @param cause - the stable caller intent carried by the current turn signal.
* @param options - cancellation options; `keepInbox` preserves pending work.
*/
@@ -515,49 +505,68 @@ abstract class Agent {
* `next-turn`/wakeup preset of {@link send}. The item becomes the sole
* ordinary message of its own turn.
* @param content - the prompt content blocks.
- * @param options - source and attached contexts.
+ * @param options - message source.
* @returns the accepted message's {@link AgentMessageId}.
*/
followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
- return this.send(content, { ...options, target: 'next-turn', wakeup: true })
+ return this.send(content, {
+ target: 'next-turn',
+ wakeup: true,
+ source: options?.source ?? { kind: 'user' },
+ })
}
/**
* Submit steering into the running turn — the `next-step`/wakeup preset of
* {@link send}. An open turn records it at the next steering checkpoint before
- * a request or continuation decision; policy may stop before another step.
- * After turn close and its checkpoint, any remainder is queued for a later
- * turn; terminal `agent/turn-stop`, cancellation, or disposal may discard it.
- * Idle steering falls back to a woken follow-up turn.
+ * a request or stop decision. If the turn fails before that boundary, the
+ * remainder stays staged without waking the agent; retry or a later prompt
+ * takes it. Idle steering falls back to a woken follow-up turn, while
+ * cancellation or disposal may discard pending steering.
* @param content - the steering content blocks.
- * @param options - source and attached contexts.
+ * @param options - message source.
* @returns the accepted message's {@link AgentMessageId}.
*/
steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
- return this.send(content, { ...options, target: 'next-step', wakeup: true })
+ return this.send(content, {
+ target: 'next-step',
+ wakeup: true,
+ source: options?.source ?? { kind: 'user' },
+ })
}
/**
- * Append detached model-facing context without running the model — the
- * `next-step`/no-wakeup preset of {@link send}. An open-turn injection joins
- * at the current log position unless the current tool batch is executing;
- * then it waits FIFO until that batch settles and drains before turn close
- * even when interrupted. Idle injection uses a one-shot turn and durability
- * checkpoint. Disposal awaits idle checkpoints; flush failures report through
- * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`.
+ * Append model-facing context without running the model — the
+ * `next-step`/no-wakeup preset of {@link send}. An open-turn injection stages
+ * at the next safe log position; an idle injection appends immediately
+ * without opening a turn. An omitted source defaults to
+ * `{ kind: 'plugin', plugin: '' }`.
* @param content - the injected context content blocks.
- * @param options - source and attached contexts.
+ * @param options - context source.
* @returns the accepted message's {@link AgentMessageId}.
*/
inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
- return this.send(content, { ...options, target: 'next-step', wakeup: false })
+ return this.send(content, {
+ target: 'next-step',
+ wakeup: false,
+ source: options?.source ?? { kind: 'plugin', plugin: '' },
+ })
}
+
+ /**
+ * Re-open a turn on the current session log without a new prompt — the
+ * explicit resummon verb. During `agent/request-error`, this schedules one
+ * retry turn after the failed turn closes; while idle, it starts one
+ * immediately. Repeated calls before the scheduled retry coalesce.
+ * @throws while other agent work is running.
+ */
+ abstract retry(): void
}
```
-`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `SessionId` is branded. `running` describes the driver-wide drain interval, which can span turn close, its durability checkpoint, and consecutive queued turns; it does not prove a turn is still open. `AgentOptions` is merge-extensible: core declares `provider?` and `model?` (dispatch requires both after `agent/request`). Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default.
+`AgentStatus` is `'idle' | 'running'`, and `SessionId` is branded. Disposal removes the agent from the registry and emits `agent/disposed`; it is not a terminal status value. `running` describes the driver-wide drain interval and may span consecutive queued turns; it does not prove a turn is still open. `AgentOptions` is merge-extensible: core declares `provider?` and `model?` (dispatch requires both after `agent/request`). Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default.
-The cause is a TypeScript-enforced same-process input. An active holder copies its discriminant into the runtime-only `AbortSignal.reason`; it is retired before `turn/end` publication. `agentInterruptReasonOf(signal)` recognizes `user`, `parent`, and lifecycle-only `disposed` without consulting ambient initiator state. Durable `turn/end` retains the coarse `{ kind: 'aborted' }` outcome; request provenance would require a separate durable event rather than overloading the terminal result.
+The cause is a required, TypeScript-enforced same-process input. An active holder copies its discriminant into the runtime-only `AbortSignal.reason`. `agentInterruptReasonOf(signal)` recognizes `user`, `parent`, and lifecycle-only `disposed` without consulting ambient initiator state. Durable `turn/end` uses `{ kind: 'aborted' }` for user or parent cancellation and `{ kind: 'disposed' }` for lifecycle teardown.
The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits.
@@ -567,7 +576,7 @@ The process-local initiator carried by `ctx.agents` is the exact `Agent` above,
## Interception decisions
-Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share `AdditionalContext`, the same `UserMessageData` content/source shape used by durable user-role input. Each `additionalContexts` entry becomes a separate injected `user/message`, preserving its provenance. Continuation reasons are steering messages and use the same content/source base.
+Prompt and post-tool decisions share `AdditionalContext`, the same `UserMessageData` content/source shape used by durable user-role input. Each `additionalContexts` entry becomes a separate `user/message`, preserving its provenance. Hook bridges map their native decision fields onto these typed results.
Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)
@@ -576,7 +585,7 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types
type AdditionalContext = UserMessageData
```
-`agent/prompt-submit` returns a `PromptDecision` (allow the turn's claimed queued message — optionally rewriting its `content` or attaching `additionalContexts` — or record `prompt/blocked` and end that zero-step turn as `rejected`):
+`agent/prompt-submit` returns a `PromptDecision` before a turn opens. Allow may rewrite the claimed prompt or attach `additionalContexts`; block rejects admission without creating turn events:
```ts type-equiv
/**
@@ -590,41 +599,14 @@ type PromptDecision =
| { kind: 'block'; reason: string }
```
-`agent/turn-continuation` returns a `ContinuationDecision` (the loop's default is `continue` when the step had tool calls or steering was injected, else `stop`; a `continue` `reason` is recorded as next-step steering in the same turn and therefore carries no attached contexts — the typed `/goal` pattern):
-
-```ts type-equiv
-/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */
-type ContinuationDecision =
- | { action: 'stop' }
- | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } }
-```
-
-`agent/request-error` receives the exact original `RequestError` beside its immutable `LlmFailure`, an immutable list of failures that already authorized another request in the consecutive sequence, the turn signal, and `next()`. Recovery plugins route on `failure.code`, not the live error's message; each policy counts only its own codes, and a successful request clears the history:
+`agent/request-error` runs after a failed model step closes and before its turn closes. Listeners can repair durable state or await policy work while the failed turn's signal is still live. A handling listener calls `agent.retry()` and returns without `next()`; repeated calls coalesce into one retry turn.
```ts type-equiv
/** Model-request failure with an optional machine-routable provider code. */
type RequestError = Error & { code?: string }
```
-It returns a `RequestErrorDecision`; `retry` opens a new numbered step after the recovery listener's durable mutation, while `fail` retains the structured failure on `turn/end`:
-
-```ts type-equiv
-/** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()`. */
-type RequestErrorDecision = { action: 'fail' } | { action: 'retry' }
-```
-
-`agent/post-step` is awaited after assistant output, real or synthetic tool results, buffered context, and steering are durable but before `step/end`. A cancelled tool batch reaches it with an aborted signal after draining; its signature is `(agent, turn, step, signal)`, and replayable facts remain in the session log rather than a transient payload.
-
-`agent/turn-stop` returns the stop-only `ContinuationStop` subset or `undefined`. The loop calls this serial checkpoint after folding the ordinary decision, its reason, and pending steering; a stop is terminal and discards pending steering.
-
-```ts type-equiv
-/**
- * The terminal subset of {@link ContinuationDecision}. A listener on
- * `agent/turn-stop` returns this to make the already-composed continuation
- * outcome terminal; `undefined` abstains.
- */
-type ContinuationStop = Extract
-```
+`agent/step` is the single serial boundary before request derivation. `agent/stopping` runs when a turn has no tool or steering continuation, before one final steering drain.
`agent/session-start` carries a `SessionStartSource` (why the session lifecycle began; a bridge keys its SessionStart matcher on it):
@@ -633,8 +615,6 @@ type ContinuationStop = Extract
type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
```
-`agent/session-prefix` composes a `Message[]` once per loop instance. The deep-frozen result is recorded in the request header and prepended to every derived history, making it the home for session-stable openers. A resumed instance recomposes; mid-session changes use append-only context channels. The waterfall returns content directly because it contributes rather than decides.
-
## `ToolDefinition`
The one pipeline-authoring type that is core: what every registered tool *is* — a model-facing `ToolSchema` plus an `execute` function and optional final-content and UI callbacks. A tool author rarely constructs it by hand (the `defineTool` DSL builds it with typed args), but it is the contract the registry holds and the loop dispatches through.
diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md
index 2048a59451..e686216530 100644
--- a/docs/core-data-structures/core.zh.md
+++ b/docs/core-data-structures/core.zh.md
@@ -456,13 +456,7 @@ type AgentCancelCause =
`Agent` 是抽象类:具体驱动器实现抽象成员,而 `followup`/`steer`/`inject` 是共享的具体委托方法,它们都委托给覆盖(`target` × `wakeup`)矩阵的唯一抽象 `send`。
```ts type-equiv
-/**
- * Public agent handle; its concrete implementation is internal to
- * `@deepseek-ai/dsh-agent-loop`. An abstract class rather than an interface so
- * the fixed-preset aliases ({@link Agent.followup}, {@link Agent.steer},
- * {@link Agent.inject}) are shared concrete delegates over the single abstract
- * {@link Agent.send} primitive; concrete drivers implement `send` once.
- */
+/** Public live-agent handle with aliases over the unified delivery primitive. */
abstract class Agent {
/** The single identity shared with {@link session}. */
abstract readonly id: SessionId
@@ -477,7 +471,7 @@ abstract class Agent {
/**
* The unified delivery primitive over the (`target` × `wakeup`) matrix.
- * Detaches, validates, and freezes one lossless-JSON item, then routes it:
+ * It routes the caller's typed content and source as follows:
*
* - `next-turn` queues an item that becomes the sole ordinary message of its
* own FIFO-ordered turn; `wakeup:true` wakes a
@@ -485,12 +479,9 @@ abstract class Agent {
* - `next-step` with `wakeup:true` submits steering into the active turn
* (idle falls back to a woken `next-turn`).
* - `next-step` with `wakeup:false` injects durable model-facing context
- * without running the model: an open turn joins at the current log position
- * (deferred behind an executing tool batch until it settles), and an idle
- * inject records a one-shot turn with its own durability checkpoint.
- *
- * Attached contexts share the same snapshot and ownership boundary. Invalid
- * input throws synchronously before any notification, enqueue, or append.
+ * without running the model: an open turn stages it for the next safe log
+ * position, while an idle injection appends it immediately without opening
+ * a turn.
* @param content - the model-facing content blocks to deliver.
* @param options - target queue, wakeup decision, and source.
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
@@ -502,8 +493,7 @@ abstract class Agent {
* turn. An effective call first emits `agent/cancel-requested` with the
* resolved typed cause. The first cause wins for the active turn, and
* `whenIdle()` resolves after cancellation reaches quiescence. Idle
- * cancellation is a no-op and does not arm later work. The active turn
- * snapshots and freezes the required cause.
+ * cancellation is a no-op and does not arm later work.
* @param cause - the stable caller intent carried by the current turn signal.
* @param options - cancellation options; `keepInbox` preserves pending work.
*/
@@ -517,49 +507,68 @@ abstract class Agent {
* `next-turn`/wakeup preset of {@link send}. The item becomes the sole
* ordinary message of its own turn.
* @param content - the prompt content blocks.
- * @param options - source and attached contexts.
+ * @param options - message source.
* @returns the accepted message's {@link AgentMessageId}.
*/
followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
- return this.send(content, { ...options, target: 'next-turn', wakeup: true })
+ return this.send(content, {
+ target: 'next-turn',
+ wakeup: true,
+ source: options?.source ?? { kind: 'user' },
+ })
}
/**
* Submit steering into the running turn — the `next-step`/wakeup preset of
* {@link send}. An open turn records it at the next steering checkpoint before
- * a request or continuation decision; policy may stop before another step.
- * After turn close and its checkpoint, any remainder is queued for a later
- * turn; terminal `agent/turn-stop`, cancellation, or disposal may discard it.
- * Idle steering falls back to a woken follow-up turn.
+ * a request or stop decision. If the turn fails before that boundary, the
+ * remainder stays staged without waking the agent; retry or a later prompt
+ * takes it. Idle steering falls back to a woken follow-up turn, while
+ * cancellation or disposal may discard pending steering.
* @param content - the steering content blocks.
- * @param options - source and attached contexts.
+ * @param options - message source.
* @returns the accepted message's {@link AgentMessageId}.
*/
steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
- return this.send(content, { ...options, target: 'next-step', wakeup: true })
+ return this.send(content, {
+ target: 'next-step',
+ wakeup: true,
+ source: options?.source ?? { kind: 'user' },
+ })
}
/**
- * Append detached model-facing context without running the model — the
- * `next-step`/no-wakeup preset of {@link send}. An open-turn injection joins
- * at the current log position unless the current tool batch is executing;
- * then it waits FIFO until that batch settles and drains before turn close
- * even when interrupted. Idle injection uses a one-shot turn and durability
- * checkpoint. Disposal awaits idle checkpoints; flush failures report through
- * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`.
+ * Append model-facing context without running the model — the
+ * `next-step`/no-wakeup preset of {@link send}. An open-turn injection stages
+ * at the next safe log position; an idle injection appends immediately
+ * without opening a turn. An omitted source defaults to
+ * `{ kind: 'plugin', plugin: '' }`.
* @param content - the injected context content blocks.
- * @param options - source and attached contexts.
+ * @param options - context source.
* @returns the accepted message's {@link AgentMessageId}.
*/
inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
- return this.send(content, { ...options, target: 'next-step', wakeup: false })
+ return this.send(content, {
+ target: 'next-step',
+ wakeup: false,
+ source: options?.source ?? { kind: 'plugin', plugin: '' },
+ })
}
+
+ /**
+ * Re-open a turn on the current session log without a new prompt — the
+ * explicit resummon verb. During `agent/request-error`, this schedules one
+ * retry turn after the failed turn closes; while idle, it starts one
+ * immediately. Repeated calls before the scheduled retry coalesce.
+ * @throws while other agent work is running.
+ */
+ abstract retry(): void
}
```
-`AgentStatus` 为 `'idle' | 'running' | 'disposed'`,`SessionId` 是品牌类型。`running` 描述整个驱动器的排空区间,可能跨越轮次关闭、其持久化检查点以及连续的排队轮次;它不能证明某个轮次仍然打开。`AgentOptions` 可合并扩展:core 声明 `provider?` 与 `model?`(在 `agent/request` 后,分发要求两者都存在)。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。
+`AgentStatus` 为 `'idle' | 'running'`,`SessionId` 是品牌类型。dispose 会从注册表中移除 agent 并发出 `agent/disposed`;它不是终态状态值。`running` 描述整个驱动器的排空区间,可能跨越连续的排队轮次;它不能证明某个轮次仍然打开。`AgentOptions` 可合并扩展:core 声明 `provider?` 与 `model?`(在 `agent/request` 后,分发要求两者都存在)。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。
-cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancellation` 持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`,并在发布 `turn/end` 前退役;冻结后的 `AbortSignal.reason` 仍可读取。`agentInterruptReasonOf(signal)` 无需查询环境中的 initiator 状态,即可识别 `user`、`parent` 与仅用于生命周期的 `disposed`。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。
+cause 是必选且由 TypeScript 强制约束的同进程输入。活跃持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`。`agentInterruptReasonOf(signal)` 无需查询环境中的 initiator 状态,即可识别 `user`、`parent` 与仅用于生命周期的 `disposed`。持久 `turn/end` 对用户或父级取消使用 `{ kind: 'aborted' }`,对生命周期拆卸使用 `{ kind: 'disposed' }`。
[事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、检查点与 waterfall(瀑布式事件)契约。轮次和步骤边界是持久会话事件,而不是 agent emit。
@@ -569,7 +578,7 @@ cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancella
## 拦截决策
-每个 `agent/*` 拦截 waterfall 都返回一个小型、特定于 seam 的类型化联合——统一的 Decision 惯用形状([tools.md](tools.md) 中工具 seam 的 `PreToolDecision`/`PostToolDecision` 也采用相同形状)。CC/Codex 钩子桥接层把其 `permissionDecision`/`decision`/`continue`/`additionalContext` 字段映射到这些联合上;原生插件则直接返回它们。提示词决策与工具后决策共享 `AdditionalContext`,它与持久用户角色输入使用相同的 `UserMessageData` content/source 形状。每个 `additionalContexts` 项都会成为一条单独注入的 `user/message`,并保留其 provenance。Continuation reason 则是 steering 消息,并使用同一个 content/source 基础类型。
+提示词决策与工具后决策共享 `AdditionalContext`,它与持久用户角色输入使用相同的 `UserMessageData` content/source 形状。每个 `additionalContexts` 项都会成为一条单独的 `user/message`,并保留其来源信息。钩子桥接层会把原生决策字段映射到这些类型化结果。
源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)
@@ -578,7 +587,7 @@ cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancella
type AdditionalContext = UserMessageData
```
-`agent/prompt-submit` 返回 `PromptDecision`(允许该轮次已领取的排队消息——可选地改写其 `content` 或附加 `additionalContexts`——或者记录 `prompt/blocked` 并以 `rejected` 结束这个零步骤轮次):
+`agent/prompt-submit` 在轮次打开前返回 `PromptDecision`。`allow` 可以改写已领取的提示词或附加 `additionalContexts`;`block` 会拒绝接纳,且不创建轮次事件:
```ts type-equiv
/**
@@ -592,41 +601,14 @@ type PromptDecision =
| { kind: 'block'; reason: string }
```
-`agent/turn-continuation` 返回 `ContinuationDecision`(步骤有工具调用或注入了 steering 时,循环默认为 `continue`,否则为 `stop`;`continue` 的 `reason` 会记录为同一轮次中下一个步骤的 steering,因此不携带上下文元数据——即类型化 `/goal` 模式):
-
-```ts type-equiv
-/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */
-type ContinuationDecision =
- | { action: 'stop' }
- | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } }
-```
-
-`agent/request-error` 接收确切的原始 `RequestError`、其不可变 `LlmFailure`、在连续序列中已批准另一次请求的不可变失败列表、轮次信号以及 `next()`。恢复插件按 `failure.code` 路由,而不是按活跃错误的消息路由;每项策略只统计自身的 code,一次成功请求会清空历史:
+`agent/request-error` 会在失败的模型步骤关闭后、其轮次关闭前运行。监听器可以在失败轮次的信号仍然有效时修复持久状态或等待策略工作。负责处理的监听器会调用 `agent.retry()` 且不调用 `next()`;重复调用会合并为一个重试轮次。
```ts type-equiv
/** Model-request failure with an optional machine-routable provider code. */
type RequestError = Error & { code?: string }
```
-它返回 `RequestErrorDecision`;`retry` 在恢复 listener 的持久变更之后打开一个带新编号的步骤,而 `fail` 在 `turn/end` 上保留结构化失败:
-
-```ts type-equiv
-/** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()`. */
-type RequestErrorDecision = { action: 'fail' } | { action: 'retry' }
-```
-
-`agent/post-step` 会在 assistant 输出、真实或合成的工具结果、缓冲上下文与 steering 持久化之后、`step/end` 之前被 await。被取消的工具批次在排空后携带 aborted signal 到达这里;其签名为 `(agent, turn, step, signal)`,可回放事实保留在会话日志中,而不是瞬态 payload 中。
-
-`agent/turn-stop` 返回仅停止的 `ContinuationStop` 子集或 `undefined`。循环在折叠普通决策、其 reason 和待处理 steering 之后调用此串行检查点;stop 是终态,会丢弃待处理的 steering。
-
-```ts type-equiv
-/**
- * The terminal subset of {@link ContinuationDecision}. A listener on
- * `agent/turn-stop` returns this to make the already-composed continuation
- * outcome terminal; `undefined` abstains.
- */
-type ContinuationStop = Extract
-```
+`agent/step` 是派生请求之前唯一的串行边界。当轮次不再因工具或 steering 继续时,`agent/stopping` 会在最后一次排空 steering 之前运行。
`agent/session-start` 携带 `SessionStartSource`(会话生命周期为何开始;桥接层据此匹配其 SessionStart):
@@ -635,8 +617,6 @@ type ContinuationStop = Extract
type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
```
-`agent/session-prefix` 在每个循环实例中组合一次 `Message[]`。深度冻结的结果被记录在请求 header 中,并前置于每次派生历史,使其成为会话稳定开场白的归属。恢复的实例会重新组合;会话中途的变更使用仅追加的上下文通道。该 waterfall 直接返回内容,因为它是贡献而非决策。
-
## `ToolDefinition`
唯一属于核心的流水线编写类型:每个已注册工具*是什么*——一个面向模型的 `ToolSchema` 加上一个 `execute` 函数,以及可选的最终内容回调与 UI 回调。工具作者很少手动构造它(`defineTool` DSL 会用类型化参数构建),但它是注册表持有、循环分发所经过的契约。
diff --git a/docs/core-data-structures/goal.md b/docs/core-data-structures/goal.md
index c0351f2f77..cb8751286f 100644
--- a/docs/core-data-structures/goal.md
+++ b/docs/core-data-structures/goal.md
@@ -105,6 +105,8 @@ interface GoalMessageSource {
readonly revision: number
/** Zero for state changes; positive for admitted continuation rounds. */
readonly round: number
+ /** Complete durable mutation carried only by round-zero state-change messages. */
+ readonly change?: GoalChangeMeta
}
```
diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md
index 257ce90cda..dbd907d335 100644
--- a/docs/core-data-structures/llm-streaming.md
+++ b/docs/core-data-structures/llm-streaming.md
@@ -57,7 +57,7 @@ Every adapter MUST obey these, and every consumer may rely on them:
- **`usage` before `finish`, nothing after `finish`.** Defer both to the provider's end-of-stream marker so a trailing usage-only chunk can't violate the ordering.
- **Tool-call `arguments` stay raw JSON strings end-to-end.** Partial fragments stream via `argumentsDelta`; a provider that hands back parsed objects re-stringifies at `block-end`.
-- **Two sanctioned error paths, one fact shape.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted', failure}` (provider in-band errors, for adapters that can't throw mid-stream). `LlmError.failure` carries the same `LlmFailure`. The final adapter boundary preserves the exact thrown `Error` object and associates immutable facts with that call; the agent loop closes the failed step and offers the error, facts, and immutable prior-retried facts to `agent/request-error`. Absent recovery the structured failure becomes the turn error, and no normal assistant message or tool side effect is committed for that attempt.
+- **Two sanctioned error paths, one fact shape.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted', failure}` (provider in-band errors, for adapters that can't throw mid-stream). `LlmError.failure` carries the same `LlmFailure`. The final adapter boundary preserves the exact thrown `Error` object and associates immutable facts with that call; the agent loop closes the failed step and offers the error and facts to `agent/request-error`. A handling listener calls `agent.retry()` after its awaited repair; absent recovery the structured failure becomes the turn error, and no normal assistant message or tool side effect is committed for that attempt.
- **One adapter call is one provider attempt.** Adapters disable library retries. Agent-level recovery opens another durable numbered step; direct `ctx.llm.stream()` callers remain single-attempt.
- **Provider stalls are bounded at the transport.** Both shipping remote adapters expose positive finite `streamIdleTimeoutMs` with a five-minute default. The watchdog arms only while iterator `next()` is outstanding, uses one stable signal for the whole request, maps its own expiry to `TIMEOUT`, and keeps an earlier caller abort as `ABORTED`.
- **Context overflow has one canonical code.** Both DeepSeek adapters classify explicit provider detail through `isContextWindowExceededError()` and surface `CONTEXT_WINDOW_EXCEEDED`, whether the failure arrives as a thrown HTTP `LlmError` or an in-band finish error. Consumers route on the code, never provider text.
diff --git a/docs/core-data-structures/session-reference.md b/docs/core-data-structures/session-reference.md
index dbe3c43d35..c33704c14f 100644
--- a/docs/core-data-structures/session-reference.md
+++ b/docs/core-data-structures/session-reference.md
@@ -36,15 +36,15 @@ interface SessionReferenceCandidate {
## Prepared messages
-Preparation preserves readable current-message content and returns at most one aggregated context. The host binds `contexts` to that exact `send()` or `steer()` call.
+Preparation preserves readable current-message content and returns at most one aggregated context.
```ts type-equiv
-/** Message payload and the zero-or-one durable snapshot contexts bound to it. */
+/** Direct message content and optional referenced-session context. */
interface PreparedReferencedMessage {
/** Readable message content after host mention tokens are removed. */
content: ContentBlock[]
- /** Empty without references; otherwise one aggregated untrusted context. */
- contexts: HookContext[]
+ /** Aggregated untrusted snapshot, absent when the message has no references. */
+ additionalContext?: AdditionalContext
}
```
diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml
index aedef3c944..a998473448 100644
--- a/docs/core-data-structures/session.i18n.yaml
+++ b/docs/core-data-structures/session.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-session.md: a6bd10000158bbb148aab984789846c0177747f4
-session.zh.md: 4532e2f46006cc637e39ad0dc1dc239d284a3ec8
+session.md: cd646d1d5913b0850bb9e1596a5f5f9795d74e4b
+session.zh.md: cf29447ecfd2383193a5185fbe78891253d48be0
diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md
index a6bd100001..cd646d1d59 100644
--- a/docs/core-data-structures/session.md
+++ b/docs/core-data-structures/session.md
@@ -480,14 +480,12 @@ An explicit `boundary` lets callers fork from a previous completed turn even if
*/
interface TurnTriggerMap {
message: { kind: 'message'; source: MessageSource }
+ /** Recovery turn reopened over the repaired current session log. */
+ retry: { kind: 'retry' }
/**
- * An out-of-band context injection (`agent.inject()`) made while the agent
- * was idle. The loop wraps the injected `user/message` (a non-`user` source,
- * plugin by default) in a one-shot turn (`turn/start` → `user/message` →
- * `turn/end`) so every event in the log stays turn-enclosed — the
- * durability/replay boundary is the turn, and a bare event between turns would
- * otherwise be indistinguishable from a crash tail on reload. The trigger's
- * `source` mirrors that message's producer.
+ * An out-of-band producer explicitly enclosed injected context in a one-shot
+ * turn. `Agent.inject()` appends idle context directly and does not use this
+ * trigger; the source mirrors the producer of the enclosed `user/message`.
*/
injection: { kind: 'injection'; source: MessageSource }
}
diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md
index 4532e2f460..cf29447ecf 100644
--- a/docs/core-data-structures/session.zh.md
+++ b/docs/core-data-structures/session.zh.md
@@ -480,14 +480,12 @@ declare class Session {
*/
interface TurnTriggerMap {
message: { kind: 'message'; source: MessageSource }
+ /** Recovery turn reopened over the repaired current session log. */
+ retry: { kind: 'retry' }
/**
- * An out-of-band context injection (`agent.inject()`) made while the agent
- * was idle. The loop wraps the injected `user/message` (a non-`user` source,
- * plugin by default) in a one-shot turn (`turn/start` → `user/message` →
- * `turn/end`) so every event in the log stays turn-enclosed — the
- * durability/replay boundary is the turn, and a bare event between turns would
- * otherwise be indistinguishable from a crash tail on reload. The trigger's
- * `source` mirrors that message's producer.
+ * An out-of-band producer explicitly enclosed injected context in a one-shot
+ * turn. `Agent.inject()` appends idle context directly and does not use this
+ * trigger; the source mirrors the producer of the enclosed `user/message`.
*/
injection: { kind: 'injection'; source: MessageSource }
}
diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md
index d68e280b24..b48fe1ef40 100644
--- a/docs/core-data-structures/tools.md
+++ b/docs/core-data-structures/tools.md
@@ -213,7 +213,9 @@ interface ToolRunContext extends ToolExecution {
* the agent loop. Contexts retain their individual source and metadata and
* are emitted in call order.
*/
- deferContext(context: HookContext): void
+ deferContext(context: AdditionalContext): void
+ /** Mark a successful final result as terminal for the current agent turn. */
+ concludeTurn(): void
}
```
@@ -290,7 +292,9 @@ interface ToolExecutionSuccess {
readonly content: ContentBlock[]
readonly error?: never
readonly meta?: JsonValue
- readonly additionalContexts?: HookContext[]
+ readonly additionalContexts?: AdditionalContext[]
+ /** The agent loop stops after committing this successful result batch. */
+ readonly concludesTurn?: true
}
```
@@ -302,7 +306,8 @@ interface ToolExecutionFailure {
readonly value?: never
readonly content: ContentBlock[]
readonly meta?: JsonValue
- readonly additionalContexts?: HookContext[]
+ readonly additionalContexts?: AdditionalContext[]
+ readonly concludesTurn?: never
}
```
@@ -338,9 +343,9 @@ type PreToolDecision =
* next request, or block by turning corrective feedback into an error result.
*/
type PostToolDecision =
- | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: HookContext[] }
- | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: HookContext[] }
- | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] }
+ | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: AdditionalContext[] }
+ | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: AdditionalContext[] }
+ | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: AdditionalContext[] }
```
Call `next()` for the default or return a decision to short-circuit. Pre-policy may deny or ask; only `allowed-once` proceeds, while a non-grant, missing approval channel or service, or agent-less request becomes a denial. Guards may still impose a final denial. Arguments cannot be rewritten because history, audit, UI, and execution must agree.
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 94b204ad43..adb9ef56d6 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -7,36 +7,33 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
-| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
-| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:359`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
-| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
-| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:507`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
-| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:335`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) |
-| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:349`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) |
-| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:325`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
-| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:457`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) |
-| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:388`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) |
-| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:404`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
-| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:418`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:472`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) |
-| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:433`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
-| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:372`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:313`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
-| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:445`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
-| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:483`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) |
-| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:494`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) |
+| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:140`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
+| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:328`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) |
+| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:270`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:279`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
+| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:436`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
+| `agent/idle` | `emit` | [`packages/core/agent/src/types.ts:423`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:306`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent) |
+| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:318`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent) |
+| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session) |
+| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:355`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
+| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:381`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:396`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:341`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
+| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:368`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`acp`](../packages/ui/acp), [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
+| `agent/stopping` | `serial` | [`packages/core/agent/src/types.ts:411`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
-| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
+| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:169`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) |
-| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
-| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:70`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) |
+| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
+| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index f4b3dcf4bc..77c3e33ed5 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -78,7 +78,7 @@ export type SessionEvent = {
}[T]
```
-Sources: [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:334`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:366`](../packages/core/session/src/types.ts)
+Sources: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts)
## Events
@@ -150,7 +150,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv
Types: [StreamChunk](core-data-structures/llm-streaming.md)
-Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts)
#### `assistant/message` — surface
@@ -166,7 +166,7 @@ Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/
Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md)
-Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:241`](../packages/core/session/src/types.ts)
### `compact/*`
@@ -329,7 +329,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:41`](../packages/plan/plan-mode/s
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
-Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts)
### `request/*`
@@ -343,7 +343,7 @@ Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
```
-Source: [`packages/core/session/src/types.ts:280`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts)
### `sandbox/*`
@@ -399,7 +399,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:44`](../packages
'steering/message': UserMessageData & { turn: number }
```
-Source: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:269`](../packages/core/session/src/types.ts)
### `step/*`
@@ -410,7 +410,7 @@ Source: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/
'step/end': { turn: number; step: number }
```
-Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:218`](../packages/core/session/src/types.ts)
#### `step/start` — log-only
@@ -419,7 +419,7 @@ Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/
'step/start': { turn: number; step: number }
```
-Source: [`packages/core/session/src/types.ts:220`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:216`](../packages/core/session/src/types.ts)
### `todo/*`
@@ -432,7 +432,7 @@ Source: [`packages/core/session/src/types.ts:220`](../packages/core/session/src/
Types: [TodoItem](core-data-structures/session.md)
-Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts)
### `tool/*`
@@ -449,7 +449,7 @@ Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/
Types: [CallId](core-data-structures/core.md)
-Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/types.ts)
#### `tool/code-dispatch` — log-only
@@ -503,7 +503,7 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c
Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md)
-Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts)
### `turn/*`
@@ -521,7 +521,7 @@ Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/
Types: [TurnEndReason](core-data-structures/session.md)
-Source: [`packages/core/session/src/types.ts:218`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:214`](../packages/core/session/src/types.ts)
#### `turn/start` — log-only
@@ -534,7 +534,7 @@ Source: [`packages/core/session/src/types.ts:218`](../packages/core/session/src/
Types: [TurnTrigger](core-data-structures/session.md)
-Source: [`packages/core/session/src/types.ts:211`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:207`](../packages/core/session/src/types.ts)
### `user/*`
@@ -552,4 +552,4 @@ Source: [`packages/core/session/src/types.ts:211`](../packages/core/session/src/
'user/message': UserMessageData
```
-Source: [`packages/core/session/src/types.ts:231`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts)
diff --git a/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts b/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts
index 254870eca9..cb0e072d2e 100644
--- a/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts
+++ b/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts
@@ -7,7 +7,7 @@ export const name = 'seed-goal'
export const inject = ['goals']
export function apply(ctx: Context): void {
- ctx.on('agent/pre-step', (agent) => {
+ ctx.on('agent/step', (agent) => {
if (ctx.goals.get(agent) !== undefined) return
ctx.goals.create(agent, {
objective: 'Prove the composed goal survives in the session log',
diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts
index 8adf2165e0..99b4b70734 100644
--- a/packages/bash/tool-bash/tests/integration.spec.ts
+++ b/packages/bash/tool-bash/tests/integration.spec.ts
@@ -114,7 +114,7 @@ describe('bash tool through the agent loop', () => {
const result = findEvent(events(agent), 'tool/result')
expect(resultText(result)).toBe(`${dshHome}\n1\nsession-env-id\n${location?.path}\nunset\nabsent\n`)
- expect(existsSync(location!.path)).toBe(true)
+ await expect.poll(() => existsSync(location!.path)).toBe(true)
const header = JSON.parse(readFileSync(location!.path, 'utf8').split('\n')[0]!) as { type: string; id: string }
expect(header).toMatchObject({ type: 'session', id: 'session-env-id' })
await handle.dispose()
diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md
index 7279cc029d..4f1754575e 100644
--- a/packages/compact/compact-basic/README.md
+++ b/packages/compact/compact-basic/README.md
@@ -8,16 +8,16 @@ This is the implementation tier of the compaction capability — see the [interf
This backend owns the compaction policy:
-- **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering.
+- **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Step-boundary pressure therefore includes the actual system prompt, tools, routing, assistant completion, tool results, buffered context, and steering.
- **Routed policy** — proactive pressure resolves capacity from the adapter that owns the latest durable provider/model route, then scales the default policy plus an optional exact-target override into concrete token budgets. Model discovery remains advisory and is not consulted.
-- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure post-step checks never prune.
+- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure step checks never prune.
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes. The optional pruner can repair an oversized closed tool unit when its text-bearing result is the removable bulk; indivisible non-tool units and non-prunable tool remainders remain out of scope.
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. It sets `GenerateOptions.purpose` to `compaction`, which adapters may forward as request attribution (the DeepSeek adapter sends `x-deepseek-harness-compact: 1`) without touching the model-visible body. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
- **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
-- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
+- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/step` listener checks pressure before request derivation. A canonical provider overflow is offered through `agent/request-error` after the failed step; the plugin compacts there and calls `agent.retry()` only after durable surface progress.
- **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
-- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational post-step failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress.
+- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational pressure failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress.
The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`.
@@ -36,7 +36,7 @@ Every setting is optional. Top-level policy fields are defaults for every routed
| `compactionRetries` | no (default `1`) | Extra attempts after the first when pressure remains above threshold. |
| `maxOverflowRetries` | no (default `1`) | Maximum retries after canonical context-window overflow; `0` disables recovery only. |
| `modelPolicies` | no (default `[]`) | Exact `{ provider, model, ...partialPolicy }` overrides; matching uses both fields and does not depend on `listModels()`. |
-| `auto` | no (default `true`) | Register post-step pressure and overflow-recovery listeners. Set `false` for manual-only. |
+| `auto` | no (default `true`) | Register step-boundary pressure and overflow-recovery listeners. Set `false` for manual-only. |
Every `modelPolicies` entry accepts the policy fields above except `auto` and `modelPolicies` itself. If an entry supplies either retention field, it replaces the default policy's retention choice; otherwise retention is inherited. Summarization provider/model remain a pair inside each entry.
diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts
index 10c64d10ca..2d5cc68ee1 100644
--- a/packages/compact/compact-basic/src/index.ts
+++ b/packages/compact/compact-basic/src/index.ts
@@ -111,6 +111,7 @@ export class BasicCompactService extends CompactService {
readonly config: ResolvedConfig
private readonly warnedPressureConfigTargets = new Set()
+ private readonly overflowRetries = new WeakMap()
constructor(ctx: Context, config: BasicCompactConfig = {}) {
super(ctx)
@@ -119,8 +120,8 @@ export class BasicCompactService extends CompactService {
}
/**
- * Register the automatic post-step pressure and context-overflow recovery
- * listeners. `compactIfNeeded` stays dynamically dispatched so subclass
+ * Register automatic between-step pressure and model-request overflow
+ * recovery. `compactIfNeeded` stays dynamically dispatched so subclass
* overrides are honored at event time.
*/
private _registerAutomaticCompaction(): void {
@@ -133,7 +134,7 @@ export class BasicCompactService extends CompactService {
)
}
- ctx.on('agent/post-step', async (
+ ctx.on('agent/step', async (
agent: Agent,
_turn: number,
_step: number,
@@ -142,35 +143,36 @@ export class BasicCompactService extends CompactService {
if (signal.aborted) return
try {
const result = await this.compactIfNeeded(agent, 'pressure', signal)
- if (result !== null) logResult(result, 'post-step pressure')
+ if (result !== null) logResult(result, 'step pressure')
} catch (error: unknown) {
if (error instanceof TargetPressureConfigError) {
if (this.warnedPressureConfigTargets.has(error.targetKey)) return
this.warnedPressureConfigTargets.add(error.targetKey)
}
const message = error instanceof Error ? error.message : String(error)
- ctx.logger.warn(`post-step compaction failed: ${message}; continuing the turn`)
+ ctx.logger.warn(`step compaction failed: ${message}; continuing the turn`)
}
})
+ ctx.on('agent/idle', (agent) => {
+ this.overflowRetries.delete(agent)
+ })
+
ctx.on('agent/request-error', async (
agent,
_turn,
_step,
_error,
failure,
- priorFailures,
signal,
next,
) => {
- const priorOverflowFailures = priorFailures.filter(
- item => item.code === CONTEXT_WINDOW_EXCEEDED_CODE,
- ).length
if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next()
const target = routedTarget(agent.session)
if (target === undefined) return next()
const policy = resolveTargetPolicy(this.config, target)
- if (priorOverflowFailures >= policy.maxOverflowRetries) return next()
+ const retries = this.overflowRetries.get(agent) ?? 0
+ if (retries >= policy.maxOverflowRetries) return next()
const generation = agent.session.surface.replaceGeneration
let result: CompactionResult | null
@@ -181,27 +183,30 @@ export class BasicCompactService extends CompactService {
// A model-free prune can land before later summary work fails. That
// durable reduction is sufficient retry proof; do not discard it just
// because the optional second phase threw. Cancellation still wins.
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited.
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while recovery is awaited.
if (!signal.aborted && agent.session.surface.replaceGeneration > generation) {
ctx.logger.warn(
`context-overflow compaction failed after durable surface progress: ${message}; `
+ 'retrying from the replacement surface',
)
- return { action: 'retry' }
+ this.overflowRetries.set(agent, retries + 1)
+ agent.retry()
+ return
}
ctx.logger.warn(
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited.
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while recovery is awaited.
`context-overflow compaction failed: ${message}; ${signal.aborted
? 'cancellation prevents retry'
: 'preserving the original request error'}`,
)
return next()
}
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while compaction is awaited.
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while compaction is awaited.
if (signal.aborted
|| agent.session.surface.replaceGeneration <= generation) return next()
if (result !== null) logResult(result, 'context overflow recovery')
- return { action: 'retry' }
+ this.overflowRetries.set(agent, retries + 1)
+ agent.retry()
})
}
@@ -228,12 +233,12 @@ export class BasicCompactService extends CompactService {
}
/**
- * Compact for replayed post-step pressure or one provider-confirmed context
+ * Compact for replayed step-boundary pressure or one provider-confirmed context
* overflow. Both triggers price the latest durable routed request envelope;
* overflow bypasses the normal threshold and retained-tail policy so it can
* force one useful balanced reduction.
* @param agent - agent whose latest durable routed request is measured.
- * @param trigger - normal post-step pressure or context-overflow recovery.
+ * @param trigger - normal step-boundary pressure or context-overflow recovery.
* @param signal - live turn cancellation signal forwarded to summarization.
* @returns the latest summary compaction result, or `null` when no summary ran.
*/
diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts
index c322f508ac..f9f46051ec 100644
--- a/packages/compact/compact-basic/src/types.ts
+++ b/packages/compact/compact-basic/src/types.ts
@@ -38,7 +38,7 @@ export interface ModelCompactPolicyConfig extends CompactPolicyConfig {
export interface BasicCompactConfig extends CompactPolicyConfig {
/** Exact provider/model overrides; duplicate targets fail plugin load. */
modelPolicies?: ModelCompactPolicyConfig[]
- /** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */
+ /** Enable automatic step-boundary pressure and overflow-recovery listeners. Defaults to `true`. */
auto?: boolean
}
diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts
index 1db86d38e4..105485a7d5 100644
--- a/packages/compact/compact-basic/tests/compact-basic.spec.ts
+++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts
@@ -66,7 +66,11 @@ function createContext(contextWindow = 1_000): Context {
}
function agent(session: Session, model?: string): Agent {
- return { session, options: model === undefined ? {} : { provider: model, model } } as Agent
+ return {
+ session,
+ options: model === undefined ? {} : { provider: model, model },
+ retry() {},
+ } as Agent
}
/** Flatten every text fragment the summarizer received, recursing tool-result blocks. */
@@ -1263,22 +1267,23 @@ describe('default one-shot summarizer', () => {
describe('automatic listener and loader composition', () => {
function postStep(ctx: Context, owner: Agent, signal = SIGNAL): Promise {
- return agentEvents(ctx, owner).serial('agent/post-step', 1, 1, signal)
+ return agentEvents(ctx, owner).serial('agent/step', 1, 1, signal)
}
function recover(
ctx: Context,
owner: Agent,
error: Error & { code?: string },
- retryAttempt = 0,
signal = SIGNAL,
- next: () => Promise<{ action: 'fail' | 'retry' }> = () => Promise.resolve({ action: 'fail' }),
- ): Promise<{ action: 'fail' | 'retry' }> {
+ next: () => Promise = () => Promise.resolve(),
+ ): Promise {
const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' }
- const priorFailures = Object.freeze(Array.from({ length: retryAttempt }, () => failure))
+ const turn = owner.session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 1
+ let retried = false
+ owner.retry = () => { retried = true }
return agentEvents(ctx, owner).waterfall(
- 'agent/request-error', 1, 1, error, failure, priorFailures, signal, next,
- )
+ 'agent/request-error', turn, 1, error, failure, signal, next,
+ ).then(() => retried)
}
function overflow(message = 'provider overflow'): Error & { code: string } {
@@ -1383,7 +1388,7 @@ describe('automatic listener and loader composition', () => {
expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(threshold)
const decision = await recover(ctx, agent(session, 'unconfigured-agent-fallback'), overflow())
- expect(decision).toEqual({ action: 'retry' })
+ expect(decision).toBe(true)
expect(session.surface.replaceGeneration).toBe(beforeGeneration + 1)
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
expect(session.surface.nodes).toContain(retainedSeq)
@@ -1402,7 +1407,7 @@ describe('automatic listener and loader composition', () => {
})
const session = oversizedToolResult()
- expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
+ expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
expect(session.surface.replaceGeneration).toBe(1)
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
expect(compact.calls).toHaveLength(0)
@@ -1421,7 +1426,7 @@ describe('automatic listener and loader composition', () => {
})
const session = toolConversation()
- expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
+ expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
expect(compact.calls).toHaveLength(1)
expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned')
@@ -1443,7 +1448,7 @@ describe('automatic listener and loader composition', () => {
compact.error = new Error('summary unavailable after prune')
const session = oversizedToolResult(3_000, true)
- expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
+ expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
expect(session.surface.replaceGeneration).toBe(1)
expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2)
expect(session.events.findLast(event => event.type === 'compact/end')?.data)
@@ -1467,8 +1472,7 @@ describe('automatic listener and loader composition', () => {
compact.error = new Error('summary cancelled after prune')
const session = oversizedToolResult(3_000, true)
- expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal))
- .toEqual({ action: 'fail' })
+ expect(await recover(ctx, agent(session, MODEL), overflow(), controller.signal)).toBe(false)
expect(session.surface.replaceGeneration).toBe(1)
})
@@ -1482,7 +1486,7 @@ describe('automatic listener and loader composition', () => {
const newestAssistant = session.surface.nodes.at(-2)!
const newestResult = session.surface.nodes.at(-1)!
- expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
+ expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
const currentAssistant = session.surface.nodes.find(node => node === newestAssistant)
const currentResult = session.surface.nodes.find(node => node === newestResult)
expect(currentAssistant).toBeDefined()
@@ -1506,7 +1510,7 @@ describe('automatic listener and loader composition', () => {
}
vi.spyOn(compact, 'compactIfNeeded').mockResolvedValue(fakeResult)
- expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
+ expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
expect(session.surface.replaceGeneration).toBe(0)
})
@@ -1521,7 +1525,6 @@ describe('automatic listener and loader composition', () => {
ctx,
agent(conversation(2), MODEL),
overflow(),
- 0,
SIGNAL,
() => {
calls += 1
@@ -1539,7 +1542,7 @@ describe('automatic listener and loader composition', () => {
compact.error = new Error('summary unavailable')
const original = overflow('original provider overflow')
- expect(await recover(ctx, agent(conversation(3), MODEL), original)).toEqual({ action: 'fail' })
+ expect(await recover(ctx, agent(conversation(3), MODEL), original)).toBe(false)
expect(original).toMatchObject({
message: 'original provider overflow',
code: CONTEXT_WINDOW_EXCEEDED_CODE,
@@ -1558,12 +1561,12 @@ describe('automatic listener and loader composition', () => {
const original = overflow('original provider failure')
let delegations = 0
- const decision = await recover(ctx, agent(session, MODEL), original, 0, SIGNAL, () => {
+ const decision = await recover(ctx, agent(session, MODEL), original, SIGNAL, () => {
delegations += 1
- return Promise.resolve({ action: 'fail' })
+ return Promise.resolve()
})
- expect(decision).toEqual({ action: 'fail' })
+ expect(decision).toBe(false)
expect(delegations).toBe(1)
expect(session.surface.replaceGeneration).toBe(generation)
expect(original).toMatchObject({
@@ -1582,7 +1585,7 @@ describe('automatic listener and loader composition', () => {
reason: 'resume',
})
expect(await recover(ctx, agent(session, MODEL), overflow('unlisted-model overflow')))
- .toEqual({ action: 'retry' })
+ .toBe(true)
})
it('delegates canonical overflow when no durable routed target exists', async () => {
@@ -1594,21 +1597,19 @@ describe('automatic listener and loader composition', () => {
trigger: { kind: 'message', source: { kind: 'user' } },
})
- await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toEqual({ action: 'fail' })
+ await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toBe(false)
})
- it('honors retry caps, non-context failures, and cancellation', async () => {
+ it('honors retry caps and ignores non-context failures', async () => {
const ctx = createContext()
const compact = new TestCompactService(ctx, { maxOverflowRetries: 1 })
const compactSpy = vi.spyOn(compact, 'compactIfNeeded')
const owner = agent(conversation(3), MODEL)
expect(await recover(ctx, owner, Object.assign(new Error('rate limit'), { code: 'RATE_LIMIT' })))
- .toEqual({ action: 'fail' })
- expect(await recover(ctx, owner, overflow(), 1)).toEqual({ action: 'fail' })
-
- const controller = new AbortController()
- controller.abort('cancelled')
- expect(await recover(ctx, owner, overflow(), 0, controller.signal)).toEqual({ action: 'fail' })
+ .toBe(false)
+ expect(await recover(ctx, owner, overflow())).toBe(true)
+ compactSpy.mockClear()
+ expect(await recover(ctx, owner, overflow())).toBe(false)
expect(compactSpy).not.toHaveBeenCalled()
})
@@ -1623,9 +1624,11 @@ describe('automatic listener and loader composition', () => {
}],
})
const compactSpy = vi.spyOn(compact, 'compactIfNeeded')
+ const owner = agent(conversation(3), MODEL)
- expect(await recover(ctx, agent(conversation(3), MODEL), overflow(), 1))
- .toEqual({ action: 'fail' })
+ expect(await recover(ctx, owner, overflow())).toBe(true)
+ compactSpy.mockClear()
+ expect(await recover(ctx, owner, overflow())).toBe(false)
expect(compactSpy).not.toHaveBeenCalled()
})
@@ -1637,8 +1640,7 @@ describe('automatic listener and loader composition', () => {
const session = conversation(3)
const generation = session.surface.replaceGeneration
- expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal))
- .toEqual({ action: 'fail' })
+ expect(await recover(ctx, agent(session, MODEL), overflow(), controller.signal)).toBe(false)
expect(session.surface.replaceGeneration).toBe(generation + 1)
})
@@ -1653,7 +1655,7 @@ describe('automatic listener and loader composition', () => {
await postStep(ctx, agent(session, MODEL))
const summaries = session.events.filter(event => event.type === 'compact/summary').length
expect(summaries).toBe(1)
- expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
+ expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
expect(session.events.filter(event => event.type === 'compact/summary')).toHaveLength(summaries)
})
@@ -1667,7 +1669,7 @@ describe('automatic listener and loader composition', () => {
const session = conversation(4)
await postStep(ctx, agent(session, MODEL))
expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
- expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
+ expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
})
it('loads and disposes the real zero-config service stack', async () => {
@@ -1696,6 +1698,6 @@ describe('automatic listener and loader composition', () => {
const session = conversation(4)
await postStep(ctx, agent(session, MODEL))
expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
- expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
+ expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
})
})
diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts
index 83c18f78b4..5cbc429d52 100644
--- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts
+++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts
@@ -15,7 +15,7 @@ import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import * as LlmRetry from '@deepseek-ai/dsh-llm-retry'
-import { SessionId, type SurfaceEvent } from '@deepseek-ai/dsh-session'
+import { Session, SessionId, type SessionEvent, type SurfaceEvent } from '@deepseek-ai/dsh-session'
/**
* CBR-001 regression through the real loop. A replacement checkpoint has a high
@@ -165,33 +165,37 @@ function waitForIdle(ctx: Context, agent: Agent): Promise {
})
}
-function seedOverflowHistory(agent: Agent): void {
+function overflowHistorySeed(): SessionEvent[] {
+ const session = new Session(SessionId('overflow-history-seed'))
for (let turn = 1; turn <= 2; turn += 1) {
const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY'
- agent.session.append('turn/start', {
+ session.append('turn/start', {
turn,
trigger: { kind: 'message', source: { kind: 'user' } },
})
- agent.session.append('user/message', {
+ session.append('user/message', {
content: [{ type: 'text', text: `${sentinel} ${'old context '.repeat(200)}` }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
- agent.session.append('step/start', { turn, step: 1 })
- agent.session.append('assistant/message', {
+ session.append('step/start', { turn, step: 1 })
+ session.append('assistant/message', {
provenance: { provider: 'mock', model: 'mock' },
turn,
step: 1,
content: [{ type: 'text', text: `historical response ${turn} ${'detail '.repeat(200)}` }],
}, { surfaceOp: 'append' })
- agent.session.append('step/end', { turn, step: 1 })
- agent.session.append('turn/end', { turn, reason: { kind: 'completed' } })
+ session.append('step/end', { turn, step: 1 })
+ session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
+ return [...session.events]
}
describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => {
it('uses the model actually routed by agent/request for post-step pressure', async () => {
const { ctx } = await harness(8)
- ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' }))
+ ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({
+ ...await next(), provider: 'mock', model: 'mock',
+ }))
try {
const agent = ctx.agentLoop.create(SessionId('routed-pressure'), {
provider: 'unconfigured-agent-fallback',
@@ -211,7 +215,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
}
})
- it('runs automatic pressure after the current tool result and before step/end', async () => {
+ it('runs automatic pressure between the completed tool step and the next step', async () => {
const { ctx } = await harness(8)
try {
const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' })
@@ -225,13 +229,19 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
event.type === 'tool/result' && event.seq < compactStart!.seq,
)
if (precedingResult?.type !== 'tool/result') throw new Error('expected a durable tool result before compaction')
- const stepEnd = events.find(event =>
+ const precedingStepEnd = events.find(event =>
event.type === 'step/end'
&& event.data.step === precedingResult.data.step
+ && event.seq > precedingResult.seq,
+ )
+ const nextStepStart = events.find(event =>
+ event.type === 'step/start'
+ && event.data.step === precedingResult.data.step + 1
&& event.seq > compactStart!.seq,
)
expect(precedingResult.seq).toBeLessThan(compactStart!.seq)
- expect(compactStart!.seq).toBeLessThan(stepEnd!.seq)
+ expect(precedingStepEnd!.seq).toBeLessThan(compactStart!.seq)
+ expect(compactStart!.seq).toBeLessThan(nextStepStart!.seq)
} finally {
await ctx.fiber.dispose()
}
@@ -281,7 +291,9 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeterService)
ctx.llm.registerAdapter(['mock'], adapter)
- ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' }))
+ ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({
+ ...await next(), provider: 'mock', model: 'mock',
+ }))
await ctx.plugin(BasicCompactService, {
thresholdRatio: 1,
retainTokens: 100,
@@ -291,11 +303,14 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
})
try {
- const agent = ctx.agentLoop.create(SessionId(`overflow-${delivery}`), {
- provider: 'unconfigured-agent-fallback',
- model: 'unconfigured-agent-fallback',
+ const { agent } = await ctx.agentLoop.createAgent(ctx, {
+ sessionId: SessionId(`overflow-${delivery}`),
+ seed: overflowHistorySeed(),
+ agentOptions: {
+ provider: 'unconfigured-agent-fallback',
+ model: 'unconfigured-agent-fallback',
+ },
})
- seedOverflowHistory(agent)
agent.followup([{ type: 'text', text: 'continue from history' }])
await agent.whenIdle()
@@ -308,11 +323,17 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
expect(retry).not.toContain('OLD HISTORY SENTINEL')
const events = [...agent.session.events]
- const failedEnd = events.find(event =>
+ const failedStepEnd = events.find(event =>
event.type === 'step/end' && event.data.turn === 3 && event.data.step === 1,
)!
+ const failedEnd = events.find(event =>
+ event.type === 'turn/end' && event.data.turn === 3,
+ )!
const retryStart = events.find(event =>
- event.type === 'step/start' && event.data.turn === 3 && event.data.step === 2,
+ event.type === 'turn/start' && event.data.turn === 4,
+ )!
+ const retryStep = events.find(event =>
+ event.type === 'step/start' && event.data.turn === 4 && event.data.step === 1,
)!
const compaction = events.filter(event =>
event.type === 'compact/start'
@@ -324,7 +345,11 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
'compact/summary',
'compact/end',
])
- expect(compaction.every(event => event.seq > failedEnd.seq && event.seq < retryStart.seq)).toBe(true)
+ expect(retryStart.seq).toBeGreaterThan(failedEnd.seq)
+ expect(compaction.every(event =>
+ event.seq > failedStepEnd.seq && event.seq < failedEnd.seq,
+ )).toBe(true)
+ expect(retryStep.seq).toBeGreaterThan(retryStart.seq)
expect(events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'completed' } },
@@ -358,17 +383,21 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
})
try {
- const agent = ctx.agentLoop.create(SessionId('alternating-recovery'), { provider: 'mock', model: 'mock' })
- seedOverflowHistory(agent)
+ const { agent } = await ctx.agentLoop.createAgent(ctx, {
+ sessionId: SessionId('alternating-recovery'),
+ seed: overflowHistorySeed(),
+ agentOptions: { provider: 'mock', model: 'mock' },
+ })
agent.followup([{ type: 'text', text: 'continue from history' }])
+ await expect.poll(() => adapter.conversationRequests.length).toBe(3)
await agent.whenIdle()
expect(adapter.conversationRequests).toHaveLength(3)
expect(adapter.summaryRequests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data))
- .toEqual([expect.objectContaining({ step: 2, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })])
- expect(agent.session.events.filter(event => event.type === 'step/start').slice(-3).map(event => event.data.step))
- .toEqual([1, 2, 3])
+ .toEqual([expect.objectContaining({ turn: 4, step: 1, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })])
+ expect(agent.session.events.filter(event => event.type === 'turn/start').slice(-3).map(event => event.data.turn))
+ .toEqual([3, 4, 5])
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'completed' } },
diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts
index 424363d4b1..d9c95f0bc4 100644
--- a/packages/context/time-context/tests/time-context.spec.ts
+++ b/packages/context/time-context/tests/time-context.spec.ts
@@ -43,7 +43,6 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
status: 'running',
ctx: new Context(),
followup: () => AgentMessageId('stub'),
- queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'),
inject(content, options) {
session.append('user/message', {
@@ -54,6 +53,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
},
send: () => AgentMessageId('stub'),
cancel() {},
+ retry() {},
whenIdle: () => Promise.resolve(),
}
}
@@ -85,7 +85,7 @@ async function fire(
step: number,
signal: AbortSignal = SIGNAL,
): Promise {
- await agentEvents(ctx, agent).serial('agent/pre-step', turn, step, signal)
+ await agentEvents(ctx, agent).serial('agent/step', turn, step, signal)
}
function textResponse(text: string): StreamChunk[] {
@@ -294,7 +294,7 @@ describe('durable step context', () => {
const agent = sessionAgent(session)
openMessageTurn(session, 1)
let ordinarySawContext = false
- ctx.on('agent/pre-step', (subject) => {
+ ctx.on('agent/step', (subject) => {
ordinarySawContext = subject.session.events.some(event => event.type === 'user/message')
})
@@ -363,11 +363,11 @@ describe('real agent-loop request history', () => {
it.each([
['throws', 'error'],
['cancels', 'aborted'],
- ] as const)('retains the preparation reading when a later pre-step listener %s', async (mode, reasonKind) => {
+ ] as const)('discards the pending preparation reading when a later step listener %s', async (mode, reasonKind) => {
const adapter = new ScriptedAdapter([textResponse('unused')])
const ctx = await loopHarness(adapter)
let laterSawReading = false
- ctx.on('agent/pre-step', (subject) => {
+ ctx.on('agent/step', (subject) => {
laterSawReading = contextTexts(subject.session).length === 1
if (mode === 'throws') throw new Error('later pre-step failure')
subject.cancel({ kind: 'user' })
@@ -377,8 +377,8 @@ describe('real agent-loop request history', () => {
agent.followup([{ type: 'text', text: 'start' }])
await agent.whenIdle()
- expect(laterSawReading).toBe(true)
- expect(contextTexts(agent.session)).toHaveLength(1)
+ expect(laterSawReading).toBe(false)
+ expect(contextTexts(agent.session)).toHaveLength(0)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false)
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts
index 1679cd201b..d397218e95 100644
--- a/packages/context/workspace-context/src/state.ts
+++ b/packages/context/workspace-context/src/state.ts
@@ -108,7 +108,9 @@ function filePathFromExecution(exec: ToolExecution): string | undefined {
return filePath.length > 0 ? filePath : undefined
}
-function isWorkspaceContextSource(source: unknown): source is WorkspaceInstructionSource {
+function isWorkspaceContextSource(
+ source: unknown,
+): source is { kind: 'workspace-instructions'; changes: unknown[] } {
return typeof source === 'object' && source !== null
&& 'kind' in source && source.kind === 'workspace-instructions'
&& 'changes' in source && Array.isArray(source.changes)
diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts
index d5378d14e4..f2ea056cdf 100644
--- a/packages/context/workspace-context/tests/workspace-context.spec.ts
+++ b/packages/context/workspace-context/tests/workspace-context.spec.ts
@@ -7,7 +7,7 @@ import Loader from '@cordisjs/plugin-loader'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session'
-import AgentRegistry, { AgentMessageId, type AdditionalContext, type Agent } from '@deepseek-ai/dsh-agent'
+import AgentRegistry, { agentEvents, AgentMessageId, type AdditionalContext, type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
@@ -178,7 +178,6 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
session,
status: 'idle',
followup: () => AgentMessageId('stub'),
- queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'),
inject(content, options) {
session.append('user/message', {
@@ -189,6 +188,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
},
send: () => AgentMessageId('stub'),
cancel() {},
+ retry() {},
whenIdle: () => Promise.resolve(),
}
}
@@ -233,11 +233,8 @@ function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: A
const composedPrefixes = new WeakMap