From faac9b4fd5ae5ea68005c8492b36f76529a2085e Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 30 Jul 2026 17:40:25 +0800 Subject: [PATCH] round 1: implement bracket-first manual compaction --- ...30-session-end-seed-log-boundary.i18n.yaml | 4 +- ...026-07-30-session-end-seed-log-boundary.md | 2 +- ...-07-30-session-end-seed-log-boundary.zh.md | 2 +- ...06-18-compaction-capability-seam.i18n.yaml | 4 +- .../2026-06-18-compaction-capability-seam.md | 36 +- ...026-06-18-compaction-capability-seam.zh.md | 36 +- ...6-07-30-queued-manual-compaction.i18n.yaml | 6 + .../2026-07-30-queued-manual-compaction.md | 106 +++ .../2026-07-30-queued-manual-compaction.zh.md | 106 +++ ...-remove-synthetic-log-only-turns.i18n.yaml | 4 +- ...6-07-28-remove-synthetic-log-only-turns.md | 8 +- ...7-28-remove-synthetic-log-only-turns.zh.md | 8 +- apps/cli/cordis.yml | 4 + apps/cli/package.json | 1 + docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 6 +- docs/architecture.zh.md | 6 +- docs/config-catalog.md | 3 +- docs/cordis-catalog/events.md | 32 +- docs/cordis-catalog/services.md | 22 +- .../core-data-structures/compaction.i18n.yaml | 4 +- docs/core-data-structures/compaction.md | 19 +- docs/core-data-structures/compaction.zh.md | 19 +- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 16 +- docs/core-data-structures/core.zh.md | 16 +- docs/event-producer-consumer.md | 32 +- docs/persistence-catalog.md | 21 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- examples/package.json | 1 + examples/tui-agent/README.i18n.yaml | 4 +- examples/tui-agent/README.md | 2 +- examples/tui-agent/README.zh.md | 2 +- examples/tui-agent/composition.md | 3 + examples/tui-agent/cordis.yml | 5 + .../terminal.expected.txt | 128 +++ examples/tui-agent/tests/tui.snapshot.ts | 281 +++++- packages/compact/README.i18n.yaml | 4 +- packages/compact/README.md | 6 +- packages/compact/README.zh.md | 6 +- .../compact/command-compact/README.i18n.yaml | 6 + packages/compact/command-compact/README.md | 66 ++ packages/compact/command-compact/README.zh.md | 66 ++ packages/compact/command-compact/package.json | 46 + packages/compact/command-compact/src/index.ts | 87 ++ .../compact/command-compact/src/invariant.ts | 30 + .../tests/command-compact.spec.ts | 207 +++++ .../command-compact/tests/invariant.spec.ts | 18 + .../tests/loader-composition.spec.ts | 134 +++ .../compact/command-compact/tsconfig.json | 27 + .../compact/compact-basic/README.i18n.yaml | 4 +- packages/compact/compact-basic/README.md | 8 +- packages/compact/compact-basic/README.zh.md | 8 +- packages/compact/compact-basic/src/index.ts | 83 +- packages/compact/compact-basic/src/region.ts | 435 +++++++-- .../compact-basic/tests/compact-basic.spec.ts | 3 +- .../tests/loader-composition.spec.ts | 6 + .../tests/manual-compact.spec.ts | 831 ++++++++++++++++++ packages/compact/compact/README.i18n.yaml | 4 +- packages/compact/compact/README.md | 19 +- packages/compact/compact/README.zh.md | 19 +- packages/compact/compact/src/index.ts | 54 ++ packages/compact/compact/src/invariant.ts | 51 +- packages/compact/compact/src/types.ts | 15 +- .../compact/compact/tests/compact.spec.ts | 15 + .../compact/compact/tests/invariant.spec.ts | 52 ++ .../time-context/tests/time-context.spec.ts | 1 + .../tmux-context/tests/tmux-context.spec.ts | 1 + .../tests/workspace-context.spec.ts | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 10 +- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 70 +- .../agent-loop/tests/turn-admission.spec.ts | 286 ++++++ packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 1 + packages/core/agent/README.zh.md | 1 + packages/core/agent/src/types.ts | 14 + packages/core/agent/tests/agent.spec.ts | 1 + .../tests/tools.spec.ts | 1 + .../command-goal/tests/command-goal.spec.ts | 1 + packages/goal/goal/tests/goal.spec.ts | 1 + packages/goal/goal/tests/projection.spec.ts | 1 + .../goal/tool-goal/tests/tool-goal.spec.ts | 1 + .../tests/api-proxy-workspace.spec.ts | 1 + packages/pty/pty-local/tests/index.spec.ts | 6 +- packages/pty/pty-local/tests/local.spec.ts | 2 +- packages/pty/pty/tests/service.spec.ts | 1 + .../tests/loader-composition.spec.ts | 1 + .../tool-bash-persistent/tests/tools.spec.ts | 1 + .../tool-pty/tests/loader-composition.spec.ts | 2 +- packages/pty/tool-pty/tests/tools.spec.ts | 2 +- .../skill/tool-skill/tests/tool-skill.spec.ts | 2 + .../tasks/tasks-local/tests/tasks.spec.ts | 1 + packages/ui/tui/tests/harness.ts | 1 + packages/ui/tui/tests/tui.spec.ts | 12 +- pnpm-lock.yaml | 36 + scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 5 + tsconfig.host.json | 1 + 101 files changed, 3452 insertions(+), 295 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md create mode 100644 examples/tui-agent/tests/snapshots/queued-manual-compact/terminal.expected.txt create mode 100644 packages/compact/command-compact/README.i18n.yaml create mode 100644 packages/compact/command-compact/README.md create mode 100644 packages/compact/command-compact/README.zh.md create mode 100644 packages/compact/command-compact/package.json create mode 100644 packages/compact/command-compact/src/index.ts create mode 100644 packages/compact/command-compact/src/invariant.ts create mode 100644 packages/compact/command-compact/tests/command-compact.spec.ts create mode 100644 packages/compact/command-compact/tests/invariant.spec.ts create mode 100644 packages/compact/command-compact/tests/loader-composition.spec.ts create mode 100644 packages/compact/command-compact/tsconfig.json create mode 100644 packages/compact/compact-basic/tests/manual-compact.spec.ts create mode 100644 packages/core/agent-loop/tests/turn-admission.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml index d0d8d7b82f..2fc587a630 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md -2026-07-30-session-end-seed-log-boundary.md: 837531ba0bd3ecf404eb47ee933438546c682a54 -2026-07-30-session-end-seed-log-boundary.zh.md: 33680c1845364de62e5b53ead13de418a389f908 +2026-07-30-session-end-seed-log-boundary.md: ce5231d0082360fb6219321365a9fbdc4e4e0d4f +2026-07-30-session-end-seed-log-boundary.zh.md: 33dacdaaeac665dc6959d40a8cc3ba0bb559e6a2 diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md index 837531ba0b..ce5231d008 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md @@ -52,4 +52,4 @@ Cost: a seeded session's log is one event longer, which moved seq expectations i `session/end-seed` joins the on-disk vocabulary. Under the pre-release stance (`SESSION_FORMAT_VERSION` pinned at `0`, no compatibility promise) older logs simply lack it, and a log without a boundary correctly classifies nothing as constructor-seed history. -Not built here: no plugin reads the boundary yet. Wiring the compaction seam's staleness check to it is the follow-up that motivated this boundary; the predicate helper belongs with that seam, where a real consumer decides its shape, rather than shipping into core untested against one. +The [queued manual compaction decision](../feature/2026-07-30-queued-manual-compaction.md) now supplies the first consumer. Its tail scan independently finds the unmatched `compact/start` and newest end-seed, treats only a start after that boundary as live, and clears the invariant trace on the same replay transition. The predicate remains in the compaction package rather than becoming a generic core helper. diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md index 33680c1845..33dacdaaea 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md @@ -52,4 +52,4 @@ Status: implemented `session/end-seed` 加入了落盘词汇表。在预发布立场下(`SESSION_FORMAT_VERSION` 固定为 `0`,不作兼容承诺),更旧的日志只是没有它,而没有边界的日志会正确地判定没有任何内容属于构造种子历史。 -此处未做:还没有任何插件读取该边界。把压缩 seam 的陈旧性检查接到它上面,是催生这条边界的后续工作;谓词辅助函数应当归属那个 seam——在那里由真实消费方决定它的形状——而不是未经真实消费方检验就先落进核心。 +[排队手动压缩决策](../feature/2026-07-30-queued-manual-compaction.md)如今提供了第一个消费方。其尾部扫描会分别查找未匹配的 `compact/start` 与最新 end-seed,只把位于该边界之后的 start 视为活动锁,并在同一个回放转换上清除不变量追踪状态。该谓词仍位于压缩包中,不会成为通用核心辅助函数。 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml index 414c63211a..714b75d942 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md -2026-06-18-compaction-capability-seam.md: 3c219b734e148b963fb5857de89c16f28c2bd402 -2026-06-18-compaction-capability-seam.zh.md: b2c7e9720b596705b60a284e6ccf1448a782b7fc +2026-06-18-compaction-capability-seam.md: ef37313bc6fb984689793fa5a3e7ac4d9238ea88 +2026-06-18-compaction-capability-seam.zh.md: 9f123a8c40f303a2635af78cafd34de448e27e03 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 3c219b734e..ef37313bc6 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 @@ -18,10 +18,10 @@ Two forces shape the design. First, compaction policy and reusable token measure Per the [capability-seams Agent Note](../architecture/2026-06-13-capability-seams.md), compaction ships as separate packages so the contract, the algorithm, and (later) the consumer surface evolve independently: -1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, the `compact/*` session events, and the canonical checkpoint message source. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*. +1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, the `compact/*` session events, the manual failure taxonomy, and the canonical checkpoint message source. It declares `compactIfNeeded()`, `compactNow()`, and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*. 2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that consumes `ctx.tokenMeter` and owns the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, post-step pressure, and canonical context-overflow recovery. `summarize()` is its sole subclass hook; pricing and replay stay with the meter. 3. **Model-free companion** — `@deepseek-ai/dsh-compact-tool-result-prune`: a concrete optional service that rewrites oversized current `tool/result` nodes before the backend selects a summary range. It is not a second compaction implementation and does not implement `CompactService`. -4. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first. +4. **Human consumer** — `@deepseek-ai/dsh-command-compact` registers argument-free `/compact` through `ctx.commands` and calls the backend-independent `compactNow()` operation. It is direct human control, not a model-facing tool. ### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation @@ -29,11 +29,11 @@ The capability-seams Agent Note states the interface package "depends only on co This is not a coupling smell — it is the contract's domain. The "only cordis" guidance was always shorthand for "the interface depends only on what the contract genuinely names, and never on an implementation." `dsh-session` and `dsh-llm` are themselves interface/vocabulary packages, not implementations; `dsh-compact` still imports no backend. The seam's real invariant — *consumers and implementations evolve independently behind an abstract service* — holds intact. -### Abstract `compactIfNeeded` / `compactRegion`, algorithm in the backend +### Three abstract operations, algorithm in the backend -An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface. That recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend and keeps the interface a statement of *what*. Token measurement is not a compaction hook at all; the singleton service lets multiple consumers share one per-session replay fold. +An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface. That recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making all three operations abstract puts every *how* decision in the backend and keeps the interface a statement of *what*. Token measurement is not a compaction hook at all; the singleton service lets multiple consumers share one per-session replay fold. -`compactIfNeeded(agent, trigger, signal)` takes an explicit `'pressure' | 'context-overflow'` trigger and cancellation. It reads only the latest durable routed request; no header means no work, while any routed provider/model target uses the singleton estimator. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for manual callers. The default summarizer resolves its target from explicit config, the latest logged routed target, then agent options, and records the provider/model pair after any `llm/stream` routing. It replays the routed request's prefix and appends the compaction directive as a trailing user message so the provider's warm KV cache is reused — see the [summary prefix-cache Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md). The call sets the provider-neutral `GenerateOptions.purpose` to `compaction`; adapters may map that purpose to model-hidden transport metadata, and the DeepSeek adapter sends `x-deepseek-harness-compact: 1`. +`compactIfNeeded(agent, trigger, signal)` takes an explicit `'pressure' | 'context-overflow'` trigger and cancellation. It reads only the latest durable routed request; no header means no work, while any routed provider/model target uses the singleton estimator. `compactNow(agent, signal)` reserves idle turn admission and performs one useful balanced reduction even below pressure, returning `null` without writes when none exists. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for explicit callers. The default summarizer resolves its target from explicit config, the latest logged routed target, then agent options, and records the provider/model pair after any `llm/stream` routing. It replays the routed request's prefix and appends the compaction directive as a trailing user message so the provider's warm KV cache is reused — see the [summary prefix-cache Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md). The call sets the provider-neutral `GenerateOptions.purpose` to `compaction`; adapters may map that purpose to model-hidden transport metadata, and the DeepSeek adapter sends `x-deepseek-harness-compact: 1`. ### Automatic pressure runs after successful durable step work @@ -91,42 +91,44 @@ The basic backend wraps the summary as established checkpoint context and tags i ### Blocking via a log-recorded lock, plus a crash/recoverable failure taxonomy -The `compact/start … compact/end` bracket is justified, in order of what now does the work: +The `compact/start … compact/end` bracket is justified by two roles: 1. **Crash-detectable orphan + provenance** (primary). Summarization is a slow model call persisted *after* `compact/start`. A crash mid-summarization leaves a `compact/start` with no matching `compact/end` — a detectable orphan. Releasing the lock last (rather than first) converts the crash window from *silent corruption* into that detectable orphan. -2. **Prevents concurrent compaction.** `compactRegion` refuses to start if the current turn holds an unmatched `compact/start`. (The loop is single-threaded across either awaited automatic seam, so this is also a re-entry tripwire — a thrown "already in progress" signals a real bug.) +2. **Prevents concurrent compaction.** Every automatic, manual, and explicit-range entry point refuses a live unmatched `compact/start`. The bracket is the single lock; no process-local mutex duplicates it. -The lock excludes another compaction, not unrelated log-only facts. The basic backend snapshots the token meter's surface nodes after `compact/start` and compares them again after asynchronous summarization; any surface mutation rejects before replacement, while a title or other log-only append leaves the selected span valid. +The lock excludes another compaction, not unrelated facts. Its markers are time points rather than an exclusive container, so idle injected context may appear between a standalone manual start and end. Automatic work requires whole-surface stability inside its turn. Manual work revalidates only the selected positional span, letting append-only context outside it remain visible after replacement. -Two failure paths, both documented: +The lifecycle boundary makes crash state unambiguous: -- **Crash** (the loop dies mid-summarization): a dangling `compact/start`, no closer. Because `compact/*` are **log-only**, the orphan is **inert** — no summary replacement lands. The derived surface remains the durable surface present at `compact/start`: full history when pruning made no replacement, or the already-pruned history when it did. Generic turn-repair (`interruptedTurnClosers`) closes the turn with a synthetic `turn/end`; the orphan sits *before* that `turn/end`, so the turn-scoped in-progress check never sees it and a crash cannot wedge future compaction. -- **Recoverable** (summarization throws but the loop survives): the backend appends `compact/end` with its **`error`** field set and lands no summary replacement. Post-step pressure warns and continues from the latest durable surface — full history if no replacement preceded the attempt, or the pruned surface if pruning already landed. Overflow recovery delegates only before any replacement; generation progress from earlier pruning authorizes a retry from that durable surface unless cancellation or disposal wins. +- **Current lifecycle:** a dangling `compact/start` after the newest `session/end-seed` is the live durable lock and reports busy. +- **Later lifecycle:** a newer constructor-written `session/end-seed` proves that the older unmatched start is stale, so resume, fork, and adoption do not remain wedged by a dead writer. +- **Recoverable failure:** once start lands, the backend makes exactly one `compact/end { error }` attempt. Summary or stability failure leaves the conversation surface unchanged while preserving the failed attempt in the log. If the close append fails, the unmatched start remains intentionally blocking. `compact/end` keeps its `error?` field (mirroring `tool/result`'s self-contained error — one event tells success from failure without correlating a sibling). There is no separate `compact/error` event. -**Core session repair stays compaction-agnostic — deliberately.** `interruptedTurnClosers` is never taught about `compact/*`. Teaching it would force every future `xxx/start … xxx/end` plugin pair to patch a core module — exactly the coupling the capability-seam architecture exists to avoid. Because the log-only orphan is inert, no special repair is needed: generic turn-repair plus the inertness of an un-landed surface mutation is sufficient. +**Core session repair stays compaction-agnostic — deliberately.** `interruptedTurnClosers` is never taught about `compact/*`. The general `session/end-seed` lifecycle boundary supplies the evidence the compaction owner needs; the compaction invariant and backend interpret it without adding plugin-specific repair to core. ## Alternatives considered -- **The full algorithm as concrete interface methods** — rejected because it recouples the contract to one retention strategy. Both core methods are abstract; reusable measurement is a separate LLM-family service and `summarize()` is basic's sole hook. +- **The full algorithm as concrete interface methods** — rejected because it recouples the contract to one retention strategy. All three operations are abstract; reusable measurement is a separate LLM-family service and `summarize()` is basic's sole hook. - **Compaction on `agent/request` or provisional `agent/pre-step` inputs** — rejected because neither proves the final durable request and both couple generic lifecycle to compaction-specific envelope data. Post-step replay plus canonical overflow recovery covers both successful and rejected calls. - **A `compact` boolean or untyped request metadata map** — rejected because multiple auxiliary call kinds would become mutually exclusive flags, while an open bag would discard compiler-checked vocabulary. One typed `purpose` discriminant extends with additional call kinds without adding another `GenerateOptions` field. - **A separate `compact/error` event** — rejected: `compact/end` keeps an `error?` field, mirroring `tool/result`'s self-contained error — one event tells success from failure without correlating a sibling. -- **Teaching core turn-repair about `compact/*`** — rejected: the log-only orphan is inert, and a core module patched for every future `xxx/start … xxx/end` plugin pair is exactly the coupling the capability-seam architecture exists to avoid. +- **Teaching core turn-repair about `compact/*`** — rejected: the general end-seed boundary already distinguishes prior-lifecycle history, and patching core for every future `xxx/start … xxx/end` pair is exactly the coupling the capability-seam architecture exists to avoid. ## Consequences -- **Packages**: `packages/compact/compact` supplies the interface, `compact-basic` supplies the backend, and `compact-tool-result-prune` supplies optional deterministic rewriting. `packages/llm/token-meter` owns replay-aware measurement independently. The consumer tier is deferred. +- **Packages**: `packages/compact/compact` supplies the interface, `compact-basic` supplies the backend, `compact-tool-result-prune` supplies optional deterministic rewriting, and `command-compact` supplies human `/compact`. `packages/llm/token-meter` owns replay-aware measurement independently. - **Automatic seams**: `agent/post-step` (`@mode serial`) handles successful-call pressure and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. Generic `agent/pre-step` remains a four-argument checkpoint with no compaction-only prompt/prefix payload. - **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. - **`dsh-compact`** owns `COMPACT_CHECKPOINT_SOURCE`, `isCompactCheckpointSource(source)`, `toolPairingBalancedBefore(session, seq)`, and `toolPairingBalancedAfter(session, seq)`. The marker identifies replacement summaries across backend implementations. The cached surface-edge checks prevent `compactRegion` and `compactIfNeeded` from splitting a tool-call/result pair, validate current membership by seq, answer both edges from one per-cut balance sequence, and reject stale or missing seqs and orphan results. -- **`dsh-session`** validates positional replacement, complete provenance, and content-only single-node `tool/result` rewrites through its one surface manager. Its invariant companion treats fresh appended tool results as executions that require an open step and pending call; validated replacements remain turn-enclosed rewrites. -- **Wiring**: `examples/tui-agent/cordis.yml` loads zero-config `dsh-token-meter`, `dsh-compact-tool-result-prune`, then `dsh-compact-basic`; service-wide defaults make the composition usable without repeated numeric policy. +- **`dsh-session`** validates positional replacement, complete provenance, and content-only single-node `tool/result` rewrites through its one surface manager. Its invariant companion treats fresh appended tool results as executions that require an open step and pending call, while the compaction companion owns numeric-turn versus standalone-null bracket relations. +- **Wiring**: `examples/tui-agent/cordis.yml` loads zero-config `dsh-token-meter`, `dsh-compact-tool-result-prune`, `dsh-compact-basic`, then `dsh-command-compact`; service-wide defaults make the composition usable without repeated numeric policy. ## Testing - **Unit:** Real Loader and invariant plugins cover whole-unit retention, pruning configuration and replay, rich-block ordering, metadata preservation, convergence, both `compact/end` outcomes, open-tail refusal, pruning-only and summarized overflow recovery, generation proof, caps, and original-error preservation. - **Loop:** Tests pin post-step after durable tool results and before `step/end`, actual `agent/request` routing, closed failed steps, fresh retry numbering, and complete thrown/in-band overflow → compaction → reconstructed retry composition. +- **Manual:** Admission, marker ordering, injection retention, live/stale orphan classification, cancellation, close/flush failures, command mapping, and the queued TUI journey are pinned without a model key. - **With-key e2e:** A real model and bash session with lowered limits triggers compaction, records a complete `compact/start…end` pair, shrinks the surface, and finishes the task. - **Snapshot gap:** Runaway-turn compaction cannot yet replay because the summarization call records no `assistant/chunk` events or `sessionId`; interleaved summarization-call replay remains follow-up work. diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md index b2c7e9720b..9f123a8c40 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -18,10 +18,10 @@ Status: implemented 遵循[能力 seam Agent Note(agent 决策记录)](../architecture/2026-06-13-capability-seams.md),压缩以独立包(package)发布,使契约、算法和(后续的)消费方 surface 各自独立演进: -1. **接口** — `@deepseek-ai/dsh-compact`:抽象 `CompactService`,拥有 `ctx.compact` 键、`CompactionResult` 词汇、`compact/*` 会话事件以及规范的检查点消息来源。它将 `compactIfNeeded()` 和 `compactRegion()` 声明为**抽象方法**——契约说明压缩*做什么*,而非*怎么做*。 +1. **接口** — `@deepseek-ai/dsh-compact`:抽象 `CompactService`,拥有 `ctx.compact` 键、`CompactionResult` 词汇、`compact/*` 会话事件、手动失败分类体系以及规范的检查点消息来源。它将 `compactIfNeeded()`、`compactNow()` 和 `compactRegion()` 声明为**抽象方法**——契约说明压缩*做什么*,而非*怎么做*。 2. **实现** — `@deepseek-ai/dsh-compact-basic`:具体的 `BasicCompactService`,消费 `ctx.tokenMeter`,并拥有尾→头保留遍历、通过 `ctx.llm.stream()` 生成摘要、surface 替换、锁、步骤后压力处理和规范的上下文溢出恢复。`summarize()` 是其唯一的子类钩子;计价与回放仍归 meter 所有。 3. **无模型配套服务** — `@deepseek-ai/dsh-compact-tool-result-prune`:一个具体的可选服务,在后端选择摘要范围之前,重写当前过大的 `tool/result` 节点。它不是第二种压缩实现,也不实现 `CompactService`。 -4. **消费方** — 推迟。一个 `/compact` 工具和斜杠命令将 `inject: ['compact']` 并调用契约;它们被有意排除在本 Agent Note 范围之外,以便 seam 先稳定下来。 +4. **面向用户的消费方** — `@deepseek-ai/dsh-command-compact` 通过 `ctx.commands` 注册无参数 `/compact`,并调用后端无关的 `compactNow()` 操作。它是供用户直接控制的命令,不是面向模型的工具。 ### 契约依赖 `dsh-session` 和 `dsh-llm`——有意为之的偏离 @@ -29,11 +29,11 @@ Status: implemented 这不是耦合异味,而是契约的领域所在。「仅 cordis」的指导原则一直是「接口仅依赖契约真正需要命名的东西,绝不依赖实现」的简写。`dsh-session` 和 `dsh-llm` 本身是接口/词汇包,不是实现;`dsh-compact` 仍然不导入任何后端。seam 的真正不变式——*消费方和实现在抽象服务背后独立演进*——完好无损。 -### 抽象 `compactIfNeeded` / `compactRegion`,算法在后端 +### 三个抽象操作,算法在后端 -早期草案将完整算法(保留遍历、token 求和、文本提取)作为接口上的具体方法。这会将契约重新耦合到一种策略:想要不同保留策略或事件排序的后端必须与继承来的具体代码对抗。将两个核心方法都设为抽象,把所有*怎么做*的决策放在后端,并让接口保持为*做什么*的声明。token 测量根本不是压缩钩子;单例服务使多个消费方能够共享逐会话的回放折叠。 +早期草案将完整算法(保留遍历、token 求和、文本提取)作为接口上的具体方法。这会将契约重新耦合到一种策略:想要不同保留策略或事件排序的后端必须与继承来的具体代码对抗。将三个操作都设为抽象,把所有*怎么做*的决策放在后端,并让接口保持为*做什么*的声明。token 测量根本不是压缩钩子;单例服务使多个消费方能够共享逐会话的回放折叠。 -`compactIfNeeded(agent, trigger, signal)` 接受显式的 `'pressure' | 'context-overflow'` 触发原因与取消信号。它只读取最新的持久化已路由请求;没有 header 就不执行工作,任何已路由的提供方/模型目标都使用单例估算器。`compactRegion(start, end, agent, signal?)` 将 `agent.session` 作为唯一会话身份,并为手动调用方保留可选 signal。默认摘要器依次从显式配置、最新记录的已路由目标和 agent 选项解析目标,并在任何 `llm/stream` 路由后记录提供方/模型对。它回放已路由请求的前缀,并将压缩指令追加为尾部 user 消息,从而复用提供方的热 KV cache;见[摘要前缀缓存 Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md)。该调用将提供方无关的 `GenerateOptions.purpose` 设为 `compaction`;适配器可以将此用途映射为对模型隐藏的传输元数据,DeepSeek 适配器会发送 `x-deepseek-harness-compact: 1`。 +`compactIfNeeded(agent, trigger, signal)` 接受显式的 `'pressure' | 'context-overflow'` 触发原因与取消信号。它只读取最新的持久化已路由请求;没有 header 就不执行工作,任何已路由的提供方/模型目标都使用单例估算器。`compactNow(agent, signal)` 会预留空闲轮次接纳,即使未达到压力也进行一次有效的平衡缩减;不存在这种范围时返回 `null`,且不写入任何内容。`compactRegion(start, end, agent, signal?)` 将 `agent.session` 作为唯一会话身份,并为显式调用方保留可选 signal。默认摘要器依次从显式配置、最新记录的已路由目标和 agent 选项解析目标,并在任何 `llm/stream` 路由后记录提供方/模型对。它回放已路由请求的前缀,并将压缩指令追加为尾部 user 消息,从而复用提供方的热 KV cache;见[摘要前缀缓存 Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md)。该调用将提供方无关的 `GenerateOptions.purpose` 设为 `compaction`;适配器可以将此用途映射为对模型隐藏的传输元数据,DeepSeek 适配器会发送 `x-deepseek-harness-compact: 1`。 ### 成功的持久步骤工作完成后运行自动压力检查 @@ -91,42 +91,44 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab ### 通过日志记录的锁实现阻塞,加上崩溃/可恢复失败的分类 -`compact/start … compact/end` 括号的存在理由,按当前实际承担的职责排序: +`compact/start … compact/end` 标记对承担两项职责: 1. **可检测的崩溃孤儿 + 来源追溯**(首要)。摘要生成是一次慢速模型调用,持久化在 `compact/start` *之后*。摘要生成中途崩溃会留下一个没有匹配 `compact/end` 的 `compact/start`——一个可检测的孤儿。最后释放锁(而非最先)将崩溃窗口从*静默损坏*转变为可检测的孤儿。 -2. **防止并发压缩。** 如果当前轮次持有未匹配的 `compact/start`,`compactRegion` 拒绝启动。(循环在任一 awaited 自动 seam 上都是单线程的,因此这也是重入绊线——抛出「already in progress」表示真正的 bug。) +2. **防止并发压缩。** 每个自动、手动和显式范围入口点都会拒绝活动的未匹配 `compact/start`。该标记对就是唯一的锁;没有进程本地 mutex 重复承担同一职责。 -该锁只排除另一项压缩,不排除无关的仅日志事实。基础后端会在 `compact/start` 之后对 token meter 的 surface 节点取快照,并在异步摘要后再次比较;任何 surface 变更都会使替换前的检查失败,而标题或其他仅日志追加不会使已选范围失效。 +该锁只排除另一项压缩,不排除无关事实。其标记是时间点,而不是排他的容器,因此空闲注入的上下文可以出现在独立手动 start 与 end 之间。自动工作要求其轮次内的整个 surface 保持稳定。手动工作只重新验证所选位置 span,使其外部的仅追加上下文在替换后保持可见。 -两种失败路径,均有文档记录: +生命周期边界使崩溃状态含义明确: -- **崩溃**(循环在摘要生成中途死亡):悬空的 `compact/start`,无关闭事件。由于 `compact/*` 是**仅日志**事件,孤儿是**惰性的**,不会落地摘要替换。派生 surface 保持为 `compact/start` 时已经持久化的 surface:如果修剪未产生替换,就是完整历史;如果已经修剪,就是已修剪历史。通用轮次修复(`interruptedTurnClosers`)用合成的 `turn/end` 关闭轮次;孤儿位于该 `turn/end` *之前*,因此轮次范围内的进行中检查永远看不到它,崩溃不会卡住未来的压缩。 -- **可恢复**(摘要生成抛出异常但循环存活):后端追加设置了 **`error`** 字段的 `compact/end`,但不落地摘要替换。步骤后压力处理发出警告,并从最新的持久 surface 继续:如果尝试前没有替换,就是完整历史;如果修剪已经落地,就是已修剪 surface。溢出恢复只会在没有任何替换前委托;先前修剪带来的 generation 进展允许从该持久 surface 重试,除非取消或资源释放胜出。 +- **当前生命周期:** 最新 `session/end-seed` 之后悬空的 `compact/start` 是活动的持久锁,并报告 busy。 +- **后续生命周期:** 构造函数写入的较新 `session/end-seed` 证明更早的未匹配 start 已陈旧,因此恢复、fork 和接手不会被已死的写入方持续卡住。 +- **可恢复失败:** start 落地后,后端会恰好尝试一次 `compact/end { error }`。摘要或稳定性失败会保持会话 surface 不变,同时在日志中保留失败尝试。如果追加闭合事件失败,未匹配 start 会继续有意阻塞。 `compact/end` 保留其 `error?` 字段(与 `tool/result` 的自包含错误一致——一个事件即可区分成功与失败,无需关联兄弟事件)。没有单独的 `compact/error` 事件。 -**核心会话修复保持对压缩无感知——这是有意为之。** `interruptedTurnClosers` 从不被教导 `compact/*`。如果教导它,每个未来的 `xxx/start … xxx/end` 插件对都必须修补核心模块——这恰好是能力 seam 架构存在的意义所要避免的耦合。由于仅日志的孤儿是惰性的,不需要特殊修复:通用轮次修复加上未落地 surface 变更的惰性就足够了。 +**核心会话修复保持对压缩无感知——这是有意为之。** `interruptedTurnClosers` 从不被教导 `compact/*`。通用 `session/end-seed` 生命周期边界提供压缩所有方所需的证据;压缩不变量与后端负责解释它,无需向核心添加插件专属修复。 ## 曾考虑的替代方案 -- **完整算法作为接口的具体方法**——否决,因为它将契约重新耦合到一种保留策略。两个核心方法都是抽象的;可复用测量属于单独的 LLM 系列服务,`summarize()` 是 basic 唯一的钩子。 +- **完整算法作为接口的具体方法**——否决,因为它将契约重新耦合到一种保留策略。三个操作都是抽象的;可复用测量属于单独的 LLM 系列服务,`summarize()` 是 basic 唯一的钩子。 - **在 `agent/request` 或临时 `agent/pre-step` 输入上执行压缩**——否决,因为两者都无法证明最终的持久请求,而且都会将通用生命周期耦合到压缩专属的信封数据。步骤后回放与规范溢出恢复同时覆盖成功和被拒绝的调用。 - **`compact` 布尔值或无类型的请求元数据 map**——否决,因为多个辅助调用种类会变成互斥标志,而开放 map 会丢弃由编译器检查的词汇。一个类型化的 `purpose` 判别字段可以扩展其他调用种类,而无需再为 `GenerateOptions` 添加字段。 - **单独的 `compact/error` 事件**——否决:`compact/end` 保留 `error?` 字段,与 `tool/result` 的自包含错误一致——一个事件即可区分成功与失败,无需关联兄弟事件。 -- **教导核心轮次修复识别 `compact/*`**——否决:仅日志的孤儿是惰性的,为每个未来的 `xxx/start … xxx/end` 插件对修补核心模块恰好是能力 seam 架构存在的意义所要避免的耦合。 +- **教导核心轮次修复识别 `compact/*`**——否决:通用 end-seed 边界已经能够区分先前生命周期的历史;为每个未来的 `xxx/start … xxx/end` 插件对修补核心模块,恰好是能力 seam 架构存在的意义所要避免的耦合。 ## 后果 -- **包**:`packages/compact/compact` 提供接口,`compact-basic` 提供后端,`compact-tool-result-prune` 提供可选的确定性重写。`packages/llm/token-meter` 独立拥有回放感知的测量。消费方层推迟。 +- **包**:`packages/compact/compact` 提供接口,`compact-basic` 提供后端,`compact-tool-result-prune` 提供可选的确定性重写,`command-compact` 提供面向用户的 `/compact`。`packages/llm/token-meter` 独立拥有回放感知的测量。 - **自动 seam**:`agent/post-step`(`@mode serial`)处理成功调用的压力,`agent/request-error`(`@mode waterfall`)处理失败步骤关闭后的最终请求失败。通用 `agent/pre-step` 保持为四参数检查点,不携带压缩专属的提示词/前缀 payload。 - **`SessionEventMap`** 通过可合并扩展的声明合并获得 `compact/start` / `compact/summary` / `compact/end`;`SurfaceEventType` **未被**触及。这些是会话事件,不是 cordis `Events`,因此事件分类门禁无需新增条目。 - **`dsh-compact`** 拥有 `COMPACT_CHECKPOINT_SOURCE`、`isCompactCheckpointSource(source)`、`toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`。该标记用于跨后端实现识别替换摘要。带缓存的 surface 边缘检查会防止 `compactRegion` 和 `compactIfNeeded` 拆分工具调用/结果对,按 seq 校验当前成员关系,从每个切割点的一条平衡序列回答两侧边缘,并拒绝陈旧或缺失的 seq 与孤立结果。 -- **`dsh-session`** 通过唯一的 surface 管理器校验位置替换、完整溯源信息和仅内容的单节点 `tool/result` 重写。其不变式配套插件将新追加的工具结果视为执行,要求存在已打开的步骤与待处理调用;已校验的替换仍是位于轮次内的重写。 -- **接线**:`examples/tui-agent/cordis.yml` 依次加载零配置的 `dsh-token-meter`、`dsh-compact-tool-result-prune` 和 `dsh-compact-basic`;服务级默认值使组合无需重复数值策略即可使用。 +- **`dsh-session`** 通过唯一的 surface 管理器校验位置替换、完整溯源信息和仅内容的单节点 `tool/result` 重写。其不变式配套插件将新追加的工具结果视为执行,要求存在已打开的步骤与待处理调用,而压缩配套组件拥有数字轮次归属与独立 `null` 归属标记对之间的关系。 +- **接线**:`examples/tui-agent/cordis.yml` 依次加载零配置的 `dsh-token-meter`、`dsh-compact-tool-result-prune`、`dsh-compact-basic`,然后加载 `dsh-command-compact`;服务级默认值使组合无需重复数值策略即可使用。 ## 测试 - **单元测试:** 使用真实 Loader 和 invariant 插件覆盖完整单元保留、修剪配置与回放、富块顺序、元数据保留、收敛、`compact/end` 的两种结果、开放尾部拒绝、仅修剪与带摘要的溢出恢复、generation 证明、上限和原始错误保留。 - **循环测试:** 测试固定步骤后处理发生在持久工具结果之后、`step/end` 之前,使用实际 `agent/request` 路由,关闭失败步骤,分配新的重试编号,并覆盖完整的抛出/带内溢出 → 压缩 → 重建重试组合。 +- **手动测试:** 无需模型密钥即可固定接纳、标记顺序、注入保留、活动/陈旧未匹配标记分类、取消、闭合/flush 失败、命令映射以及排队 TUI 流程。 - **带密钥 e2e:** 真实模型和 bash 会话在降低的限制下触发压缩,记录完整的 `compact/start…end` 对,缩小 surface,并完成任务。 - **快照缺口:** 失控轮次压缩尚无法回放,因为摘要调用未记录 `assistant/chunk` 事件或 `sessionId`;交错摘要调用的回放仍是后续工作。 diff --git a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml new file mode 100644 index 0000000000..e4b5e883b1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md +2026-07-30-queued-manual-compaction.md: 65cd2041be5caa7b437fc649ba862ec18f0cff9a +2026-07-30-queued-manual-compaction.zh.md: 03d6c4e4cc4da8041238fc7174823349a03cf803 diff --git a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md new file mode 100644 index 0000000000..65cd2041be --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md @@ -0,0 +1,106 @@ +# Agent Note: Queued manual compaction with one durable lock + +Status: implemented + +English | [中文](2026-07-30-queued-manual-compaction.zh.md) + +## Problem + +Automatic compaction protects the context window, but an interactive user also needs a deterministic way to condense accumulated history before pressure policy fires. Sending `/compact` as prompt text would spend a model turn and let the conversation model reinterpret a direct control action. Implementing it inside one UI would duplicate command discovery, lifecycle logging, cancellation, and backend policy. + +The human command arrives between turns and must summarize asynchronously. A prompt accepted during that wait must keep its ordinary identity, FIFO position, and wakeup behavior, but it must not derive a request from history that compaction is about to replace. A status check is insufficient: a waking send schedules the driver's claim as a microtask, leaving a same-tick interval where status still reads idle even though the prompt already has right of way. + +Compaction also needs one mutual-exclusion fact shared by manual, pressure, overflow, and explicit-range entry points. A process-local flag alone cannot explain a crash-recovered log, while a summarize-first transaction leaves no durable evidence during the expensive interval. Conversely, treating marker pairs as exclusive containers would forbid valid idle injection even though injection is explicitly non-waking and immediate between turns. + +This note extends the [compaction capability seam](2026-06-18-compaction-capability-seam.md), the [session end-seed boundary](../architecture/2026-07-30-session-end-seed-log-boundary.md), and the [removal of synthetic log-only turns](../simplification/2026-07-28-remove-synthetic-log-only-turns.md). The supersession audit found partial overlap only: each remains active and owns its broader decision. + +## Decision + +### `/compact` is a command over a backend-independent seam + +`@deepseek-ai/dsh-command-compact` registers one argument-free human command through `ctx.commands`. It calls the third abstract `CompactService` operation, `compactNow(agent, signal)`, and maps the closed `ManualCompactionError` taxonomy (`busy | changed | summary | commit | persistence`) to direct UI results. `command/run` and `command/done` preserve the command lifecycle without entering model history or consuming a model-loop turn. + +The seam's `ManualCompactAgentContext` adds only `reserveTurnAdmission()` to the session and routing facts compaction already needs. Retention, balancing, summarization, marker ordering, replacement, and durability remain backend responsibilities. + +### Idle turn admission is synchronously reservable + +`Agent.reserveTurnAdmission(): (() => void) | undefined` claims the boundary before the next ordinary turn. It succeeds only when the driver is idle, no reservation exists, and no accepted waking item already owns the next turn, including a wake whose claim is still a pending microtask. + +The reservation does not create a second queue. Later sends keep their `InboxItemId`, placement, FIFO order, and wakeup facts. `acceptsNextStep` remains false, so waking next-step input becomes an ordinary queued follow-up rather than steering. Release is idempotent and re-arms the existing driver path. `inject()` is not withheld. + +`whenIdle()` treats a reservation as unfinished activity, including when it holds a waking item. Lifecycle teardown still drains the driver's own activity promise rather than awaiting an external operation, so disposal can cancel and unwind without depending on the reservation holder. + +### One parameterized transaction owns every bracket + +`dsh-compact-basic` has one region transaction parameterized by bracket owner (`number | null`), stability rule (whole surface or selected span), and an optional flush. It performs one ordering: + +1. validate the selected positional range and inspect the durable tail; +2. reject a live unmatched compaction marker; +3. append `compact/start` synchronously; +4. prepare and await summarization; +5. revalidate the required stability; +6. append `compact/summary` and the replacement `user/message`; +7. make exactly one `compact/end` attempt; +8. flush when the manual caller requested durability. + +Automatic and explicit-region work use the numeric owner recovered from the open turn and require whole-surface stability. Manual work reserves admission first, selects a useful range before the transaction, and writes nothing when selection returns `null`. Its bracket uses `turn: null`, requires only selected-span stability, and flushes every successfully closed attempt before releasing admission in `finally`. + +`compact/start` is therefore the only compaction lock. There is no `WeakSet`, wrapper mutex, locked/unlocked method split, or redundant activity check around the transaction. + +### Bracket-first deliberately differs from the surveyed implementations + +Codex models manual compaction as a `CompactTask` occupying its active-turn slot while automatic compaction runs inline. Pi uses the existence of a compaction abort controller as its mutex and appends compaction only after success. Claude Code shares one compaction routine between automatic and manual paths but constructs its boundary after summary streaming. + +DSH deliberately records `compact/start` before calling the summarizer. A slow or crashed attempt is observable, automatic and manual paths share the same durable lock, and a later writer cannot mistake an in-flight summary for an unlocked session. This is a conscious divergence from summarize-first behavior, not an accidental event-order difference. + +### Markers are time points, not an event container + +`compact/start` and `compact/end` mean lock acquisition and release. They do not claim exclusive ownership of every event between their seqs. An idle `inject()` may append a `user/message` while a manual summary is pending, so that unrelated event can sit inside the marker interval. + +Manual stability checks only the selected span: it must remain present, contiguous, ordered, equally priced, and balanced. Append-only context outside it does not stale the summary. Positional replacement places the checkpoint at the old span's surface position and leaves injected context after it in derived model history, even though the injection's log seq precedes the later summary and replacement events. + +Failed `changed` or `summary` attempts leave the conversation surface unchanged, but the log is not unchanged: it contains `compact/start` and `compact/end { error }`. User-facing text states that distinction. + +### End-seed distinguishes live and stale orphans + +Tail scanning finds the current turn, unmatched compaction start, and newest `session/end-seed` independently. An unmatched start after the newest end-seed is live and blocks every compaction entry point. An unmatched start before a later end-seed belongs to an earlier session lifecycle and is stale, so it does not wedge the resumed or forked session. + +The compaction invariant uses the same transition logic during seed replay: `session/end-seed` clears an open historical trace. The boundary need not publish live from the constructor for this case; replay is the load-bearing path. + +Once a transaction has appended its start, every later failure makes one closing attempt. A failed close leaves the unmatched start deliberately visible and blocking, and no flush is attempted. A closed manual attempt is flushed even when it reports an expected failure. Cancellation retains exact-reason precedence after required close and flush cleanup. + +### Reference implementation boundaries + +[PR #835](https://github.com/deepseek-harness/deepseek-harness/pull/835) was used as a reference implementation for the command, reservation, tests, and snapshot shape, but was not merged. Its process-local `WeakSet` lock and locked/unlocked method splits were considered and not adopted because the durable bracket is the single reachable lock. + +That reference also carried client-side replacement-anchor machinery to preserve transcript placement. The log-ordered transcript projection already consumes compaction from event order and does not consult mutable surface positions, so those anchors were considered and not adopted. + +## Alternatives considered + +**Check `agent.status` without reserving admission.** Rejected because an accepted waking send can still be waiting on its claim microtask while status reads idle. + +**Queue the command itself.** Rejected because `/compact` is direct control, not model input, and a prompt already accepted first must retain right of way rather than being reordered around a second command queue. + +**Summarize before appending `compact/start`.** Rejected because the expensive in-flight operation would be invisible and would not participate in the lock shared by automatic compaction. + +**Use both a durable marker and a process-local mutex.** Rejected because two authorities can disagree after replay and require wrapper branches for states the bracket already expresses. + +**Hold injection with waking prompts.** Rejected because idle injection is non-waking durable context by contract; delaying it would make plugin ordering depend on a UI command. + +**Require the marker interval to contain only compaction events.** Rejected because markers represent lock time points. Provenance names the selected and shadowed seqs exactly; exclusivity would add no correctness and would reject valid injection. + +**Treat every unmatched marker as permanently busy.** Rejected because a crash-recovered or forked session would remain wedged. `session/end-seed` is the explicit lifecycle evidence that distinguishes stale history from a live process-local attempt. + +## Verification + +Agent-loop tests cover same-tick right of way, preserved IDs and FIFO lifecycle, waking and quiet queued work, idempotent release, `whenIdle()`, cancellation, and teardown. Compact tests cover standalone and numbered invariant ownership, end-seed replay, live versus stale orphans, re-entrant listeners, selected-span drift, commit and close failures, flush ordering, exact cancellation causes, raw output and usage preservation, and automatic/manual mutual exclusion. + +The command package pins registration, Loader composition, argument rejection, exact success/failure text, cancellation, and absence from model history. The `queued-manual-compact` terminal snapshot drives real keystrokes through the assembled TUI: `/help` discovers the command, a held summary admits a queued prompt and immediate injection, `turn: null` markers and the flush precede the queued prompt turn, command lifecycle stays log-only, and the derived order is checkpoint → injection → queued prompt. + +## Consequences + +Interactive users can compact useful history without spending a conversation-model turn. A prompt accepted before the command wins; one submitted during the command waits with its original queue identity. Manual compaction consumes session seqs but no turn number. + +The log exposes slow, failed, crashed, and successful attempts through the same bracket. A stale pre-boundary orphan no longer wedges a new lifecycle, while a current unmatched start remains a hard busy signal. Marker intervals may contain unrelated events, so consumers use provenance and relative ordering rather than assuming a contiguous compaction-only slice. + +The shared transaction keeps one ordering and one lock across every entry point. Failure reporting is precise about whether only the log changed, the surface may have partially changed, or the in-memory commit could not be persisted. diff --git a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md new file mode 100644 index 0000000000..03d6c4e4cc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md @@ -0,0 +1,106 @@ +# Agent Note: 使用单一持久锁实现排队手动压缩 + +Status: implemented + +[English](2026-07-30-queued-manual-compaction.md) | 中文 + +## 问题 + +自动压缩(compaction)可以保护上下文窗口,但交互用户还需要一种确定性方法,在压力策略触发前压缩累积的历史。把 `/compact` 作为提示词文本发送会消耗一个模型轮次,还会让会话模型重新解释一项直接控制操作。在某个 UI 内实现该功能,则会重复命令发现、生命周期日志记录、取消与后端策略。 + +面向用户的命令在轮次之间到达,并且必须异步生成摘要。在等待期间获接纳的提示词必须保留普通身份、FIFO 位置与唤醒行为,但不得从即将被压缩替换的历史派生请求。仅检查状态并不足够:唤醒发送会把驱动器的认领安排为 microtask,因此在同一 tick 内存在一段间隔,此时状态仍显示 idle,但提示词已经拥有优先权。 + +手动、压力、溢出和显式范围入口点还需要共享同一项互斥事实。仅使用进程本地标志无法解释一份崩溃恢复后的日志,而先摘要再记录的事务在开销较大的等待期间不会留下持久证据。反过来,把标记对视为排他容器又会禁止有效的空闲注入,尽管注入按定义不会唤醒,并且会在轮次之间立即执行。 + +本 Agent Note 扩展[压缩能力 seam](2026-06-18-compaction-capability-seam.md)、[会话 end-seed 边界](../architecture/2026-07-30-session-end-seed-log-boundary.md)和[移除纯日志事件的合成轮次](../simplification/2026-07-28-remove-synthetic-log-only-turns.md)。取代关系审计只发现部分重叠:三者均保持活动状态,并拥有各自更广泛的决策。 + +## 决策 + +### `/compact` 是基于后端无关 seam 的命令 + +`@deepseek-ai/dsh-command-compact` 通过 `ctx.commands` 注册一个无参数、面向用户的命令。它调用第三个抽象 `CompactService` 操作 `compactNow(agent, signal)`,并把封闭的 `ManualCompactionError` 分类体系(`busy | changed | summary | commit | persistence`)映射为直接 UI 结果。`command/run` 和 `command/done` 保留命令生命周期,同时不进入模型历史,也不消耗模型循环轮次。 + +该 seam 的 `ManualCompactAgentContext` 只在压缩已需使用的会话与路由事实之上增加 `reserveTurnAdmission()`。保留、平衡、摘要、标记排序、替换与持久性仍由后端负责。 + +### 可以同步预留空闲轮次接纳 + +`Agent.reserveTurnAdmission(): (() => void) | undefined` 会在下一个普通轮次之前认领边界。仅当驱动器空闲、没有既存预留,而且尚无已获接纳的唤醒项拥有下一轮次时,它才会成功;仍在等待 microtask 认领的唤醒项也包括在内。 + +该预留不会创建第二个队列。之后发送的项保留其 `InboxItemId`、位置、FIFO 顺序与唤醒信息。`acceptsNextStep` 保持 false,因此唤醒的 next-step 输入会成为普通的排队 follow-up,而不是 steering(中途引导)。释放操作可幂等调用,并重新启用既有驱动器路径。`inject()` 不受阻塞。 + +`whenIdle()` 会把预留视为尚未完成的活动,包括预留持有唤醒项的情况。生命周期 teardown 仍会排空驱动器自身的 activity promise,而不会等待外部操作,因此 dispose(资源释放)可以执行取消并完成退出清理,无需依赖预留持有方。 + +### 一个参数化事务拥有每一对标记 + +`dsh-compact-basic` 只有一个区域事务,由标记归属值(`number | null`)、稳定性规则(整个 surface 或所选 span)与可选 flush 参数化。它按同一顺序执行: + +1. 验证所选位置范围,并检查持久日志尾部; +2. 拒绝活动的未匹配压缩标记; +3. 同步追加 `compact/start`; +4. 准备并等待摘要; +5. 重新验证所需稳定性; +6. 追加 `compact/summary` 与替换用的 `user/message`; +7. 恰好尝试一次 `compact/end`; +8. 当手动调用方要求持久性时执行 flush。 + +自动和显式区域工作使用从开放轮次恢复的数字归属值,并要求整个 surface 保持稳定。手动工作会先预留接纳,在进入事务前选择有效范围;选择结果为 `null` 时不写入任何内容。其标记对使用 `turn: null`,只要求所选 span 保持稳定,并在 `finally` 中释放接纳预留前 flush 每次成功闭合的尝试。 + +因此,`compact/start` 是唯一的压缩锁。不存在 `WeakSet`、包装层 mutex、locked/unlocked 方法拆分,也不存在事务外部重复的活动状态检查。 + +### 先记录标记有意不同于调研过的实现 + +Codex 将手动压缩建模为占用其活动轮次槽位的 `CompactTask`,自动压缩则以内联方式运行。Pi 使用压缩 abort controller 是否存在作为 mutex,并仅在成功后追加压缩。Claude Code 的自动和手动路径共享同一个压缩例程,但会在摘要流结束后才构造边界。 + +DSH 有意在调用摘要器前记录 `compact/start`。缓慢或崩溃的尝试因此可观察,自动与手动路径共享同一个持久锁,之后的写入方也不会把正在生成的摘要误判为未锁定会话。这是对先摘要行为的主动偏离,而不是偶然的事件顺序差异。 + +### 标记是时间点,而不是事件容器 + +`compact/start` 和 `compact/end` 表示获取与释放锁。它们不声称排他拥有二者 seq 之间的每个事件。手动摘要等待期间,空闲的 `inject()` 可以追加 `user/message`,因此该不相关事件可能位于标记区间内。 + +手动稳定性只检查所选 span:它必须仍然存在、连续、有序、计价相同且保持平衡。其外部的仅追加上下文不会使摘要陈旧。位置替换会把检查点放在旧 span 的 surface 位置,并使注入上下文在派生模型历史中位于其后,即使注入的日志 seq 早于后续摘要和替换事件。 + +失败的 `changed` 或 `summary` 尝试会保持会话 surface 不变,但日志并非没有变化:其中会包含 `compact/start` 和 `compact/end { error }`。面向用户的文本会明确说明这一区别。 + +### End-seed 区分活动与陈旧的未匹配标记 + +尾部扫描会分别查找当前轮次、未匹配的 compaction start 与最新 `session/end-seed`。位于最新 end-seed 之后的未匹配 start 是活动锁,会阻塞每个压缩入口点。位于较新 end-seed 之前的未匹配 start 属于更早的会话生命周期,已经陈旧,因此不会卡住恢复或 fork 后的会话。 + +压缩不变量在 seed 回放期间使用同一项转换逻辑:`session/end-seed` 会清除开放的历史追踪状态。此场景不要求构造函数实时发布该边界;回放才是承重路径。 + +事务追加 start 后,每次后续失败都会进行一次闭合尝试。闭合失败会有意留下可见且具有阻塞作用的未匹配 start,并且不尝试 flush。已闭合的手动尝试即使报告预期失败也会 flush。完成必需的闭合与 flush 清理后,取消仍保留原始原因优先级。 + +### 参考实现边界 + +[PR #835](https://github.com/deepseek-harness/deepseek-harness/pull/835) 用作命令、预留、测试与快照结构的参考实现,但未被合并。它的进程本地 `WeakSet` 锁与 locked/unlocked 方法拆分经过评估后未被采用,因为持久标记对是唯一可达的锁。 + +该参考实现还包含客户端侧替换锚点机制,用于保留 transcript(文本记录)位置。按日志顺序排列的 transcript 投影已经从事件顺序消费压缩,并且不会查询可变 surface 位置,因此这些锚点经过评估后未被采用。 + +## 曾考虑的替代方案 + +**仅检查 `agent.status`,不预留接纳。** 不予采用,因为已获接纳的唤醒发送可能仍在等待其认领 microtask,而状态仍显示 idle。 + +**把命令本身加入队列。** 不予采用,因为 `/compact` 是直接控制而非模型输入;先获接纳的提示词必须保留优先权,不能围绕第二个命令队列重新排序。 + +**在追加 `compact/start` 前生成摘要。** 不予采用,因为开销较大的进行中操作将不可见,也不会参与自动压缩共享的锁。 + +**同时使用持久标记与进程本地 mutex。** 不予采用,因为两项权威在回放后可能产生分歧,还会要求用包装层分支处理标记对已经表达的状态。 + +**与唤醒提示词一起阻塞注入。** 不予采用,因为按契约,空闲注入是不会唤醒的持久上下文;延迟注入会使插件排序依赖某个 UI 命令。 + +**要求标记区间只包含压缩事件。** 不予采用,因为标记表示锁的时间点。溯源信息会精确指明所选 seq 与被遮蔽 seq;排他性不会增加正确性,只会拒绝有效注入。 + +**把每个未匹配标记都永久视为 busy。** 不予采用,因为崩溃恢复或 fork 后的会话会永久卡住。`session/end-seed` 是区分陈旧历史与当前进程活动尝试的显式生命周期证据。 + +## 验证 + +Agent loop 测试覆盖同一 tick 内的优先权、保留 ID 与 FIFO 生命周期、会唤醒和静默的排队工作、幂等释放、`whenIdle()`、取消与 teardown。压缩测试覆盖独立与数字形式的不变量 owner、end-seed 回放、活动与陈旧未匹配标记、listener 重入、所选 span 漂移、commit 与闭合失败、flush 顺序、原始取消原因、raw output 与 usage 保留,以及自动/手动互斥。 + +命令包固定注册行为、Loader 组合、参数拒绝、精确的成功/失败文本、取消和不进入模型历史的保证。`queued-manual-compact` 终端快照通过已组装 TUI 驱动真实按键:`/help` 可发现该命令;被暂停的摘要会接纳一个排队提示词和即时注入;`turn: null` 标记与 flush 先于排队提示词轮次;命令生命周期保持纯日志;派生顺序固定为检查点 → 注入 → 排队提示词。 + +## 后果 + +交互用户无需消耗会话模型轮次即可压缩有效历史。在命令前获接纳的提示词胜出;命令期间提交的提示词会以原有队列身份等待。手动压缩会消耗会话 seq,但不消耗轮次编号。 + +日志通过同一对标记暴露缓慢、失败、崩溃与成功的尝试。边界前的陈旧未匹配标记不会再卡住新的生命周期,而当前未匹配 start 仍是严格的 busy 信号。标记区间可以包含不相关事件,因此消费方使用溯源信息与相对顺序,而不假定存在连续且仅含压缩事件的切片。 + +共享事务让每个入口点保持同一种顺序并使用同一把锁。失败报告会精确区分只有日志发生变化、surface 可能部分改变,以及内存 commit 无法持久化这三种情况。 diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml index 9031dc8eb0..75e8ca8d37 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md -2026-07-28-remove-synthetic-log-only-turns.md: af4da00f4fe1d7aebff845cd55053bb5b807c979 -2026-07-28-remove-synthetic-log-only-turns.zh.md: 9d72781d6b7cf396a830790d108f4ff25adc816a +2026-07-28-remove-synthetic-log-only-turns.md: fc76667924ec839301aad993efd996112c9a6b09 +2026-07-28-remove-synthetic-log-only-turns.zh.md: 7520c33e2219c5fe7ab7d8da6312247e42cc69b0 diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md index af4da00f4f..fc76667924 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md @@ -18,11 +18,11 @@ The generic seam also duplicated domain policy. Its marker map said which plugin Core session invariants continue to enforce core-owned execution relations: turn and step numbering, enclosure of steering, assistant, tool, todo, and request-header events, and same-step tool call/result pairing. Core permits merge-extensible events between turns because only their declaring plugin knows whether they are execution-scoped or standalone. Plugin invariant companions remain responsible for their own event relations. -The title service appends `session/title` directly after its existing service, revision, cancellation, and live-session checks. The bundled model helper appends its literal `session/title-llm-request` record before dispatch. Persistence observes both through the eager `session/event` path and drains them at ordinary checkpoints and lifecycle teardown; neither append forces a flush merely because it is between turns. A fallback, auxiliary request record, or accepted provider title may therefore appear after `turn/end` and before the next `turn/start`. +The title service appends `session/title` directly after its existing service, revision, cancellation, and live-session checks. The bundled model helper appends its literal `session/title-llm-request` record before dispatch. Persistence observes both through the eager `session/event` path and drains them at ordinary checkpoints and lifecycle teardown; neither append forces a flush merely because it is between turns. A fallback, auxiliary request record, or accepted provider title may therefore appear after `turn/end` and before the next `turn/start`. Manual compaction uses the same between-turn capability for a `compact/* { turn: null }` bracket, but explicitly flushes the closed attempt because `/compact` promises durability before releasing queued prompt admission. A session fork may end at any stable event position outside an open turn, not only at `turn/end`. This preserves standalone title and context records in a default fork while still rejecting a prefix cut through active execution. -The historical [universal turn-enclosure decision](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md) remains useful only as the reason the synthetic mechanism was introduced. The [context-injection decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) established the current meaning: one turn represents one model-loop execution. +The historical [universal turn-enclosure decision](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md) remains useful only as the reason the synthetic mechanism was introduced. The [context-injection decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) established the current meaning: one turn represents one model-loop execution. The [queued manual compaction decision](../feature/2026-07-30-queued-manual-compaction.md) applies that rule to a durable multi-event bracket and owns its marker and admission semantics. ## Alternatives considered @@ -36,8 +36,8 @@ The historical [universal turn-enclosure decision](../../archived/architecture/2 ## Verification -Core invariant tests accept an unknown plugin event between turns while continuing to reject built-in execution events there. Hook, compaction, plan-mode, Code Mode dispatch, and approval invariant companions replay existing logs and reject the same execution-scoped events before commit when no turn is open. Session-title service tests pin one direct fallback event under concurrent refresh, detached-session rejection, and newest-revision acceptance. JSONL and SQLite round trips preserve a title appended after `turn/end` through the persistence lifecycle drain, and fork tests retain a standalone log-only tail while rejecting boundaries inside an open turn. A keyless assembled ACP snapshot delays the model-backed title until after `turn/end` and pins one standalone provider title with no synthetic turn. Generated API and type-equivalence catalogs contain no removed symbol. +Core invariant tests accept an unknown plugin event between turns while continuing to reject built-in execution events there. Hook, plan-mode, Code Mode dispatch, and approval invariant companions reject their execution-scoped events when no turn is open; the compaction companion separately accepts a balanced `turn: null` manual bracket between turns and requires numeric owners to match an open turn. Session-title service tests pin one direct fallback event under concurrent refresh, detached-session rejection, and newest-revision acceptance. JSONL and SQLite round trips preserve a title appended after `turn/end` through the persistence lifecycle drain, and fork tests retain a standalone log-only tail while rejecting boundaries inside an open turn. A keyless assembled ACP snapshot delays the model-backed title until after `turn/end` and pins one standalone provider title with no synthetic turn. Generated API and type-equivalence catalogs contain no removed symbol. ## Consequences -Turn counts and outcomes again describe model-loop executions only. Standalone events consume session seqs, start eager persistence like every other append, and require owners to request an explicit durability barrier only when their operation promises one. Generic plugin mistakes no longer fail under a core default enclosure rule, so each plugin that needs an execution relation must state and test that relation itself. The title capability keeps revision ordering and lifecycle persistence with less core state, no duplicate type seam, and no turn-number collision. +Turn counts and outcomes again describe model-loop executions only. Standalone events and manual compaction brackets consume session seqs without consuming a turn number, start eager persistence like every other append, and require owners to request an explicit durability barrier only when their operation promises one. Generic plugin mistakes no longer fail under a core default enclosure rule, so each plugin that needs an execution relation must state and test that relation itself. The title capability keeps revision ordering and lifecycle persistence with less core state, and manual compaction gains durable control with no synthetic-turn or turn-number collision. diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md index 9d72781d6b..7520c33e22 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md @@ -18,11 +18,11 @@ Status: implemented 核心会话不变量继续强制核心所属的执行关系:轮次与步骤编号、steering、助手、工具、待办和请求头事件的封闭,以及同一步骤内的工具调用/结果配对。核心允许可合并扩展事件位于轮次之间,因为只有声明它们的插件知道这些事件受执行作用域约束,还是可以独立存在。插件的不变量配套组件仍负责其自身的事件关系。 -标题服务会在完成既有的服务状态、修订、取消和实时会话检查后,直接追加 `session/title`。随附模型辅助函数会在发起调用前追加其字面量 `session/title-llm-request` 记录。持久化通过尽快处理的 `session/event` 路径观察两者,并在常规检查点与生命周期 teardown 时排空;二者都不会仅因为位于轮次之间就强制 flush。因此,回退标题、辅助请求记录或已接受的提供方标题可以出现在 `turn/end` 之后、下一个 `turn/start` 之前。 +标题服务会在完成既有的服务状态、修订、取消和实时会话检查后,直接追加 `session/title`。随附模型辅助函数会在发起调用前追加其字面量 `session/title-llm-request` 记录。持久化通过尽快处理的 `session/event` 路径观察两者,并在常规检查点与生命周期 teardown 时排空;二者都不会仅因为位于轮次之间就强制 flush。因此,回退标题、辅助请求记录或已接受的提供方标题可以出现在 `turn/end` 之后、下一个 `turn/start` 之前。手动压缩(compaction)利用同一项轮次间能力记录 `compact/* { turn: null }` 标记对,但会显式 flush 已闭合的尝试,因为 `/compact` 承诺在释放排队提示词接纳预留前完成持久化。 会话 fork 可以结束于开放轮次之外的任意稳定事件位置,而不限于 `turn/end`。这样,默认 fork 会保留独立标题和上下文记录,同时仍拒绝在活跃执行过程中截断前缀。 -历史上的[通用轮次封闭决策](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md)如今只适合用于解释为何曾引入合成机制。[上下文注入决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)确立了当前语义:一个轮次表示一次模型循环执行。 +历史上的[通用轮次封闭决策](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md)如今只适合用于解释为何曾引入合成机制。[上下文注入决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)确立了当前语义:一个轮次表示一次模型循环执行。[排队手动压缩决策](../feature/2026-07-30-queued-manual-compaction.md)将该规则应用于持久多事件标记对,并拥有其标记与接纳语义。 ## 曾考虑的替代方案 @@ -36,8 +36,8 @@ Status: implemented ## 验证 -核心不变量测试会接受轮次之间的未知插件事件,同时继续拒绝位于该处的内置执行事件。钩子、压缩(compaction)、plan-mode、Code Mode 分发和审批的不变量配套组件会回放既有日志,并在没有开放轮次时,于提交前拒绝相同的执行作用域事件。会话标题服务测试会在并发刷新、会话脱离拒绝和最新修订接受场景下,固定一个直接追加的回退事件。JSONL 和 SQLite 往返测试会通过持久化生命周期排空保留追加在 `turn/end` 之后的标题;fork 测试会保留独立纯日志尾部,同时拒绝位于开放轮次内的边界。一个无密钥、经完整组装的 ACP(Agent Client Protocol)快照会将模型生成的标题延迟到 `turn/end` 之后,并固定一个不含合成轮次的独立提供方标题。生成的 API 和类型等价性目录不含任何已移除符号。 +核心不变量测试会接受轮次之间的未知插件事件,同时继续拒绝位于该处的内置执行事件。钩子、plan-mode、Code Mode 分发和审批的不变量配套组件会在没有开放轮次时拒绝其执行作用域事件;压缩配套组件则另外接受轮次之间平衡的 `turn: null` 手动标记对,并要求数字 owner 匹配一个开放轮次。会话标题服务测试会在并发刷新、会话脱离拒绝和最新修订接受场景下,固定一个直接追加的回退事件。JSONL 和 SQLite 往返测试会通过持久化生命周期排空保留追加在 `turn/end` 之后的标题;fork 测试会保留独立纯日志尾部,同时拒绝位于开放轮次内的边界。一个无密钥、经完整组装的 ACP(Agent Client Protocol)快照会将模型生成的标题延迟到 `turn/end` 之后,并固定一个不含合成轮次的独立提供方标题。生成的 API 和类型等价性目录不含任何已移除符号。 ## 后果 -轮次计数和结果重新只描述模型循环执行。独立事件会占用会话 seq,像其他追加一样启动尽快持久化,并且仅当操作承诺持久性时,才要求事件所有方请求显式持久性屏障。通用插件错误不再因核心默认的封闭规则而失败,因此每个需要执行关系的插件都必须自行声明并测试该关系。标题功能保留修订排序和生命周期持久化,同时减少了核心状态,不再重复类型 seam,并消除了轮次编号冲突。 +轮次计数和结果重新只描述模型循环执行。独立事件和手动压缩标记对会占用会话 seq,但不占用轮次编号;它们像其他追加一样启动尽快持久化,并且仅当操作承诺持久性时,才要求事件所有方请求显式持久性屏障。通用插件错误不再因核心默认的封闭规则而失败,因此每个需要执行关系的插件都必须自行声明并测试该关系。标题功能保留修订排序和生命周期持久化,同时减少了核心状态;手动压缩则获得持久控制,不产生合成轮次或轮次编号冲突。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index ed44f75c7f..12939011e2 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -265,6 +265,10 @@ - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' +# Human `/compact` for the same registry the web '/' menu projects. +- id: command-compact + name: '@deepseek-ai/dsh-command-compact' + - id: subagent name: '@deepseek-ai/dsh-subagent' diff --git a/apps/cli/package.json b/apps/cli/package.json index 0914aaf5aa..75daa2cd54 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -45,6 +45,7 @@ "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", + "@deepseek-ai/dsh-command-compact": "workspace:^", "@deepseek-ai/dsh-command-goal": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index dfff50708f..0c0a4b1c26 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 1fd9bd128d1bcc0dd91d46131981ea4fc331bd74 -architecture.zh.md: 8521f09c6e415f9f8d1c0a44f7534b59c876decc +architecture.md: 623f99e1913535983f46548a5395af1317b09c9e +architecture.zh.md: 0c5cee6029730369021f7dc21d3e5370d42ed36b diff --git a/docs/architecture.md b/docs/architecture.md index 1fd9bd128d..623f99e191 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -127,11 +127,11 @@ Adapter failures close their step before `agent/request-error` receives the exac Other failures use `agent/error`. Cancellation and disposal beat recovery. Before request-header commit, the turn signal cancels asynchronous model-capability preparation; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. Effective `cancel(cause)` emits its cause before queue clearing and abort; observers cannot veto; idle calls emit nothing. Durability records user or parent cancellation as `aborted`, teardown as `disposed`; teardown awaits quiescence. The cause affects reporting, not late result-context handling ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). -Turn and step events are turn-enclosed; idle injected `user/message` events may sit between turns. Reload closes an interrupted tail with a synthetic turn end. After close, only `agent/error` reports failures. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). +Turn and step events are turn-enclosed. Idle injected `user/message` events and standalone manual `compact/* { turn: null }` brackets may sit between turns; neither consumes a turn number. Compaction markers are lock time points rather than an exclusive container, so unrelated idle injection may appear between a manual start and end. Reload closes an interrupted turn tail with a synthetic turn end; `session/end-seed` also separates stale compaction orphans from locks created in the current process lifecycle. After close, only `agent/error` reports turn failures. 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 full `send()` options or `followup()`, `steer()`, and `inject()` presets; `cancel()` and `whenIdle()` control lifecycle. One awaited disposer coordinates teardown ownership. +`ctx.agents` owns live agents and returns `AgentHandle { agent, dispose() }`. Plugins use full `send()` options or `followup()`, `steer()`, and `inject()` presets; [`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) synchronously holds the idle boundary for standalone durable work without changing queued prompt identity. `cancel()` and `whenIdle()` control lifecycle. One awaited disposer coordinates teardown ownership. ### Agent Scope @@ -147,7 +147,7 @@ The session log is authoritative. `deriveMessages()` projects model history; raw Durability is a plugin concern. Backends eagerly drain synchronous `session/event` notifications. `session/flush` barriers precede each request and top-level tool dispatch, then follow `turn/end` before another queued turn or idle observation. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, while SQLite shares the contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). -Log-only events may sit between turns. Owners append through `Session`, flushing only for durability. `session/title` relies on eager persistence and lifecycle drains. Latest title wins with provenance; fallback and provider work never delays responses. Such records are fork boundaries, so forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). +Log-only events may sit between turns. Owners append through `Session`, flushing only for durability. `session/title` relies on eager persistence and lifecycle drains; a manual compaction explicitly flushes its closed standalone bracket before releasing turn admission. Latest title wins with provenance; fallback and provider work never delays responses. Such records are fork boundaries, so forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). ### Model Content diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 8521f09c6e..0c5cee6029 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -127,11 +127,11 @@ idle inject: 其他故障使用 `agent/error`。取消和资源释放优先于恢复。在提交请求头之前,轮次信号会取消异步模型能力准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 在清空队列和中止前发出原因;观察方不能否决;空闲调用不发事件。持久化层将用户或父级取消记录为 `aborted`,拆卸记录为 `disposed`;拆卸会等待完全停稳。原因只影响报告方式,不影响延迟完成的结果上下文处理([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 -轮次和步骤事件均位于轮次边界内;空闲时注入的 `user/message` 可以位于两个轮次之间。重新加载会用合成的轮次结束事件闭合中断尾部。关闭后仅由 `agent/error` 报告故障。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 +轮次和步骤事件均位于轮次边界内。空闲时注入的 `user/message` 事件和独立的手动 `compact/* { turn: null }` 标记对可以位于两个轮次之间;两者都不占用轮次编号。压缩标记是锁的时间点,而不是排他的容器,因此不相关的空闲注入可以出现在手动 start 与 end 之间。重新加载会用合成的轮次结束事件闭合中断的轮次尾部;`session/end-seed` 还会区分陈旧的未匹配压缩标记与当前进程生命周期创建的锁。关闭后仅由 `agent/error` 报告轮次故障。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 ### Agent 句柄 -`ctx.agents` 拥有活跃 agent,并返回 `AgentHandle { agent, dispose() }`。插件使用全部 `send()` 选项,或 `followup()`、`steer()` 和 `inject()` 预设;`cancel()` 与 `whenIdle()` 控制生命周期。一个需等待完成的 disposer 协调拆卸归属。 +`ctx.agents` 拥有活跃 agent,并返回 `AgentHandle { agent, dispose() }`。插件使用全部 `send()` 选项,或 `followup()`、`steer()` 和 `inject()` 预设;[`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) 为独立持久工作同步保留空闲边界,同时不改变排队提示词身份。`cancel()` 与 `whenIdle()` 控制生命周期。一个需等待完成的 disposer 协调拆卸归属。 ### Agent 作用域 @@ -147,7 +147,7 @@ idle inject: 持久性由插件负责。后端会尽快排空同步的 `session/event` 通知。`session/flush` 屏障位于每次请求与顶层工具分发之前,并在 `turn/end` 之后、处理另一个已排队轮次或观察到空闲状态之前执行。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 -纯日志事件可以位于轮次之间。事件所有方通过 `Session` 追加,仅为持久性而刷写。`session/title` 依赖尽快持久化与生命周期排空。最新标题按后写覆盖并携带来源信息;回退与提供方工作绝不会延迟响应。这类记录可作为 fork 边界,因此 fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 +纯日志事件可以位于轮次之间。事件所有方通过 `Session` 追加,仅为持久性而刷写。`session/title` 依赖尽快持久化与生命周期排空;手动压缩会在释放轮次接纳预留前,显式 flush 已闭合的独立标记对。最新标题按后写覆盖并携带来源信息;回退与提供方工作绝不会延迟响应。这类记录可作为 fork 边界,因此 fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 ### 模型内容 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4c68290ec3..92a5c6c1b7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -349,7 +349,7 @@ Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:25`](../package ## `@deepseek-ai/dsh-compact-basic` -Requires: `llm` · `tokenMeter` +Requires: `llm` · `tokenMeter` · `sessions` ```ts config-catalog /** Basic compaction configuration with an optional exact-target policy table. */ @@ -2305,6 +2305,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) - `@deepseek-ai/dsh-client-ui-workspace` ([`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts)) +- `@deepseek-ai/dsh-command-compact` — requires `commands` · `compact` ([`packages/compact/command-compact/src/index.ts`](../packages/compact/command-compact/src/index.ts)) - `@deepseek-ai/dsh-command-goal` — requires `commands` · `goals` ([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts)) - `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index dafa342d5a..cdfcffa359 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/outbox work is cleared 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:319`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:250`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:433`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:447`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -117,7 +117,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:297`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -140,7 +140,7 @@ Pending inbox items were dropped without delivering them, so every enqueue occur Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:309`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:323`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -161,7 +161,7 @@ An item entered the queued or steering inbox. `placement` is the acceptance-time Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:292`](../../packages/core/agent/src/types.ts) ### `agent/inbox/update` — emit @@ -181,7 +181,7 @@ A still-pending queued item changed content. The item id, placement, and positio Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:287`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:301`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -204,7 +204,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message or Types: [Agent](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:346`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:360`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -228,7 +228,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:372`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:386`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -258,7 +258,7 @@ Handle a model-request failure after its failed step has closed but before the f Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:391`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:405`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -280,7 +280,7 @@ 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:332`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:346`](../../packages/core/agent/src/types.ts) ### `agent/settled` — emit @@ -305,7 +305,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:420`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:434`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -325,7 +325,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:268`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:282`](../../packages/core/agent/src/types.ts) ### `agent/step` — serial @@ -349,7 +349,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:359`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:373`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -375,7 +375,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:406`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:420`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 13f178b4cd..d127292e42 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -463,6 +463,26 @@ Abstract compaction service. Implementations own trigger policy, retention, and */ abstract compactIfNeeded( agent: CompactAgentContext, trigger: CompactionTrigger, signal: AbortSignal, ): Promise +/** + * Explicitly compact useful history even below automatic pressure thresholds. + * Implementations reserve idle turn admission synchronously before any + * asynchronous work, select a useful range without writing on a no-op, then + * append a standalone `compact/start` before summarization. That durable + * marker is the compaction lock until one `compact/end` attempt. Later waking + * prompts remain accepted in FIFO order and start only after the optional + * durability checkpoint and admission release. Context injected while the + * summary runs may sit between the marker pair; only the selected span must + * remain stable. + * + * @param agent - idle agent whose durable history should be compacted. + * @param signal - command-owned cancellation forwarded to summarization. + * @returns the compaction result, or `null` when no safe useful range exists. + * @throws {@link ManualCompactionError} for expected busy, changed-span, + * summarization/shrink, commit-stage, or persistence failures, and the exact + * abort reason when cancelled. Failed attempts remain visible in the log. + */ +abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): Promise + /** * Forcibly compact a range of surface nodes into a single summary node. * `start` and `end` name an inclusive span by surface position, not numeric seq @@ -486,7 +506,7 @@ abstract compactRegion( start: number, end: number, agent: CompactAgentContext, Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionTrigger](../core-data-structures/compaction.md) -Source: [`packages/compact/compact/src/index.ts:45`](../../packages/compact/compact/src/index.ts) +Source: [`packages/compact/compact/src/index.ts:76`](../../packages/compact/compact/src/index.ts) ## `ctx.directoryPicker` — `DirectoryPicker` (abstract seam) diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml index 89783b5d16..0d6f022cef 100644 --- a/docs/core-data-structures/compaction.i18n.yaml +++ b/docs/core-data-structures/compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/compaction.md -compaction.md: 911b71d00fa4b42e9cdfa67f67d4e9b29e354a4a -compaction.zh.md: 643a116ff2edbbb53d300b4f5ff0ad36d401130b +compaction.md: bf509d5fa1686b87a364f3de905352f1c1020a58 +compaction.zh.md: 20af51b5ac31a0f3a28e5223fb4732550621ab80 diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 911b71d00f..bf509d5fa1 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -2,7 +2,7 @@ English | [中文](compaction.zh.md) -The compaction seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) split like bash: interface ([dsh-compact](../../packages/compact/compact), `ctx.compact`), implementation (a backend such as [dsh-compact-basic](../../packages/compact/compact-basic)), and consumer (a `/compact` tool, deferred). Compaction is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A tokenizer- or template-based backend is a sibling package implementing the same interface. Unlike bash, the interface necessarily depends on `dsh-session` and `dsh-llm`: its verbs act on an agent-owned `Session`, and its durable summary event uses the `ContentBlock` vocabulary (see the [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)). +The compaction seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) split like bash: interface ([dsh-compact](../../packages/compact/compact), `ctx.compact`), implementation (a backend such as [dsh-compact-basic](../../packages/compact/compact-basic)), and human consumer ([dsh-command-compact](../../packages/compact/command-compact)). Compaction is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A tokenizer- or template-based backend is a sibling package implementing the same interface. Unlike bash, the interface necessarily depends on `dsh-session` and `dsh-llm`: its verbs act on an agent-owned `Session`, and its durable summary event uses the `ContentBlock` vocabulary (see the [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)). Source: [`packages/compact/compact/src/types.ts`](../../packages/compact/compact/src/types.ts) @@ -12,12 +12,14 @@ Compaction extends [`SessionEventMap`](session.md) with three event types via de | Event | Payload | Role | |---|---|---| -| `compact/start` | `{ turn }` | acquires the log-recorded lock | +| `compact/start` | `{ turn }` | acquires the log-recorded lock; a number identifies the open automatic turn, while `null` identifies a standalone manual attempt | | `compact/summary` | `{ summary, rawOutput?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance: the safe summary projection, optional complete provider output and usage, the shadowed surface-boundary pair (`start`/`end` seqs — a position span, not a numeric interval), the shadowed seqs in surface order, the estimated token count, and the summarize call's envelope (`provider`, `model`, plus its generation cap when one applied) — logged so the one-shot request is reconstructable from log + code (the reconstructability Agent Note) | -| `compact/end` | `{ turn, error? }` | releases the lock (`error` set when summarization threw) | +| `compact/end` | `{ turn, error? }` | releases the lock with the same numeric-or-null owner (`error` records an unsuccessful attempt) | The lock brackets the **whole** operation: `compact/start` is appended first, then summarization, the `compact/summary` provenance record, and the `user/message` replacement all land, and only then `compact/end`. Releasing the lock last turns a crash mid-operation into a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished. +The markers are lock time points, not an exclusive container. An unrelated idle injection can appear between a standalone manual start and end while summarization is pending. The manual path revalidates only its selected positional span, so that injected context survives after the replacement checkpoint. A live unmatched start blocks every entry point; an unmatched start before a newer `session/end-seed` is stale evidence from a prior lifecycle and is ignored. + These variants are merged inside a `declare module '@deepseek-ai/dsh-session'` block, so — unlike the top-level types on the other sub-pages — they are not pasted as a drift-checked ` ```ts type-equiv ` block (the `verify-type-equiv` extractor matches only top-level declarations by name). The payload table above is the catalog entry; follow the source link for the authoritative shapes. ## `CompactionResult` @@ -60,7 +62,16 @@ Automatic callers state why policy is running; implementations may treat confirm 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. +`CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, `compactNow(agent, signal)` for one useful idle-session reduction even below pressure, and `compactRegion(...)` for an explicit inclusive surface range. `compactNow()` synchronously reserves the agent's next-turn admission, returns `null` without writing when no useful range exists, records a standalone `turn: null` bracket before summarization, flushes a closed attempt, and then releases admission so ordinary queued prompts derive from the new surface. 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. + +Expected manual failures use `ManualCompactionErrorCode`: + +```ts type-equiv +/** Expected failure classes for an explicit idle-session compaction request. */ +type ManualCompactionErrorCode = 'busy' | 'changed' | 'summary' | 'commit' | 'persistence' +``` + +`changed` and `summary` leave the conversation surface unchanged but still close and persist the failed attempt in the log. `commit` may follow partial mutation; `persistence` means the in-memory bracket closed but its flush failed. Cancellation remains separate and throws the exact abort reason after required cleanup. Pressure compaction runs at serial `agent/step` before request derivation. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and returns a retry action only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling. diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md index 643a116ff2..20af51b5ac 100644 --- a/docs/core-data-structures/compaction.zh.md +++ b/docs/core-data-structures/compaction.zh.md @@ -2,7 +2,7 @@ [English](compaction.md) | 中文 -压缩 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md),与 bash 一样分为接口([dsh-compact](../../packages/compact/compact),`ctx.compact`)、实现(例如 [dsh-compact-basic](../../packages/compact/compact-basic) 后端)和消费方(延期实现的 `/compact` 工具)。压缩是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。基于 tokenizer 或模板的后端是实现同一接口的兄弟包(package)。与 bash 不同,该接口必然依赖 `dsh-session` 和 `dsh-llm`:其动词作用于 agent 所有的 `Session`,而其持久摘要事件使用 `ContentBlock` 词汇(见[压缩能力 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md))。 +压缩 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md),与 bash 一样分为接口([dsh-compact](../../packages/compact/compact),`ctx.compact`)、实现(例如 [dsh-compact-basic](../../packages/compact/compact-basic) 后端)和面向用户的消费方([dsh-command-compact](../../packages/compact/command-compact))。压缩是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。基于 tokenizer 或模板的后端是实现同一接口的兄弟包(package)。与 bash 不同,该接口必然依赖 `dsh-session` 和 `dsh-llm`:其动词作用于 agent 所有的 `Session`,而其持久摘要事件使用 `ContentBlock` 词汇(见[压缩能力 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md))。 源码:[`packages/compact/compact/src/types.ts`](../../packages/compact/compact/src/types.ts) @@ -12,12 +12,14 @@ | 事件 | 载荷 | 作用 | |---|---|---| -| `compact/start` | `{ turn }` | 获取日志记录的锁 | +| `compact/start` | `{ turn }` | 获取日志记录的锁;数字标识打开的自动轮次,`null` 标识独立手动尝试 | | `compact/summary` | `{ summary, rawOutput?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance:安全摘要投影、可选的完整 provider 输出与 usage、被遮蔽的 surface 边界对(`start`/`end` seq——位置跨度,而非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算 token 数,以及摘要调用的 envelope(`provider`、`model`,若有生成上限则还包括该上限)——写入日志后,该一次性请求可由日志 + 代码重建(见可重建性 Agent Note) | -| `compact/end` | `{ turn, error? }` | 释放锁(摘要调用抛出异常时设置 `error`) | +| `compact/end` | `{ turn, error? }` | 使用相同的数字或 `null` 归属值释放锁(`error` 记录失败尝试) | 锁括住**整个**操作:先追加 `compact/start`,然后执行摘要生成、写入 `compact/summary` 来源记录与 `user/message` 替换,最后才追加 `compact/end`。最后释放锁意味着操作中途崩溃会表现为可检测的遗留锁(有 `compact/start` 而无匹配的 `compact/end`),而非一个虚假声称压缩已完成的 `compact/end`。 +这些标记表示锁的时间点,而不是排他的容器。摘要等待期间,不相关的空闲注入可以出现在独立的手动 start 与 end 之间。手动路径只重新验证所选位置 span,因此替换检查点之后仍保留该注入上下文。活动的未匹配 start 会阻塞所有入口点;较新 `session/end-seed` 之前的未匹配 start 是先前生命周期留下的陈旧证据,会被忽略。 + 这些变体在 `declare module '@deepseek-ai/dsh-session'` 块内合并,因此——与其他子页面上的顶层类型不同——它们不以漂移检查的 ` ```ts type-equiv ` 块粘贴(`verify-type-equiv` 提取器只按名称匹配顶层声明)。上方的载荷表即为目录条目;权威形状请循源码链接查看。 ## `CompactionResult` @@ -60,7 +62,16 @@ interface CompactionResult { type CompactionTrigger = 'pressure' | 'context-overflow' ``` -`CompactService` 暴露 `compactIfNeeded(agent, trigger, signal)` 以执行自动 `pressure` 或 `context-overflow` 策略;没有可安全执行的工作时返回 `null`。它还针对显式、两端均包含的 surface 范围暴露 `compactRegion(...)`。每个后端都使用包导出的 `COMPACT_CHECKPOINT_SOURCE` 标记其替换用的 `user/message`;消费方调用 `isCompactCheckpointSource()`,而不是把检查点识别逻辑耦合到某一个后端。实现必须把传入的 signal 转发给摘要流程。该 seam 不拥有计价 API:单例 [`ctx.tokenMeter`](token-meter.md) 直接拥有估算与回放,而 `dsh-compact-basic` 拥有保留策略、事件排序、按路由执行的摘要调用及其配置。 +`CompactService` 暴露 `compactIfNeeded(agent, trigger, signal)` 以执行自动 `pressure` 或 `context-overflow` 策略,暴露 `compactNow(agent, signal)` 以便即使未达到压力也对空闲会话进行一次有效缩减,还针对显式、两端均包含的 surface 范围暴露 `compactRegion(...)`。`compactNow()` 会同步预留 agent 的下一轮次接纳;没有有效范围时返回 `null` 且不写入;在摘要前记录独立的 `turn: null` 标记对;flush 已闭合尝试;随后释放接纳预留,使普通排队提示词从新表层派生。每个后端都使用包导出的 `COMPACT_CHECKPOINT_SOURCE` 标记其替换用的 `user/message`;消费方调用 `isCompactCheckpointSource()`,而不是把检查点识别逻辑耦合到某一个后端。实现必须把传入的 signal 转发给摘要流程。该 seam 不拥有计价 API:单例 [`ctx.tokenMeter`](token-meter.md) 直接拥有估算与回放,而 `dsh-compact-basic` 拥有保留策略、事件排序、按路由执行的摘要调用及其配置。 + +预期的手动失败使用 `ManualCompactionErrorCode`: + +```ts type-equiv +/** Expected failure classes for an explicit idle-session compaction request. */ +type ManualCompactionErrorCode = 'busy' | 'changed' | 'summary' | 'commit' | 'persistence' +``` + +`changed` 和 `summary` 保持会话表层不变,但仍会闭合失败尝试并将其持久化到日志。`commit` 可能发生在部分变更之后;`persistence` 表示内存中的标记对已闭合,但 flush 失败。取消独立于这些失败,并在完成必要清理后抛出原始 abort 原因。 压力压缩在串行 `agent/step` 中运行,先于请求推导。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才返回重试动作,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 6321c85127..80e240cdca 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: dad533cee00646a40f57bd9097b2cceb8e9de9e2 -core.zh.md: 9e8afac0744fcf0df8c35dad5debce746d6614c6 +core.md: 5c211750d64af50fd60b7f46463d61a0735ae13b +core.zh.md: 1a4b0c761519492cfda437ebb7bd111392a669eb diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index dad533cee0..5c211750d6 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -547,6 +547,20 @@ interface Agent { */ send(message: UserMessage, options: SendOptions): void + /** + * Reserve admission of the next ordinary turn while this agent is idle, so an + * operation can mutate durable history before any queued prompt derives a + * request from it. Already-accepted waking work has right of way, including a + * send whose wake is still a pending microtask. Later sends keep their + * ordinary placement, FIFO order, and `wakeup` facts, and + * {@link acceptsNextStep} stays `false`, so a waking `next-step` send becomes + * a queued follow-up rather than steering; cancellation and disposal may + * still discard them. {@link inject} is not withheld. {@link whenIdle} treats + * a live reservation as activity, while lifecycle teardown does not await it. + * @returns the idempotent release, or `undefined` when the agent is running, already reserved, or already committed to waking work. + */ + reserveTurnAdmission(): (() => void) | undefined + /** * Mutate one still-pending queued occurrence synchronously. Editing preserves * the message identity and queue position; removal publishes its terminal @@ -604,7 +618,7 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running'`, and `SessionId` is branded. Disposal removes the agent from the registry and emits `agent/disposed`; it is not a terminal status value. `running` describes the driver-wide drain interval and may span consecutive queued turns; it does not prove a turn is still open. `acceptsNextStep` is the narrower routing predicate for callers that must choose between steering the current admission/turn and submitting a fresh admitted prompt. `AgentOptions` is merge-extensible: core declares `provider?`, `model?`, and `maxTokens?` (dispatch requires provider and model after `agent/request`). When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission leaves the provider default in control. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. +`AgentStatus` is `'idle' | 'running'`, and `SessionId` is branded. Disposal removes the agent from the registry and emits `agent/disposed`; it is not a terminal status value. `running` describes the driver-wide drain interval and may span consecutive queued turns; it does not prove a turn is still open. `acceptsNextStep` is the narrower routing predicate for callers that must choose between steering the current admission/turn and submitting a fresh admitted prompt. A live turn-admission reservation is quiescence-relevant without changing `status` or turning later queue entries into steering; its only authority is to defer the next driver claim until release. `AgentOptions` is merge-extensible: core declares `provider?`, `model?`, and `maxTokens?` (dispatch requires provider and model after `agent/request`). When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission leaves the provider default in control. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. The cause is a TypeScript-enforced same-process input. An active `TurnCancellation` holder copies its discriminant into the runtime-only `AbortSignal.reason` and is retired before `turn/end` publication; the frozen `AbortSignal.reason` remains readable after that retirement. Only the loop reads the cause (`user`, `parent`, or lifecycle-only `disposed`) back off its own machine-private signal at settlement — there is no public reader, and a signal grants cooperating listeners no classification authority. Durable `turn/end` retains the coarse `{ kind: 'aborted' }` outcome; request provenance would require a separate durable event rather than overloading the terminal result. diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 9e8afac074..1a4b0c7615 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -555,6 +555,20 @@ interface Agent { */ send(message: UserMessage, options: SendOptions): void + /** + * Reserve admission of the next ordinary turn while this agent is idle, so an + * operation can mutate durable history before any queued prompt derives a + * request from it. Already-accepted waking work has right of way, including a + * send whose wake is still a pending microtask. Later sends keep their + * ordinary placement, FIFO order, and `wakeup` facts, and + * {@link acceptsNextStep} stays `false`, so a waking `next-step` send becomes + * a queued follow-up rather than steering; cancellation and disposal may + * still discard them. {@link inject} is not withheld. {@link whenIdle} treats + * a live reservation as activity, while lifecycle teardown does not await it. + * @returns the idempotent release, or `undefined` when the agent is running, already reserved, or already committed to waking work. + */ + reserveTurnAdmission(): (() => void) | undefined + /** * Mutate one still-pending queued occurrence synchronously. Editing preserves * the message identity and queue position; removal publishes its terminal @@ -612,7 +626,7 @@ interface Agent { } ``` -`AgentStatus` 为 `'idle' | 'running'`,`SessionId` 是品牌类型。dispose(资源释放)会把 agent 从注册表移除并发出 `agent/disposed`;它不是一个终态 status 值。`running` 描述整个驱动器的排空区间,可能跨越连续的排队轮次;它不能证明某个轮次仍然打开。对于需要在把输入作为 steering 加入当前提示词准入/轮次,还是提交为一个新的待准入提示词之间做选择的调用方,`acceptsNextStep` 才是更窄且准确的路由判断条件。`AgentOptions` 可合并扩展:core 声明 `provider?`、`model?` 与 `maxTokens?`(在 `agent/request` 后,分发要求 provider 与 model 都存在)。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时由提供方默认值控制。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 +`AgentStatus` 为 `'idle' | 'running'`,`SessionId` 是品牌类型。dispose(资源释放)会把 agent 从注册表移除并发出 `agent/disposed`;它不是一个终态 status 值。`running` 描述整个驱动器的排空区间,可能跨越连续的排队轮次;它不能证明某个轮次仍然打开。对于需要在把输入作为 steering 加入当前提示词准入/轮次,还是提交为一个新的待准入提示词之间做选择的调用方,`acceptsNextStep` 才是更窄且准确的路由判断条件。活动的轮次接纳预留与完全停稳相关,但不会改变 `status`,也不会把之后的队列项变成 steering;它的唯一权限是将驱动器的下一次认领延迟到释放时。`AgentOptions` 可合并扩展:core 声明 `provider?`、`model?` 与 `maxTokens?`(在 `agent/request` 后,分发要求 provider 与 model 都存在)。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时由提供方默认值控制。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancellation` 持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`,并在发布 `turn/end` 前退役;冻结后的 `AbortSignal.reason` 仍可读取。只有 loop 会在结算时从自己机器私有的 signal 上读回 cause(`user`、`parent` 或仅用于生命周期的 `disposed`)——不存在公开的读取器,signal 也不授予协作监听器任何分类权限。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 596f8f99f5..43ecf5ad15 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,22 +8,22 @@ 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:148`](../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:319`](../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:250`](../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:259`](../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:433`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:297`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:309`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | -| `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:287`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:372`](../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:391`](../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:332`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | -| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:420`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:268`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:359`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:406`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:333`](../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:264`](../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:273`](../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:447`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:323`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:292`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | +| `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:301`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:360`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:386`](../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:405`](../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:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | +| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:434`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:282`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:420`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 524ba6ffd7..a1732fd730 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -209,20 +209,27 @@ Source: [`packages/ui/commands/src/index.ts:132`](../packages/ui/commands/src/in #### `compact/end` — log-only ```ts persistence-catalog -/** Marks the end of a compaction — log-only, releases the lock. `error` set if summarization failed. */ -'compact/end': { turn: number; error?: string } +/** + * Marks the end of a compaction — log-only, releases the lock. Its owner + * matches `compact/start`; `error` records an unsuccessful attempt. + */ +'compact/end': { turn: number | null; error?: string } ``` -Source: [`packages/compact/compact/src/types.ts:44`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:51`](../packages/compact/compact/src/types.ts) #### `compact/start` — log-only ```ts persistence-catalog -/** Marks the start of a compaction — log-only, holds the lock until `compact/end`. */ -'compact/start': { turn: number } +/** + * Marks the start of a compaction — log-only, holds the lock until + * `compact/end`. A numbered owner is strictly enclosed by that open turn; + * `null` identifies a standalone manual transaction between turns. + */ +'compact/start': { turn: number | null } ``` -Source: [`packages/compact/compact/src/types.ts:15`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact/src/types.ts) #### `compact/summary` — log-only @@ -258,7 +265,7 @@ Source: [`packages/compact/compact/src/types.ts:15`](../packages/compact/compact Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:26`](../packages/compact/compact/src/types.ts) ### `hook/*` diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index c47cb8c89f..f00c9f5d51 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/package.json b/examples/package.json index d98371a5ef..9906cef9b3 100644 --- a/examples/package.json +++ b/examples/package.json @@ -14,6 +14,7 @@ "@deepseek-ai/dsh-bash-sandbox": "workspace:*", "@deepseek-ai/dsh-cli-demo": "workspace:*", "@deepseek-ai/dsh-code-runtime-worker": "workspace:*", + "@deepseek-ai/dsh-command-compact": "workspace:*", "@deepseek-ai/dsh-compact-basic": "workspace:*", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", diff --git a/examples/tui-agent/README.i18n.yaml b/examples/tui-agent/README.i18n.yaml index 863092ffb8..ad6e224576 100644 --- a/examples/tui-agent/README.i18n.yaml +++ b/examples/tui-agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/tui-agent/README.md -README.md: ea8695d37ea247a38644392a4572c1ea9855fd44 -README.zh.md: c6acd39d8713816d870c00fa8597754d0d09880a +README.md: 41e9060e3c9c67762897ce766d663c79208ce626 +README.zh.md: 2948424c0d0fcec6c8e74a7c45c4cd89bcb72377 diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index ea8695d37e..41e9060e3c 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -19,7 +19,7 @@ Type a coding task. The agent works through the `read`/`write`/`edit` filesystem The `todo_write` task tracker is opt-in and not in the shipped config: add `@deepseek-ai/dsh-tool-todo` to `cordis.yml` (or a personal-config overlay under `~/.dsh`) to expose it. Once loaded, the model records a whole-list plan to the session log and the TUI renders it. -The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/plan` selects plan mode for the next step; `/plan ` also submits the message into that step, while `/plan off` selects the default mode without model input. `/status` expands the current session's identity, activity counts, exact token/cache buckets, context use, and timestamps without interrupting a running turn. `/model` opens a keyboard selector for the current provider catalog; use Up/Down to focus a model, Shift+Tab to cycle its advertised reasoning efforts, and Enter to select, or use `/model ` and `/model /` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. +The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. Bare `/compact` summarizes useful older history while idle, even below automatic pressure; it rejects arguments and reports only after the standalone replacement bracket is saved. A prompt submitted while compaction runs keeps its queue identity and starts afterwards, while injected context remains visible after the checkpoint. `/plan` selects plan mode for the next step; `/plan ` also submits the message into that step, while `/plan off` selects the default mode without model input. `/status` expands the current session's identity, activity counts, exact token/cache buckets, context use, and timestamps without interrupting a running turn. `/model` opens a keyboard selector for the current provider catalog; use Up/Down to focus a model, Shift+Tab to cycle its advertised reasoning efforts, and Enter to select, or use `/model ` and `/model /` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. ### Resuming a prior session diff --git a/examples/tui-agent/README.zh.md b/examples/tui-agent/README.zh.md index c6acd39d87..2948424c0d 100644 --- a/examples/tui-agent/README.zh.md +++ b/examples/tui-agent/README.zh.md @@ -19,7 +19,7 @@ pnpm run demo:tui `todo_write` 任务跟踪器是选用的,不在已交付配置中:请将 `@deepseek-ai/dsh-tool-todo` 添加到 `cordis.yml`(或在 `~/.dsh` 下使用个人配置覆盖)以公开该工具。加载后,模型会把整表计划记录到会话日志,TUI 则渲染它。 -TUI 渲染 Markdown 历史、推理(reasoning)、工具自有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。`/plan` 为下一步骤选择 plan mode;`/plan ` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 聚焦模型,使用 Shift+Tab 循环切换为该模型公布的推理强度,再用 Enter 选择;也可以使用 `/model ` 和 `/model /` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。 +TUI 渲染 Markdown 历史、推理(reasoning)、工具自有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。空闲时,裸 `/compact` 即使未达到自动压力,也会摘要有效的较早历史;它拒绝参数,并仅在保存独立替换标记对后报告结果。压缩期间提交的提示词会保留其队列身份并在压缩后启动,注入的上下文则在检查点之后保持可见。`/plan` 为下一步骤选择 plan mode;`/plan ` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 聚焦模型,使用 Shift+Tab 循环切换为该模型公布的推理强度,再用 Enter 选择;也可以使用 `/model ` 和 `/model /` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。 ### 恢复早先的会话 diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index 86129d3fa4..b8e1719e21 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -35,6 +35,8 @@ flowchart LR cfg --> plugin_tui_tool_result_prune plugin_tui_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] cfg --> plugin_tui_compact_basic + plugin_tui_command_compact["command-compact
@deepseek-ai/dsh-command-compact"] + cfg --> plugin_tui_command_compact plugin_tui_subagent["subagent
@deepseek-ai/dsh-subagent"] cfg --> plugin_tui_subagent plugin_tui_subagent_spawn["subagent-spawn
@deepseek-ai/dsh-subagent-spawn"] @@ -81,6 +83,7 @@ flowchart LR | `token-meter` | `@deepseek-ai/dsh-token-meter` | | `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` | | `compact-basic` | `@deepseek-ai/dsh-compact-basic` | +| `command-compact` | `@deepseek-ai/dsh-command-compact` | | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 2b14271c96..431f260e91 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -94,6 +94,11 @@ - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' +# Human `/compact`: one useful reduction below the automatic threshold. Backend +# independent, so it follows whichever compaction service this leaf mounts. +- id: command-compact + name: '@deepseek-ai/dsh-command-compact' + # Expose fresh-child `spawn` and completed-prefix `fork` through independent # in-process backends. Each tool instance needs a distinct `toolName`; the registry # rejects duplicates. These leaves follow the app because it provides `ctx.agents` and `ctx.tools`. diff --git a/examples/tui-agent/tests/snapshots/queued-manual-compact/terminal.expected.txt b/examples/tui-agent/tests/snapshots/queued-manual-compact/terminal.expected.txt new file mode 100644 index 0000000000..0df46a7018 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/queued-manual-compact/terminal.expected.txt @@ -0,0 +1,128 @@ +terminal 100x36 buffer=normal length=66 base=30 viewport=30 +lifecycle started=1 stopped=0 progress=inactive +title "Reply with exactly the word: — DSH TUI snapshot" +cursor hidden column=7 viewportRow=35 bufferRow=65 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Reply with exactly the word:" + style 1-28 dim +2| " main-session" + style 1-12 dim +3| +4| "Context · snapshot-seed" + style 0-22 dim +5| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " + style 0-99 dim +6| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " + style 0-99 dim +7| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " + style 0-99 dim +8| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " + style 0-99 dim +9| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " + style 0-99 dim +10| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " + style 0-99 dim +11| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " + style 0-99 dim +12| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " + style 0-99 dim +13| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " + style 0-99 dim +14| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " + style 0-99 dim +15| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " + style 0-99 dim +16| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " + style 0-99 dim +17| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " + style 0-99 dim +18| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " + style 0-99 dim +19| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. " + style 0-94 dim +20| +21| "You " + style 0-2 fg=bright-magenta bold underline +22| "Reply with exactly the word: ONE. No tools. " +23| +24| "Assistant " + style 0-8 fg=bright-magenta bold underline +25| "Reasoning " + style 0-8 dim italic +26| "The user wants me to reply with exactly the word \"ONE\" and use no tools. " + style 0-71 dim italic +27| "ONE " +28| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +29| +30| "Keyboard shortcuts " + style 0-17 fg=bright-magenta bold +31| "Enter send • Shift/Alt+Enter newline • Up/Down prompt history " + style 0-60 dim +32| "Esc cancel turn • Ctrl+O cycle cards (collapse/expand/hide) • Ctrl+R toggle reasoning • Ctrl+L " + style 0-99 dim +33| "redraw " + style 0-5 dim +34| "Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " + style 0-72 dim +35| " " +36| "/clear — Clear the transcript view (session history is unchanged) " + style 0-64 dim +37| "/compact — Compact older conversation history " + style 0-44 dim +38| "/exit — Exit after the active turn reaches idle " + style 0-46 dim +39| "/help — Show keyboard shortcuts and commands " + style 0-43 dim +40| "/model [[provider/]model] — Show or switch this session's model " + style 0-62 dim +41| "/palette — Show every color and attribute role this terminal renders " + style 0-67 dim +42| "/quit — Exit after the active turn reaches idle " + style 0-46 dim +43| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " + style 0-87 dim +44| "/resume — List this workspace's resumable sessions " + style 0-49 dim +45| "/status — Show session diagnostics, system prompt, and registered tools " + style 0-70 dim +46| "/skill: [instructions] — load a skill into the conversation " + style 0-64 dim +47| +48| "Context · snapshot-injector" + style 0-26 dim +49| "Injected while compaction was running. " + style 0-37 dim +50| +51| "… earlier context was compacted … " + style 0-32 dim +52| +53| "You " + style 0-2 fg=bright-magenta bold underline +54| "Reply with exactly the word: TWO. No tools. " +55| +56| "Compacted 2 history items (~387 tokens). " + style 0-39 dim +57| +58| "Assistant " + style 0-8 fg=bright-magenta bold underline +59| "Reasoning " + style 0-8 dim italic +60| "The user wants me to reply with exactly the word \"TWO\" and no tools. " + style 0-67 dim italic +61| "TWO " +62| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +63| +64| "/workspace/project deepseek-v4-flash ↑2.9k ↓41 cache 49% 3% cont" + style 0-49 fg=bright-magenta bold + style 52-68 dim + style 71-90 dim + style 93-99 dim +65| " dsh ◍ " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 95b960e3b6..c8d30ebfad 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -11,8 +11,12 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker' import CommandService from '@deepseek-ai/dsh-commands' +import * as CommandCompact from '@deepseek-ai/dsh-command-compact' +import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' +import type { SummarizationInput } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' +import { createUserMessage } from '@deepseek-ai/dsh-llm' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' @@ -45,6 +49,8 @@ type ScenarioInteraction = 'skill-invocation-policy' interface Scenario { name: string + /** Replay fixture owned by an earlier scenario, for a derived presentation case. */ + fixture?: string composition: Composition expectedTools: string[] expectedEventCounts?: Record @@ -68,6 +74,13 @@ interface Scenario { spillMaxInlineBytes?: number /** Run scenario-specific terminal input instead of replaying recorded user prompts. */ interaction?: ScenarioInteraction + /** + * Mount a deterministic compaction backend plus `/compact`, then run the + * human command with a held summary while a prompt and injected context + * arrive. Proves queued input waits for the standalone bracket's durability + * checkpoint instead of racing the replacement. + */ + manualCompact?: boolean } const SCENARIOS: Scenario[] = [ @@ -80,6 +93,14 @@ const SCENARIOS: Scenario[] = [ leavePlanModeAfterFirstTurn: true, recorded: true, }, + { + name: 'queued-manual-compact', + fixture: 'multi-turn-conversation', + composition: 'native', + expectedTools: [], + recorded: false, + manualCompact: true, + }, { name: 'todo-plan', composition: 'native', @@ -149,6 +170,44 @@ function snapshotModeFromEnv(value: string | undefined): SnapshotMode { const MODE = snapshotModeFromEnv(process.env.DSH_SNAPSHOT) const observedScenarios = new Set() +const workerState = Reflect.get(globalThis, '__vitest_worker__') as + | { readonly config?: { readonly testNamePattern?: RegExp } } + | undefined +// Worker argv omits the parent CLI's `-t`; the serialized runner config is the +// authoritative distinction between a focused replay and the full suite. +const TEST_NAME_FILTERED = workerState?.config?.testNamePattern !== undefined + +/** + * Deterministic keyless summary that pauses so the scenario can submit a real + * prompt and inject context while manual compaction holds turn admission. + */ +class DeferredSnapshotCompactService extends BasicCompactService { + readonly summaryStarted = Promise.withResolvers() + readonly releaseSummary = Promise.withResolvers() + + override async summarize( + _input: SummarizationInput, + _agent: Agent, + signal?: AbortSignal, + ): Promise<{ summary: [{ type: 'text'; text: string }]; provider: string; model: string }> { + this.summaryStarted.resolve(undefined) + await this.releaseSummary.promise + signal?.throwIfAborted() + return { + summary: [{ type: 'text', text: 'Keyless manual compaction checkpoint.' }], + provider: 'snapshot', + model: 'snapshot-compactor', + } + } +} + +/** Seed between-turn model-visible history without inventing a loop execution. */ +function seedCompactableHistory(agent: Agent): void { + agent.inject(createUserMessage({ + content: [{ type: 'text', text: 'Older snapshot context. '.repeat(60) }], + source: { kind: 'plugin', plugin: 'snapshot-seed' }, + })) +} function snapshotDisplayPath(displayPath: string, cwd: string, displayCwd: string): string { const rel = relative(cwd, displayPath) @@ -161,10 +220,15 @@ function scenarioDir(scenario: Scenario): string { return join(SNAPSHOTS_DIR, scenario.name) } +/** Directory owning the replay fixture: the scenario's own, or the one it derives from. */ +function fixtureDir(scenario: Scenario): string { + return join(SNAPSHOTS_DIR, scenario.fixture ?? scenario.name) +} + function childFixturePaths(scenario: Scenario): string[] { return Array.from( { length: scenario.childSessions ?? 0 }, - (_, index) => join(scenarioDir(scenario), `session.${index + 1}.jsonl`), + (_, index) => join(fixtureDir(scenario), `session.${index + 1}.jsonl`), ) } @@ -206,6 +270,24 @@ async function settleTerminal(terminal: HeadlessTerminal): Promise { if (stable < 3) throw new Error('TUI frames did not quiesce within 200ms') } +/** Bound deterministic in-process coordination waits with actionable state. */ +async function snapshotDeadline( + operation: Promise, + detail: () => string, +): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timer = setTimeout(() => { reject(new Error(detail())) }, 5_000) + }), + ]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} + async function mountScenarioContext( scenario: Scenario, cwd: string, @@ -232,6 +314,9 @@ async function mountScenarioContext( skills: { local: { agentsHome: join(cwd, '.agents') } }, }) await ctx.plugin(TokenMeterService) + if (scenario.manualCompact === true) { + await ctx.plugin(DeferredSnapshotCompactService, { auto: false }) + } await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' }) @@ -249,6 +334,7 @@ async function mountScenarioContext( await ctx.plugin(ToolWorkflow) await ctx.plugin(ToolRalph) await ctx.plugin(CommandService) + if (scenario.manualCompact === true) await ctx.plugin(CommandCompact) if (scenario.enterPlanMode === true) { await ctx.plugin(PlanModeService, { section: 'Snapshot plan mode instructions.' }) } @@ -277,8 +363,7 @@ interface ScenarioResult { async function runScenario(scenario: Scenario): Promise { const clock = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 12, 0, 0).getTime()) - const dir = scenarioDir(scenario) - const fixtureFile = join(dir, 'session.jsonl') + const fixtureFile = join(fixtureDir(scenario), 'session.jsonl') const childFiles = childFixturePaths(scenario) const prompts = userPrompts(await readFile(fixtureFile, 'utf8')) if (scenario.interaction === undefined) { @@ -292,7 +377,7 @@ async function runScenario(scenario: Scenario): Promise { const terminal = new HeadlessTerminal(100, 36) try { if (scenario.seedWorkspace === true) { - const source = join(scenarioDir(scenario), 'workspace') + const source = join(fixtureDir(scenario), 'workspace') await cp(source, cwd, { recursive: true }) } ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles) @@ -308,6 +393,7 @@ async function runScenario(scenario: Scenario): Promise { agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, }) const agent: Agent = handle.agent + if (scenario.manualCompact === true) seedCompactableHistory(agent) controller = createTuiChat(ctx, { sessionId: 'main-session', theme: { color: true }, @@ -380,6 +466,14 @@ async function runScenario(scenario: Scenario): Promise { } let remainingPrompts = prompts + let queuedPrompt: string | undefined + let manualOrder: string[] | undefined + let manualCommandId: string | undefined + if (scenario.manualCompact === true) { + expect(prompts.length, 'queued manual compaction needs a second replayed prompt').toBeGreaterThanOrEqual(2) + queuedPrompt = prompts.at(-1) + remainingPrompts = prompts.slice(0, -1) + } if (scenario.enterPlanMode === true) { const firstPrompt = prompts[0]! terminal.send(`/plan ${firstPrompt}`) @@ -396,12 +490,86 @@ async function runScenario(scenario: Scenario): Promise { } for (const prompt of remainingPrompts) { + const admitted = agent.session.events.filter(event => + event.type === 'user/message' && event.data.source.kind === 'user').length terminal.send(prompt) terminal.send('\r') + await terminal.flush() + await expect.poll(() => agent.session.events.filter(event => + event.type === 'user/message' && event.data.source.kind === 'user').length).toBe(admitted + 1) await agent.whenIdle() await settleTerminal(terminal) } + if (scenario.manualCompact === true && queuedPrompt !== undefined) { + terminal.send('/help') + terminal.send('\r') + await settleTerminal(terminal) + expect(await terminal.snapshot({ includeScrollback: true })) + .toContain('/compact — Compact older conversation history') + + const compact = ctx.compact as DeferredSnapshotCompactService + const inbox: string[] = [] + manualOrder = [] + ctx.on('agent/inbox/enqueue', (subject, item) => { + if (subject === agent) inbox.push(`enqueue:${item.placement}:${item.id}`) + }) + ctx.on('agent/inbox/dequeue', (subject, message) => { + if (subject === agent) inbox.push(`dequeue:${message.id}`) + }) + ctx.on('session/event', (session, event) => { + if (session !== agent.session) return + if (event.type === 'command/run' && event.data.name === 'compact') { + manualCommandId = event.data.commandId + manualOrder?.push('command/run') + } + if (event.type === 'command/done' && event.data.commandId === manualCommandId) { + manualOrder?.push('command/done') + } + if (event.type.startsWith('compact/')) manualOrder?.push(event.type) + if (event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'compact') manualOrder?.push('checkpoint') + if (event.type === 'turn/start') manualOrder?.push(`turn/start:${event.data.trigger.kind}`) + }) + ctx.on('session/flush', (session) => { + if (session === agent.session) manualOrder?.push('flush') + }) + + terminal.send('/compact') + terminal.send('\r') + await terminal.flush() + await snapshotDeadline(compact.summaryStarted.promise, () => + `manual summary did not start; status=${agent.status}; tail=${ + agent.session.events.slice(-8).map(event => event.type).join(',') + }`) + + // Real keystrokes: the prompt keeps its ordinary queue identity while + // admission is reserved, and an injection appends immediately. + terminal.send(queuedPrompt) + terminal.send('\r') + await terminal.flush() + await expect.poll(() => inbox.length).toBe(1) + agent.inject(createUserMessage({ + content: [{ type: 'text', text: 'Injected while compaction was running.' }], + source: { kind: 'plugin', plugin: 'snapshot-injector' }, + })) + expect(inbox[0]).toMatch(/^enqueue:queued:/u) + expect(agent.status).toBe('idle') + expect(agent.session.events.some(event => event.type === 'user/message' + && event.data.source.kind === 'user' + && event.data.content.some(block => block.type === 'text' && block.text === queuedPrompt))).toBe(false) + + const idle = agent.whenIdle() + compact.releaseSummary.resolve(undefined) + await snapshotDeadline(idle, () => + `manual compaction did not reach idle; status=${agent.status}; order=${manualOrder?.join(',') ?? ''}; tail=${ + agent.session.events.slice(-12).map(event => event.type).join(',') + }`) + await settleTerminal(terminal) + expect(inbox).toEqual([inbox[0], `dequeue:${inbox[0]?.slice('enqueue:queued:'.length) ?? ''}`]) + } + const events: SessionEvent[] = [...agent.session.events] const firstHeader = events.find(event => event.type === 'request/header') expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system) @@ -437,6 +605,87 @@ async function runScenario(scenario: Scenario): Promise { expect(events.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin').map(event => (event.data as { content: unknown }).content)) .toContainEqual([{ type: 'text', text: 'The user switched this session back to the default mode.' }]) } + if (scenario.manualCompact === true) { + const compactStart = events.find(event => event.type === 'compact/start') + const compactSummary = events.find(event => event.type === 'compact/summary') + const compactCheckpoint = events.find(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' && event.data.source.plugin === 'compact') + const injectedEvent = events.find(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' && event.data.source.plugin === 'snapshot-injector') + const compactEnd = events.find(event => event.type === 'compact/end') + expect(compactStart?.data.turn).toBeNull() + expect(compactEnd?.data.turn).toBeNull() + expect(events.filter(event => event.type === 'compact/summary')).toHaveLength(1) + if (compactStart === undefined || compactSummary === undefined + || compactCheckpoint === undefined || injectedEvent === undefined + || compactEnd === undefined) { + throw new Error('manual compaction snapshot is missing its durable marker, summary, checkpoint, or injection') + } + // The markers are time points, not an exclusive container: unrelated + // idle injection is allowed between them while the selected span stays stable. + expect(compactStart.seq).toBeLessThan(injectedEvent.seq) + expect(injectedEvent.seq).toBeLessThan(compactSummary.seq) + expect(compactSummary.seq).toBeLessThan(compactCheckpoint.seq) + expect(compactCheckpoint.seq).toBeLessThan(compactEnd.seq) + + const manualTimeline = manualOrder ?? [] + const commandRunIndex = manualTimeline.indexOf('command/run') + const compactStartIndex = manualTimeline.indexOf('compact/start') + const compactEndIndex = manualTimeline.indexOf('compact/end') + const firstFlushIndex = manualTimeline.indexOf('flush') + const queuedTurnIndex = manualTimeline.indexOf('turn/start:message') + const commandDoneIndex = manualTimeline.indexOf('command/done') + expect(manualTimeline.filter(item => item === 'command/run')).toHaveLength(1) + expect(manualTimeline.filter(item => item === 'command/done')).toHaveLength(1) + expect(compactStartIndex).toBeGreaterThan(commandRunIndex) + expect(compactEndIndex).toBeGreaterThan(compactStartIndex) + expect(firstFlushIndex).toBeGreaterThan(compactEndIndex) + expect(queuedTurnIndex).toBeGreaterThan(firstFlushIndex) + expect(commandDoneIndex).toBeGreaterThan(firstFlushIndex) + + const commandRun = events.find(event => event.type === 'command/run' + && event.data.name === 'compact') + const commandRunId = commandRun?.type === 'command/run' + ? commandRun.data.commandId + : undefined + const commandDone = events.find(event => event.type === 'command/done' + && event.data.commandId === commandRunId) + expect(commandRun?.type === 'command/run' && commandRun.data).toEqual({ + commandId: commandRunId, + name: 'compact', + args: '', + source: { kind: 'user' }, + }) + expect(commandDone?.type === 'command/done' && commandDone.data).toEqual({ + commandId: commandRunId, + kind: 'success', + text: 'Compacted 2 history items (~387 tokens).', + }) + expect(commandRun !== undefined && commandRun.seq < compactStart.seq).toBe(true) + expect(commandDone !== undefined && commandDone.seq > compactEnd.seq).toBe(true) + expect(agent.session.surface.nodes).not.toContain(commandRun?.seq) + expect(agent.session.surface.nodes).not.toContain(commandDone?.seq) + + // The command line itself never becomes a prompt. + expect(events.some(event => event.type === 'user/message' + && event.data.source.kind === 'user' + && event.data.content.some(block => block.type === 'text' && block.text.trim() === '/compact'))).toBe(false) + const derived = agent.session.deriveMessages().map(message => message.content + .map(block => block.type === 'text' ? block.text : '') + .join('')) + const checkpoint = derived.findIndex(text => text.includes('Keyless manual compaction checkpoint.')) + const injected = derived.findIndex(text => text.includes('Injected while compaction was running.')) + const queued = derived.findIndex(text => text === queuedPrompt) + expect(checkpoint).toBe(0) + expect(injected).toBeGreaterThan(checkpoint) + expect(queued).toBeGreaterThan(injected) + expect(derived).not.toContain('/compact') + expect(derived).not.toContain('Compacted 2 history items (~387 tokens).') + expect(derived.filter(text => text.includes('Injected while compaction was running.'))).toHaveLength(1) + expect(compactSummary.data.shadowedSeqs).not.toContain(injectedEvent.seq) + const queuedTurn = events.findLast(event => event.type === 'turn/start') + expect(queuedTurn !== undefined && compactEnd.seq < queuedTurn.seq).toBe(true) + } if (scenario.spillMaxInlineBytes !== undefined) { // The REAL pipeline ran (tools execute on replay too): the durable // dispatch copy is bounded to a preview + locator under the run cwd, @@ -514,7 +763,23 @@ describe('TUI recorded-session terminal snapshots', () => { }) afterAll(async () => { - expect([...observedScenarios].sort()).toEqual(SCENARIOS.map(scenario => scenario.name).sort()) + const scenarioNames = SCENARIOS.map(scenario => scenario.name).sort() + const observedNames = [...observedScenarios].sort() + if (TEST_NAME_FILTERED) { + expect(observedNames).not.toHaveLength(0) + expect(scenarioNames).toEqual(expect.arrayContaining(observedNames)) + } else { + expect(observedNames).toEqual(scenarioNames) + } + for (const [index, scenario] of SCENARIOS.entries()) { + if (scenario.fixture === undefined) continue + const sourceIndex = SCENARIOS.findIndex(candidate => candidate.name === scenario.fixture) + expect(sourceIndex, `${scenario.name} fixture source ${scenario.fixture} must exist`).toBeGreaterThanOrEqual(0) + expect(sourceIndex, `${scenario.name} fixture source must precede it`).toBeLessThan(index) + const source = SCENARIOS[sourceIndex] + expect(source?.fixture, `${scenario.name} fixture source must own its replay files`).toBeUndefined() + expect(source?.recorded, `${scenario.name} fixture source must be recordable`).toBe(true) + } const directories = (await readdir(SNAPSHOTS_DIR, { withFileTypes: true })) .filter(entry => entry.isDirectory()) .map(entry => entry.name) @@ -522,14 +787,14 @@ afterAll(async () => { expect(directories).toEqual(SCENARIOS.map(scenario => scenario.name).sort()) for (const scenario of SCENARIOS) { const expected = [ - 'session.jsonl', + ...scenario.fixture === undefined ? ['session.jsonl'] : [], 'terminal.expected.txt', - ...scenario.seedWorkspace === true ? ['workspace'] : [], + ...scenario.seedWorkspace === true && scenario.fixture === undefined ? ['workspace'] : [], ...Array.from({ length: scenario.childSessions ?? 0 }, (_, index) => `session.${index + 1}.jsonl`), ].sort() expect((await readdir(scenarioDir(scenario))).sort()).toEqual(expected) for (const fixture of ['session.jsonl', ...childFixturePaths(scenario).map(path => basename(path))]) { - const content = await readFile(join(scenarioDir(scenario), fixture), 'utf8') + const content = await readFile(join(fixtureDir(scenario), fixture), 'utf8') expect(scrubRequestHeaders(content), `${scenario.name}/${fixture} carries request-header bulk`).toBe(content) } } diff --git a/packages/compact/README.i18n.yaml b/packages/compact/README.i18n.yaml index 95109e1c76..92031652b7 100644 --- a/packages/compact/README.i18n.yaml +++ b/packages/compact/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/compact/README.md -README.md: 3c3644adce23c12db37241bf797ea614d273a0fb -README.zh.md: 260a92154ecd33cb127391af5ded399a2bc20038 +README.md: aa9fa6d9419de87a7df23a437f5ea8694d981b28 +README.zh.md: e771eb4bc76358242737d92f92ec36324f55bf2b diff --git a/packages/compact/README.md b/packages/compact/README.md index 3c3644adce..aa9fa6d941 100644 --- a/packages/compact/README.md +++ b/packages/compact/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -A compaction capability family (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract interface, a summarizing backend, a model-free tool-result pruning companion, and a deferred model-facing consumer. All **product** packages. +A compaction capability family (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract interface, a summarizing backend, a model-free tool-result pruning companion, and a human command adapter. All **product** packages. | Package | Role | ctx key | |---|---|---| | `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` | | `compact-basic/` | A backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | | `compact-tool-result-prune/` | Optional model-free head/middle/tail rewriting before summary compaction | `ctx.toolResultPrune` | -| `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) | +| `command-compact/` | Human `/compact` command over the backend-independent `compactNow()` seam | (registers on `ctx.commands`) | -The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`, and deterministic pruning at `compact/compact-tool-result-prune/`. Unlike the bash seam, the interface depends on `dsh-session` and `dsh-llm` because its verbs are defined over a `Session` and its output uses `ContentBlock`. That deviation is recorded in the [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement remains a reusable LLM-family service; a template- or model-backed compactor can replace `compact-basic` without changing the meter, pruner, or callers. +The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`, deterministic pruning at `compact/compact-tool-result-prune/`, and the command at `compact/command-compact/`. Unlike the bash seam, the interface depends on `dsh-session` and `dsh-llm` because its verbs are defined over a `Session` and its output uses `ContentBlock`. That deviation is recorded in the [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement remains a reusable LLM-family service; a template- or model-backed compactor can replace `compact-basic` without changing the meter, pruner, command, or automatic callers. diff --git a/packages/compact/README.zh.md b/packages/compact/README.zh.md index 260a92154e..e771eb4bc7 100644 --- a/packages/compact/README.zh.md +++ b/packages/compact/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -一个压缩(compaction)能力家族(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象接口、摘要生成后端、不依赖模型的工具结果剪枝配套组件,以及暂缓实现的面向模型消费方。这些全是**产品**包(package)。 +一个压缩(compaction)能力家族(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象接口、摘要生成后端、不依赖模型的工具结果剪枝配套组件,以及面向用户的命令适配器。这些全是**产品**包(package)。 | 包 | 职责 | ctx key | |---|---|---| | `compact/` | 抽象压缩 seam(接口 + `compact/*` 事件 + `CompactionResult`) | `ctx.compact` | | `compact-basic/` | 后端:`ctx.tokenMeter` 压力 + 按 token 预算保留内容 + `llm.stream()` 摘要生成 | (注册 `ctx.compact`) | | `compact-tool-result-prune/` | 可选的不依赖模型的头/中/尾重写,在摘要压缩之前运行 | `ctx.toolResultPrune` | -| `tool-compact/`(暂缓) | 面向模型的 `/compact` 工具,基于 `ctx.compact` | (注册到 `ctx.tools`) | +| `command-compact/` | 面向用户的 `/compact` 命令,基于后端无关的 `compactNow()` seam | (注册到 `ctx.commands`) | -接口位于 `compact/compact/`,后端位于 `compact/compact-basic/`,确定性剪枝位于 `compact/compact-tool-result-prune/`。与 bash seam 不同,该接口依赖 `dsh-session` 和 `dsh-llm`,因为它的操作以 `Session` 为对象,输出则使用 `ContentBlock`。这项偏差记录在[压缩能力 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。token 测量仍是可复用的 LLM(大语言模型)家族服务;基于模板或模型的压缩器可以替换 `compact-basic`,而无需更改计量器、剪枝器或调用方。 +接口位于 `compact/compact/`,后端位于 `compact/compact-basic/`,确定性剪枝位于 `compact/compact-tool-result-prune/`,命令位于 `compact/command-compact/`。与 bash seam 不同,该接口依赖 `dsh-session` 和 `dsh-llm`,因为它的操作以 `Session` 为对象,输出则使用 `ContentBlock`。这项偏差记录在[压缩能力 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。token 测量仍是可复用的 LLM(大语言模型)家族服务;基于模板或模型的压缩器可以替换 `compact-basic`,而无需更改计量器、剪枝器、命令或自动调用方。 diff --git a/packages/compact/command-compact/README.i18n.yaml b/packages/compact/command-compact/README.i18n.yaml new file mode 100644 index 0000000000..b66b4faa9b --- /dev/null +++ b/packages/compact/command-compact/README.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 packages/compact/command-compact/README.md +README.md: 314b259025a2b7a13faf27f9d3935cca370224fe +README.zh.md: 9fb30c6720c3c3d69ed64242e995d953ec8a008f diff --git a/packages/compact/command-compact/README.md b/packages/compact/command-compact/README.md new file mode 100644 index 0000000000..314b259025 --- /dev/null +++ b/packages/compact/command-compact/README.md @@ -0,0 +1,66 @@ +# @deepseek-ai/dsh-command-compact + +English | [中文](README.zh.md) + +Human-facing `/compact` control over [`ctx.compact`](../compact/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI executes it without a model turn. The [queued manual compaction Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md) owns the admission, lock, and durability decisions. + +## Command contract + +| Input | Result | +|---|---| +| `/compact` | Summarize one useful balanced older span even below automatic pressure, then report the replaced history-item count and estimated tokens after the standalone bracket is flushed. | +| `/compact` with no compactable history | `No compactable history yet.` — no marker or surface mutation is written. | +| `/compact ` | `Usage: /compact (no arguments)` — the command takes no arguments and calls no compaction backend. | + +The command is backend-independent: it depends only on `compactNow(agent, signal)`. The invoking agent is the exact target, and the dispatching UI's cancellation signal is forwarded through the seam. Every resolved invocation records the executor-owned log-only pair `command/run` / `command/done`; neither event joins model history. + +Expected `ManualCompactionError` codes become stable direct errors: + +| Code | Direct result | +|---|---| +| `busy` | `Compaction is unavailable because this process has an active compaction, or the agent is not idle.` | +| `changed` | `The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.` | +| `summary` | `Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.` | +| `commit` | `Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.` | +| `persistence` | `Compaction finished, but the session could not be saved.` | + +The busy result is intentionally process-scoped: a live unmatched marker blocks, while a marker older than the newest `session/end-seed` is stale and does not. Unexpected implementation failures reject dispatch. Cancellation remains authoritative; the backend completes its required close/flush cleanup, and the command settles internally as `Compaction cancelled.` while the command executor stops waiting with its cancellation error. + +Prompts submitted while compaction runs remain accepted in the agent's ordinary FIFO with the same identity and wakeup facts. They start only after the compaction's explicit durability checkpoint and admission release. Idle injected context is not held: it may be logged between `compact/start` and `compact/end`, and positional replacement leaves it visible after the checkpoint. + +## Composition + +The producer injects `commands` and `compact`. Mount the command registry, one backend, and this plugin: + +```yaml +- id: commands + name: '@deepseek-ai/dsh-commands' +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' +- id: command-compact + name: '@deepseek-ai/dsh-command-compact' +``` + +The TUI example and CLI host mount it beside `compact-basic`. Automation surfaces that compose no command registry keep automatic compaction only. + +## Model Experience + +### Human `/compact` control + +#### What the model sees + +The slash input and direct result never enter a model request. An accepted compaction separately replaces an older span with the backend's user-role checkpoint inside a standalone `compact/* { turn: null }` bracket. + +#### Token effect + +The command lifecycle adds no model tokens. A successful compaction reduces later requests by replacing the selected span with one framed summary; summarization itself is one auxiliary request. + +#### KV Cache effect + +Discovery and command bookkeeping do not affect the cache. The accepted surface replacement invalidates reuse from the first shadowed history token. + +## Known Limitations and Deferred Work + +- **Idle-only** — `/compact` reports `busy` when a turn or already accepted waking prompt has right of way; the command itself is not queued. +- **No range or policy arguments** — the argument-free form keeps behavior stable across command adapters. Explicit ranges remain the programmatic `compactRegion()` path. +- **Command adapters only** — surfaces without `ctx.commands` cannot invoke it and rely on automatic pressure compaction. diff --git a/packages/compact/command-compact/README.zh.md b/packages/compact/command-compact/README.zh.md new file mode 100644 index 0000000000..9fb30c6720 --- /dev/null +++ b/packages/compact/command-compact/README.zh.md @@ -0,0 +1,66 @@ +# @deepseek-ai/dsh-command-compact + +[English](README.md) | 中文 + +通过 [`ctx.compact`](../compact/README.md) 提供面向用户的 `/compact` 压缩(compaction)控制。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此组合中的每个命令适配器都能发现它;随附 TUI 无需模型轮次即可执行该命令。[排队手动压缩 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md)拥有接纳、锁与持久性决策。 + +## 命令契约 + +| 输入 | 结果 | +|---|---| +| `/compact` | 即使未达到自动压力,也摘要一段有效、平衡的较早范围;独立标记对 flush 后,报告被替换的历史项数量与估算 token 数。 | +| `/compact`,但没有可压缩历史 | `No compactable history yet.`:不会写入标记,也不会变更 surface。 | +| `/compact ` | `Usage: /compact (no arguments)`:该命令不接受参数,也不会调用压缩后端。 | + +该命令与后端无关,只依赖 `compactNow(agent, signal)`。调用该命令的 agent(智能体)就是操作的确切目标,发起分发的 UI 会通过 seam 转发取消信号。每次完成的调用都会记录执行器所属的纯日志事件对 `command/run` / `command/done`;两者都不进入模型历史。 + +预期的 `ManualCompactionError` 代码会成为稳定的直接错误: + +| 代码 | 直接结果 | +|---|---| +| `busy` | `Compaction is unavailable because this process has an active compaction, or the agent is not idle.` | +| `changed` | `The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.` | +| `summary` | `Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.` | +| `commit` | `Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.` | +| `persistence` | `Compaction finished, but the session could not be saved.` | + +busy 结果有意限定在进程范围内:活动的未匹配标记会阻塞,而早于最新 `session/end-seed` 的标记已陈旧,不会阻塞。意外实现故障会拒绝分发。取消仍具有最终决定权;后端会完成必需的闭合/flush 清理,命令内部以 `Compaction cancelled.` 结算,而命令执行器会因取消错误停止等待。 + +压缩运行期间提交的提示词仍会按 agent 的普通 FIFO 获得接纳,保留相同的身份与唤醒信息。它们仅在压缩的显式持久性检查点和接纳预留释放后启动。空闲注入的上下文不受阻塞:它可以记录在 `compact/start` 与 `compact/end` 之间,位置替换会使其在检查点之后保持可见。 + +## 组合 + +生产方注入 `commands` 和 `compact`。挂载命令注册表、一个后端与本插件: + +```yaml +- id: commands + name: '@deepseek-ai/dsh-commands' +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' +- id: command-compact + name: '@deepseek-ai/dsh-command-compact' +``` + +TUI 示例与 CLI host 将它挂载在 `compact-basic` 旁。未组合命令注册表的自动化接口只保留自动压缩。 + +## 模型体验 + +### 用户 `/compact` 控制 + +#### 模型看到什么 + +斜杠输入与直接结果绝不会进入模型请求。已获接纳的压缩会另外在独立的 `compact/* { turn: null }` 标记对内,用后端的 user 角色检查点替换一段较早范围。 + +#### Token 影响 + +命令生命周期不会增加模型 token。成功压缩会用一份带框架的摘要替换所选范围,从而减少后续请求;摘要生成本身需要一次辅助请求。 + +#### KV Cache 影响 + +命令发现与簿记不会影响缓存。已获接纳的 surface 替换会从第一个被遮蔽的历史 token 起使复用失效。 + +## 已知限制与暂缓事项 + +- **仅限空闲状态**:当一个轮次或已获接纳的唤醒提示词拥有优先权时,`/compact` 会报告 `busy`;命令本身不会排队。 +- **不接受范围或策略参数**:无参数形式使各命令适配器的行为保持稳定。显式范围仍由编程接口 `compactRegion()` 处理。 +- **仅限命令适配器**:没有 `ctx.commands` 的接口无法调用该命令,只能依赖自动压力压缩。 diff --git a/packages/compact/command-compact/package.json b/packages/compact/command-compact/package.json new file mode 100644 index 0000000000..3b009b45d7 --- /dev/null +++ b/packages/compact/command-compact/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-command-compact", + "description": "Human-facing slash command for explicit session compaction", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-commands": "^0.0.1", + "@deepseek-ai/dsh-compact": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/compact/command-compact/src/index.ts b/packages/compact/command-compact/src/index.ts new file mode 100644 index 0000000000..782057d8ec --- /dev/null +++ b/packages/compact/command-compact/src/index.ts @@ -0,0 +1,87 @@ +/** + * Human-facing `/compact` command over the backend-independent compaction seam. + * @module @deepseek-ai/dsh-command-compact + */ + +import type { Context } from 'cordis' +import { ManualCompactionError } from '@deepseek-ai/dsh-compact' +import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' + +export const name = 'command-compact' +export const inject = ['commands', 'compact'] + +const USAGE = 'Usage: /compact (no arguments)' + +/** Fail loudly if a locally closed union gains an unhandled member. */ +/* v8 ignore start -- closed-union backstop is unreachable without violating the TypeScript contract */ +function assertNever(value: never): never { + throw new TypeError(`unknown manual compaction error code: ${String(value)}`) +} +/* v8 ignore stop */ + +/** Convert expected capability failures into concise human-only outcomes. */ +function expectedFailure(error: ManualCompactionError): CommandResult { + switch (error.code) { + case 'busy': + return { + kind: 'error', + text: 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.', + } + case 'changed': + return { + kind: 'error', + text: 'The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.', + } + case 'summary': + return { + kind: 'error', + text: 'Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.', + } + case 'commit': + return { + kind: 'error', + text: 'Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.', + } + case 'persistence': + return { + kind: 'error', + text: 'Compaction finished, but the session could not be saved.', + } + /* v8 ignore next 2 -- ManualCompactionErrorCode is closed and every member is handled above */ + default: return assertNever(error.code) + } +} + +/** Execute one argument-free manual compaction request. */ +async function executeCompact( + ctx: Context, + invocation: CommandInvocation, +): Promise { + if (invocation.rawInput.trim().length > 0) { + return { kind: 'error', text: USAGE } + } + try { + const result = await ctx.compact.compactNow(invocation.agent, invocation.signal) + if (result === null) return { kind: 'success', text: 'No compactable history yet.' } + return { + kind: 'success', + text: `Compacted ${result.shadowedSeqs.length} history items (~${result.shadowedTokenCount} tokens).`, + } + } catch (error: unknown) { + if (invocation.signal.aborted) return { kind: 'error', text: 'Compaction cancelled.' } + if (error instanceof ManualCompactionError) return expectedFailure(error) + throw error + } +} + +/** + * Register `/compact` for every composed human-command adapter. + * @param ctx - context carrying the command registry and the compaction seam. + */ +export function apply(ctx: Context): void { + ctx.commands.register({ + name: 'compact', + description: 'Compact older conversation history', + handler: invocation => executeCompact(ctx, invocation), + }) +} diff --git a/packages/compact/command-compact/src/invariant.ts b/packages/compact/command-compact/src/invariant.ts new file mode 100644 index 0000000000..09b3c04d8d --- /dev/null +++ b/packages/compact/command-compact/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-command-compact`. + * @module @deepseek-ai/dsh-command-compact/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-command-compact' + +/** Cordis companion plugin name. */ +export const name = 'command-compact-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this command adapter owns no state or event stream; the compaction seam owns + * the balanced durable transaction and the command registry owns registration and dispatch lifecycle. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/compact/command-compact/tests/command-compact.spec.ts b/packages/compact/command-compact/tests/command-compact.spec.ts new file mode 100644 index 0000000000..3a3f8d8bb9 --- /dev/null +++ b/packages/compact/command-compact/tests/command-compact.spec.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import type { Agent } from '@deepseek-ai/dsh-agent' +import CommandService from '@deepseek-ai/dsh-commands' +import { + CompactService, + ManualCompactionError, + type CompactAgentContext, + type CompactionResult, + type CompactionTrigger, + type ManualCompactAgentContext, +} from '@deepseek-ai/dsh-compact' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import * as commandCompact from '@deepseek-ai/dsh-command-compact' + +const RESULT: CompactionResult = { + startSeq: 10, + summarySeq: 11, + endSeq: 13, + summary: [{ type: 'text', text: 'summary' }], + shadowedRange: { start: 1, end: 7 }, + shadowedSeqs: [1, 3, 7], + shadowedTokenCount: 42, +} + +class StubCompactService extends CompactService { + result: CompactionResult | null = RESULT + failure: unknown + operation: (() => Promise) | undefined + calls: { agent: ManualCompactAgentContext; signal: AbortSignal }[] = [] + + override compactIfNeeded( + _agent: CompactAgentContext, + _trigger: CompactionTrigger, + _signal: AbortSignal, + ): Promise { + return Promise.resolve(null) + } + + override compactRegion(): Promise { + return Promise.resolve(RESULT) + } + + override compactNow( + agent: ManualCompactAgentContext, + signal: AbortSignal, + ): Promise { + this.calls.push({ agent, signal }) + if (this.operation !== undefined) return this.operation() + return this.failure === undefined + ? Promise.resolve(this.result) + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise arbitrary backend rejection values. + : Promise.reject(this.failure) + } +} + +interface Harness { + readonly ctx: Context + readonly compact: StubCompactService + readonly agent: Agent + readonly plugin: Awaited> +} + +async function harness(): Promise { + const ctx = new Context() + await ctx.plugin(CommandService) + const compact = new StubCompactService(ctx) + const plugin = await ctx.plugin(commandCompact) + const session = new Session(SessionId('command-compact')) + const agent = { + session, + status: 'idle', + options: {}, + reserveTurnAdmission: () => () => undefined, + } as unknown as Agent + return { ctx, compact, agent, plugin } +} + +async function run( + test: Harness, + suffix = '', + controller = new AbortController(), +): Promise>>> { + const execution = await test.ctx.commands.execute(test.agent, `/compact${suffix}`, controller.signal) + if (execution === undefined) throw new Error('compact command was not registered') + return execution +} + +/** Assert the executor-owned lifecycle pair and absence from model history. */ +function expectLastLifecycle( + test: Harness, + args: string, + outcome: { readonly kind: 'success' | 'error'; readonly text?: string }, +): string { + const lifecycle = test.agent.session.events.slice(-2) + const runEvent = lifecycle[0] + const doneEvent = lifecycle[1] + if (runEvent?.type !== 'command/run' || doneEvent?.type !== 'command/done') { + throw new Error(`expected command lifecycle pair, got ${lifecycle.map(event => event.type).join(',')}`) + } + expect(lifecycle.map(event => ({ type: event.type, data: event.data }))).toEqual([ + { + type: 'command/run', + data: { + commandId: runEvent.data.commandId, + name: 'compact', + args, + source: { kind: 'user' }, + }, + }, + { + type: 'command/done', + data: { + commandId: runEvent.data.commandId, + ...outcome, + }, + }, + ]) + expect(doneEvent.data.commandId).toBe(runEvent.data.commandId) + expect(test.agent.session.surface.nodes).toEqual([]) + expect(test.agent.session.deriveMessages()).toEqual([]) + return runEvent.data.commandId +} + +describe('@deepseek-ai/dsh-command-compact registration', () => { + it('registers one argument-free command with Loader-safe exports and disposes it', async () => { + const test = await harness() + expect(commandCompact.name).toBe('command-compact') + expect(commandCompact.inject).toEqual(['commands', 'compact']) + expect('default' in commandCompact).toBe(false) + const loader = Object.create(Loader.prototype) as Loader + expect(loader.unwrapExports(commandCompact)).toBe(commandCompact) + expect(test.ctx.commands.list(test.agent)).toContainEqual({ + name: 'compact', + description: 'Compact older conversation history', + }) + + await test.plugin.dispose() + expect(test.ctx.commands.find(test.agent, 'compact')).toBeUndefined() + }) +}) + +describe('/compact human command', () => { + it('reports success with useful accounting and forwards the exact target and signal', async () => { + const test = await harness() + const controller = new AbortController() + const execution = await run(test, '', controller) + expect(execution.result).toEqual({ + kind: 'success', + text: 'Compacted 3 history items (~42 tokens).', + }) + expect(execution.commandId).toBe(expectLastLifecycle(test, '', execution.result)) + expect(test.compact.calls).toEqual([{ agent: test.agent, signal: controller.signal }]) + }) + + it('returns direct no-history and argument-rejection results', async () => { + const test = await harness() + test.compact.result = null + const empty = await run(test) + expect(empty.result).toEqual({ + kind: 'success', + text: 'No compactable history yet.', + }) + expect(empty.commandId).toBe(expectLastLifecycle(test, '', empty.result)) + + const rejected = await run(test, ' now') + expect(rejected.result).toEqual({ + kind: 'error', + text: 'Usage: /compact (no arguments)', + }) + expect(rejected.commandId).toBe(expectLastLifecycle(test, ' now', rejected.result)) + expect(test.compact.calls).toHaveLength(1) + }) + + it.each([ + ['busy', 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.'], + ['changed', 'The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.'], + ['summary', 'Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.'], + ['commit', 'Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.'], + ['persistence', 'Compaction finished, but the session could not be saved.'], + ] as const)('maps expected %s failures to direct errors', async (code, text) => { + const test = await harness() + test.compact.failure = new ManualCompactionError(code, 'backend detail') + const execution = await run(test) + expect(execution.result).toEqual({ kind: 'error', text }) + expect(execution.commandId).toBe(expectLastLifecycle(test, '', execution.result)) + }) + + it('preserves cancellation and unexpected implementation failures', async () => { + const cancelled = await harness() + const controller = new AbortController() + const abort = new Error('operator cancelled') + cancelled.compact.operation = () => { + controller.abort(abort) + return Promise.reject(new ManualCompactionError('summary', 'late failure')) + } + await expect(run(cancelled, '', controller)).rejects.toBe(abort) + expectLastLifecycle(cancelled, '', { kind: 'error', text: abort.message }) + + const unexpected = await harness() + const bug = new Error('unexpected backend bug') + unexpected.compact.failure = bug + await expect(run(unexpected)).rejects.toBe(bug) + expectLastLifecycle(unexpected, '', { kind: 'error', text: bug.message }) + }) +}) diff --git a/packages/compact/command-compact/tests/invariant.spec.ts b/packages/compact/command-compact/tests/invariant.spec.ts new file mode 100644 index 0000000000..c5aa4dc3dc --- /dev/null +++ b/packages/compact/command-compact/tests/invariant.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from 'vitest' +import * as invariant from '@deepseek-ai/dsh-command-compact/invariant' + +describe('command-compact invariant companion', () => { + it('registers the package-owned no-op installer', async () => { + const register = vi.fn().mockReturnValue(() => {}) + const ctx = { invariants: { register } } as never + const dispose = await invariant.apply(ctx) + expect(invariant.name).toBe('command-compact-invariant') + expect(invariant.inject).toEqual(['invariants']) + expect(register).toHaveBeenCalledWith('@deepseek-ai/dsh-command-compact', expect.any(Function)) + expect(() => { + const install = register.mock.calls[0]![1] as () => void + install() + }).not.toThrow() + expect(dispose).toBeTypeOf('function') + }) +}) diff --git a/packages/compact/command-compact/tests/loader-composition.spec.ts b/packages/compact/command-compact/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..1ebfac01db --- /dev/null +++ b/packages/compact/command-compact/tests/loader-composition.spec.ts @@ -0,0 +1,134 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import type { Agent } from '@deepseek-ai/dsh-agent' +import CommandService from '@deepseek-ai/dsh-commands' +import { + CompactService, + type CompactAgentContext, + type CompactionResult, + type CompactionTrigger, + type ManualCompactAgentContext, +} from '@deepseek-ai/dsh-compact' +import * as commandCompact from '@deepseek-ai/dsh-command-compact' +import { Session, SessionId } from '@deepseek-ai/dsh-session' + +const RESULT: CompactionResult = { + startSeq: 1, + summarySeq: 2, + endSeq: 4, + summary: [{ type: 'text', text: 'loader summary' }], + shadowedRange: { start: 3, end: 8 }, + shadowedSeqs: [3, 5, 8], + shadowedTokenCount: 99, +} + +class LoaderCompactService extends CompactService { + override compactIfNeeded( + _agent: CompactAgentContext, + _trigger: CompactionTrigger, + _signal: AbortSignal, + ): Promise { + return Promise.resolve(null) + } + + override compactRegion(): Promise { + return Promise.resolve(RESULT) + } + + override compactNow( + _agent: ManualCompactAgentContext, + _signal: AbortSignal, + ): Promise { + return Promise.resolve(RESULT) + } +} + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +describe('command-compact real Loader composition', () => { + it('discovers and executes /compact through the assembled command plane', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-command-compact-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-commands'", + "- name: '@test/compact-backend'", + "- name: '@deepseek-ai/dsh-command-compact'", + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-commands', CommandService], + ['@test/compact-backend', LoaderCompactService], + ['@deepseek-ai/dsh-command-compact', commandCompact], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + + const session = new Session(SessionId('loader-command-compact')) + const agent = { + session, + status: 'idle', + options: {}, + reserveTurnAdmission: () => () => undefined, + } as unknown as Agent + expect(context.commands.list(agent)).toContainEqual({ + name: 'compact', + description: 'Compact older conversation history', + }) + const execution = await context.commands.execute(agent, '/compact', new AbortController().signal) + if (execution === undefined) throw new Error('Loader composition did not resolve /compact') + expect(execution.result).toEqual({ + kind: 'success', + text: 'Compacted 3 history items (~99 tokens).', + }) + expect(session.events.map(event => ({ type: event.type, data: event.data }))).toEqual([ + { + type: 'command/run', + data: { + commandId: execution.commandId, + name: 'compact', + args: '', + source: { kind: 'user' }, + }, + }, + { + type: 'command/done', + data: { + commandId: execution.commandId, + kind: 'success', + text: 'Compacted 3 history items (~99 tokens).', + }, + }, + ]) + expect(session.surface.nodes).toEqual([]) + expect(session.deriveMessages()).toEqual([]) + }) +}) diff --git a/packages/compact/command-compact/tsconfig.json b/packages/compact/command-compact/tsconfig.json new file mode 100644 index 0000000000..f99f2b98b4 --- /dev/null +++ b/packages/compact/command-compact/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../ui/commands" + }, + { + "path": "../compact" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/compact/compact-basic/README.i18n.yaml b/packages/compact/compact-basic/README.i18n.yaml index c7cb49d338..b410a75304 100644 --- a/packages/compact/compact-basic/README.i18n.yaml +++ b/packages/compact/compact-basic/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/compact/compact-basic/README.md -README.md: 775355f1ac1a7c79c16f66a5b2489d73df7b960d -README.zh.md: bfa139596b5ef61c23d29575bdea5534fa82b158 +README.md: 49b350758b65ada552cba48549ae7976b57119e8 +README.zh.md: 38350b413af6cc968a3d07bef09a7f7e78dc1a5f diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 775355f1ac..49b350758b 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -17,11 +17,11 @@ This backend owns the compaction policy: - **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/step` listener checks pressure before request derivation. A canonical provider overflow is offered through `agent/request-error` after the failed step; the plugin compacts there and returns a retry action only after durable surface progress. +- **Lifecycle** — all entry points share one bracket-first region transaction. It validates the range and live lock, appends `compact/start` synchronously, prepares and awaits the summary, revalidates, appends provenance plus the replacement, and makes exactly one closing attempt. Automatic and explicit-region calls require a numeric open-turn owner and whole-surface stability. `compactNow()` reserves idle admission, uses `turn: null`, accepts append-only context outside its selected span, flushes every closed attempt, and releases admission in `finally`. - **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 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. +- **Failure handling** — a live unmatched `compact/start` is the durable lock. An unmatched marker before a newer `session/end-seed` is stale evidence from a prior lifecycle and does not block; one after that boundary reports `busy`. Summary and changed-span failures close with an error and leave the conversation surface untouched, though the attempt remains in the log. A failed close deliberately leaves a blocking orphan. 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 cleanup and durability. -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`. +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 safe summary plus the complete provider output, call envelope, and usage when available (`{ summary, rawOutput?, provider, model, maxTokens?, usage? }`); the transaction preserves those fields on `compact/summary`. ## Config (`BasicCompactConfig`) @@ -60,7 +60,7 @@ export function apply(ctx: Context): void { } ``` -Loading the plugin registers `ctx.compact`. Add [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) as a sibling before this plugin to enable the optional model-free pass. With `auto: true` (the default) it compacts automatically under token pressure; a consumer (a future `/compact` tool) can also call `ctx.compact.compactIfNeeded(...)` or `ctx.compact.compactRegion(...)` directly. +Loading the plugin registers `ctx.compact`. Add [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) as a sibling before this plugin to enable the optional model-free pass. With `auto: true` (the default) it compacts automatically under token pressure. The sibling [`dsh-command-compact`](../command-compact/README.md) calls `ctx.compact.compactNow(...)`; programmatic callers may also use any seam operation directly. For example, the same compact plugin can safely serve models with different capacities and one target-specific policy: diff --git a/packages/compact/compact-basic/README.zh.md b/packages/compact/compact-basic/README.zh.md index bfa139596b..38350b413a 100644 --- a/packages/compact/compact-basic/README.zh.md +++ b/packages/compact/compact-basic/README.zh.md @@ -17,11 +17,11 @@ - **收敛**:最多按 `compactionRetries` 重试头部检查点压缩;拒绝不能缩小源内容的摘要,如果重试仍无法回到阈值以下,则抛出异常。 - **摘要**:直接 `llm/stream` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 agent 目标,而不运行仅用于 agent loop 的 `agent/request` seam。该调用会逐字回放会话自身的系统提示词、工具与已遮蔽区域消息,并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache,而非使它失效。它将 `GenerateOptions.purpose` 设为 `compaction`,适配器可将其作为请求归因转发(DeepSeek 适配器发送 `x-deepseek-harness-compact: 1`),但不会触碰模型可见的请求体。只有返回的文本会进入检查点;推理(reasoning)和工具调用都会被排除,以免泄露私有推理或产生遗留调用。 - **框定**:替换 user 消息使用 `` 标签标记已建立的检查点上下文。原始摘要保留在溯源事件上,后续自动周期会合并之前的检查点。 -- **生命周期**:`compactRegion()` 会更改 `agent.session`,并记录开始、摘要、替换与结束。异步摘要后,如果表层节点快照已改变,它会拒绝操作,而不相关的仅日志事件可以追加,不会使已选 span 失效。串行 `agent/step` listener 会在派生请求之前检查压力。规范提供方溢出会在失败步骤之后经由 `agent/request-error` 交给本插件;插件在此执行压缩,并且只在表层取得持久进展后才返回重试动作。 +- **生命周期**:所有入口点共享一个先记录标记的区域事务。它会验证范围与活动锁,同步追加 `compact/start`,准备并等待摘要,重新验证,再追加溯源信息和替换,最后恰好进行一次闭合尝试。自动调用和显式范围调用要求数字标识的开放轮次归属,并要求整个表层保持稳定。`compactNow()` 会预留空闲接纳,使用 `turn: null`,允许所选 span 之外追加仅追加上下文,flush 每次已闭合尝试,并在 `finally` 中释放接纳预留。 - **溢出恢复**:提供方已确认的溢出不需容量元数据。它会绕过常规压力与保留,执行剪枝,再尝试一次最大平衡头部缩减,并留下最新不可分单元。只要 `surface.replaceGeneration` 前进,就允许重试,包括剪枝在后续摘要工作抛出异常前已落地的情况。如果没有替换、目标特定上限已耗尽、已取消,或遇到未知/非规范错误,则保留原始提供方失败。 -- **失败处理**:未配对的 `compact/start` 是不起作用的崩溃标记,因为没有摘要替换落地。区域失败会记录错误结束;除非剪枝已落地,否则表层保持不变。压力检查中的运行故障会发出警告并继续;只有此前没有替换推进表层时,溢出恢复失败才保留原始提供方错误。即使已经取得进展,取消仍具有最终决定权。 +- **失败处理**:活动的未匹配 `compact/start` 是持久锁。位于较新 `session/end-seed` 之前的未匹配标记,是先前生命周期留下的陈旧证据,不会阻塞;位于该边界之后的标记报告 `busy`。摘要和 span 变更失败会以错误闭合,并保持会话表层不变,但日志中仍保留该尝试。闭合失败会有意留下阻塞性的未匹配标记。压力检查中的运行故障会发出警告并继续;只有此前没有替换推进表层时,溢出恢复失败才保留原始提供方错误。完成清理与持久化后,取消仍具有最终决定权。 -受保护的 `summarize()` 方法是唯一的子类钩子。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍由 `ctx.tokenMeter` 负责。钩子会将摘要块与它使用的调用 envelope 一并返回(`{ summary, provider, model, maxTokens? }`),并记录在 `compact/summary` 上。 +受保护的 `summarize()` 方法是唯一的子类钩子。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍由 `ctx.tokenMeter` 负责。钩子返回安全摘要,以及完整提供方输出、调用 envelope 和可用时的 usage(`{ summary, rawOutput?, provider, model, maxTokens?, usage? }`);事务会在 `compact/summary` 上保留这些字段。 ## 配置(`BasicCompactConfig`) @@ -60,7 +60,7 @@ export function apply(ctx: Context): void { } ``` -加载插件会注册 `ctx.compact`。在该插件之前添加同级 [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) 以启用可选的不依赖模型的处理阶段。当 `auto: true`(默认)时,它会在 token 压力下自动压缩;消费方(未来的 `/compact` 工具)也可直接调用 `ctx.compact.compactIfNeeded(...)` 或 `ctx.compact.compactRegion(...)`。 +加载插件会注册 `ctx.compact`。在该插件之前添加同级 [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) 以启用可选的不依赖模型的处理阶段。当 `auto: true`(默认)时,它会在 token 压力下自动压缩。同级 [`dsh-command-compact`](../command-compact/README.md) 调用 `ctx.compact.compactNow(...)`;编程调用方也可以直接使用任一 seam 操作。 例如,同一个压缩插件可以安全服务于容量不同的模型,并应用一项目标特定策略: diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index da0ca469a3..f64e05280d 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -6,11 +6,12 @@ import { Context } from 'cordis' import z from 'schemastery' -import { CompactService } from '@deepseek-ai/dsh-compact' +import { CompactService, ManualCompactionError } from '@deepseek-ai/dsh-compact' import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact' +import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter' import type { Session } from '@deepseek-ai/dsh-session' import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, LlmCallConfig } from '@deepseek-ai/dsh-llm' +import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' // Type-only: makes the optional sibling service available to `ctx.get()`. import type {} from '@deepseek-ai/dsh-compact-tool-result-prune' @@ -20,9 +21,13 @@ import { resolveTargetPolicy, TargetPressureConfigError, } from './config.ts' -import { compactSurfaceRegion, selectCompactableRange } from './region.ts' +import { + assertNoActiveCompaction, + compactSurfaceRegion, + selectCompactableRange, +} from './region.ts' import { summarizeWithLlm } from './summarizer.ts' -import type { SummarizationInput } from './summarizer.ts' +import type { SummarizationInput, SummaryResult } from './summarizer.ts' import type { BasicCompactConfig, ModelCompactPolicyConfig, @@ -39,6 +44,9 @@ export type { ResolvedTargetPolicy, } from './types.ts' +/** The region transaction's view of this service's dynamically dispatched summarizer. */ +type RegionSummarize = (input: SummarizationInput, agent: Agent, signal?: AbortSignal) => Promise + /** Resolve the exact provider/model durably routed for the latest request. */ function routedTarget( session: Session, @@ -92,7 +100,7 @@ const modelPolicy: z = z.object({ * token meter. */ export class BasicCompactService extends CompactService { - static inject = ['llm', 'tokenMeter'] + static inject = ['llm', 'tokenMeter', 'sessions'] static Config: z = z.object({ thresholdRatio: thresholdRatioSchema, @@ -235,7 +243,7 @@ export class BasicCompactService extends CompactService { input: SummarizationInput, agent: Agent, signal?: AbortSignal, - ): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> { + ): Promise { const target = conversationTarget(agent) const config = target === undefined ? this.config @@ -289,6 +297,7 @@ export class BasicCompactService extends CompactService { } const context = (await this.ctx.llm.resolveModelInfo(target.provider, target.model, signal)).context + assertNoActiveCompaction(agent.session, 'automatic pressure compaction') const targetKey = `${target.provider}/${target.model}` if (context === undefined) { throw new TargetPressureConfigError( @@ -343,11 +352,67 @@ export class BasicCompactService extends CompactService { agent: Agent, signal?: AbortSignal, ): Promise { - const session = agent.session - return compactSurfaceRegion({ + return compactSurfaceRegion( + this.regionDependencies(), + agent.session, + start, + end, + agent, + { owner: 'current-turn', stability: 'whole-surface' }, + signal, + ) + } + + /** + * Force one useful idle-session compaction below the pressure threshold, and + * resolve only after its standalone marker pair is durably checkpointed. + * @param agent - idle agent whose next-turn admission this call reserves. + * @param signal - command-owned cancellation forwarded to summarization. + * @returns the committed result, or `null` when no safe useful range exists. + */ + override async compactNow( + agent: Agent, + signal: AbortSignal, + ): Promise { + signal.throwIfAborted() + const releaseTurnAdmission = agent.reserveTurnAdmission() + if (releaseTurnAdmission === undefined) { + throw new ManualCompactionError( + 'busy', + 'manual compaction requires an idle agent with no waking queued work', + ) + } + try { + const range = selectCompactableRange( + agent.session, + this.ctx.tokenMeter.measure(agent.session), + 0, + ) + if (range === null) return null + return await compactSurfaceRegion( + this.regionDependencies(), + agent.session, + range.start, + range.end, + agent, + { + owner: null, + stability: 'selected-span', + flush: () => this.ctx.sessions.flush(agent.session), + }, + signal, + ) + } finally { + releaseTurnAdmission() + } + } + + /** Bind the effective token meter and dynamically dispatched summarizer hook. */ + private regionDependencies(): { meter: TokenMeterService; summarize: RegionSummarize } { + return { meter: this.ctx.tokenMeter, summarize: (input, owner, abort) => this.summarize(input, owner, abort), - }, session, start, end, agent, signal) + } } } diff --git a/packages/compact/compact-basic/src/region.ts b/packages/compact/compact-basic/src/region.ts index c236eea843..c1331d6d66 100644 --- a/packages/compact/compact-basic/src/region.ts +++ b/packages/compact/compact-basic/src/region.ts @@ -1,5 +1,6 @@ /** - * Surface retention selection and the log-recorded compaction transaction. + * Surface retention selection and the shared log-recorded compaction + * transaction for automatic open-turn and manual idle-session compaction. * * @module @deepseek-ai/dsh-compact-basic/region */ @@ -7,12 +8,13 @@ import { isDeepStrictEqual } from 'node:util' import { COMPACT_CHECKPOINT_SOURCE, + ManualCompactionError, toolPairingBalancedAfter, toolPairingBalancedBefore, } from '@deepseek-ai/dsh-compact' import type { CompactionResult } from '@deepseek-ai/dsh-compact' -import { createUserMessage } from '@deepseek-ai/dsh-llm' -import type { Message } from '@deepseek-ai/dsh-llm' +import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' +import type { Message, UserMessage } from '@deepseek-ai/dsh-llm' import type { TokenMeasurement, TokenMeterService } from '@deepseek-ai/dsh-token-meter' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -24,6 +26,62 @@ interface RegionDependencies { summarize(input: SummarizationInput, agent: Agent, signal?: AbortSignal): Promise } +/** One validated inclusive span of current surface positions. */ +interface SurfaceSelection { + readonly start: number + readonly end: number + readonly startIdx: number + readonly endIdx: number + readonly shadowedSeqs: readonly number[] +} + +/** A selection with its priced snapshot and the replay input built from it. */ +interface PreparedCompaction extends SurfaceSelection { + readonly measurement: TokenMeasurement + readonly selectedNodes: TokenMeasurement['nodes'] + readonly shadowedTokenCount: number + readonly input: SummarizationInput +} + +interface SummarizedCompaction extends PreparedCompaction, SummaryResult { + readonly checkpointMessage: UserMessage +} + +interface CompactionTransactionOptions { + /** `current-turn` derives a numbered owner; `null` writes a standalone bracket. */ + readonly owner: 'current-turn' | null + /** Surface relationship that must survive asynchronous summarization. */ + readonly stability: 'whole-surface' | 'selected-span' + /** Optional durability checkpoint after a successfully closed bracket. */ + readonly flush?: () => Promise +} + +interface TurnTail { + readonly turn: number | null + readonly compactionStart: SessionEvent<'compact/start'> | undefined + readonly endSeedSeq: number | undefined +} + +/** + * Rejects a summary whose replacement boundaries are no longer the ones it was + * built from, distinguished from summarizer and shrink failures so a manual + * caller can report the two causes differently. + */ +class SurfaceChangedError extends Error {} + +/** Whether the summary may still replace the span it was built from. */ +type StabilityCheck = ( + dependencies: RegionDependencies, + session: Session, + prepared: PreparedCompaction, +) => void + +/** Failure captured after `compact/start` has committed. */ +interface TransactionFailure { + readonly error: unknown + readonly stage: 'summary' | 'commit' +} + /** * Resolve the next head-anchored range while retaining a priced recent tail * and never splitting an assistant tool-call/result pair. @@ -71,12 +129,18 @@ export function selectCompactableRange( } /** - * Validate and compact one positional surface span. + * Run the single compaction transaction over one selected positional span. + * Selection and validation are read-only. Idle/log validation and + * `compact/start` are synchronously adjacent, so the durable opening marker is + * the compaction lock before summarization yields. Every later failure makes + * exactly one `compact/end` attempt; a failed close deliberately leaves the + * unmatched start detectable. * @param dependencies - conversation meter and dynamically dispatched summarizer hook. * @param session - session whose surface is mutated. * @param start - inclusive first surface-node seq. * @param end - inclusive last surface-node seq. * @param agent - agent used by the summarizer. + * @param options - bracket owner, stability rule, and optional durability checkpoint. * @param signal - optional summarization cancellation signal. * @returns the successful durable compaction result. */ @@ -86,8 +150,142 @@ export async function compactSurfaceRegion( start: number, end: number, agent: Agent, + options: CompactionTransactionOptions, signal?: AbortSignal, ): Promise { + if (options.owner === null) signal?.throwIfAborted() + const selection = validateSurfaceRegion(session, start, end) + const tail = inspectTurnTail(session.events) + assertCompactionInactive(tail.compactionStart, tail.endSeedSeq, 'compaction') + + let owner: number | null + if (options.owner === null) { + if (tail.turn !== null) { + throw new ManualCompactionError('busy', 'manual compaction: the session already has an open turn') + } + owner = null + } else { + if (tail.turn === null) { + throw new Error('compactRegion: no open turn — automatic compaction events must be enclosed in a turn') + } + owner = tail.turn + } + + const startEvent = session.append('compact/start', { turn: owner }) + const assertStable: StabilityCheck = options.stability === 'whole-surface' + ? assertWholeSurfaceUnchanged + : assertSelectedSpanStable + let failure: TransactionFailure | undefined + let flushFailure: unknown + let result: CompactionResult | undefined + let closed = false + let closing = false + let stage: TransactionFailure['stage'] = 'summary' + + try { + const prepared = prepareCompaction(dependencies, session, selection) + const summarized = await summarizeCompaction(dependencies, prepared, agent, signal) + if (options.owner === null) signal?.throwIfAborted() + assertStable(dependencies, session, summarized) + stage = 'commit' + const pending = commitCompactionBody(session, startEvent, summarized) + closing = true + const endEvent = session.append('compact/end', { turn: owner }) + closed = true + result = completeCompaction(pending, endEvent) + } catch (error: unknown) { + failure = { error, stage: closing ? 'commit' : stage } + if (!closing) { + closing = true + try { + session.append('compact/end', { turn: owner, error: errorChain(error) }) + closed = true + } catch (closeError: unknown) { + failure = { error: closeError, stage: 'commit' } + } + } + } + + if (closed && options.flush !== undefined) { + try { + await options.flush() + } catch (error: unknown) { + flushFailure = error + } + } + + if (options.owner === null) signal?.throwIfAborted() + if (failure !== undefined) { + if (options.owner === null) throwManualFailure(failure) + throw failure.error + } + if (flushFailure !== undefined) { + throw new ManualCompactionError( + 'persistence', + 'manual compaction durability checkpoint failed', + { cause: flushFailure }, + ) + } + /* v8 ignore next -- every path without a result records and throws a failure above. */ + if (result === undefined) throw new Error('compaction committed without a result') + return result +} + +/** Classify one closed manual attempt without weakening cancellation precedence. */ +function throwManualFailure(failure: TransactionFailure): never { + if (failure.stage === 'commit') { + throw new ManualCompactionError( + 'commit', + 'manual compaction did not commit cleanly', + { cause: failure.error }, + ) + } + if (failure.error instanceof SurfaceChangedError) { + throw new ManualCompactionError( + 'changed', + 'the compacted history changed during manual compaction', + { cause: failure.error }, + ) + } + throw new ManualCompactionError( + 'summary', + 'manual compaction could not produce a smaller summary', + { cause: failure.error }, + ) +} + +/** + * Reject a durable unmatched compaction marker unless a later constructor-seed + * boundary proves that its owner belongs to an earlier session lifecycle. + * @param compactionStart - latest unmatched opening marker, if any. + * @param endSeedSeq - newest constructor-seed boundary, if any. + * @param stage - operation label included in the busy diagnostic. + */ +function assertCompactionInactive( + compactionStart: SessionEvent<'compact/start'> | undefined, + endSeedSeq: number | undefined, + stage: string, +): void { + if (compactionStart === undefined + || (endSeedSeq !== undefined && endSeedSeq > compactionStart.seq)) return + throw new ManualCompactionError( + 'busy', + `${stage}: compaction already in progress; the session compaction lock is already active`, + ) +} + +/** + * Recheck the durable compaction lock after an asynchronous policy decision. + * @param session - session whose latest marker state is inspected. + * @param stage - operation label included in the busy diagnostic. + */ +export function assertNoActiveCompaction(session: Session, stage: string): void { + const tail = inspectTurnTail(session.events) + assertCompactionInactive(tail.compactionStart, tail.endSeedSeq, stage) +} + +/** Validate one requested surface-position span before asynchronous work begins. */ +function validateSurfaceRegion(session: Session, start: number, end: number): SurfaceSelection { const nodes = session.surface.nodes const startIdx = nodes.indexOf(start) const endIdx = nodes.indexOf(end) @@ -107,75 +305,145 @@ export async function compactSurfaceRegion( throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`) } - const tail = inspectTurnTail(session.events) - if (tail.compactionInProgress) throw new Error('compaction already in progress') - if (tail.turn === null) { - throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn') - } + return { start, end, startIdx, endIdx, shadowedSeqs: nodes.slice(startIdx, endIdx + 1) } +} - const shadowedSeqs = nodes.slice(startIdx, endIdx + 1) - const startEvent = session.append('compact/start', { turn: tail.turn }) +/** Snapshot pricing and replay input for a validated surface range. */ +function prepareCompaction( + dependencies: RegionDependencies, + session: Session, + selection: SurfaceSelection, +): PreparedCompaction { + const measurement = dependencies.meter.measure(session) + const selectedNodes = measurement.nodes.slice(selection.startIdx, selection.endIdx + 1) + if (selectedNodes.length !== selection.shadowedSeqs.length + || selectedNodes.some((node, index) => node.seq !== selection.shadowedSeqs[index])) { + throw new SurfaceChangedError('compaction: selected surface changed before summarization began') + } + return { + ...selection, + measurement, + selectedNodes, + shadowedTokenCount: selectedNodes.reduce((total, node) => total + node.tokens, 0), + input: buildSummarizationInput(session, selection.shadowedSeqs), + } +} + +/** Run the summarizer and frame its replacement checkpoint. */ +async function summarizeCompaction( + dependencies: RegionDependencies, + prepared: PreparedCompaction, + agent: Agent, + signal?: AbortSignal, +): Promise { + const summaryResult = await dependencies.summarize(prepared.input, agent, signal) + const checkpointMessage = createUserMessage({ + content: frameSummary(summaryResult.summary), + source: COMPACT_CHECKPOINT_SOURCE, + }) + const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage) + if (framedSummaryTokenCount >= prepared.shadowedTokenCount) { + throw new Error( + `summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${prepared.shadowedTokenCount})`, + ) + } + return { + ...prepared, + ...summaryResult, + checkpointMessage, + } +} + +/** Reject a summary prepared against any earlier surface generation. */ +function assertWholeSurfaceUnchanged( + dependencies: RegionDependencies, + session: Session, + prepared: PreparedCompaction, +): void { + const current = dependencies.meter.measure(session) + if (!isDeepStrictEqual(current.nodes, prepared.measurement.nodes)) { + throw new SurfaceChangedError('compaction: session surface changed during summarization') + } +} + +/** + * Require only that the selected span remain the same present, contiguous, + * equally priced, balanced replacement target. Nodes added outside it remain + * visible and do not invalidate the summary. + */ +function assertSelectedSpanStable( + dependencies: RegionDependencies, + session: Session, + prepared: PreparedCompaction, +): void { + let current: SurfaceSelection try { - // Capture after the lock event so a later surface mutation invalidates the - // async selection before replacement. Unrelated log-only facts may append. - const lockedMeasurement = dependencies.meter.measure(session) - const selected = lockedMeasurement.nodes.slice(startIdx, endIdx + 1) - if (selected.length !== shadowedSeqs.length - || selected.some((node, index) => node.seq !== shadowedSeqs[index])) { - throw new Error('compaction: selected surface changed before summarization began') - } - const shadowedTokenCount = selected.reduce((total, node) => total + node.tokens, 0) - const summarizationInput = buildSummarizationInput(session, shadowedSeqs) - const { - summary, rawOutput, provider, model, maxTokens, usage, - } = await dependencies.summarize(summarizationInput, agent, signal) - - const currentMeasurement = dependencies.meter.measure(session) - if (!isDeepStrictEqual(currentMeasurement.nodes, lockedMeasurement.nodes)) { - throw new Error('compaction: session surface changed during summarization') - } - const framedSummary = frameSummary(summary) - const checkpointMessage = createUserMessage({ - content: framedSummary, - source: COMPACT_CHECKPOINT_SOURCE, - }) - const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage) - if (framedSummaryTokenCount >= shadowedTokenCount) { - throw new Error( - `summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${shadowedTokenCount})`, - ) - } - - const summaryEvent = session.append('compact/summary', { - summary, - ...rawOutput === undefined ? {} : { rawOutput }, - shadowedRange: { start, end }, - shadowedSeqs, - shadowedTokenCount, - provider, - model, - ...maxTokens === undefined ? {} : { maxTokens }, - ...usage === undefined ? {} : { usage }, - }) - session.append('user/message', checkpointMessage, { - surfaceOp: { op: 'replace', start, end }, - sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs], - }) - const endEvent = session.append('compact/end', { turn: tail.turn }) - return { - startSeq: startEvent.seq, - summarySeq: summaryEvent.seq, - endSeq: endEvent.seq, - summary, - shadowedRange: { start, end }, - shadowedSeqs, - shadowedTokenCount, - } + current = validateSurfaceRegion(session, prepared.start, prepared.end) } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error) - session.append('compact/end', { turn: tail.turn, error: message }) - throw error + throw new SurfaceChangedError( + 'compaction: the selected span is no longer a valid replacement target', + { cause: error }, + ) } + if (!isDeepStrictEqual([...current.shadowedSeqs], [...prepared.shadowedSeqs])) { + throw new SurfaceChangedError('compaction: the selected span changed during summarization') + } + const measured = dependencies.meter.measure(session).nodes.slice(current.startIdx, current.endIdx + 1) + if (!isDeepStrictEqual(measured, prepared.selectedNodes)) { + throw new SurfaceChangedError('compaction: the selected span was rewritten during summarization') + } +} + +/** Append one already-summarized provenance and replacement body without yielding. */ +function commitCompactionBody( + session: Session, + startEvent: SessionEvent<'compact/start'>, + summarized: SummarizedCompaction, +): Omit { + const { + start, + end, + shadowedSeqs, + shadowedTokenCount, + summary, + rawOutput, + provider, + model, + maxTokens, + usage, + checkpointMessage, + } = summarized + const summaryEvent = session.append('compact/summary', { + summary, + ...rawOutput === undefined ? {} : { rawOutput }, + shadowedRange: { start, end }, + shadowedSeqs: [...shadowedSeqs], + shadowedTokenCount, + provider, + model, + ...maxTokens === undefined ? {} : { maxTokens }, + ...usage === undefined ? {} : { usage }, + }) + session.append('user/message', checkpointMessage, { + surfaceOp: { op: 'replace', start, end }, + sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs], + }) + return { + startSeq: startEvent.seq, + summarySeq: summaryEvent.seq, + summary, + shadowedRange: { start, end }, + shadowedSeqs: [...shadowedSeqs], + shadowedTokenCount, + } +} + +/** Attach the successfully appended close event to a pending result. */ +function completeCompaction( + pending: Omit, + endEvent: SessionEvent<'compact/end'>, +): CompactionResult { + return { ...pending, endSeq: endEvent.seq } } /** @@ -206,25 +474,36 @@ function buildSummarizationInput( } } -/** Inspect the current turn boundary and latest compaction bracket once. */ -function inspectTurnTail( - events: readonly SessionEvent[], -): { turn: number | null; compactionInProgress: boolean } { - let compactionInProgress = false +/** Inspect turn state, unmatched compaction, and newest seed boundary independently. */ +function inspectTurnTail(events: readonly SessionEvent[]): TurnTail { + let turn: number | null = null + let turnStateKnown = false + let compactionStart: SessionEvent<'compact/start'> | undefined let compactionStateKnown = false + let endSeedSeq: number | undefined for (let index = events.length - 1; index >= 0; index -= 1) { // oxlint-disable-next-line typescript/no-non-null-assertion const event = events[index]! + if (endSeedSeq === undefined && event.type === 'session/end-seed') { + endSeedSeq = event.seq + } if (!compactionStateKnown) { if (event.type === 'compact/start') { - compactionInProgress = true + compactionStart = event compactionStateKnown = true } else if (event.type === 'compact/end') { compactionStateKnown = true } } - if (event.type === 'turn/start') return { turn: event.data.turn, compactionInProgress } - if (event.type === 'turn/end') return { turn: null, compactionInProgress } + if (!turnStateKnown) { + if (event.type === 'turn/start') { + turn = event.data.turn + turnStateKnown = true + } else if (event.type === 'turn/end') { + turnStateKnown = true + } + } + if (turnStateKnown && compactionStateKnown && endSeedSeq !== undefined) break } - return { turn: null, compactionInProgress } + return { turn, compactionStart, endSeedSeq } } diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 2b3cdecf17..3ea841ee9a 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -21,7 +21,7 @@ import type { StreamChunk, TokenUsage, } from '@deepseek-ai/dsh-llm' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import { agentEvents, type Agent, type RequestErrorAction } from '@deepseek-ai/dsh-agent' import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' @@ -1781,6 +1781,7 @@ describe('automatic listener and loader composition', () => { it('loads and disposes the real zero-config service stack', async () => { const ctx = new Context() await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) const meterFiber = await ctx.plugin(TokenMeterService) const compactFiber = await ctx.plugin(BasicCompactService, { auto: false }) diff --git a/packages/compact/compact-basic/tests/loader-composition.spec.ts b/packages/compact/compact-basic/tests/loader-composition.spec.ts index 74f8423e52..dc75828f2e 100644 --- a/packages/compact/compact-basic/tests/loader-composition.spec.ts +++ b/packages/compact/compact-basic/tests/loader-composition.spec.ts @@ -7,6 +7,7 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import BasicCompactService from '@deepseek-ai/dsh-compact-basic' import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' @@ -32,6 +33,7 @@ async function loadYaml(lines: readonly string[]): Promise { context.loader.builtins.include = Include const modules = new Map([ ['@deepseek-ai/dsh-llm', LlmService], + ['@deepseek-ai/dsh-session', SessionStore], ['@deepseek-ai/dsh-token-meter', TokenMeterService], ['@deepseek-ai/dsh-compact-tool-result-prune', ToolResultPruneService], ['@deepseek-ai/dsh-compact-basic', BasicCompactService], @@ -55,6 +57,7 @@ describe('real Loader composition', () => { it('loads the shipped token-meter, pruning, and compact-basic YAML order', async () => { const loaded = await loadYaml([ "- name: '@deepseek-ai/dsh-llm'", + "- name: '@deepseek-ai/dsh-session'", "- name: '@deepseek-ai/dsh-token-meter'", "- name: '@deepseek-ai/dsh-compact-tool-result-prune'", ' config:', @@ -91,6 +94,7 @@ describe('real Loader composition', () => { it('rejects stale compact-basic config after Schemastery normalization', async () => { context = new Context() await context.plugin(LlmService) + await context.plugin(SessionStore) await context.plugin(TokenMeterService) await expect(context.plugin(BasicCompactService, { models: { legacy: { thresholdRatio: 0.5 } }, @@ -100,6 +104,7 @@ describe('real Loader composition', () => { it('rejects a capacity-independent merged ratio conflict during plugin load', async () => { context = new Context() await context.plugin(LlmService) + await context.plugin(SessionStore) await context.plugin(TokenMeterService) await expect(context.plugin(BasicCompactService, { retainRatio: 0.2, @@ -114,6 +119,7 @@ describe('real Loader composition', () => { it('rejects an incomplete model-policy summarization pair during plugin load', async () => { context = new Context() await context.plugin(LlmService) + await context.plugin(SessionStore) await context.plugin(TokenMeterService) await expect(context.plugin(BasicCompactService, { summarizationProvider: 'default-provider', diff --git a/packages/compact/compact-basic/tests/manual-compact.spec.ts b/packages/compact/compact-basic/tests/manual-compact.spec.ts new file mode 100644 index 0000000000..aadf50f306 --- /dev/null +++ b/packages/compact/compact-basic/tests/manual-compact.spec.ts @@ -0,0 +1,831 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' +import * as CompactInvariant from '@deepseek-ai/dsh-compact/invariant' +import * as CompactBasicInvariant from '@deepseek-ai/dsh-compact-basic/invariant' +import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' +import { isCompactCheckpointSource, ManualCompactionError } from '@deepseek-ai/dsh-compact' +import type { CompactionResult } from '@deepseek-ai/dsh-compact' +import { + createAssistantMessage, + createUserMessage, + LlmAdapter, +} from '@deepseek-ai/dsh-llm' +import type { + ContentBlock, + LlmResolvedModelInfo, + Message, + StreamChunk, + TokenUsage, +} from '@deepseek-ai/dsh-llm' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import LlmService from '@deepseek-ai/dsh-llm' +import TokenMeterService from '@deepseek-ai/dsh-token-meter' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { + SummarizationInput, + SummaryResult, +} from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts' + +const MODEL = 'mock' +const SIGNAL = new AbortController().signal +const PROMPT = 'older conversation history '.repeat(60) + +/** A summarizer under test control: it can block, fail, or mutate mid-call. */ +class GatedCompactService extends BasicCompactService { + summary: ContentBlock[] = [{ type: 'text', text: 'checkpoint' }] + rawOutput: ContentBlock[] | undefined + usage: TokenUsage | undefined + error: unknown + gate: Promise | undefined + duringSummary: (() => void) | undefined + calls: SummarizationInput[] = [] + + override async summarize( + input: SummarizationInput, + _agent: Agent, + _signal?: AbortSignal, + ): Promise { + this.calls.push(input) + this.duringSummary?.() + if (this.gate !== undefined) await this.gate + if (this.error !== undefined) throw this.error + return { + summary: this.summary, + ...this.rawOutput === undefined ? {} : { rawOutput: this.rawOutput }, + provider: 'summary-provider', + model: 'summary-model', + ...this.usage === undefined ? {} : { usage: this.usage }, + } + } +} + +/** One text answer per request, with a context window large enough to avoid pressure. */ +class TextAdapter extends LlmAdapter { + readonly requests: Message[][] = [] + + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ + provider, + id: model, + name: model, + context: { contextWindow: 100_000 }, + }) + } + + override async * stream(options: { messages: readonly Message[] }): AsyncIterable { + this.requests.push([...options.messages]) + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: 'answer' } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +interface LoopHarness { + readonly ctx: Context + readonly agent: Agent + readonly compact: GatedCompactService + readonly adapter: TextAdapter + readonly log: string[] +} + +/** Real loop, session store, and invariant companions around manual compaction. */ +async function loopHarness(): Promise { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) + await ctx.plugin(CompactInvariant) + await ctx.plugin(CompactBasicInvariant) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(TokenMeterService) + const adapter = new TextAdapter() + ctx.llm.registerAdapter([MODEL], adapter) + const compact = new GatedCompactService(ctx, { auto: false }) + const agent = ctx.agentLoop.create(SessionId('manual-compact'), { provider: MODEL, model: MODEL }) + const log: string[] = [] + ctx.on('session/event', (_session, event) => { + if (event.type === 'turn/start') log.push(`turn/start:${event.data.trigger.kind}`) + if (event.type === 'turn/end') log.push('turn/end') + if (event.type === 'compact/start') log.push(`compact/start:${String(event.data.turn)}`) + if (event.type === 'compact/summary') log.push('compact/summary') + if (event.type === 'compact/end') log.push(`compact/end:${String(event.data.turn)}`) + if (event.type === 'user/message') log.push('user/message') + }) + ctx.on('session/flush', () => { log.push('flush') }) + return { ctx, agent, compact, adapter, log } +} + +/** Drive one real turn so the closed history holds a compactable older span. */ +async function seedHistory(harness: LoopHarness): Promise { + harness.agent.followup(createUserMessage({ + content: [{ type: 'text', text: PROMPT }], + source: { kind: 'user' }, + })) + await harness.agent.whenIdle() + harness.log.length = 0 +} + +/** Text of every derived model-visible message, in request order. */ +function derivedText(session: Session): string[] { + return session.deriveMessages().map((message: Message) => message.content + .map(block => block.type === 'text' ? block.text : '') + .join('')) +} + +/** Await one classified manual-compaction rejection. */ +async function rejection(operation: Promise): Promise { + const caught: unknown = await operation.then( + (value: unknown) => { throw new Error(`expected a rejection, resolved with ${String(value)}`) }, + (error: unknown) => error, + ) + if (!(caught instanceof ManualCompactionError)) { + throw new Error(`expected a ManualCompactionError, got ${String(caught)}`) + } + return caught +} + +/** The Error a classified failure wraps. */ +function causeOf(error: ManualCompactionError): Error { + const { cause } = error + if (!(cause instanceof Error)) throw new Error(`expected an Error cause, got ${String(cause)}`) + return cause +} + +function deferred(): { promise: Promise; resolve: () => void } { + const { promise, resolve } = Promise.withResolvers() + return { promise, resolve: () => { resolve(undefined) } } +} + +/** A closed-tail session with compactable exchanges and no live agent. */ +function closedConversation(turns = 2, lastTurnNumber = turns): Session { + const session = new Session(SessionId(`closed-${turns}-${lastTurnNumber}`)) + for (let index = 1; index <= turns; index += 1) { + const turn = index === turns ? lastTurnNumber : index + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: `${PROMPT} ${turn}` }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('step/start', { turn, step: 1 }) + if (index === 1) { + session.append('request/header', { + header: { config: { provider: MODEL, model: MODEL } }, + reason: 'initial', + }) + } + session.append('assistant/message', { + turn, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: `answer ${turn}` }], + source: { provider: MODEL, model: MODEL }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + return session +} + +/** A fake idle agent whose admission reservation is scripted per test. */ +function fakeAgent( + session: Session, + reserve: () => (() => void) | undefined, +): Agent { + return { + session, + options: { provider: MODEL, model: MODEL }, + reserveTurnAdmission: reserve, + } as unknown as Agent +} + +/** Service over a store-detached session for failure classification. */ +function detachedService(): { ctx: Context; compact: GatedCompactService; flushes: () => number } { + const ctx = new Context() + void new LlmService(ctx) + void new SessionStore(ctx) + void new TokenMeterService(ctx) + ctx.llm.registerAdapter([MODEL], new TextAdapter()) + let flushes = 0 + vi.spyOn(ctx.sessions, 'flush').mockImplementation(() => { + flushes += 1 + return Promise.resolve() + }) + return { ctx, compact: new GatedCompactService(ctx, { auto: false }), flushes: () => flushes } +} + +function compactEvents(session: Session): Array { + return session.events.filter(event => event.type.startsWith('compact/')) +} + +describe('compactNow through the real loop', () => { + it('holds a prompt accepted during summarization until the standalone bracket is flushed', async () => { + const harness = await loopHarness() + const { agent, compact, adapter, log } = harness + await seedHistory(harness) + const gate = deferred() + compact.gate = gate.promise + + const running = compact.compactNow(agent, SIGNAL) + await Promise.resolve() + expect(log).toEqual(['compact/start:null']) + agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'after compaction' }], + source: { kind: 'user' }, + })) + await new Promise((resolve) => { setTimeout(resolve, 5) }) + + expect(agent.status).toBe('idle') + expect(adapter.requests).toHaveLength(1) + expect(log).toEqual(['compact/start:null']) + + gate.resolve() + const result = await running + expect(result).not.toBeNull() + await agent.whenIdle() + + const start = log.indexOf('compact/start:null') + const summary = log.indexOf('compact/summary') + const end = log.indexOf('compact/end:null') + const flush = log.indexOf('flush') + const nextTurn = log.indexOf('turn/start:message') + expect(start).toBeLessThan(summary) + expect(summary).toBeLessThan(end) + expect(end).toBeLessThan(flush) + expect(flush).toBeLessThan(nextTurn) + expect(adapter.requests).toHaveLength(2) + const second = (adapter.requests[1] ?? []).map(message => message.content + .map(block => block.type === 'text' ? block.text : '') + .join('')) + expect(second[0]).toContain('checkpoint') + expect(second.at(-1)).toBe('after compaction') + expect(second.some(text => text.includes(PROMPT))).toBe(false) + }) + + it('keeps context injected during summarization between the markers and after the checkpoint', async () => { + const harness = await loopHarness() + const { agent, compact } = harness + await seedHistory(harness) + compact.duringSummary = () => { + agent.inject(createUserMessage({ + content: [{ type: 'text', text: 'INJECTED CONTEXT' }], + source: { kind: 'plugin', plugin: 'test' }, + })) + } + + const result = await compact.compactNow(agent, SIGNAL) + + expect(result).not.toBeNull() + const start = agent.session.events.findLast(event => event.type === 'compact/start') + const injected = agent.session.events.findLast(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' && event.data.source.plugin === 'test') + const end = agent.session.events.findLast(event => event.type === 'compact/end') + expect(start).toBeDefined() + expect(injected).toBeDefined() + expect(end).toBeDefined() + expect(start!.seq).toBeLessThan(injected!.seq) + expect(injected!.seq).toBeLessThan(end!.seq) + expect(result?.shadowedSeqs).not.toContain(injected?.seq) + const messages = derivedText(agent.session) + expect(messages[0]).toContain('checkpoint') + expect(messages.at(-1)).toContain('INJECTED CONTEXT') + expect(messages.filter(text => text.includes('INJECTED CONTEXT'))).toHaveLength(1) + }) + + it('keeps the marker order when listeners attempt a re-entrant injection', async () => { + const harness = await loopHarness() + const { ctx, agent, compact } = harness + await seedHistory(harness) + const attempts: string[] = [] + ctx.on('session/event', (_session, event) => { + if (event.type !== 'compact/start' && event.type !== 'compact/summary') return + attempts.push(event.type) + agent.inject(createUserMessage({ + content: [{ type: 'text', text: `from ${event.type}` }], + source: { kind: 'plugin', plugin: 'listener' }, + })) + }) + + const result = await compact.compactNow(agent, SIGNAL) + + expect(attempts).toEqual(['compact/start', 'compact/summary']) + expect(result).not.toBeNull() + expect(derivedText(agent.session)[0]).toContain('checkpoint') + expect(agent.session.events.filter(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' && event.data.source.plugin === 'listener')).toHaveLength(0) + const types = compactEvents(agent.session).map(event => event.type) + expect(types).toEqual(['compact/start', 'compact/summary', 'compact/end']) + }) + + it('reports busy without summarizing when a prompt already owns the next turn', async () => { + const harness = await loopHarness() + const { agent, compact, adapter } = harness + await seedHistory(harness) + + agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'first in line' }], + source: { kind: 'user' }, + })) + expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy') + expect(compact.calls).toHaveLength(0) + + await agent.whenIdle() + expect(adapter.requests).toHaveLength(2) + expect(agent.session.events.some(event => event.type === 'compact/start')).toBe(false) + }) + + it('releases turn admission after a summarizer failure and records the failed attempt', async () => { + const harness = await loopHarness() + const { agent, compact, adapter } = harness + await seedHistory(harness) + compact.error = new Error('summarizer unavailable') + const before = [...agent.session.surface.nodes] + + expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('summary') + expect(agent.session.surface.nodes).toEqual(before) + const markers = compactEvents(agent.session) + expect(markers.map(event => event.type)).toEqual(['compact/start', 'compact/end']) + expect(markers[1]?.type === 'compact/end' && markers[1].data.error) + .toContain('summarizer unavailable') + + agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'runs after the failure' }], + source: { kind: 'user' }, + })) + await agent.whenIdle() + expect(adapter.requests).toHaveLength(2) + }) +}) + +describe('compactNow transaction and failure classification', () => { + it('returns null without writing a bracket for history that cannot be compacted', async () => { + const { compact } = detachedService() + const session = new Session(SessionId('empty')) + let released = 0 + const agent = fakeAgent(session, () => () => { released += 1 }) + + expect(await compact.compactNow(agent, SIGNAL)).toBeNull() + expect(released).toBe(1) + expect(compact.calls).toHaveLength(0) + expect(compactEvents(session)).toEqual([]) + }) + + it('commits a standalone bracket without consuming a turn number and checkpoints durability', async () => { + const { compact, flushes } = detachedService() + const session = closedConversation(2, 7) + const agent = fakeAgent(session, () => () => undefined) + + const result = await compact.compactNow(agent, SIGNAL) + + expect(result).not.toBeNull() + expect(flushes()).toBe(1) + expect(session.events.filter(event => event.type === 'turn/start').at(-1)?.data.turn).toBe(7) + expect(session.events.findLast(event => event.type === 'compact/start')?.data) + .toEqual({ turn: null }) + expect(session.events.findLast(event => event.type === 'compact/end')?.data) + .toEqual({ turn: null }) + }) + + it('reports a live unmatched bracket as busy without summarizing', async () => { + const { compact } = detachedService() + const session = closedConversation(2) + session.append('compact/start', { turn: null }) + const agent = fakeAgent(session, () => () => undefined) + + const error = await rejection(compact.compactNow(agent, SIGNAL)) + expect(error.code).toBe('busy') + expect(error.message).toContain('compaction lock is already active') + expect(compact.calls).toHaveLength(0) + }) + + it('ignores an unmatched bracket inherited before a later end-seed marker', async () => { + const { compact } = detachedService() + const original = closedConversation(2) + original.append('compact/start', { turn: null }) + const reloaded = new Session(SessionId('stale-orphan'), [...original.events]) + const boundary = reloaded.events.findLast(event => event.type === 'session/end-seed') + const orphan = reloaded.events.find(event => event.type === 'compact/start') + const agent = fakeAgent(reloaded, () => () => undefined) + + expect(boundary?.seq).toBeGreaterThan(orphan?.seq ?? Number.MAX_SAFE_INTEGER) + await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull() + expect(compact.calls).toHaveLength(1) + }) + + it('scans a stale orphan independently of later repaired turn state', async () => { + const { compact } = detachedService() + const original = closedConversation(2) + original.append('compact/start', { turn: null }) + original.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) + original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } }) + const reloaded = new Session(SessionId('reloaded-orphan'), [...original.events]) + const agent = fakeAgent(reloaded, () => () => undefined) + + await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull() + expect(compact.calls).toHaveLength(1) + }) + + it('refuses an open turn in the log', async () => { + const { compact } = detachedService() + const session = closedConversation(2) + session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) + const agent = fakeAgent(session, () => () => undefined) + + const error = await rejection(compact.compactNow(agent, SIGNAL)) + expect(error.code).toBe('busy') + expect(error.message).toContain('already has an open turn') + }) + + it('reports busy and skips summarization when admission is unavailable', async () => { + const { compact } = detachedService() + const agent = fakeAgent(closedConversation(2), () => undefined) + + expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy') + expect(compact.calls).toHaveLength(0) + }) + + it('rejects a selected span replaced during summarization and records an error close', async () => { + const { compact, flushes } = detachedService() + const session = closedConversation(2) + let released = 0 + const agent = fakeAgent(session, () => () => { released += 1 }) + compact.duringSummary = () => { + const [head] = session.surface.nodes + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'competing replacement' }], + source: { kind: 'plugin', plugin: 'rival' }, + }), { + surfaceOp: { op: 'replace', start: head!, end: head! }, + sourceEventSeqs: [head!], + }) + } + + expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('changed') + expect(released).toBe(1) + expect(flushes()).toBe(1) + expect(compactEvents(session).map(event => event.type)).toEqual(['compact/start', 'compact/end']) + }) + + it('rejects a selected span whose middle node was replaced during summarization', async () => { + const { compact } = detachedService() + const session = closedConversation(3) + const agent = fakeAgent(session, () => () => undefined) + compact.duringSummary = () => { + const middle = session.surface.nodes[1] + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'rewritten middle node' }], + source: { kind: 'plugin', plugin: 'rival' }, + }), { + surfaceOp: { op: 'replace', start: middle!, end: middle! }, + sourceEventSeqs: [middle!], + }) + } + + const error = await rejection(compact.compactNow(agent, SIGNAL)) + expect(error.code).toBe('changed') + expect(causeOf(error).message).toContain('span changed during summarization') + }) + + it('revalidates the selected span after the summarizer continuation settles', async () => { + const { compact, flushes } = detachedService() + const session = closedConversation(2) + const gate = deferred() + compact.gate = gate.promise + let released = 0 + const agent = fakeAgent(session, () => () => { released += 1 }) + const head = session.surface.nodes[0]! + const generation = session.surface.replaceGeneration + + const running = compact.compactNow(agent, SIGNAL) + await Promise.resolve() + expect(compact.calls).toHaveLength(1) + + gate.resolve() + queueMicrotask(() => { + queueMicrotask(() => { + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'late competing replacement' }], + source: { kind: 'plugin', plugin: 'rival' }, + }), { + surfaceOp: { op: 'replace', start: head, end: head }, + sourceEventSeqs: [head], + }) + }) + }) + + const error = await rejection(running) + expect(error.code).toBe('changed') + expect(causeOf(error).message).toContain('selected span') + expect(released).toBe(1) + expect(flushes()).toBe(1) + expect(session.surface.replaceGeneration).toBe(generation + 1) + expect(session.surface.nodes).not.toContain(head) + expect(compactEvents(session).map(event => event.type)).toEqual(['compact/start', 'compact/end']) + expect(session.events.some(event => event.type === 'user/message' + && isCompactCheckpointSource(event.data.source))).toBe(false) + }) + + it('classifies a failing compact/end as commit failure and leaves one orphan', async () => { + const { compact, flushes } = detachedService() + const session = closedConversation(2) + const agent = fakeAgent(session, () => () => undefined) + const append = session.append.bind(session) + vi.spyOn(session, 'append').mockImplementation(((type: string, ...rest: never[]) => { + if (type === 'compact/end') throw new Error('boundary rejected') + return (append as (...args: never[]) => unknown)(type as never, ...rest) + }) as never) + + const error = await rejection(compact.compactNow(agent, SIGNAL)) + expect(error.code).toBe('commit') + expect(causeOf(error).message).toBe('boundary rejected') + vi.restoreAllMocks() + expect(flushes()).toBe(0) + expect(session.events.findLast(event => event.type.startsWith('compact/'))?.type) + .toBe('compact/summary') + expect(compactEvents(session).filter(event => event.type === 'compact/start')).toHaveLength(1) + + const calls = compact.calls.length + expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy') + expect(compact.calls).toHaveLength(calls) + }) + + it('keeps a failed error-close as the commit failure and does not flush', async () => { + const { compact, flushes } = detachedService() + const session = closedConversation(2) + let released = 0 + const agent = fakeAgent(session, () => () => { released += 1 }) + compact.error = new Error('summary rejected') + const append = session.append.bind(session) + vi.spyOn(session, 'append').mockImplementation(((type: string, ...rest: never[]) => { + if (type === 'compact/end') throw new Error('error boundary rejected') + return (append as (...args: never[]) => unknown)(type as never, ...rest) + }) as never) + + const error = await rejection(compact.compactNow(agent, SIGNAL)) + vi.restoreAllMocks() + expect(error.code).toBe('commit') + expect(causeOf(error).message).toBe('error boundary rejected') + expect(released).toBe(1) + expect(flushes()).toBe(0) + expect(compactEvents(session).map(event => event.type)).toEqual(['compact/start']) + }) + + it('rejects a selected span whose pricing changed during summarization', async () => { + const { ctx, compact } = detachedService() + const session = closedConversation(2) + const agent = fakeAgent(session, () => () => undefined) + const meter = ctx.tokenMeter + const original = meter.measure.bind(meter) + compact.duringSummary = () => { + vi.spyOn(meter, 'measure').mockImplementationOnce((target) => { + const measurement = original(target) + return { + ...measurement, + nodes: measurement.nodes.map((node, index) => + index === 0 ? { ...node, tokens: node.tokens + 1 } : node), + } + }) + } + + expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('changed') + vi.restoreAllMocks() + }) + + it('classifies a commit-body failure and still releases admission', async () => { + const { compact } = detachedService() + const session = closedConversation(2) + let released = 0 + const agent = fakeAgent(session, () => () => { released += 1 }) + const append = session.append.bind(session) + vi.spyOn(session, 'append').mockImplementation(((type: string, ...rest: never[]) => { + if (type === 'compact/summary') throw new Error('provenance rejected') + return (append as (...args: never[]) => unknown)(type as never, ...rest) + }) as never) + + const error = await rejection(compact.compactNow(agent, SIGNAL)) + vi.restoreAllMocks() + expect(error.code).toBe('commit') + expect(released).toBe(1) + const end = session.events.findLast(event => event.type === 'compact/end') + expect(end?.type === 'compact/end' && end.data.error).toContain('provenance rejected') + expect(end?.type === 'compact/end' && end.data.turn).toBeNull() + }) + + it('keeps a commit failure when the durability checkpoint also fails', async () => { + const { ctx, compact } = detachedService() + const session = closedConversation(2) + const agent = fakeAgent(session, () => () => undefined) + const append = session.append.bind(session) + vi.spyOn(session, 'append').mockImplementation(((type: string, ...rest: never[]) => { + if (type === 'compact/summary') throw new Error('provenance rejected') + return (append as (...args: never[]) => unknown)(type as never, ...rest) + }) as never) + vi.spyOn(ctx.sessions, 'flush').mockRejectedValueOnce(new Error('disk full')) + + const error = await rejection(compact.compactNow(agent, SIGNAL)) + expect(error.code).toBe('commit') + expect(causeOf(error).message).toBe('provenance rejected') + vi.restoreAllMocks() + }) + + it('compacts a session with no durable turn boundary without creating one', async () => { + const { compact } = detachedService() + const session = new Session(SessionId('turnless')) + for (const text of [PROMPT, 'recent tail']) { + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + } + const agent = fakeAgent(session, () => () => undefined) + + const result = await compact.compactNow(agent, SIGNAL) + + expect(result).not.toBeNull() + expect(session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(session.events.find(event => event.type === 'compact/start')?.data) + .toEqual({ turn: null }) + }) + + it('classifies a durability failure after the standalone bracket committed', async () => { + const { ctx, compact } = detachedService() + const session = closedConversation(2) + const agent = fakeAgent(session, () => () => undefined) + vi.spyOn(ctx.sessions, 'flush').mockRejectedValueOnce(new Error('disk full')) + + expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('persistence') + vi.restoreAllMocks() + expect(session.events.some(event => event.type === 'compact/summary')).toBe(true) + expect(session.events.findLast(event => event.type === 'compact/end')?.data) + .toEqual({ turn: null }) + }) + + it('lets a pre-aborted signal win before reservation, measurement, or summarization', async () => { + const cases = [ + { name: 'busy', session: closedConversation(2), release: undefined }, + { name: 'empty', session: new Session(SessionId('pre-aborted-empty')), release: () => undefined }, + { name: 'compactable', session: closedConversation(2, 9), release: () => undefined }, + ] as const + + for (const testCase of cases) { + const { ctx, compact } = detachedService() + const reserve = vi.fn(() => testCase.release) + const measure = vi.spyOn(ctx.tokenMeter, 'measure') + const agent = fakeAgent(testCase.session, reserve) + const before = [...testCase.session.events] + const reason = Object.freeze({ kind: 'cancelled', case: testCase.name }) + const controller = new AbortController() + controller.abort(reason) + + await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason) + expect(reserve).not.toHaveBeenCalled() + expect(measure).not.toHaveBeenCalled() + expect(compact.calls).toHaveLength(0) + expect(testCase.session.events).toEqual(before) + vi.restoreAllMocks() + } + }) + + it('preserves the exact cancellation reason when the summarizer also rejects', async () => { + const { compact, flushes } = detachedService() + const controller = new AbortController() + const reason = new Error('cancelled by the caller') + let released = 0 + const session = closedConversation(2) + const agent = fakeAgent(session, () => () => { released += 1 }) + compact.duringSummary = () => { controller.abort(reason) } + compact.error = new Error('summarizer aborted') + + await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason) + expect(released).toBe(1) + expect(flushes()).toBe(1) + const events = compactEvents(session) + expect(events.map(event => event.type)).toEqual(['compact/start', 'compact/end']) + expect(events[1]?.type === 'compact/end' && events[1].data.error) + .toContain('summarizer aborted') + }) + + it('aborts before committing when cancellation lands after summarization', async () => { + const { compact } = detachedService() + const controller = new AbortController() + const reason = new Error('cancelled by the caller') + const session = closedConversation(2) + const agent = fakeAgent(session, () => () => undefined) + compact.duringSummary = () => { controller.abort(reason) } + + await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason) + expect(compactEvents(session).map(event => event.type)).toEqual(['compact/start', 'compact/end']) + expect(session.events.some(event => event.type === 'compact/summary')).toBe(false) + }) + + it('waits for the durability checkpoint before cancellation wins and admission releases', async () => { + const { ctx, compact } = detachedService() + const controller = new AbortController() + const reason = new Error('cancelled during flush') + const flushGate = Promise.withResolvers() + const flush = vi.spyOn(ctx.sessions, 'flush').mockReturnValueOnce(flushGate.promise) + const session = closedConversation(2) + let released = 0 + const agent = fakeAgent(session, () => () => { released += 1 }) + + const running = compact.compactNow(agent, controller.signal) + let settled = false + void running.then( + () => { settled = true }, + () => { settled = true }, + ) + await vi.waitFor(() => { + expect(flush).toHaveBeenCalledWith(session) + }) + controller.abort(reason) + await Promise.resolve() + expect(settled).toBe(false) + expect(released).toBe(0) + + flushGate.resolve(undefined) + await expect(running).rejects.toBe(reason) + expect(released).toBe(1) + }) + + it('preserves raw output and usage in the manual summary event', async () => { + const { compact } = detachedService() + const session = closedConversation(2) + const agent = fakeAgent(session, () => () => undefined) + compact.rawOutput = [ + { type: 'text', text: 'checkpoint' }, + { type: 'reasoning', text: 'hidden reasoning' }, + ] + compact.usage = { inputTokens: 40, outputTokens: 5 } + + await compact.compactNow(agent, SIGNAL) + + const summary = session.events.find(event => event.type === 'compact/summary') + expect(summary?.type === 'compact/summary' && summary.data.rawOutput).toEqual(compact.rawOutput) + expect(summary?.type === 'compact/summary' && summary.data.usage).toEqual(compact.usage) + }) + + it('makes duration derivable from the opening and closing marker times', async () => { + const { compact } = detachedService() + const session = closedConversation(2) + const agent = fakeAgent(session, () => () => undefined) + compact.gate = new Promise((resolve) => { + setTimeout(() => { resolve(undefined) }, 5) + }) + + await compact.compactNow(agent, SIGNAL) + + const start = session.events.findLast(event => event.type === 'compact/start') + const end = session.events.findLast(event => event.type === 'compact/end') + expect(start).toBeDefined() + expect(end).toBeDefined() + expect(end!.time - start!.time).toBeGreaterThan(0) + }) + + it('excludes concurrent automatic and manual compaction of one session', async () => { + const { compact } = detachedService() + const session = closedConversation(3) + const agent = fakeAgent(session, () => () => undefined) + const gate = deferred() + compact.gate = gate.promise + + const manual = compact.compactNow(agent, SIGNAL) + await Promise.resolve() + const nodes = session.surface.nodes + await expect(compact.compactRegion( + nodes[0]!, + nodes[1]!, + agent, + )).rejects.toThrow('compaction lock is already active') + + gate.resolve() + compact.gate = undefined + const result: CompactionResult | null = await manual + expect(result).not.toBeNull() + }) + + it('excludes a manual request while an explicit region compaction runs', async () => { + const { compact } = detachedService() + const session = closedConversation(3) + session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }) + const agent = fakeAgent(session, () => () => undefined) + const gate = deferred() + compact.gate = gate.promise + const nodes = session.surface.nodes + const region = compact.compactRegion(nodes[0]!, nodes[1]!, agent) + await Promise.resolve() + + expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy') + + gate.resolve() + compact.gate = undefined + await expect(region).resolves.toMatchObject({ shadowedSeqs: nodes.slice(0, 2) }) + }) +}) diff --git a/packages/compact/compact/README.i18n.yaml b/packages/compact/compact/README.i18n.yaml index c7e54d1f93..fef90981a8 100644 --- a/packages/compact/compact/README.i18n.yaml +++ b/packages/compact/compact/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/compact/compact/README.md -README.md: b6386e8fed9c10cf072683fbdf78c85fb8ac8866 -README.zh.md: 7763faad101a4f7f6f8034b76dff9284909667e2 +README.md: 9c322db998a3179ac96e8fbee26727f3cedef7bb +README.zh.md: 2318df4dc5e34d5d35910f957ba75b3eef1488eb diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index b6386e8fed..9c322db998 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -10,22 +10,25 @@ This package is the interface tier of the compaction capability, split so each c |---|---| | `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + canonical checkpoint source + tool-pairing boundary helpers | | `@deepseek-ai/dsh-compact-basic` | a backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization | -| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` | +| `@deepseek-ai/dsh-command-compact` | the human `/compact` command over `ctx.compact.compactNow()` | Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md). ## Service API (`ctx.compact`) -Both methods are **abstract** — the backend owns trigger policy, retention, event sequencing, and summarization. Reusable request measurement is a separate service, [`ctx.tokenMeter`](../../llm/token-meter/README.md), rather than part of this interface. +All three operations are **abstract** — the backend owns trigger policy, retention, event sequencing, and summarization. Reusable request measurement is a separate service, [`ctx.tokenMeter`](../../llm/token-meter/README.md), rather than part of this interface. | Member | Semantics | |---|---| | `compactIfNeeded(agent, trigger, signal)` | Consider automatic compaction for `trigger: 'pressure' \| 'context-overflow'`. A pressure trigger may apply the backend's threshold and retained-tail policy; a confirmed overflow may force a useful balanced reduction. Returns the `CompactionResult`, or `null` when no safe range exists. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. | +| `compactNow(agent, signal)` | Explicitly compact one useful balanced older span even below automatic pressure. It synchronously reserves idle turn admission before yielding, writes nothing when no useful span exists, records a standalone `compact/* { turn: null }` attempt before summarization, and awaits its durability checkpoint before release. Expected operational failures use `ManualCompactionError`; cancellation rethrows the exact abort reason. | | `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node whose source is `COMPACT_CHECKPOINT_SOURCE`. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | `CompactionResult` keeps the raw summary and bookkeeping-event seqs available to callers alongside the shadowed range and token accounting; its drift-checked shape lives in the [compaction data-structure reference](../../../docs/core-data-structures/compaction.md#compactionresult). -`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is recoverable from the owned session's log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value. +`compactIfNeeded` and `compactNow` take a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization. Automatic and explicit-region brackets recover their numeric owner from the currently open turn. Manual brackets require no open turn and stamp `turn: null`. + +`ManualCompactionError.code` is the closed set `busy | changed | summary | commit | persistence`. `changed` and `summary` mean the selected conversation surface was not replaced, but their failed attempt is still recorded in the session log. `commit` is deliberately neutral about partial mutation, and `persistence` means the in-memory bracket closed but its explicit flush failed. ## Tool-pairing boundaries @@ -45,11 +48,15 @@ The private per-session cache is keyed by `session.surface.replaceGeneration` an The surface mutation (step 4) sits **inside** the lock bracket: `compact/end` is the last event, so the lock is never released before the mutation lands. A crash between `compact/start` and `compact/end` therefore leaves a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished while the surface was never shadowed. +The marker pair names lock acquisition and release, not an exclusive event container. An idle `inject()` may append unrelated context between a manual start and end while summarization is pending. Manual stability therefore revalidates the selected span rather than demanding whole-surface equality; the positional replacement leaves that injected context visible after the checkpoint. Automatic compaction keeps whole-surface equality inside its active turn. + `deriveMessages()` then renders the summary as a user-role message followed by the retained nodes. The shadowed events remain in the raw log, so replay is deterministic. ## Blocking -Compaction is serialized via a log-recorded lock: `compactRegion` refuses to start if the last `compact/start` has no matching `compact/end` after it. The lock is the log (not an in-memory mutex), so it survives replay and a persistence backend can detect an orphaned `compact/start` on reload. The lock brackets the **whole** operation — summarization, the `compact/summary` provenance record, *and* the `user/message` surface replacement all happen before `compact/end` — so a `session/event` listener firing on `compact/end` never observes the lock free while the surface mutation is still pending. The basic backend revalidates the selected surface after summarization: a surface change rejects, while an unrelated log-only append does not invalidate the replacement. `compact/end` is appended even when summarization throws, so a failure can never wedge the lock. +Compaction is serialized by one log-recorded lock shared by all entry points. Tail inspection independently finds the latest unmatched `compact/start` and the newest `session/end-seed`. An unmatched start after that boundary is live and reports `busy`; an older unmatched start is stale evidence from a prior process lifecycle and does not block. The same end-seed transition clears the invariant companion's replay trace. + +The lock is the durable bracket, not a `WeakSet`, wrapper mutex, or client-side anchor. `compact/start` is appended synchronously before summarization yields. Every later failure makes exactly one `compact/end { error }` attempt; if that close append itself fails, the unmatched start remains the intentional busy signal and no flush is attempted. A successfully closed manual attempt is flushed even when it reports `changed` or `summary`, preserving the recorded attempt before turn admission is released. ## Events @@ -57,7 +64,7 @@ The `compact/*` events extend `SessionEventMap` (merge-extensible) via declarati ## Implementing a backend -Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. Every successful backend uses `COMPACT_CHECKPOINT_SOURCE` on its replacement user message; `isCompactCheckpointSource()` recognizes the marker after persistence or cloning without depending on backend identity. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter. +Subclass `CompactService`, implement `compactIfNeeded`, `compactNow`, and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. Every successful backend uses `COMPACT_CHECKPOINT_SOURCE` on its replacement user message; `isCompactCheckpointSource()` recognizes the marker after persistence or cloning without depending on backend identity. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter. ## Recognizing a checkpoint outside the host program (`./checkpoint`) @@ -81,6 +88,6 @@ A successful backend replacement invalidates reuse from the first shadowed histo ## Known Limitations and Deferred Work -- **No model-facing consumer tier yet** — `@deepseek-ai/dsh-tool-compact` (the `/compact` tool) is deferred; compaction is reachable only via direct `ctx.compact` calls or a backend's auto listener. +- **Human command, not a model tool** — `@deepseek-ai/dsh-command-compact` exposes argument-free `/compact` through `ctx.commands`; no model-facing compaction tool is registered. - **Some single-unit overflow is out of contract** — balanced summary compaction cannot split one indivisible unit. The optional pruning companion can still repair a closed tool pair when text-bearing tool-result bulk is removable; a large non-tool node or a tool unit whose non-prunable remainder is oversized cannot be compacted. - **An envelope that alone approaches the window is not surface-compaction work** — compaction shrinks derived history, never the system prompt, tools, or session prefix. diff --git a/packages/compact/compact/README.zh.md b/packages/compact/compact/README.zh.md index 7763faad10..2318df4dc5 100644 --- a/packages/compact/compact/README.zh.md +++ b/packages/compact/compact/README.zh.md @@ -10,22 +10,25 @@ |---|---| | `@deepseek-ai/dsh-compact`(本包) | 接口:抽象服务 + `compact/*` 事件 + `CompactionResult` + 规范检查点源 + 工具配对边界 helper | | `@deepseek-ai/dsh-compact-basic` | 后端:`ctx.tokenMeter` 压力 + token 预算保留 + `llm.stream()` 摘要 | -| `@deepseek-ai/dsh-tool-compact`(暂缓) | 面向模型的 `/compact` 工具,基于 `ctx.compact` 实现 | +| `@deepseek-ai/dsh-command-compact` | 面向用户的 `/compact` 命令,基于 `ctx.compact.compactNow()` 实现 | 与 bash seam 不同,该接口依赖 `@deepseek-ai/dsh-session` 和 `@deepseek-ai/dsh-llm`。契约的动词基于 `Session` 定义,其输出使用 `ContentBlock` 词汇,因此无法在不指名这些包的情况下表达。这项对「接口只依赖 cordis」指引的偏离是有意的,并记录在 [压缩能力 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。 ## 服务 API(`ctx.compact`) -两个方法都是**抽象方法**:触发策略、保留、事件顺序与摘要均属于后端。可复用的请求测量是独立服务 [`ctx.tokenMeter`](../../llm/token-meter/README.md),而非本接口的一部分。 +三个操作都是**抽象方法**:触发策略、保留、事件顺序与摘要均属于后端。可复用的请求测量是独立服务 [`ctx.tokenMeter`](../../llm/token-meter/README.md),而非本接口的一部分。 | 成员 | 语义 | |---|---| | `compactIfNeeded(agent, trigger, signal)` | 根据 `trigger: 'pressure' \| 'context-overflow'` 判断是否需要自动压缩。压力触发可应用后端的阈值与保留尾部策略;已确认溢出可强制进行有效的平衡缩减。返回 `CompactionResult`,无安全范围时则返回 `null`。后端摘要请求是直接的 `ctx.llm.stream()` 调用(不是 agent loop 步骤),因此每次调用都可在 `llm/stream` 处拦截。 | +| `compactNow(agent, signal)` | 即使未达到自动压力,也显式压缩一段有效、平衡的较早范围。该操作会在让出控制权前同步预留空闲轮次接纳;没有有效范围时不写入任何内容;在摘要前记录独立的 `compact/* { turn: null }` 尝试;释放预留前等待其持久性检查点。预期操作失败使用 `ManualCompactionError`;取消会原样重新抛出 abort 原因。 | | `compactRegion(start, end, agent, signal?)` | 强制将表层节点 `[start, end]`(包含两端 seq)从 `agent.session` 摘要为单个替换节点,其源为 `COMPACT_CHECKPOINT_SOURCE`。如果压缩已在进行、`start`/`end` 不是表层节点,或 `start` 在表层上位于 `end` 之后,则**抛出异常**。该范围是表层位置范围,不是数值 seq 区间:在之前的 replace 将新生成的高 seq 摘要节点放到已遮蔽范围的位置之后,表层顺序不再跟随 seq 顺序。 | `CompactionResult` 向调用方保留原始摘要与记录操作过程的事件 seq,同时保留已遮蔽范围与 token 计量;其结构由漂移检查保障,定义见 [压缩数据结构参考](../../../docs/core-data-structures/compaction.md#compactionresult)。 -`compactIfNeeded` 必须传入 `signal`;`compactRegion` 的该参数可选。通过 `ctx.llm.stream()` 摘要的后端**必须** 将它转发到调用的 `GenerateOptions.signal`,因此 abort 或 fiber dispose(资源释放) 会停止进行中的摘要,不会留下越过取消时点继续运行的遗留模型调用。可以从所拥有会话的日志(当前尚未结束的轮次)恢复 `compact/*` 事件所属轮次,因此后端从日志中标记该值,而不信任调用方提供的值。 +`compactIfNeeded` 和 `compactNow` 必须传入 `signal`;`compactRegion` 的该参数可选。通过 `ctx.llm.stream()` 摘要的后端**必须** 将它转发到调用的 `GenerateOptions.signal`,因此 abort 或 fiber dispose(资源释放)会停止进行中的摘要。自动和显式范围标记对会从当前打开的轮次恢复其数字形式归属。手动标记对不要求存在打开的轮次,并标记 `turn: null`。 + +`ManualCompactionError.code` 是封闭集合 `busy | changed | summary | commit | persistence`。`changed` 和 `summary` 表示所选会话表层未被替换,但日志仍会记录失败尝试。`commit` 有意不判断是否发生了部分变更;`persistence` 表示内存中的 bracket 已闭合,但显式 flush 失败。 ## 工具配对边界 @@ -45,11 +48,15 @@ 表层变更(第 4 步)位于锁的起止范围**内**:`compact/end` 是最后一个事件,因此表层变更落地前绝不会释放锁。如果在 `compact/start` 与 `compact/end` 之间崩溃,会留下可检测的遗留锁(一个 `compact/start` 没有匹配的 `compact/end`),而不是虚假声称压缩已完成、但表层从未被遮蔽的 `compact/end`。 +这对标记表示获取和释放锁的时间点,并非排他的事件容器。手动摘要等待期间,空闲的 `inject()` 可以在 start 与 end 之间追加不相关的上下文。因此,手动稳定性检查会重新验证所选 span,而不要求整个表层相等;位置替换会让该注入上下文在检查点之后保持可见。自动压缩则要求其活动轮次内的整个表层保持相等。 + `deriveMessages()` 随后将摘要渲染为 user 角色消息,再跟上已保留节点。已遮蔽事件仍保留在原始日志中,因此回放具有确定性。 ## 阻塞 -压缩通过日志记录的锁串行化:`compactRegion` 会拒绝启动,条件是最后一个 `compact/start` 之后没有匹配的 `compact/end`。锁由日志记录(而非内存 mutex),因此回放后仍然有效,持久化后端也可以在重新加载时检测遗留 `compact/start`。锁会覆盖**整个**操作:摘要、`compact/summary` 溯源记录*以及* `user/message` 表层替换全部发生在 `compact/end` 之前,因此 `session/event` listener 即使在 `compact/end` 时触发,也绝不会看到锁已释放而表层变更仍在等待。基础后端会在摘要后重新验证已选表层:表层变更会导致拒绝,不相关的仅日志追加不会使替换失效。即使摘要抛出异常,也会追加 `compact/end`,因此失败绝不会将锁卡死。 +压缩由所有入口点共享的一个日志记录锁串行化。尾部检查会分别查找最新的未匹配 `compact/start` 和最新的 `session/end-seed`。位于该边界之后的未匹配 start 是活动锁并报告 `busy`;更早的未匹配 start 是先前进程生命周期留下的陈旧证据,不会阻塞。同一个 end-seed 转换会清除不变量配套组件的回放追踪状态。 + +锁就是持久标记对,而非 `WeakSet`、包装层 mutex 或客户端侧锚点。`compact/start` 会在摘要让出控制权之前同步追加。之后每次失败都会恰好尝试一次 `compact/end { error }`;如果追加该闭合事件本身失败,未匹配 start 会继续作为有意保留的 busy 信号,并且不会尝试 flush。已成功闭合的手动尝试即使报告 `changed` 或 `summary` 也会 flush,从而在释放轮次接纳预留前保留该记录。 ## 事件 @@ -57,7 +64,7 @@ ## 实现后端 -继承 `CompactService`,实现 `compactIfNeeded` 与 `compactRegion`,再将子类作为插件加载:它会注册为 `ctx.compact`。每个成功后端都在替换 user 消息上使用 `COMPACT_CHECKPOINT_SOURCE`;`isCompactCheckpointSource()` 可在持久化或克隆后识别该标记,无需依赖后端身份。基于模板或模型的实现可以放在同级包中,不需更改调用方或共享 token meter。 +继承 `CompactService`,实现 `compactIfNeeded`、`compactNow` 与 `compactRegion`,再将子类作为插件加载:它会注册为 `ctx.compact`。每个成功后端都在替换 user 消息上使用 `COMPACT_CHECKPOINT_SOURCE`;`isCompactCheckpointSource()` 可在持久化或克隆后识别该标记,无需依赖后端身份。基于模板或模型的实现可以放在同级包中,不需更改调用方或共享 token meter。 ## 在 host 程序之外识别检查点(`./checkpoint`) @@ -81,6 +88,6 @@ ## 已知限制与暂缓事项 -- **尚无面向模型的消费方层**:`@deepseek-ai/dsh-tool-compact`(`/compact` 工具)已暂缓;只能通过直接 `ctx.compact` 调用或后端的自动 listener 进行压缩。 +- **面向用户的命令,而非模型工具**:`@deepseek-ai/dsh-command-compact` 通过 `ctx.commands` 暴露无参数 `/compact`;不会注册面向模型的压缩工具。 - **部分单元溢出不在契约内**:平衡摘要压缩无法拆分一个不可分单元。当闭合工具对中可移除的主要部分是承载文本的工具结果时,可选剪枝配套服务仍可修复该工具对;无法压缩大型非工具节点,或不可剪枝剩余部分过大的工具单元。 - **单独接近窗口大小的 envelope 不属于表层压缩工作**:压缩缩减派生历史,绝不缩减系统提示词、工具或会话前缀。 diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index dd105ca5fb..d51df1d778 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -21,12 +21,43 @@ export { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from './checkpoi /** Why automatic policy is asking a backend to consider compaction. */ export type CompactionTrigger = 'pressure' | 'context-overflow' +/** Expected failure classes for an explicit idle-session compaction request. */ +export type ManualCompactionErrorCode = 'busy' | 'changed' | 'summary' | 'commit' | 'persistence' + +/** Expected manual-compaction failure suitable for a direct human-command result. */ +export class ManualCompactionError extends Error { + override readonly name = 'ManualCompactionError' + + /** + * Create one classified manual-compaction failure. + * @param code - stable failure class for a human-command consumer. + * @param message - backend diagnostic retained as the Error message. + * @param options - optional original failure. + */ + constructor( + readonly code: ManualCompactionErrorCode, + message: string, + options?: ErrorOptions, + ) { + super(message, options) + } +} + /** Minimal agent context compaction needs without depending on the agent package. */ export interface CompactAgentContext { session: Session options: { provider?: string; model?: string } } +/** + * Agent capability required to serialize an explicit idle-session compaction + * against driver turns. The durable `compact/start` marker separately excludes + * other compaction transactions. + */ +export interface ManualCompactAgentContext extends CompactAgentContext { + reserveTurnAdmission(): (() => void) | undefined +} + declare module 'cordis' { interface Context { compact: CompactService @@ -65,6 +96,29 @@ export abstract class CompactService extends Service { signal: AbortSignal, ): Promise + /** + * Explicitly compact useful history even below automatic pressure thresholds. + * Implementations reserve idle turn admission synchronously before any + * asynchronous work, select a useful range without writing on a no-op, then + * append a standalone `compact/start` before summarization. That durable + * marker is the compaction lock until one `compact/end` attempt. Later waking + * prompts remain accepted in FIFO order and start only after the optional + * durability checkpoint and admission release. Context injected while the + * summary runs may sit between the marker pair; only the selected span must + * remain stable. + * + * @param agent - idle agent whose durable history should be compacted. + * @param signal - command-owned cancellation forwarded to summarization. + * @returns the compaction result, or `null` when no safe useful range exists. + * @throws {@link ManualCompactionError} for expected busy, changed-span, + * summarization/shrink, commit-stage, or persistence failures, and the exact + * abort reason when cancelled. Failed attempts remain visible in the log. + */ + abstract compactNow( + agent: ManualCompactAgentContext, + signal: AbortSignal, + ): Promise + /** * Forcibly compact a range of surface nodes into a single summary node. * `start` and `end` name an inclusive span by surface position, not numeric seq diff --git a/packages/compact/compact/src/invariant.ts b/packages/compact/compact/src/invariant.ts index 7dd06722e8..918f1033f8 100644 --- a/packages/compact/compact/src/invariant.ts +++ b/packages/compact/compact/src/invariant.ts @@ -13,7 +13,7 @@ export const name = 'compact-invariant' export const inject = ['invariants'] interface CompactionTrace { - turn: number + turn: number | null summarized: boolean } @@ -23,9 +23,30 @@ interface SessionTrace { } type CompactionTransition = - | { kind: 'start'; turn: number } - | { kind: 'summary'; turn: number } + | { kind: 'start'; turn: number | null } + | { kind: 'summary'; turn: number | null } | { kind: 'end' } + | { kind: 'end-seed' } + +/** Require a numbered bracket inside its exact turn, or a standalone bracket between turns. */ +function validateOwner( + owner: number | null, + openTurn: number | null, + eventType: 'compact/start' | 'compact/summary' | 'compact/end', + fail: InvariantFailure, +): void { + if (owner === null) { + if (openTurn !== null) fail(`${eventType} is standalone but turn ${openTurn} is open`) + return + } + if (openTurn === null) fail(`${eventType} for turn ${owner} appended outside any open turn`) + if (owner !== openTurn) { + if (eventType === 'compact/summary') { + fail(`compact/summary belongs to turn ${owner} but open turn is ${openTurn}`) + } + fail(`${eventType} names turn ${owner} but open turn is ${openTurn}`) + } +} /** Validate one compaction event without advancing committed trace state. */ function validateCompactionEvent( @@ -33,23 +54,22 @@ function validateCompactionEvent( event: SessionEvent, fail: InvariantFailure, ): CompactionTransition | undefined { + if (event.type === 'session/end-seed') return { kind: 'end-seed' } if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') { return undefined } - if (trace.openTurn === null) fail(`${event.type} appended outside any open turn`) const open = trace.compaction if (event.type === 'compact/start') { - if (open !== undefined) fail(`compact/start for turn ${event.data.turn} while turn ${open.turn} is still compacting`) - if (event.data.turn !== trace.openTurn) { - fail(`compact/start names turn ${event.data.turn} but open turn is ${trace.openTurn}`) + if (open !== undefined) { + const owner = open.turn === null ? 'standalone compaction' : `turn ${open.turn}` + fail(`compact/start while ${owner} is still compacting`) } + validateOwner(event.data.turn, trace.openTurn, event.type, fail) return { kind: 'start', turn: event.data.turn } } if (event.type === 'compact/summary') { if (open === undefined) fail('compact/summary has no matching compact/start') - if (open.turn !== trace.openTurn) { - fail(`compact/summary belongs to turn ${open.turn} but open turn is ${trace.openTurn}`) - } + validateOwner(open.turn, trace.openTurn, event.type, fail) if (open.summarized) fail('compact/summary repeated within one compaction') const seqs = event.data.shadowedSeqs if (seqs.length === 0) fail('compact/summary shadowedSeqs must be non-empty') @@ -63,11 +83,9 @@ function validateCompactionEvent( } if (open === undefined) fail('compact/end has no matching compact/start') if (event.data.turn !== open.turn) { - fail(`compact/end turn ${event.data.turn} does not match compact/start turn ${open.turn}`) - } - if (event.data.turn !== trace.openTurn) { - fail(`compact/end names turn ${event.data.turn} but open turn is ${trace.openTurn}`) + fail(`compact/end owner ${String(event.data.turn)} does not match compact/start owner ${String(open.turn)}`) } + validateOwner(open.turn, trace.openTurn, event.type, fail) if (event.data.error === undefined && !open.summarized) { fail('successful compact/end requires one compact/summary') } @@ -114,7 +132,10 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant trace.openTurn = null return } - if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') return + if (event.type !== 'session/end-seed' + && event.type !== 'compact/start' + && event.type !== 'compact/summary' + && event.type !== 'compact/end') return const candidate = staged.get(event) /* v8 ignore next -- internal/dispatch stages every compaction event */ if (candidate === undefined || candidate.session !== session) return fail('compaction event published without pre-commit validation') diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index b77735b229..173366bc4b 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -11,8 +11,12 @@ import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { - /** Marks the start of a compaction — log-only, holds the lock until `compact/end`. */ - 'compact/start': { turn: number } + /** + * Marks the start of a compaction — log-only, holds the lock until + * `compact/end`. A numbered owner is strictly enclosed by that open turn; + * `null` identifies a standalone manual transaction between turns. + */ + 'compact/start': { turn: number | null } /** * Provenance record of a completed summarization — log-only, no surfaceOp. * The summary content is in `data.summary`; the actual surface replacement @@ -40,8 +44,11 @@ declare module '@deepseek-ai/dsh-session' { /** Provider-reported token usage for the summarization request, when emitted. */ usage?: TokenUsage } - /** Marks the end of a compaction — log-only, releases the lock. `error` set if summarization failed. */ - 'compact/end': { turn: number; error?: string } + /** + * Marks the end of a compaction — log-only, releases the lock. Its owner + * matches `compact/start`; `error` records an unsuccessful attempt. + */ + 'compact/end': { turn: number | null; error?: string } } } diff --git a/packages/compact/compact/tests/compact.spec.ts b/packages/compact/compact/tests/compact.spec.ts index 24f10a861b..38098c89b7 100644 --- a/packages/compact/compact/tests/compact.spec.ts +++ b/packages/compact/compact/tests/compact.spec.ts @@ -9,6 +9,7 @@ import { import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { CompactAgentContext } from '@deepseek-ai/dsh-compact' +import type { ManualCompactAgentContext } from '@deepseek-ai/dsh-compact' /** * A trivial concrete CompactService implementing the abstract contract. The @@ -29,6 +30,14 @@ class StubCompactService extends CompactService { return null } + override async compactNow( + _agent: ManualCompactAgentContext, + signal: AbortSignal, + ): Promise { + this.lastSignal = signal + return null + } + override async compactRegion( start: number, end: number, @@ -98,6 +107,12 @@ describe('CompactService seam', () => { const svc = new StubCompactService(ctx) const session = new Session(SessionId('s')) expect(await svc.compactIfNeeded(stubAgent(session), 'pressure', new AbortController().signal)).toBeNull() + const signal = new AbortController().signal + expect(await svc.compactNow({ + ...stubAgent(session), + reserveTurnAdmission: () => () => undefined, + }, signal)).toBeNull() + expect(svc.lastSignal).toBe(signal) }) it('compact/* events merge into SessionEventMap and are log-only', async () => { diff --git a/packages/compact/compact/tests/invariant.spec.ts b/packages/compact/compact/tests/invariant.spec.ts index f5fc5c87c7..6d2bc0c142 100644 --- a/packages/compact/compact/tests/invariant.spec.ts +++ b/packages/compact/compact/tests/invariant.spec.ts @@ -41,6 +41,38 @@ describe('compaction invariants', () => { failed.append('compact/end', { turn: 2, error: 'provider failed' }) }) + it('accepts standalone successful and failed compaction lifecycles between turns', async () => { + const ctx = await setup() + const success = ctx.sessions.create() + success.append('compact/start', { turn: null }) + success.append('compact/summary', summary()) + success.append('compact/end', { turn: null }) + + const failed = ctx.sessions.create() + failed.append('compact/start', { turn: null }) + failed.append('compact/end', { turn: null, error: 'provider failed' }) + }) + + it('clears an inherited open compaction trace at end-seed during replay', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const source = new Session(SessionId('stale-compaction-source')) + source.append('compact/start', { turn: null }) + const replayed = ctx.sessions.create(SessionId('stale-compaction-replay'), { + seed: source.events, + }) + expect(replayed.events.map(event => event.type)) + .toEqual(['compact/start', 'session/end-seed']) + + await ctx.plugin(InvariantService) + await ctx.plugin(CompactInvariant) + + expect(() => { + replayed.append('compact/start', { turn: null }) + replayed.append('compact/end', { turn: null, error: 'new attempt failed' }) + }).not.toThrow() + }) + it('rebuilds an open trace when the companion loads after the session', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -78,6 +110,26 @@ describe('compaction invariants', () => { expect(() => session.append('compact/start', { turn: 2 })).toThrow(/but open turn is 1/) }) + it('rejects a standalone bracket while a turn is open and a numbered bracket between turns', async () => { + const ctx = await setup() + const open = ctx.sessions.create() + startTurn(open) + expect(() => open.append('compact/start', { turn: null })) + .toThrow(/standalone but turn 1 is open/) + + const idle = ctx.sessions.create() + expect(() => idle.append('compact/start', { turn: 1 })) + .toThrow(/outside any open turn/) + }) + + it('attributes a nested standalone start to the standalone owner', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + session.append('compact/start', { turn: null }) + expect(() => session.append('compact/start', { turn: null })) + .toThrow(/standalone compaction is still compacting/) + }) + it('rejects an unenclosed compaction event when replaying an existing session', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 2b87fad68d..022232cb28 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -50,6 +50,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { }, send: () => {}, updateInbox: () => 'not-found', + reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index 4c96fe0a78..acdb7da8f7 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -106,6 +106,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { session.append('user/message', input, { surfaceOp: 'append' }) }, send: () => {}, + reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index edbb4b3145..01ceeec7e3 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -185,6 +185,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { }, send: () => {}, updateInbox: () => 'not-found', + reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d7cdc4e253..571dad2dae 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -258,6 +258,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract compactIfNeeded( agent: CompactAgentContext, trigger: CompactionTrigger, signal: AbortSignal, ): Promise', jsDoc: '/**\n * Consider automatic compaction for one explicit trigger. Pressure policy\n * uses the latest durable routed request, while context-overflow policy may\n * force a useful balanced reduction even below the normal threshold. Return\n * `null` when no safe range can be compacted. A single oversized retained\n * unit or request envelope cannot be repaired through surface compaction.\n *\n * @param agent - agent context owning the session surface and routing options.\n * @param trigger - normal pressure or provider-confirmed context overflow.\n * @param signal - cancellation signal; model-backed implementations must forward it.\n * @returns the compaction result, or `null` if no compaction was needed.\n */', }, + { + signature: 'abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): Promise', + jsDoc: '/**\n * Explicitly compact useful history even below automatic pressure thresholds.\n * Implementations reserve idle turn admission synchronously before any\n * asynchronous work, select a useful range without writing on a no-op, then\n * append a standalone `compact/start` before summarization. That durable\n * marker is the compaction lock until one `compact/end` attempt. Later waking\n * prompts remain accepted in FIFO order and start only after the optional\n * durability checkpoint and admission release. Context injected while the\n * summary runs may sit between the marker pair; only the selected span must\n * remain stable.\n *\n * @param agent - idle agent whose durable history should be compacted.\n * @param signal - command-owned cancellation forwarded to summarization.\n * @returns the compaction result, or `null` when no safe useful range exists.\n * @throws {@link ManualCompactionError} for expected busy, changed-span,\n * summarization/shrink, commit-stage, or persistence failures, and the exact\n * abort reason when cancelled. Failed attempts remain visible in the log.\n */', + }, { signature: 'abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise', jsDoc: '/**\n * Forcibly compact a range of surface nodes into a single summary node.\n * `start` and `end` name an inclusive span by surface position, not numeric seq\n * order; replacements can make visible seqs non-monotonic. Both edges must be\n * balanced so assistant tool calls remain paired with their results. A model-\n * backed implementation forwards cancellation and rejects active, missing,\n * reversed, or unbalanced ranges. The target session is `agent.session`.\n * Its replacement user message must use {@link COMPACT_CHECKPOINT_SOURCE}.\n * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}\n * for the edge checks.\n *\n * @param start - first surface seq, inclusive.\n * @param end - last surface seq, inclusive.\n * @param agent - context whose session is mutated and whose routing options guide summarization.\n * @param signal - optional cancellation; model-backed implementations must forward it.\n * @throws when compaction is active or the range is missing, reversed, or unbalanced.\n * @returns the appended event seqs, summary, replaced range, and token accounting.\n */', @@ -1461,7 +1465,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ export const TYPE_API: readonly TypeApiEntry[] = [ { name: 'Agent', - declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}', }, { name: 'AgentCancelCause', @@ -1939,6 +1943,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LlmResolvedModelInfo', declaration: 'export interface LlmResolvedModelInfo extends LlmModelInfo {\n context?: LlmModelContext;\n reasoning?: LlmModelReasoningInfo;\n}', }, + { + name: 'ManualCompactAgentContext', + declaration: 'export interface ManualCompactAgentContext extends CompactAgentContext {\n reserveTurnAdmission(): (() => void) | undefined;\n}', + }, { name: 'Message', declaration: 'export interface Message {\n readonly id: MessageId;\n readonly role: \'system\' | \'user\' | \'assistant\';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n}', diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 3a82c693ca..0215796988 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: a1617a1ef871f61157e0d70a06d055168170dced -README.zh.md: 6ba945a41e700331929dabb557802c14256921fb +README.md: a837c3eb71c923f8035cf912096026c5a052b900 +README.zh.md: 7b0b158bae2fe90b2e3430dffe3528f0adb6037e diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index a1617a1ef8..a837c3eb71 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -55,7 +55,7 @@ Configured agents start automatically. A model call requires both `provider` and The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. -The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. +The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. `reserveTurnAdmission()` can synchronously hold that idle boundary for a standalone durable operation: accepted waking work has right of way, later sends keep their ordinary queue identity and FIFO position, release re-arms the same driver path, and `whenIdle()` waits for the reservation without making teardown await it. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous queued-item boundary: edit freezes replacement content without changing message identity or position, while remove publishes discard. Edit publishes `agent/inbox/update`; steering and claimed occurrences return `not-found`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update cannot rewrite durable history; `cancel()` without `keepInbox` publishes `agent/inbox/discard`. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 6ba945a41e..7b0b158bae 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -55,7 +55,7 @@ interface Config { 实体 `ReactLoopAgent`、其排队输入、outbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent,而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。 -统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO,除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering(中途引导)及与其一同暂存的上下文则继续待处理,以供重试或之后获准的提示词使用。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。 +统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO,除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。`reserveTurnAdmission()` 可以为独立持久操作同步保留该空闲边界:已获接纳的唤醒工作拥有优先权,之后发送的项保留普通队列身份与 FIFO 位置,释放会重新启用同一驱动器路径,`whenIdle()` 会等待预留结束,但 teardown 不会等待它。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering(中途引导)及与其一同暂存的上下文则继续待处理,以供重试或之后获准的提示词使用。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。 每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard。编辑会发布 `agent/inbox/update`;steering 项和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 1a317aa342..99edf08ca6 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -2,7 +2,8 @@ * Concrete Agent loop over two pending-input lists: queued prompts each open a * turn that logs its admitted input after `turn/start` commits, while steering * and injected context enter through the outbox at step boundaries. Every - * request is derived from the session log. + * request is derived from the session log. An idle turn-admission reservation + * can withhold the driver from the queue without touching its contents. * * @module dsh-agent-loop/agent */ @@ -68,6 +69,12 @@ export class ReactLoopAgent implements Agent { private busy = false /** Whether an idle waking send has deferred driver admission. */ private wakeScheduled = false + /** + * The live idle turn-admission reservation, holding the driver out of the + * queue until its owner releases. It settles idle waiters instead of + * {@link done} so lifecycle teardown never awaits the reserving operation. + */ + private admission: { readonly settled: Promise; readonly settle: () => void } | undefined /** Whether next-step input belongs to the current admission or open turn. */ acceptsNextStep = false /** Abort owner for the current admission or turn. */ @@ -192,6 +199,32 @@ export class ReactLoopAgent implements Agent { }) } + /** + * Hold the idle admission boundary so no queued prompt can open a turn until + * the returned release runs. Later sends keep their ordinary placement and + * `wakeup` facts; only the driver's claim waits. + * @returns the idempotent release, or `undefined` when the driver is active or already committed to waking work. + */ + reserveTurnAdmission(): (() => void) | undefined { + // `busy` covers every abort owner: kick() and run() mark the interval + // running before they install one. `wakeScheduled` is the same-tick state + // of an accepted waking prompt whose claim is still a pending microtask. + if (this.busy || this.wakeScheduled || this.admission !== undefined + || this.queued.some(item => item.wakeup)) return undefined + const pending = Promise.withResolvers() + const reservation = { settled: pending.promise, settle: pending.resolve } + this.admission = reservation + return () => { + // Idempotent, and inert once a later reservation owns the boundary. + if (this.admission !== reservation) return + this.admission = undefined + // Re-arm the ordinary path first, so an idle waiter released below + // re-reads live admission activity instead of settled state. + if (this.queued.some(item => item.wakeup)) this.scheduleKick() + reservation.settle() + } + } + /** * Clear all pending work and abort the active turn; the first cause wins. * The cause is signal payload for observers and the durable turn/end @@ -225,19 +258,34 @@ export class ReactLoopAgent implements Agent { /** Resolve at idle quiescence: no run driving and no waking prompt waiting. */ async whenIdle(): Promise { - // `done` is replaced per activity, so re-reading it follows chained turns. - // Every driver failure today is contained before it can reject `done`, - // but the waiter must not gamble quiescence on that: a future escape - // still counts as settled activity. - /* v8 ignore next 3 -- the catch arm backstops rejection paths that are all currently contained */ - while (this.busy || this.wakeScheduled || this.abort !== undefined || this.queued.some(item => item.wakeup)) { - await this.done.catch(() => undefined) + while (true) { + // `done` is replaced per activity, so re-reading it follows chained turns. + // Every driver failure today is contained before it can reject `done`, + // but the waiter must not gamble quiescence on that: a future escape + // still counts as settled activity. + /* v8 ignore next 3 -- the catch arm backstops rejection paths that are all currently contained */ + while (this.busy || this.wakeScheduled || this.abort !== undefined || this.runnableWakingQueued) { + await this.done.catch(() => undefined) + } + // A reservation is unfinished activity even with an empty queue, and a + // prompt it withholds is not quiescent — but `done` never owns it, so + // waiting on the queue alone would spin on an already-settled promise. + const reservation = this.admission + if (reservation === undefined) return + await reservation.settled } } + /** Whether a queued waking prompt may claim the driver now. */ + private get runnableWakingQueued(): boolean { + return this.admission === undefined && this.queued.some(item => item.wakeup) + } + /** Defer idle admission while keeping {@link done} as its quiescence owner. */ private scheduleKick(): void { - if (this.abort !== undefined || this.wakeScheduled) return + // A held reservation keeps the item queued with no scheduled claim; its + // release re-arms this path for whatever is queued by then. + if (this.abort !== undefined || this.wakeScheduled || this.admission !== undefined) return this.wakeScheduled = true const pending = Promise.withResolvers() const scheduled = pending.promise @@ -259,7 +307,7 @@ export class ReactLoopAgent implements Agent { /** Claim and admit the next queued prompt, then start its turn. */ private kick(): void { - if (this.abort !== undefined || !this.queued.some(item => item.wakeup)) return + if (this.abort !== undefined || !this.runnableWakingQueued) return // The some() guard above proves the queue is non-empty; the non-null // assertion expresses that invariant. // oxlint-disable-next-line typescript/no-non-null-assertion @@ -762,7 +810,7 @@ export class ReactLoopAgent implements Agent { /** Continue with a waking prompt, or publish the idle status. */ private continueOrIdle(): void { - if (this.queued.some(item => item.wakeup)) { + if (this.runnableWakingQueued) { this.kick() } else { // Every caller sits inside an admission or run whose install marked the diff --git a/packages/core/agent-loop/tests/turn-admission.spec.ts b/packages/core/agent-loop/tests/turn-admission.spec.ts new file mode 100644 index 0000000000..e5d4d2ebb3 --- /dev/null +++ b/packages/core/agent-loop/tests/turn-admission.spec.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import AgentRegistry, { type Agent, type InboxItem } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import LlmService, { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import { MockAdapter, textResponse } from './mock-adapter.ts' + +async function harness(adapter: MockAdapter): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} + +function prompt(agent: Agent, text: string): void { + agent.followup(createUserMessage({ + content: [{ type: 'text', text }], + source: { kind: 'user' }, + })) +} + +function itemText(item: InboxItem): string { + return item.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('') +} + +interface InboxRecording { + readonly events: string[] + readonly enqueued: InboxItem['id'][] + readonly dequeued: InboxItem['id'][] + readonly discarded: InboxItem['id'][] +} + +/** Record the complete inbox lifecycle of one agent for order and identity assertions. */ +function recordInbox(ctx: Context): InboxRecording { + const events: string[] = [] + const enqueued: InboxItem['id'][] = [] + const dequeued: InboxItem['id'][] = [] + const discarded: InboxItem['id'][] = [] + ctx.on('agent/inbox/enqueue', (_agent, item) => { + events.push(`enqueue:${item.placement}:${itemText(item)}`) + enqueued.push(item.id) + }) + ctx.on('agent/inbox/dequeue', (_agent, item) => { + events.push(`dequeue:${itemText(item)}`) + dequeued.push(item.id) + }) + ctx.on('agent/inbox/discard', (_agent, items) => { + events.push(`discard:${items.map(itemText).join(',')}`) + discarded.push(...items.map(item => item.id)) + }) + return { events, enqueued, dequeued, discarded } +} + +/** Text of every ordinary prompt the log admitted, in durable order. */ +function promptTexts(agent: Agent): string[] { + return agent.session.events.flatMap(event => event.type === 'user/message' + ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) + : []) +} + +describe('idle turn admission reservation', () => { + it('holds later waking prompts in the FIFO until release', async () => { + const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const inbox = recordInbox(ctx) + + const release = agent.reserveTurnAdmission() + expect(release).toBeDefined() + + prompt(agent, 'first prompt') + prompt(agent, 'second prompt') + expect(agent.acceptsNextStep).toBe(false) + await new Promise((resolve) => { setTimeout(resolve, 5) }) + + expect(agent.status).toBe('idle') + expect(adapter.requests).toHaveLength(0) + expect(agent.session.events).toHaveLength(0) + expect(inbox.events).toEqual([ + 'enqueue:queued:first prompt', + 'enqueue:queued:second prompt', + ]) + + release?.() + await agent.whenIdle() + + expect(promptTexts(agent)).toEqual(['first prompt', 'second prompt']) + expect(agent.session.events.flatMap(event => + event.type === 'turn/start' ? [event.data.turn] : [])).toEqual([1, 2]) + expect(inbox.events).toEqual([ + 'enqueue:queued:first prompt', + 'enqueue:queued:second prompt', + 'dequeue:first prompt', + 'dequeue:second prompt', + ]) + expect(inbox.dequeued).toEqual(inbox.enqueued) + expect(inbox.discarded).toEqual([]) + }) + + it('refuses acquisition when an accepted waking prompt still owns the next turn', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + prompt(agent, 'accepted first') + expect(agent.status).toBe('idle') + expect(agent.reserveTurnAdmission()).toBeUndefined() + + await agent.whenIdle() + expect(adapter.requests).toHaveLength(1) + }) + + it('refuses acquisition while a turn is running', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const reserved: unknown[] = [] + ctx.on('agent/step', () => { + reserved.push(agent.reserveTurnAdmission()) + }) + + prompt(agent, 'running') + await agent.whenIdle() + + expect(agent.status).toBe('idle') + expect(reserved).toEqual([undefined]) + }) + + it('refuses a second reservation and releases idempotently', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + const release = agent.reserveTurnAdmission() + expect(agent.reserveTurnAdmission()).toBeUndefined() + prompt(agent, 'queued behind the reservation') + + release?.() + release?.() + await agent.whenIdle() + + expect(promptTexts(agent)).toEqual(['queued behind the reservation']) + expect(adapter.requests).toHaveLength(1) + const second = agent.reserveTurnAdmission() + expect(second).toBeDefined() + second?.() + }) + + it('ignores a stale release once a later reservation owns the boundary', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + const stale = agent.reserveTurnAdmission() + stale?.() + const live = agent.reserveTurnAdmission() + prompt(agent, 'held by the live reservation') + stale?.() + await new Promise((resolve) => { setTimeout(resolve, 5) }) + + expect(adapter.requests).toHaveLength(0) + live?.() + await agent.whenIdle() + expect(adapter.requests).toHaveLength(1) + }) + + it('acquires beside quiet queued work and leaves it queued', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + agent.send(createUserMessage({ + content: [{ type: 'text', text: 'quiet' }], + source: { kind: 'user' }, + }), { + target: 'next-turn', + wakeup: false, + }) + const release = agent.reserveTurnAdmission() + expect(release).toBeDefined() + + release?.() + await agent.whenIdle() + expect(adapter.requests).toHaveLength(0) + }) + + it('makes whenIdle() wait for release without spinning on a settled promise', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const machine = agent as Agent & { done: Promise } + let backing = machine.done + let reads = 0 + Object.defineProperty(agent, 'done', { + configurable: true, + get(): Promise { + reads += 1 + return backing + }, + set(value: Promise) { + backing = value + }, + }) + + const release = agent.reserveTurnAdmission() + prompt(agent, 'waiting for the reservation') + let settled = false + const idle = agent.whenIdle().then(() => { settled = true }) + for (let tick = 0; tick < 5; tick += 1) { + await new Promise((resolve) => { setTimeout(resolve, 1) }) + } + + expect(settled).toBe(false) + expect(reads).toBeLessThanOrEqual(2) + + release?.() + await idle + expect(settled).toBe(true) + expect(adapter.requests).toHaveLength(1) + }) + + it('resolves whenIdle() after release with nothing queued', async () => { + const adapter = new MockAdapter([]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + const release = agent.reserveTurnAdmission() + let settled = false + const idle = agent.whenIdle().then(() => { settled = true }) + await new Promise((resolve) => { setTimeout(resolve, 5) }) + expect(settled).toBe(false) + + release?.() + await idle + expect(agent.status).toBe('idle') + }) + + it('lets cancellation discard held prompts and keeps the boundary quiet', async () => { + const adapter = new MockAdapter([]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const inbox = recordInbox(ctx) + + const release = agent.reserveTurnAdmission() + prompt(agent, 'discarded while held') + agent.cancel({ kind: 'user' }) + + expect(inbox.events).toEqual([ + 'enqueue:queued:discarded while held', + 'discard:discarded while held', + ]) + expect(inbox.discarded).toEqual(inbox.enqueued) + expect(inbox.dequeued).toEqual([]) + + release?.() + await agent.whenIdle() + expect(adapter.requests).toHaveLength(0) + expect(agent.session.events).toHaveLength(0) + }) + + it('disposes the agent without waiting for the reservation to be released', async () => { + const adapter = new MockAdapter([]) + const ctx = await harness(adapter) + const handle = await ctx.agents.create({ + sessionId: SessionId('a1'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const { agent } = handle + + const release = agent.reserveTurnAdmission() + prompt(agent, 'discarded by disposal') + await handle.dispose() + + expect(ctx.agents.list()).toEqual([]) + expect(adapter.requests).toHaveLength(0) + release?.() + }) +}) diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 78a933294a..c1fb59894f 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent/README.md -README.md: 6bd5279ace93b6d2569833be6f102c854105c2eb -README.zh.md: cdbb0c0b70124e0037a0f7a7a03ddedf1e3b78d3 +README.md: 12b40631e55da69afa69046b4eaab59ca11b415e +README.zh.md: 32f272b32c40909e7675c8b7cae089516d788765 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 6bd5279ace..12b40631e5 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -61,6 +61,7 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: - `agent.send(message, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `message` is an already identified, frozen `UserMessage`; callers normally create it with `createUserMessage()` before routing begins. `SendOptions` owns only the `target` and `wakeup` policy. Each accepted FIFO occurrence receives its own `InboxItemId`, even when callers reuse a `MessageId`; `agent/inbox/enqueue`/`update` and the terminal `dequeue` or `discard` carry that complete `InboxItem`. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. +- `agent.reserveTurnAdmission()` — synchronously reserve the idle boundary before any queued waking prompt can claim its turn. An accepted prompt, including a same-tick pending wake, has right of way and makes reservation return `undefined`. Later sends keep their ordinary IDs, FIFO placement, and wakeup facts while held; `acceptsNextStep` remains false, `inject()` is not withheld, `whenIdle()` counts the reservation as activity, and the returned release is idempotent. This narrow coordination capability lets standalone durable operations such as manual compaction finish and flush before queued prompts derive from the session. - `agent.updateInbox(itemId, action)` — synchronously edits or removes one still-pending queued occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, and FIFO position while replacing frozen content; remove emits the occurrence's terminal discard. Steering and claimed occurrences return `not-found`. - `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. - `agent.steer(input)` — the `next-step`/wakeup preset: during prompt admission or an open turn, stage steering for the next safe boundary without dispatching `agent/prompt-submit`; outside that acceptance window, delegate to a woken follow-up. Admission failure leaves staged steering for retry or a later admitted prompt, while cancellation or disposal may discard it. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index cdbb0c0b70..32f272b32c 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -61,6 +61,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, 每个插件面向的 handle: - `agent.send(message, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`message` 是已有标识且已冻结的 `UserMessage`;调用方通常会在开始路由前使用 `createUserMessage()` 创建它。`SendOptions` 只持有 `target` 与 `wakeup` 策略。每次获准进入 FIFO 的项都会获得独立的 `InboxItemId`,即使调用方复用了同一个 `MessageId`;`agent/inbox/enqueue`/`update` 及终态 `dequeue` 或 `discard` 都会携带这一完整 `InboxItem`。`target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'` 且 `wakeup: true` 提交 steering(中途引导),而 `target: 'next-step'` 且 `wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。 +- `agent.reserveTurnAdmission()`:在任何已排队唤醒提示词认领其轮次之前,同步预留空闲边界。已获接纳的提示词拥有优先权,包括同一 tick 内仍在等待唤醒的项,此时预留返回 `undefined`。预留期间,之后发送的项保留其普通 ID、FIFO 位置与唤醒信息;`acceptsNextStep` 保持 false,`inject()` 不受阻塞,`whenIdle()` 将该预留计为活动,返回的释放函数可幂等调用。这项范围有限的协调能力使手动压缩(compaction)等独立持久操作能够在排队提示词从会话派生内容前完成并 flush。 - `agent.updateInbox(itemId, action)`:同步编辑或移除一个仍处于待处理状态的 queued 入队项。编辑会替换已冻结的内容,同时保留其 `MessageId`、`InboxItemId`、来源与 FIFO 位置;移除会发出该项的终态 discard。steering 项和已被认领的项会返回 `not-found`。 - `agent.followup(input)`:`send()` 的 `next-turn`/wakeup 预设:排队一个普通后续轮次并唤醒驱动器。 - `agent.steer(input)`:`next-step`/wakeup 预设:提示词接纳期间或轮次打开时,为下一个安全边界暂存 steering,且不分发 `agent/prompt-submit`;该接收窗口之外则委托给会唤醒的后续轮次。接纳失败会保留暂存的 steering,以供重试或之后获准的提示词使用,而取消或 dispose 可能丢弃它。 diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index e1d978459d..525653776f 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -178,6 +178,20 @@ export interface Agent { */ send(message: UserMessage, options: SendOptions): void + /** + * Reserve admission of the next ordinary turn while this agent is idle, so an + * operation can mutate durable history before any queued prompt derives a + * request from it. Already-accepted waking work has right of way, including a + * send whose wake is still a pending microtask. Later sends keep their + * ordinary placement, FIFO order, and `wakeup` facts, and + * {@link acceptsNextStep} stays `false`, so a waking `next-step` send becomes + * a queued follow-up rather than steering; cancellation and disposal may + * still discard them. {@link inject} is not withheld. {@link whenIdle} treats + * a live reservation as activity, while lifecycle teardown does not await it. + * @returns the idempotent release, or `undefined` when the agent is running, already reserved, or already committed to waking work. + */ + reserveTurnAdmission(): (() => void) | undefined + /** * Mutate one still-pending queued occurrence synchronously. Editing preserves * the message identity and queue position; removal publishes its terminal diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 0f54718ce4..8d586313b6 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -28,6 +28,7 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { followup: () => {}, steer: () => {}, inject: () => {}, + reserveTurnAdmission: () => undefined, cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 0a3b65ba5e..797263897f 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -40,6 +40,7 @@ function agent(ctx: Context, cwd: string): Agent { inject: () => {}, send: () => {}, updateInbox: () => 'not-found', + reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 0a09a71f71..1da1e0da48 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -42,6 +42,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } followup: () => {}, steer: () => {}, inject(input) { appendInjection(session, input) }, + reserveTurnAdmission: () => undefined, cancel() { status = 'idle' }, whenIdle() { return Promise.resolve() }, } diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 0e42cba851..7c4b4d9b28 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -55,6 +55,7 @@ function stubAgentForSession(session: Session): StubAgent { if (shouldDefer) deferred.push(input) else appendInjection(session, input) }, + reserveTurnAdmission: () => undefined, cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 82569e72d3..308395a0a1 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -45,6 +45,7 @@ function liveAgent(ctx: Context, session: Session): Agent { inject(input: UserMessage) { session.append('user/message', input, { surfaceOp: 'append' }) }, + reserveTurnAdmission: () => undefined, cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 443a0f616a..e68b9f73bb 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -39,6 +39,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, + reserveTurnAdmission: () => undefined, cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index f520f8766a..32264114f5 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -52,6 +52,7 @@ function stubAgent(session: Session): Agent { inject: () => {}, send: () => {}, updateInbox: () => 'not-found', + reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index ad517c4314..83adfc066c 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -42,7 +42,7 @@ function agent(ctx: Context): Agent { const id = SessionId('agent') return { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } } @@ -249,7 +249,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) @@ -292,7 +292,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const gate = Promise.withResolvers() diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index c3fb33c75c..46c4f1f6c9 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) return { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 301ea798a6..08bd39c28a 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -33,6 +33,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { inject: () => {}, send: () => {}, updateInbox: () => 'not-found', + reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts index 9e18402477..cca0554595 100644 --- a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts @@ -50,6 +50,7 @@ function agent(ctx: Context, cwd: string): Agent { inject: () => {}, send: () => {}, updateInbox: () => 'not-found', + reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 9949879292..2c767de795 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -46,6 +46,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent { inject: () => {}, send: () => {}, updateInbox: () => 'not-found', + reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index f6ad1084c6..d5164261c7 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -40,7 +40,7 @@ function agent(ctx: Context): Agent { const id = SessionId('pty-loader-agent') const value: Agent = { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(value) return value diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index a09fcac714..bef549d483 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const agent: Agent = { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(agent) return agent diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 4d850508d4..ce92c12b39 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -53,6 +53,7 @@ function agentForCwd(cwd: string): Agent { inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, + reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } @@ -73,6 +74,7 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent { inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, + reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index fd661d0583..765bdebf86 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -30,6 +30,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { inject: () => {}, send: () => {}, updateInbox: (): 'not-found' => 'not-found', + reserveTurnAdmission: () => undefined, cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 13b8c2ac1e..d3496a9ac1 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -235,6 +235,7 @@ export async function createTuiTestHarness undefined, cancel(cause) { cancelled.push(cause) }, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 1c73b97498..931da582ba 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -5071,7 +5071,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() }) @@ -5096,7 +5096,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). @@ -5131,14 +5131,14 @@ describe('terminal mounting', () => { const otherSession = ctx.sessions.create(SessionId('other-session')) ctx.agents.register({ id: otherSession.id, options: {}, session: otherSession, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), }) expect(terminal.started).toBe(0) const session = ctx.sessions.create(SessionId('late-session')) const agent = { id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } as Agent ctx.agents.register(agent) await tick() @@ -5169,7 +5169,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main-session')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), }) await tick() expect(terminal.started).toBe(0) @@ -5213,7 +5213,7 @@ describe('terminal mounting', () => { session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, status: 'running', acceptsNextStep: true, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f1cf3d328..ba719be733 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,6 +210,9 @@ importers: '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:^ version: link:../../packages/code-runtime/code-runtime-worker + '@deepseek-ai/dsh-command-compact': + specifier: workspace:^ + version: link:../../packages/compact/command-compact '@deepseek-ai/dsh-command-goal': specifier: workspace:^ version: link:../../packages/goal/command-goal @@ -473,6 +476,9 @@ importers: '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:* version: link:../packages/code-runtime/code-runtime-worker + '@deepseek-ai/dsh-command-compact': + specifier: workspace:* + version: link:../packages/compact/command-compact '@deepseek-ai/dsh-compact-basic': specifier: workspace:* version: link:../packages/compact/compact-basic @@ -1779,6 +1785,36 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/compact/command-compact: + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../ui/commands + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../compact + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/compact/compact: devDependencies: '@deepseek-ai/dsh-invariants': diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 4c30f8f2c5..5eb6530e4e 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -224,6 +224,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { BashEnvContributor: 'service-local extension type is owned by packages/bash/tool-bash/src/index.ts', BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts', CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts', + ManualCompactAgentContext: 'manual compaction service input is owned by packages/compact/compact/src/index.ts', DirectoryPickerCapability: 'picker interaction contract is owned by packages/host/directory-picker/README.md', CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md', Domain: 'domain interface is owned by packages/storage/storage-domain/README.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index d886b33df2..937d2bac4b 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1044,6 +1044,11 @@ "symbol": "CompactionTrigger", "source": "packages/compact/compact/src/index.ts" }, + { + "doc": "docs/core-data-structures/compaction.md", + "symbol": "ManualCompactionErrorCode", + "source": "packages/compact/compact/src/index.ts" + }, { "doc": "docs/core-data-structures/compaction.md", "symbol": "PrunedEntry", diff --git a/tsconfig.host.json b/tsconfig.host.json index f55010712e..1827b8c474 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -133,6 +133,7 @@ { "path": "./packages/fs/tool-str-replace-editor" }, { "path": "./packages/compact/compact" }, { "path": "./packages/compact/compact-basic" }, + { "path": "./packages/compact/command-compact" }, { "path": "./packages/compact/compact-tool-result-prune" }, { "path": "./packages/web/web" }, { "path": "./packages/web/web-search-exa" },