From 12484104c89fd614a247b1a903f3c9c7821aac3d Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 16:50:44 +0800 Subject: [PATCH] feat(compact): recover context overflow (PR3 phase 2) --- docs/agent-lifecycle.md | 2 + docs/architecture.md | 2 + docs/capability-seams.md | 2 +- docs/config-catalog.md | 4 +- docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- docs/cordis-catalog/events.md | 28 +- docs/cordis-catalog/services.md | 6 +- docs/core-data-structures/compaction.md | 10 +- docs/event-producer-consumer.md | 20 +- docs/rfc/INDEX.md | 1 + .../2026-06-11-microkernel-event-taxonomy.md | 4 +- ...t-variables-and-tool-guidance-ownership.md | 4 +- .../2026-07-05-reconstructable-requests.md | 4 +- ...n-pressure-and-overflow-recovery.i18n.yaml | 6 + ...mpaction-pressure-and-overflow-recovery.md | 63 +++ ...ction-pressure-and-overflow-recovery.zh.md | 63 +++ ...07-15-replay-token-meter-service.i18n.yaml | 4 +- .../2026-07-15-replay-token-meter-service.md | 8 +- ...026-07-15-replay-token-meter-service.zh.md | 8 +- .../2026-06-18-compaction-capability-seam.md | 42 +- .../feature/2026-07-07-session-prefix.md | 8 +- examples/coding-agent/cordis.yml | 4 +- packages/compact/compact-basic/README.md | 17 +- .../compact/compact-basic/src/automatic.ts | 58 ++- packages/compact/compact-basic/src/config.ts | 3 + packages/compact/compact-basic/src/index.ts | 64 ++-- .../compact/compact-basic/src/summarizer.ts | 2 +- packages/compact/compact-basic/src/types.ts | 5 +- .../compact-basic/tests/compact-basic.spec.ts | 360 ++++++++++++++++-- .../tests/compact-loop-repro.spec.ts | 176 ++++++++- packages/compact/compact/README.md | 7 +- packages/compact/compact/src/index.ts | 22 +- .../compact/compact/tests/compact.spec.ts | 10 +- .../cordis/tool-cordis/src/api-catalog.ts | 10 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/loop.ts | 12 +- .../agent-loop/tests/interception.spec.ts | 16 +- packages/core/agent-loop/tests/loop.spec.ts | 21 +- .../agent-loop/tests/request-recovery.spec.ts | 42 ++ packages/core/agent/src/types.ts | 18 +- .../invariants/tests/invariants.spec.ts | 2 +- .../ui/user-approval/tests/approval.spec.ts | 2 +- scripts/gen-doc-graphs.ts | 4 +- scripts/type-equiv.manifest.json | 1 + 46 files changed, 913 insertions(+), 242 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md create mode 100644 docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index e4ed315e5b..c6010d4139 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -55,6 +55,8 @@ sequenceDiagram The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set. +`dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts and `agent/request-error` only for canonical context overflow. Recovery compacts between the closed failed step and a fresh retry step, and returns retry only when the surface replacement generation advances; otherwise the original request error remains authoritative. + SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors. Maintenance mode: curated Mermaid sequence; exact event signatures live in the generated Cordis catalog. diff --git a/docs/architecture.md b/docs/architecture.md index 01988a43df..387327a526 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,6 +105,8 @@ Each step renders one prompt assembly. Plugins contribute ordered sections, tool Post-tool context follows all results, preserving call/result adjacency. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering while the step signal remains open. Leftover steering becomes next-turn input. `agent/turn-stop` is terminal through close and flush: later steering is discarded, while ordinary queued prompts survive. +When loaded, `dsh-compact-basic` consumes that post-step checkpoint for `ctx.tokenMeter` pressure under the actual routed header. It also consumes canonical context overflow at `agent/request-error`, but authorizes retry only after a tool-balanced compaction advances `surface.replaceGeneration`. The same turn signal owns both summarization paths. + ### Failure Boundaries The turn is the containment boundary. `LlmService` preserves and privately tags errors from final adapter selection, dispatch, and iteration. Those errors and terminal in-band error/aborted finishes close the failed step before `agent/request-error`; retry reconstructs the next numbered step from the log, while decline or failed recovery preserves the provider error. Attempts count consecutive failures and reset after success. diff --git a/docs/capability-seams.md b/docs/capability-seams.md index fae89bfcfb..5f93bf0433 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -203,7 +203,7 @@ flowchart LR | `ctx.permission` | `core` | [`permission`](../packages/ui/permission) | - | [`acp`](../packages/ui/acp) | - | User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events. | | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate. | -| `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend currently consumes the pre-step event directly; a model-facing compact tool remains deferred. | +| `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-mock`](../packages/support/subagent-mock) | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow) | - | One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 22fc421f8a..886d7a63a1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -220,7 +220,9 @@ export interface BasicCompactConfig { maxTokens?: number /** Extra attempts after the first compaction when pressure remains above threshold. Defaults to `1`. */ compactionRetries?: number - /** Enable the automatic `agent/pre-step` pressure listener. Defaults to `true`. */ + /** Maximum retries after canonical context overflow; `0` disables recovery. Defaults to `1`. */ + maxOverflowRetries?: number + /** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */ auto?: boolean } diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index ef1d8d606f..6408aaeea1 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -extension-cookbook.md: 40ee22b352c884d7f295c87726c54ab8e166c844 -extension-cookbook.zh.md: 4e5bc68c973649574bcb2404bea00096eb9ca41f +extension-cookbook.md: 712e4f2f1f98cabfeea1a899c111a39957d16e21 +extension-cookbook.zh.md: 494cb559cf17f13a494e6c695a661c559a0add12 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 40ee22b352..712e4f2f1f 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -102,7 +102,7 @@ Every product feature maps to a listener on a documented extension seam — the | `/loop` | on the `turn/end` session event, `send()` the next iteration; or force-continue | | Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and terminal `agent/turn-stop` | | Queued + steering messages | core `Agent.send()` / `Agent.steer()` | -| Context compaction (auto + manual) | the `ctx.compact` seam + a backend (`dsh-compact-basic`) on the serial `agent/pre-step` seam; auto = token-pressure check before each step; a manual trigger invokes the same `ctx.compact` routine ([compaction RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) | +| Context compaction (auto + manual) | the `ctx.compact` seam + `dsh-compact-basic`; automatic pressure runs on serial `agent/post-step`, canonical overflow recovery runs on `agent/request-error`, and manual callers use the same compact service ([compaction RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) | | System prompt configurability | `ctx.systemPrompt.section()` with ordering and scope-local shadowing | | AGENTS.md (root) | a section provider reading the file | | AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 4e5bc68c97..494cb559cf 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -102,7 +102,7 @@ export function apply(ctx: Context) { | `/loop` | 在 `turn/end` 会话事件上 `send()` 下一次迭代;或强制继续 | | 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和终端 `agent/turn-stop` 来强制输出 | | 排队消息 + steering(中途引导) | 核心 `Agent.send()` / `Agent.steer()` | -| 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + 串行 `agent/pre-step` seam 上的后端(`dsh-compact-basic`);自动 = 每步之前的 token 压力检查;手动触发调用同一个 `ctx.compact` 例程([压缩 RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) | +| 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + `dsh-compact-basic`;自动压力检查运行在串行 `agent/post-step`,规范化溢出恢复运行在 `agent/request-error`,手动调用方使用同一个压缩服务([压缩 RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) | | 系统提示词可配置性 | `ctx.systemPrompt.section()`,支持排序与作用域局部覆盖 | | AGENTS.md(根目录) | 一个读取该文件的 section provider | | AGENTS.md(子目录,按需触发)+ 文件变更通知 | 从 watcher / tool-result 监听器调用 `agent.inject()` | diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 7ce58b6140..131297b797 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:314`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:308`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -59,19 +59,19 @@ Awaited serial checkpoint after the response, tool results, injected context, an Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:267`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:261`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial -Awaited serial checkpoint for session-surface mutation after prompt assembly and before `step/start`; appends land outside the pending step. The loop derives history once afterward, so compaction records and replacements are included without rewriting an assembled request. The prompt and prefix are the exact pressure inputs for that request, and `signal` cancels listener work. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. +Awaited serial checkpoint before `step/start`; appends land outside the pending step and are included when the loop derives request history. `signal` cancels listener work. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog -'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise | void +'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void ``` -Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) +Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -83,7 +83,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -107,7 +107,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -119,11 +119,11 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:281`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:275`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall -Compose request-only messages placed before derived history. The frozen result is computed once per loop instance, logged on its anchoring request header, and reused so the provider prefix remains stable. Interrupted composition is discarded. Composition precedes the first `agent/pre-step` and request boundary, so listener appends join the current request and pressure accounting sees the composed prefix. Changing context belongs in history; contributors should prepend to `await next()` to preserve registration order. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. +Compose request-only messages placed before derived history. The frozen result is computed once per loop instance, logged on its anchoring request header, and reused so the provider prefix remains stable. Interrupted composition is discarded. Composition precedes the first `agent/pre-step` and request boundary, so listener appends join the current request. Changing context belongs in history; contributors should prepend to `await next()` to preserve registration order. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog 'agent/session-prefix'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise @@ -131,7 +131,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:245`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -167,7 +167,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:256`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:250`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -179,7 +179,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:291`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -191,7 +191,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:301`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts) ## `approval/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 990a2bc1bc..02705dee99 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -89,13 +89,11 @@ Source: [`packages/code-runtime/code-runtime/src/index.ts:31`](../../packages/co Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. Load one implementation per context as `ctx.compact`. ```ts cordis-catalog -abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise +abstract compactIfNeeded( agent: CompactAgentContext, trigger: CompactionTrigger, signal: AbortSignal, ): Promise abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise ``` -Types: [Message](../core-data-structures/core.md) - -Source: [`packages/compact/compact/src/index.ts:38`](../../packages/compact/compact/src/index.ts) +Source: [`packages/compact/compact/src/index.ts:40`](../../packages/compact/compact/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 50a80208a9..f60d1e3e08 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -50,8 +50,14 @@ interface CompactionResult { ## The service -`CompactService` exposes `compactIfNeeded(...)` for pressure-triggered compaction, returning `null` when no compaction is needed, and `compactRegion(...)` for an explicit inclusive surface range. The pre-step caller supplies the agent, full prompt, session prefix, and abort signal; implementations must forward that signal to summarization. The seam owns no pricing API: `dsh-compact-basic` resolves the routed model through [`ctx.tokenMeter`](token-meter.md), whose model-bound handle owns estimation and replay, while the backend owns retention, event sequencing, and summarization. +Automatic callers state why policy is running; implementations may treat confirmed overflow more aggressively than ordinary pressure. -Auto-compaction runs at serial `agent/pre-step`, before the step and request derivation, so it can replace surface nodes while keeping trace events outside the step. Region boundaries preserve tool-call/result pairing but do not preserve whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns the retention and failure details. +```ts type-equiv +export 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. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: `dsh-compact-basic` resolves the durable routed model through [`ctx.tokenMeter`](token-meter.md), whose model-bound handle owns estimation and replay, while the backend owns retention, event sequencing, and summarization. + +Pressure compaction runs at serial `agent/post-step`, after successful assistant output, tool results, buffered context, and steering are durable but before `step/end`. Failed-request recovery runs through `agent/request-error` after the failed step closes, and authorizes a fresh numbered-step retry only when the surface replacement generation advances. Region boundaries preserve tool-call/result pairing but do not preserve whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling. The seam exports `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)` for those edge checks. Both validate current surface membership, reject stale or missing seqs and orphan results, and ignore a caller-retained `node.next`; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) owns their cache semantics. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e6872669de..1f280d74b1 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,19 +9,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | --- | --- | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:145`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:154`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:314`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:267`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:208`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:218`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:308`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:261`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:173`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:281`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:245`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:275`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:186`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio`](../packages/ui/stdio) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:256`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:291`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:301`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:68`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 4f41579a5b..0a80128a4e 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -142,6 +142,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [A shared timeout/deadline primitive, with hard-kill left to each capability](implemented/architecture/2026-07-06-timeout-deadline-library.md) | 2026-07-06 | | [Tool-call timeout policy as a plugin](implemented/architecture/2026-07-07-tool-call-timeout-policy.md) | 2026-07-07 | | [The agent is a registration scope](implemented/architecture/2026-07-08-agent-scope-contexts.md) | 2026-07-08 | +| [After-call compaction pressure and context-overflow recovery](implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md) | 2026-07-10 | | [Single-file executable SDK runtime distribution (single-exe)](implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) | 2026-07-10 | | [Agent-scope runtime design and correctness](implemented/architecture/2026-07-12-agent-scope-runtime-design.md) | 2026-07-12 | | [Replay token meter service](implemented/architecture/2026-07-15-replay-token-meter-service.md) | 2026-07-15 | diff --git a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md index 8293924d37..1d10bfa479 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md +++ b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md @@ -10,8 +10,8 @@ The product principle is "everything is a plugin": hooks, /goal, /loop, dynamic Pure Cordis event taxonomy. The loop's extension seams are typed events with deliberate dispatch modes: -- **waterfall** (around-middleware) where plugins transform, veto, or wrap: `agent/prompt-submit`, `agent/request`, `agent/step-result`, `agent/turn-continuation`, `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `llm/stream`, `system-prompt/assemble`. -- **serial** (awaited in listener order; a bail value stops later listeners) for ordered checkpoints: every `agent/pre-step` listener runs when all abstain, while the first stop returned from `agent/turn-stop` makes the terminal decision final. +- **waterfall** (around-middleware) where plugins transform, veto, recover, or wrap: `agent/prompt-submit`, `agent/request`, `agent/request-error`, `agent/step-result`, `agent/turn-continuation`, `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `llm/stream`, `system-prompt/assemble`. +- **serial** (awaited in listener order; a bail value stops later listeners) for ordered checkpoints: every `agent/pre-step` and `agent/post-step` listener runs when all abstain, while the first stop returned from `agent/turn-stop` makes the terminal decision final. - **parallel** (awaited fan-out) where every listener must get an independent chance: the `session/flush` durability checkpoint. - **emit** (synchronous fire-and-forget) for notifications: turn/step boundaries, stream chunks, lifecycle, errors, and the contained immutable `tools/result` observation. diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 854807c109..e40759a182 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -30,7 +30,7 @@ Plugins register `{{name}}` values through `ctx.systemPrompt.variable(name, prov ### Persona as the order-0 section -`dsh-system-prompt` owns `harness:identity` at order `-100` and the configured `deployment:persona` at order 0, so both survive a replacement loop. Prompt rendering has one path, `renderPrompt(assembly)`, and `agent/pre-step` therefore measures the exact prompt used for compaction. An agent-scoped `deployment:persona` shadows the global default and lets subagent providers install a persona before publication. The conventional order bands are identity `-100`, persona `0`, and tool guidance `100–199`. +`dsh-system-prompt` owns `harness:identity` at order `-100` and the configured `deployment:persona` at order 0, so both survive a replacement loop. Prompt rendering has one path, `renderPrompt(assembly)`, and the routed request header therefore records the exact prompt later replayed by `ctx.tokenMeter` for compaction pressure. An agent-scoped `deployment:persona` shadows the global default and lets subagent providers install a persona before publication. The conventional order bands are identity `-100`, persona `0`, and tool guidance `100–199`. ### Tool guidance ownership @@ -43,7 +43,7 @@ Per-tool semantics and selection guidance live in tool descriptions. Prompt sect ## Alternatives considered - **The loop composes an identity line itself** — hardcodes model-facing prose in the one package that must stay thin ("plugins, not loop changes"), and outside the section pipeline it would be a second composition path. (The identity DOES ship as a code literal — but as an ordinary section registered by `dsh-system-prompt`, whose `system-prompt/assemble` waterfall remains the escape valve for a deployment that must drop it.) -- **Inject the model name via the `agent/request` waterfall** — prompt text composed in two places, and `agent/pre-step`'s `fullSystemPrompt` would omit it, so compaction would measure a prompt that is not what the model sees. +- **Inject the model name via the `agent/request` waterfall** — prompt text would be composed in two places and the earlier rendered persona could disagree with the final routed header. The request plugin that owns late routing must also own any earlier prompt claim about that model. - **Hand-write the model name in each persona** — duplicates the `model:` key one line above and silently lies after a config edit; the exact disease this RFC cures. - **Lenient interpolation (leave unknown refs verbatim, or substitute empty)** — a typo ships `{{modle}}` (or a hole) to the model and nobody notices until transcript review. - **Per-instance subagent wording in config** — returns model-facing prose to every deployment × instance, the P2 disease again. **Keying wording off the provider NAME** — `providerName` is itself config, so a renamed provider silently gets the wrong words. diff --git a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md index d95a709ada..34bae2a2f5 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -22,9 +22,9 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro `EpochHeader` records the request's non-history state: call config, rendered system prompt, tool schemas, and session prefix, with empty values canonicalized to absence. `request/header` writes a full initial, resume, or fallback snapshot. `request/header-delta` encodes system changes by common-prefix/suffix line trim, tools by name-keyed additions/removals/changes, and config or prefix by full replacement. `foldRequestHeader`, `diffHeader`, and `applyHeaderDelta` are the pure codec. Each loop instance writes a snapshot on its first request to anchor process boundaries. Deltas are only an optimization: the writer verifies round-trip equality and falls back to a full snapshot for unrepresentable changes such as pure tool reordering. -Each step rebuilds prompt assembly. On the instance's first step, `agent/session-prefix` extends a frozen empty seed with request-only opener messages; the result is frozen and cached for that loop instance. `agent/pre-step` then receives the composed prefix before messages are snapshotted immediately ahead of `step/start`. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header. `agent/request` may replace only that frozen config seed, while model-visible content enters through logged channels. The loop records the owed header event—the prefix's only durable home—builds `GenerateOptions` from prefix, snapshot, and header, and deep-freezes it while leaving `AbortSignal` live. Per-instance state is only the cached prefix and whether its anchoring snapshot has been written. +Each step rebuilds prompt assembly. On the instance's first step, `agent/session-prefix` extends a frozen empty seed with request-only opener messages; the result is frozen and cached for that loop instance before the generic `agent/pre-step` checkpoint and boundary snapshot. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header. `agent/request` may replace only that frozen config seed, while model-visible content enters through logged channels. The loop records the owed header event—the prefix's only durable home—builds `GenerateOptions` from prefix, snapshot, and header, and deep-freezes it while leaving `AbortSignal` live. Per-instance state is only the cached prefix and whether its anchoring snapshot has been written. -**`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step` is the seam for content needed by the current request. Header reconstruction folds through the step's own `request/header*` event, or carries the prior fold when no new header is written. +**`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step(agent, turn, step, signal)` remains the generic seam for content needed by the current request. Header reconstruction folds through the step's own `request/header*` event, or carries the prior fold when no new header is written. **Enforcement.** In development, `dsh-invariants` independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Loop requests are identified by their frozen shape and session id; direct one-shots are excluded. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml new file mode 100644 index 0000000000..dbb9c76bbf --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 92167dc7d444a3620abfbaab721260ed1c828db9 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: fe3b6617b25a58ef2c1c311df088f9c805fe9ef4 diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md new file mode 100644 index 0000000000..92167dc7d4 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md @@ -0,0 +1,63 @@ +# RFC: After-call compaction pressure and context-overflow recovery + +Status: implemented + +English | [中文](2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md) + +## Problem + +Automatic compaction originally ran at `agent/pre-step` and received an assembled prompt and session prefix. That boundary was necessarily provisional: `agent/request` could still route another model or change call configuration, tool schemas were not frozen with the compaction inputs, and the next assistant output, tool results, buffered context, and steering did not exist yet. Expanding the pre-step signature could move the stale boundary but could not make it exact. + +Successful calls are not the only pressure signal. A provider can reject a request for exceeding its context window before it returns usage, and some successful calls omit usage. The system therefore needs replayable post-call pressure plus a narrow failure-recovery path that preserves the provider error whenever compaction cannot prove useful progress. + +## Decision + +### Successful pressure moves to a durable post-step checkpoint + +`agent/pre-step` is narrowed to `(agent, turn, step, signal)`. It remains a generic serial checkpoint before `step/start`, but it carries no compaction-only prompt or prefix fields. + +The loop fires awaited serial `agent/post-step(agent, turn, step, signal)` after assistant output, every dispatched or synthetic tool result, post-tool context, and steering are durable, but before `step/end`. This placement gives pressure policy the complete successful-call state without splitting an assistant tool call from its result. A listener failure is an ordinary turn failure; it never enters model-request recovery. + +`dsh-compact-basic` resolves the exact latest routed model from the durable request header and asks that model's `ctx.tokenMeter` handle to measure the canonical logged envelope and current surface. It does not fall back to `AgentOptions.model` for automatic pressure. A headerless session has no completed routed request to assess and produces no work. A durable unknown model throws `TOKEN_METER_MODEL_UNCONFIGURED` with its exact name and fails the otherwise-successful turn; operational selection or summarization failures warn and continue with full history. + +### Request recovery is limited to the final model boundary + +`RequestError`, `RequestErrorDecision`, and the `agent/request-error` waterfall represent failures after the final adapter has been selected. Private `WeakSet` tagging preserves the original thrown error identity across dispatch, iterator construction, and iteration. Terminal in-band `error` or `aborted` finishes enter the same path. Prompt assembly, request middleware, request logging, result processing, tools, post-step listeners, and cleanup remain ordinary failures. + +The failed step closes before recovery runs. A retry opens the next numbered step and rebuilds the request from the durable log; consecutive recovery attempts reset only after a successful provider request. Both DeepSeek adapters normalize recognized provider context-limit failures to `CONTEXT_WINDOW_EXCEEDED`. + +If cancellation lands after assistant tool calls are durable but before all calls dispatch, the loop records a synthetic aborted `tool/result` for every undispatched call before following the normal abort path. The surface therefore never retains orphaned durable tool calls merely because cancellation won the race. + +### CompactService exposes intent, not token accounting + +`CompactService.compactIfNeeded(agent, trigger, signal)` accepts `trigger: 'pressure' | 'context-overflow'`. The interface gains no estimation methods or token types; `ctx.tokenMeter` remains the reusable accounting owner. + +For `pressure`, compact-basic applies the selected meter profile's threshold and retained-tail policy, compares scalar and surface `logRevision`, and uses the same meter for range pricing, provenance, shadowed token counts, and non-shrinking-summary rejection. The common defaults remain threshold ratio `0.8`, retained history `floor(contextWindow × 0.16)`, summarization model `''`, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`. + +For canonical overflow, compact-basic bypasses scalar pressure and the normal retained-token budget. It chooses the maximal tool-balanced head range while leaving the newest indivisible unit, then attempts exactly one shrinking compaction under the same signal. The automatic listener snapshots `session.surface.replaceGeneration` and returns `{ action: 'retry' }` only when compaction succeeds and the generation increases. A backend returning a result without replacement cannot authorize retry. + +`maxOverflowRetries` is optional and defaults to `1`; `0` disables overflow recovery without disabling pressure. `auto: false` registers neither automatic listener. Noncanonical errors, exhausted attempts, an already-aborted signal, missing or unknown routed models, no safe range, no generation change, and recovery throws all delegate to the next listener. With no later recovery, the loop reports the original provider error object and code. Cancellation or disposal remains authoritative even if recovery work completes concurrently. + +The default summarizer still resolves explicit configuration, then the latest logged route, then agent options. Because direct `llm/stream` middleware may reroute that auxiliary call, `compact/summary.model` records the final mutable `GenerateOptions.model` observed after dispatch rather than the pre-waterfall candidate. + +## Testing + +Lifecycle tests pin post-step ordering after durable tool/context/steering work, content-less and max-token successes, final-adapter dispatch/iterator/in-band boundaries, retry numbering, attempt reset, cancellation, disposal, synthetic tool results, and original error identity. + +Compact tests pin low-friction defaults, actual routed-model selection, exact unknown-model behavior, below-threshold forced overflow, newest tool-pair retention, non-shrinking rejection, generation proof, caps, disabled listeners, single downstream delegation, and auxiliary summary routing provenance. Real-loop composition covers both thrown and in-band overflow: the failed step closes, compaction lands between attempts, and the next numbered request is reconstructed from the replacement surface. + +## Alternatives considered + +- **Keep provisional pre-step pressure and add more arguments** — rejected because later routing and request mutation remain outside any earlier snapshot, while generic lifecycle becomes coupled to one plugin. +- **Retry the same numbered step** — rejected because recovery appends durable events after the failed boundary. A new step preserves balanced nesting and reconstructability. +- **Retry whenever `compactIfNeeded` returns a result** — rejected because a custom backend can report success without changing model-visible state. `replaceGeneration` is the authoritative proof. +- **Let compact-basic parse provider wording** — rejected because classification belongs at adapters and must cover both thrown and in-band delivery. +- **Use a universal model/window fallback during recovery** — rejected because destructive policy under the wrong context capacity can hide the original provider failure. Unknown durable routes delegate unchanged. + +## Consequences + +Pressure now describes the actual completed routed request, including durable tool results and request-only prefix fields, rather than a provisional next-call guess. Canonical overflow supplies the backstop when no successful usage anchor exists. Recovery is bounded, cancellation-owned, and monotonic: it retries only after a visible surface generation change. + +The cost is one additional serial checkpoint on successful steps and adapter-maintained overflow classification. Provider wording and heuristic character density remain maintenance risks. Surface compaction still cannot repair an envelope that alone exceeds the window or split one indivisible oversized message/tool unit. + +This RFC supersedes only the pre-step automatic-trigger portion of the [compaction capability-seam RFC](../feature/2026-06-18-compaction-capability-seam.md). The service split, standalone token meter, balanced range contract, log-recorded lock, summary replacement, and sole `summarize()` subclass hook remain unchanged. diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md new file mode 100644 index 0000000000..fe3b6617b2 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md @@ -0,0 +1,63 @@ +# RFC:调用后压缩压力与上下文溢出恢复 + +Status: implemented + +[English](2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md) | 中文 + +## 问题 + +自动压缩最初运行在 `agent/pre-step`,并接收已装配提示词与会话前缀。这个边界必然只是临时状态:`agent/request` 仍可能路由到另一个模型或改变调用配置,工具 schema 没有与压缩输入在同一位置冻结,而下一次 assistant 输出、工具结果、缓冲上下文与 steering 此时还不存在。继续扩充 pre-step 签名只能移动陈旧边界,无法让它变得精确。 + +成功调用也不是唯一的压力信号。提供方可能在返回 usage 之前就因上下文窗口超限拒绝请求,一些成功调用也不提供 usage。因此,系统需要可重放的调用后压力,以及一条狭窄的失败恢复路径;当压缩无法证明取得有效进展时,必须保留原始提供方错误。 + +## 决策 + +### 成功压力移动到持久 post-step 检查点 + +`agent/pre-step` 收窄为 `(agent, turn, step, signal)`。它仍是 `step/start` 之前的通用串行检查点,但不再携带压缩专用的提示词或前缀字段。 + +循环在 assistant 输出、所有已分发或合成的工具结果、工具后上下文与 steering 都持久化之后、`step/end` 之前,触发等待式串行 `agent/post-step(agent, turn, step, signal)`。该位置让压力策略看到完整的成功调用状态,同时不会拆开 assistant 工具调用与其结果。监听器失败属于普通 turn 失败,绝不会进入模型请求恢复。 + +`dsh-compact-basic` 从持久请求头解析精确的最新实际路由模型,并让该模型的 `ctx.tokenMeter` handle 计量规范日志信封与当前表层。自动压力不会回退到 `AgentOptions.model`。没有请求头的会话尚无已完成路由请求可供判断,因此不执行工作。持久记录的未知模型会携带精确名称抛出 `TOKEN_METER_MODEL_UNCONFIGURED`,使原本成功的 turn 失败;操作性的选择或摘要失败则警告并继续使用完整历史。 + +### 请求恢复只覆盖最终模型边界 + +`RequestError`、`RequestErrorDecision` 与 `agent/request-error` waterfall 表示最终适配器已经选定之后的失败。私有 `WeakSet` 标记在分发、异步迭代器构造与迭代过程中保留原始抛出错误的身份。终止性的带内 `error` 或 `aborted` finish 进入同一路径。提示词装配、请求中间件、请求日志、结果处理、工具、post-step 监听器与清理仍属于普通失败。 + +恢复运行前,失败 step 已经关闭。重试会打开下一个编号 step,并从持久日志重建请求;连续恢复尝试计数只在提供方请求成功后重置。两个 DeepSeek 适配器都把识别出的提供方上下文限制错误规范化为 `CONTEXT_WINDOW_EXCEEDED`。 + +如果取消发生在 assistant 工具调用已经持久化之后、所有调用完成分发之前,循环会为每个尚未分发的调用记录合成的 aborted `tool/result`,随后进入正常中止路径。因此,表层不会仅因取消赢得竞态而留下孤立的持久工具调用。 + +### CompactService 暴露意图,而不拥有 token 核算 + +`CompactService.compactIfNeeded(agent, trigger, signal)` 接收 `trigger: 'pressure' | 'context-overflow'`。接口不增加估算方法或 token 类型;`ctx.tokenMeter` 继续作为可复用的核算所有者。 + +对于 `pressure`,compact-basic 应用所选 meter profile 的阈值与保留尾部策略,比较标量和表层的 `logRevision`,并用同一个 meter 完成范围定价、来源、被遮蔽 token 数与非缩小摘要拒绝。通用默认值保持为阈值比例 `0.8`、保留历史 `floor(contextWindow × 0.16)`、摘要模型 `''`、`maxTokens: 8192`、`compactionRetries: 1` 与 `auto: true`。 + +对于规范化溢出,compact-basic 绕过标量压力与普通保留 token 预算。它在保留最新不可分割单元的同时,选择最大的工具配对平衡头部范围,并在同一 signal 下只尝试一次缩小压缩。自动监听器先记录 `session.surface.replaceGeneration`,只有压缩成功且 generation 增加时才返回 `{ action: 'retry' }`。后端若只返回结果但没有替换表层,不能授权重试。 + +`maxOverflowRetries` 可选且默认为 `1`;`0` 只禁用溢出恢复,不会禁用压力检查。`auto: false` 不注册任何自动监听器。非规范化错误、尝试耗尽、已经中止的 signal、缺失或未知路由模型、没有安全范围、generation 未变化,以及恢复抛错都会委托给下一个监听器。若没有后续恢复,循环报告原始提供方错误对象与代码。即使恢复工作并发完成,取消或销毁仍具有最终优先级。 + +默认摘要器仍依次解析显式配置、最近记录的路由与 agent options。因为直接 `llm/stream` 中间件可以重新路由该辅助调用,`compact/summary.model` 记录分发后最终可变的 `GenerateOptions.model`,而不是 waterfall 之前的候选值。 + +## 测试 + +生命周期测试固定 post-step 位于持久工具、上下文与 steering 工作之后,覆盖无内容与达到 token 上限的成功、最终适配器分发/迭代器/带内边界、重试编号、尝试重置、取消、销毁、合成工具结果与原始错误身份。 + +压缩测试固定低摩擦默认值、实际路由模型选择、精确未知模型行为、低于阈值的强制溢出、最新工具配对保留、非缩小拒绝、generation 证明、上限、禁用监听器、单次下游委托与辅助摘要路由来源。真实循环组合同时覆盖抛出式和带内溢出:失败 step 关闭,压缩落在两次尝试之间,下一个编号请求从替换表层重建。 + +## 考虑过的替代方案 + +- **保留临时 pre-step 压力并增加更多参数**——不予采纳,因为后续路由与请求变换仍在更早快照之外,同时通用生命周期会耦合到单个插件。 +- **重试相同编号的 step**——不予采纳,因为恢复会在失败边界之后追加持久事件。新 step 保持边界配对与可重建性。 +- **只要 `compactIfNeeded` 返回结果就重试**——不予采纳,因为自定义后端可能报告成功却没有改变模型可见状态。`replaceGeneration` 才是权威证明。 +- **让 compact-basic 解析提供方措辞**——不予采纳,因为分类属于适配器,而且必须同时覆盖抛出式与带内交付。 +- **恢复时使用通用模型/窗口回退**——不予采纳,因为基于错误上下文容量执行破坏性策略可能掩盖原始提供方失败。未知持久路由会原样委托。 + +## 后果 + +压力现在描述实际完成的路由请求,包括持久工具结果与仅请求前缀字段,而不是对下一次调用的临时猜测。当成功 usage 锚点不存在时,规范化溢出提供兜底路径。恢复有上限、受取消所有,并保持单调:只有模型可见的表层 generation 变化后才重试。 + +代价是成功 step 增加一个串行检查点,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分单个不可分割的超大消息或工具单元。 + +本 RFC 只取代[压缩能力接缝 RFC](../feature/2026-06-18-compaction-capability-seam.md) 中的 pre-step 自动触发部分。服务拆分、独立 token meter、平衡范围契约、日志记录锁、摘要替换与唯一 `summarize()` 子类 hook 均保持不变。 diff --git a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml index 99c6301f25..dbb2a582f0 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-replay-token-meter-service.md: 4452c151e122c4a4ad72e3f0bc2616cd2fa28b9d -2026-07-15-replay-token-meter-service.zh.md: 23edc11ffd19b9cfaeb794f3608e9ced4e7dbb7b +2026-07-15-replay-token-meter-service.md: 079eb58f38a69a40b3f47a23e72d159f7025d285 +2026-07-15-replay-token-meter-service.zh.md: 7c3c9ce47ad81029b03747ddb17b87dc55def8e7 diff --git a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md index 4452c151e1..079eb58f38 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md +++ b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md @@ -32,13 +32,13 @@ Usage sums the disjoint input, cache-read, cache-write, and output buckets. Reas `dsh-compact-basic` requires `ctx.tokenMeter`; `CompactService` gains no token methods or types. The backend is factored into configuration, automatic triggering, region transaction, and summarizer modules, while `summarize()` remains its sole subclass hook. The conversation model's meter consistently prices pressure, retention, shadowed content, provenance, and non-shrinking-summary rejection. -Every metered model receives a compact policy with defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, summarization model `''`, maximum summary output `8192`, one extra compaction attempt, and automatic triggering enabled. Per-model compact overrides merge `thresholdRatio` and `retainTokens`; retention must remain below the resulting threshold. Empty summarization model resolves the latest logged routed model, then `AgentOptions.model`. +Every metered model receives a compact policy with defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, summarization model `''`, maximum summary output `8192`, one extra pressure-compaction attempt, one context-overflow retry, and automatic triggering enabled. Per-model compact overrides merge `thresholdRatio` and `retainTokens`; retention must remain below the resulting threshold. Empty summarization model resolves the latest logged routed model, then `AgentOptions.model`. -The pre-step trigger measures a provisional envelope: the current prompt and prefix override logged values, while the latest logged header supplies model, tools, and other call config. A model-less router-only agent skips that provisional check because `agent/request` can route later; naming an unknown model remains an error. +Automatic pressure runs at `agent/post-step` and measures the canonical durable envelope under the model actually selected by `agent/request`. A headerless session has no completed routed request to assess and produces no work; a durable unknown routed model remains an exact typed error. Canonical overflow recovery uses the same meter for forced range selection, and retries only after a proven surface replacement. ## Testing -Unit coverage pins profiles, field-wise overrides, custom and unknown models, envelope invalidation, model switching, usage and missing-usage paths, seeded append/replace replay, signed deltas, provenance modes, malformed boundaries, immutable snapshots, listener ordering, reload, compact defaults, routing fallback, retention, convergence, and transaction rollback. A real Loader/Include YAML fixture loads the exact zero-config token-meter and compact-basic package names in dependency order. +Unit coverage pins profiles, field-wise overrides, custom and unknown models, envelope invalidation, model switching, usage and missing-usage paths, seeded append/replace replay, signed deltas, provenance modes, malformed boundaries, immutable snapshots, listener ordering, reload, compact defaults, actual routing, retention, convergence, forced overflow, and transaction rollback. A real Loader/Include YAML fixture loads the exact zero-config token-meter and compact-basic package names in dependency order. ## Alternatives considered @@ -54,4 +54,4 @@ Unit coverage pins profiles, field-wise overrides, custom and unknown models, en - Defaults make the bundled DeepSeek composition usable with two zero-config plugin entries, while custom models must state the one fact that cannot be guessed safely: context capacity. - Heuristic density and provider usage remain estimates of provider behavior. Maintainers must update built-in profiles and overflow wording as models evolve. - Measurements fail loudly on malformed durable boundaries. This turns corrupted replay into a named integration failure instead of silently drifting pressure. -- The pre-step compact integration can skip a router-only first check and can miss tool or routing changes applied later in request middleware. +- Post-step pressure reads the exact logged routing/tools/prefix boundary; provider overflow classification remains the adapter-maintained backstop for requests rejected before a successful usage anchor. diff --git a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md index 23edc11ffd..7c3c9ce47a 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md @@ -32,13 +32,13 @@ Usage 会对互不重叠的输入、缓存读取、缓存写入与输出 bucket `dsh-compact-basic` 要求 `ctx.tokenMeter`;`CompactService` 不增加 token 方法或类型。后端拆分为配置、自动触发、区域事务与摘要器模块,而 `summarize()` 仍是唯一的子类 hook。会话模型的 meter 一致用于压力、保留、被遮蔽内容、来源以及非缩小摘要拒绝。 -每个已计量模型都会获得默认压缩策略:阈值比例 `0.8`、保留尾部 `floor(contextWindow × 0.16)`、摘要模型 `''`、摘要最大输出 `8192`、一次额外压缩尝试,以及启用自动触发。逐模型压缩覆盖按字段合并 `thresholdRatio` 与 `retainTokens`;保留值必须小于最终阈值。空摘要模型先解析最近记录的实际路由模型,再使用 `AgentOptions.model`。 +每个已计量模型都会获得默认压缩策略:阈值比例 `0.8`、保留尾部 `floor(contextWindow × 0.16)`、摘要模型 `''`、摘要最大输出 `8192`、一次额外压力压缩尝试、一次上下文溢出重试,以及启用自动触发。逐模型压缩覆盖按字段合并 `thresholdRatio` 与 `retainTokens`;保留值必须小于最终阈值。空摘要模型先解析最近记录的实际路由模型,再使用 `AgentOptions.model`。 -pre-step 触发器计量临时请求信封:当前提示词与前缀覆盖日志值,最近记录的请求头提供模型、工具及其他调用配置。没有模型的纯路由 agent 会跳过该临时检查,因为 `agent/request` 仍可稍后路由;显式命名未知模型仍然报错。 +自动压力检查运行在 `agent/post-step`,并使用 `agent/request` 实际选择的模型计量规范持久信封。没有请求头的会话尚无已完成的路由请求可供判断,因此不执行工作;持久记录的未知路由模型仍抛出带精确名称的类型化错误。规范化溢出恢复使用同一 meter 强制选择范围,并且只有在表层替换得到证明后才重试。 ## 测试 -单元覆盖固定 profile、按字段覆盖、自定义与未知模型、信封失效、模型切换、有无 usage 的路径、种子追加/替换重放、有符号增量、来源模式、畸形边界、不可变快照、监听器顺序、重载、压缩默认值、路由回退、保留、收敛与事务回滚。真实 Loader/Include YAML fixture 按依赖顺序加载精确的零配置 token-meter 与 compact-basic package 名称。 +单元覆盖固定 profile、按字段覆盖、自定义与未知模型、信封失效、模型切换、有无 usage 的路径、种子追加/替换重放、有符号增量、来源模式、畸形边界、不可变快照、监听器顺序、重载、压缩默认值、实际路由、保留、收敛、强制溢出与事务回滚。真实 Loader/Include YAML fixture 按依赖顺序加载精确的零配置 token-meter 与 compact-basic package 名称。 ## 考虑过的替代方案 @@ -54,4 +54,4 @@ pre-step 触发器计量临时请求信封:当前提示词与前缀覆盖日 - 默认值让内置 DeepSeek 组合只需两个零配置插件条目即可使用,而自定义模型必须声明唯一不能安全猜测的事实:上下文容量。 - 启发式密度与提供方 usage 仍然只是提供方行为的估计。随着模型演进,维护者必须更新内置 profile 与溢出措辞。 - 遇到畸形持久边界时,计量会明确失败。这会把损坏的重放转化为具名集成错误,而不是让压力静默漂移。 -- pre-step 压缩集成可能跳过纯路由的首次检查,也可能错过请求中间件稍后应用的工具或路由变化。 +- post-step 压力检查读取精确记录的路由、工具与前缀边界;对于在成功 usage 锚点出现前就被拒绝的请求,提供方溢出分类仍是由适配器维护的兜底路径。 diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index 42b93f1225..7714db14b7 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -17,7 +17,7 @@ Two forces shape the design. First, compaction policy and reusable token measure Per the [capability-seams RFC](../../implemented/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, and the `compact/*` session events. It declares `compactIfNeeded()` 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, and the `agent/pre-step` auto-compaction listener. `summarize()` is its sole subclass hook; pricing and replay stay with the meter. +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. **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. ### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation @@ -30,27 +30,27 @@ This is not a coupling smell — it is the contract's domain. The "only cordis" 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 standalone service lets multiple consumers share one model/session replay fold. -`compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` takes required pressure inputs and cancellation. The session comes from the agent. `compactRegion(session, start, end, agent, signal?)` keeps an optional signal for manual callers and requires `session === agent.session`; implementations reject mismatch before model resolution, lock acquisition, summarization, or log mutation. The pre-step integration resolves a provisional model from the latest logged request header, then `AgentOptions.model`; a model-less router-only first step skips pressure because `agent/request` can route later. The default summarizer resolves its model from explicit config, the latest logged routed model, then agent options. +`compactIfNeeded(agent, trigger, signal)` takes an explicit `'pressure' | 'context-overflow'` trigger and cancellation. It resolves only the latest durable routed request model; no header means no work, while a named unconfigured model produces the token meter's exact typed error. `compactRegion(session, start, end, agent, signal?)` keeps an optional signal for manual callers and requires `session === agent.session`; implementations reject mismatch before model resolution, lock acquisition, summarization, or log mutation. The default summarizer resolves its model from explicit config, the latest logged routed model, then agent options, and records the model after any `llm/stream` routing. -### Auto-compaction runs on `agent/pre-step`, a dedicated surface-mutation seam +### Automatic pressure runs after successful durable step work -Compaction mutates the session surface, so it runs before the step opens and before messages are derived. `agent/request` remains a call-config transform and never needs to rebuild history after a surface change. +The original pre-step placement used a provisional envelope and could not see final `agent/request` routing, tools, provider output, tool results, buffered context, or steering. The corrected lifecycle fires serial `agent/post-step(agent, turn, step, signal)` after those successful facts are durable and before `step/end`. `dsh-compact-basic` measures the canonical logged request through `ctx.tokenMeter`, so the next request sees any replacement without a speculative envelope override. -The fix is a dedicated loop seam, **`agent/pre-step`** (`@mode serial`), fired by the loop *after* system assembly and *before* the step opens (`step/start`): +Canonical provider context overflow takes a separate path. The failed step closes, `agent/request-error` receives the original request error and consecutive retry count, and compact-basic forces one useful balanced reduction. It returns retry only if `session.surface.replaceGeneration` increases; the loop then opens a new numbered step and reconstructs its request from the durable log. No range, no replacement, recovery failure, cancellation, an exhausted cap, or an unrelated error preserves the original provider failure. The complete lifecycle decision is in the [after-call recovery RFC](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md). ``` -assembly = ctx.systemPrompt.assemble() -await ctx.serial('agent/pre-step', agent, turn, step, system, prefix, signal) ⟵ compaction mutates the surface here -session('step/start') ⟵ the step opens AFTER the seam -messages = session.deriveMessages() ⟵ single derive, reflects the compaction -request = waterfall agent/request ⟵ pure request transform (hooks, model switch) -``` +assistant/message → tool/result/context/steering +await serial agent/post-step ⟵ pressure compaction inside the successful step +step/end -The loop derives messages once after `agent/pre-step`. Running before `step/start` keeps compaction records outside any half-open step, simplifying crash repair. The seam is awaited and serial so surface mutations cannot interleave; listeners return `void` and do not use Cordis bail values as vetoes. +provider overflow → step/end +await waterfall agent/request-error ⟵ forced compaction between attempts +retry → next numbered step/start ⟵ derives from the replacement surface +``` ### Retention is turn-agnostic; tool-pairing balance is the only structural guard -Auto-compaction fires before **every** step, not once per turn. This is **load-bearing for runaway-turn survival**: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows *within* a turn. A single turn can grow past the window on its own (a "runaway turn") — and the only moment to rescue it before the next model call overflows is the next step's `pre-step` checkpoint. Gating compaction to a turn's first step (or, worse, retaining the whole in-flight turn verbatim) re-opens exactly the hole compaction exists to close: the harness would die when compaction is most needed. +Auto-compaction checks after **every successful** step, not once per turn. This is load-bearing for runaway-turn survival: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows within a turn. The post-step check can compact early closed tool pairs before continuation opens the next step, and provider-confirmed overflow remains the backstop when a request crosses the limit first. `compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches `retainTokens` and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `dsh-compact` exports the before/after edge helpers; their per-session cache folds only appended surface-tail nodes while `replaceGeneration` is unchanged, does no event reads for log-only growth, and rebuilds current membership, positional successors, and balances after replacement. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention. @@ -64,7 +64,7 @@ Auto-compaction always starts at the surface head, merging the prior checkpoint ### Approximate convergence invariant -`resolveConfig` supplies usable common defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, empty summarization-model override, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`. Optional per-model threshold/retention fields merge over those defaults and must name a configured meter profile; retained tokens must be below the resulting threshold. Convergence remains dynamic because provider output caps can be spent on hidden or surfaced reasoning tokens and summary size is unpredictable. If the compacted surface remains over threshold, `compactIfNeeded()` re-compacts the head checkpoint up to the configured retry count, but each committed summary must be smaller than what it shadows. +`resolveConfig` supplies usable common defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, empty summarization-model override, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 1`, and `auto: true`. Optional per-model threshold/retention fields merge over those defaults and must name a configured meter profile; retained tokens must be below the resulting threshold. Convergence remains dynamic because provider output caps can be spent on hidden or surfaced reasoning tokens and summary size is unpredictable. If pressure remains over threshold, `compactIfNeeded()` re-compacts the head checkpoint up to the configured retry count, but each committed summary must be smaller than what it shadows. Overflow bypasses threshold and retained-tail policy for one maximal balanced head reduction, leaving the newest indivisible unit. ### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary @@ -90,12 +90,12 @@ The basic backend wraps the summary as established checkpoint context and tags i The `compact/start … compact/end` bracket is justified, in order of what now does the work: 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 the awaited `pre-step`, so this is also a re-entry tripwire — a thrown "already in progress" signals a real bug.) +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.) Two failure paths, both documented: -- **Crash** (the loop dies mid-summarization): a dangling `compact/start`, no closer. Because `compact/*` are **log-only**, the orphan is **inert** — the surface replacement never landed, so the full, uncompacted history derives correctly. 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 can't wedge future compaction. Compaction simply re-attempts at the next `pre-step`. -- **Recoverable** (summarization throws but the loop survives): the backend appends `compact/end` with its **`error`** field set, leaving the surface untouched, and the model call proceeds with full history. +- **Crash** (the loop dies mid-summarization): a dangling `compact/start`, no closer. Because `compact/*` are **log-only**, the orphan is **inert** — the surface replacement never landed, so the full, uncompacted history derives correctly. 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 leaves the surface untouched. Post-step pressure warns and continues; overflow recovery delegates so the original provider error remains authoritative. `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. @@ -104,14 +104,14 @@ Two failure paths, both documented: ## 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. -- **Compaction on the `agent/request` waterfall** — the earlier cut; rejected for the double-derive it forced and for handing the listener context it structurally cannot compact. The dedicated `agent/pre-step` seam makes the layering correct by construction. +- **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 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. ## Consequences - **Packages**: `packages/compact/compact` supplies the interface and `compact-basic` supplies the backend. `packages/llm/token-meter` owns replay-aware measurement independently. The consumer tier is deferred. -- **New loop seam**: `agent/pre-step` (`@mode serial`) declared in `dsh-agent` and emitted by `dsh-agent-loop` after system assembly and before `step/start`. This is a documented change to the loop — `docs/architecture.md` records it and the generated cordis catalog carries its signature. +- **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 `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and resolves after-edges from its positional successor map instead of trusting a caller-retained `node.next`; stale or missing seqs and orphan results reject. `dsh-session` continues to own the surface `replace` operation, positional nodes, and rewrite generation. - **`dsh-invariants`** drops its `surface replace: start must be <= end` assertion: a head-anchored compaction lands a high-seq replacement node at an older range's *position*, so `start > end` numerically is normal and valid (the range is positional, validated by the surface's `indexOf` checks that remain). The turn-enclosure invariant is reused unchanged. @@ -119,7 +119,7 @@ Two failure paths, both documented: ## Testing -- **Unit:** Real Loader and invariant plugins cover whole-unit retention, convergence failure, both `compact/end` outcomes, head anchoring, open-tail refusal, inert crash orphans, and compacting closed steps inside one oversized open turn. -- **Loop:** Tests pin one awaited `agent/pre-step` per step between `turn/start` and `step/start`; a surface mutation there lands outside the step and appears in the single derived request. +- **Unit:** Real Loader and invariant plugins cover whole-unit retention, convergence failure, both `compact/end` outcomes, head anchoring, open-tail refusal, inert crash orphans, forced below-threshold overflow, 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. - **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/docs/rfc/implemented/feature/2026-07-07-session-prefix.md b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md index f0d458368a..e6ecf69a74 100644 --- a/docs/rfc/implemented/feature/2026-07-07-session-prefix.md +++ b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md @@ -16,13 +16,13 @@ Three properties carry the design: - **Request-only, header-logged.** `deriveMessages()` never returns the prefix; its one durable record is `EpochHeader.messagePrefix` on the instance's anchoring `request/header` snapshot — the channel the reconstructable-requests RFC already owns for the request's non-history half, so no new session event exists. The dev invariant ([dsh-invariants](../../../../packages/support/invariants/src/index.ts)) recomputes `messagePrefix + boundary derivation` against every loop-built request; an unlogged prefix cannot reach the wire. - **Frozen per instance.** Reuse is structural, not disciplined: the cached product cannot change mid-session, so the provider's prompt cache holds by construction and the prefix extends the cacheable region at zero marginal cost per step. A process restart or `ctx.agents.resume()` is a new instance: it recomposes, and any drift lands attributably on the `'resume'` header snapshot. This is the routing rule the seam creates: session-frozen openers ride the prefix; content that changes mid-session rides the append-only history channels (`agent.inject()`, a `tools/post-execute` decision's `additionalContext`, prompt-submit `additionalContext` — [the interception-seams RFC](2026-06-30-interception-seams.md)), each a durable `context/message` paid once and prefix-cached thereafter. -- **Composed before the pressure gate.** Composition precedes the instance's first `agent/pre-step`, and the seam hands the composed value through: `agent/pre-step` carries a `sessionPrefix` parameter and `CompactService.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` counts it in its token-pressure estimate — a gate reading the previous instance's folded prefix instead would under-gate a resumed or forked instance whose contributor grew, skipping compaction and shipping an over-window first request. A composition interrupted by a cancel/dispose landing inside the waterfall is discarded, never cached: an abort-aware listener's degraded fallback cannot leak into later requests, and the next turn recomposes under a live signal. +- **Exact in the durable request envelope.** Composition precedes the instance's first `agent/pre-step` and request boundary. The first routed request logs the current prefix on its header, so post-step token pressure reads the exact prefix together with the actual prompt, tools, and routed model; no compaction-only parameter is carried through the generic pre-step seam. A composition interrupted by cancel/dispose is discarded, never cached: an abort-aware listener's degraded fallback cannot leak into later requests, and the next turn recomposes under a live signal. Because composition runs before the boundary snapshot, a composing listener's session append joins the CURRENT request's derived history. Compaction structurally cannot touch the prefix (or the system prompt): it rewrites surface nodes, and header state never enters the surface. ## Testing -[Interception tests](../../../../packages/core/agent-loop/tests/interception.spec.ts) pin compose-once reuse with no header deltas, prepend order, empty-prefix omission, immutability, and composition before pre-step; [cancellation tests](../../../../packages/core/agent-loop/tests/cancel.spec.ts) pin discard and recomposition. Session codec, invariant, and compaction tests cover header round trips, request reconstruction, and prefix-aware pressure accounting. Snapshot normalization preserves prefix counts, while the [pinned-header scenario](../testing/2026-07-06-pin-request-header-content-in-one-scenario.md) owns content and the default example remains prefix-free. No prefix-specific e2e is needed because the seam is deterministic and provider-independent; the with-key [request-cache e2e](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts) covers its cache economics. +[Interception tests](../../../../packages/core/agent-loop/tests/interception.spec.ts) pin compose-once reuse with no header deltas, prepend order, empty-prefix omission, immutability, composition before pre-step, and the prefix on the routed header; [cancellation tests](../../../../packages/core/agent-loop/tests/cancel.spec.ts) pin discard and recomposition. Session codec, invariant, token-meter, and compaction tests cover header round trips, request reconstruction, and durable prefix-aware pressure accounting. Snapshot normalization preserves prefix counts, while the [pinned-header scenario](../testing/2026-07-06-pin-request-header-content-in-one-scenario.md) owns content and the default example remains prefix-free. No prefix-specific e2e is needed because the seam is deterministic and provider-independent; the with-key [request-cache e2e](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts) covers its cache economics. ## Alternatives considered @@ -30,12 +30,12 @@ Because composition runs before the boundary snapshot, a composing listener's se - **A system-prompt section** (`system-prompt/assemble`) — rejected for this content: the assembly renders to the single `system` string, so message-shaped openers do not fit, and the system prompt is deliberately re-assembled per step (with header deltas when it changes) while the opener wants instance-frozen semantics. - **A durable history opener** (`inject()` at session start) — rejected: permanent history is the failure mode in the problem statement — replayed everywhere, compactable, stale across resumes. - **Compose per turn instead of per instance** — rejected: a turn-boundary recompose either desyncs silently from the log or forces a header delta per change, and it busts the provider cache exactly as often as it fires; the legitimate refresh point is the instance boundary, where the `'resume'` snapshot already records drift attributably. -- **Compose lazily at the first request and let compaction read the folded header** (the shape as first merged) — superseded in review: the fold matches the live prefix only from the instance's second request on, so on a resumed/forked instance's first step the pressure gate read the PREVIOUS instance's prefix and could under-gate. Composing before the first pre-step and handing the live value through the seam makes the estimate exact at every step. +- **Carry prompt/prefix through `agent/pre-step` for provisional pressure** — superseded by post-step replay. It coupled a generic lifecycle seam to one consumer and still missed later request routing/tools; the routed header is the exact durable home for all request-envelope fields. - **A dedicated session event carrying the prefix** — rejected: the header events are the request's non-history record by design; a second event would be a second home for the same fact and another codec to keep total. ## Consequences -- `agent/pre-step` and `CompactService.compactIfNeeded` carry a `sessionPrefix` parameter: every pre-step listener and compaction backend sees the real per-instance value (all in-repo implementations updated in the same change, per the pre-release stance). +- `agent/pre-step` stays a generic `(agent, turn, step, signal)` checkpoint. Compaction receives no prefix parameter; `ctx.tokenMeter` folds the prefix from the canonical routed header at post-step. - A contributor whose content changes mid-session is not re-read until the next instance — by design. A deployment needing mid-session catalog updates routes the change notice through the append-only history channels and pays one durable `context/message`. - The dropped `after` slot leaves no request-only channel near the request tail; nothing in the repo needs one, and adding it back would re-open the every-step re-pay cost the design exists to avoid. - The `request/header-delta` `messagePrefix` arm (whole-array replacement, empty array encoding transition to absence) exists for codec totality; the loop never exercises it, because the cached prefix cannot change within an instance. diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 1208480388..281ae3e9d1 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -49,8 +49,8 @@ - id: token-meter name: '@deepseek-ai/dsh-token-meter' -# Summarize an older range when measured history approaches the context window. -# Built-in model policies provide the ordinary threshold and retained-tail defaults. +# Summarize an older range after measured pressure or a canonical provider overflow. +# Built-in policies provide pressure, retention, and one overflow-retry default. - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index c9264e2e99..b3b910cfb7 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -8,13 +8,14 @@ This is the implementation tier of the compaction capability — see the [interf This backend owns the compaction policy: -- **Measurement** — the effective conversation model's `ModelTokenMeter` prices the provisional request envelope and current surface at one consumed-log revision. The current prompt and prefix override their logged values; the pre-step boundary reuses logged tools and call config. +- **Measurement** — the latest durable routed request model's `ModelTokenMeter` prices the canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering. - **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope. - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. - **Summarization** — a direct `llm/stream` call uses the configured model and cap without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; 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()` requires its agent to own the exact target session and rejects mismatch before resolution or mutation; a valid call records its start, summary, replacement, and end. The serial `agent/pre-step` listener checks pressure before every step, outside an open step, so a tool-heavy turn remains compactable and the loop derives history once after mutation. -- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Recoverable failure records an error end and leaves the surface unchanged. +- **Lifecycle** — `compactRegion()` requires its agent to own the exact target session and rejects mismatch before resolution or mutation; a valid call records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes. +- **Overflow recovery** — below-threshold overflow bypasses normal retention and attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized only when `surface.replaceGeneration` advances; no range, no replacement, recovery failure, an exhausted 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 replacement landed. Operational post-step failures warn and continue, while an actually routed model without a meter profile fails the otherwise-successful turn with the typed meter error. `summarize()` 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 the conversation model's meter. The hook returns the summary blocks together with the call envelope it used (`{ summary, model, maxTokens? }`), which is logged on `compact/summary`. @@ -29,7 +30,8 @@ Every common setting is optional. Every model known to `ctx.tokenMeter` receives | `summarizationModel` | no (default `''`) | Empty resolves the latest logged routed model, then `AgentOptions.model`. | | `maxTokens` | no (default `8192`) | Provider generation cap for the summarization call; may include reasoning tokens. | | `compactionRetries` | no (default `1`) | Extra attempts after the first when pressure remains above threshold. | -| `auto` | no (default `true`) | Register the `agent/pre-step` automatic listener. Set `false` for manual-only. | +| `maxOverflowRetries` | no (default `1`) | Maximum retries after canonical context-window overflow; `0` disables recovery only. | +| `auto` | no (default `true`) | Register post-step pressure and overflow-recovery listeners. Set `false` for manual-only. | ## Usage @@ -39,7 +41,7 @@ import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import TokenMeterService from '@deepseek-ai/dsh-token-meter' export const name = 'compact-basic' -export const inject = ['llm'] +export const inject = ['llm', 'tokenMeter'] export function apply(ctx: Context): void { ctx.plugin(TokenMeterService) @@ -53,7 +55,7 @@ Loading the plugin registers `ctx.compact`. With `auto: true` (the default) it c ### Conversation history -**What the model sees**: Before a step whose estimated envelope and history exceed the threshold, the conversation model receives the checkpoint preamble below, a blank line, ``, the data-dependent summary, and ``. This one checkpoint replaces the selected older range and is followed by the retained recent units. +**What the model sees**: After a successful step crosses the threshold, the next request receives the checkpoint preamble below, a blank line, ``, the data-dependent summary, and ``. Overflow recovery rebuilds the immediate retry from that replacement. This one checkpoint replaces the selected older range and is followed by the retained recent units. **Token effect**: The replacement reduces future input history rather than appending a second copy. The summary remains until a later compaction replaces it; one oversized indivisible unit can still exceed the budget. @@ -115,8 +117,9 @@ Rules: ## Known Limitations and Deferred Work -- **Pre-step sees a provisional request envelope** — the current prompt and prefix are exact, but routing and tool changes made later in `agent/request` are not logged yet. A router-only agent with no provisional model skips that check. - **Meter accuracy follows the selected profile** — missing provider usage falls back to the token meter's configured character density and structural overhead. +- **Overflow classification is adapter-maintained** — provider wording can change; both DeepSeek adapters normalize currently recognized context-limit failures to `CONTEXT_WINDOW_EXCEEDED`. +- **Single-unit and envelope-only overflow remain outside surface compaction** — recovery cannot split one indivisible message/tool unit or shrink system/tools/prefix. - **`compactRegion` requires an open turn** — a manual call on a fully-closed session throws ("no open turn") rather than compacting. - **Summarization failure fails closed with full, over-budget history** — including truncation at the summarization `maxTokens`, which hidden reasoning tokens can consume; the auto path logs a warning and proceeds. - **The summarization call has no transcript-snapshot coverage** — `dsh-llm-replay` derives calls from `assistant/chunk` events, so this chunk-less direct `ctx.llm.stream()` call cannot replay (named deferred replay infrastructure in [the seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)). diff --git a/packages/compact/compact-basic/src/automatic.ts b/packages/compact/compact-basic/src/automatic.ts index 504b0d8a9f..16e71c081a 100644 --- a/packages/compact/compact-basic/src/automatic.ts +++ b/packages/compact/compact-basic/src/automatic.ts @@ -1,12 +1,12 @@ /** - * Automatic pre-step pressure listener for compact-basic. + * Automatic post-step pressure and context-overflow recovery listeners. * * @module @deepseek-ai/dsh-compact-basic/automatic */ import type { Context } from 'cordis' -import type { CompactionResult } from '@deepseek-ai/dsh-compact' -import type { Message } from '@deepseek-ai/dsh-llm' +import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact' +import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm' import { TOKEN_METER_MODEL_UNCONFIGURED, TokenMeterError, @@ -14,10 +14,10 @@ import { import type { Agent } from '@deepseek-ai/dsh-agent' interface AutomaticCompactor { + readonly config: { readonly maxOverflowRetries: number } compactIfNeeded( agent: Agent, - fullSystemPrompt: string, - sessionPrefix: readonly Message[], + trigger: CompactionTrigger, signal: AbortSignal, ): Promise } @@ -31,30 +31,54 @@ export function registerAutomaticCompaction( ctx: Context, service: AutomaticCompactor, ): void { - ctx.on('agent/pre-step', async ( + const logResult = (result: CompactionResult, trigger: string): void => { + ctx.logger.info( + `compaction (${trigger}): shadowed ${result.shadowedSeqs.length} surface nodes ` + + `(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` + + `~${result.shadowedTokenCount} tokens)`, + ) + } + + ctx.on('agent/post-step', async ( agent: Agent, _turn: number, _step: number, - fullSystemPrompt: string, - sessionPrefix: readonly Message[], signal: AbortSignal, ) => { try { - const result = await service.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal) - if (result !== null) { - ctx.logger.info( - `compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` - + `(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` - + `~${result.shadowedTokenCount} tokens)`, - ) - } + const result = await service.compactIfNeeded(agent, 'pressure', signal) + if (result !== null) logResult(result, 'post-step pressure') } catch (error: unknown) { // A named routed model without a meter profile is configuration failure, // not an optional operational compaction miss. if (error instanceof TokenMeterError && error.code === TOKEN_METER_MODEL_UNCONFIGURED) throw error const message = error instanceof Error ? error.message : String(error) - ctx.logger.warn(`compaction failed: ${message}; proceeding with full history`) + ctx.logger.warn(`post-step compaction failed: ${message}; continuing the turn`) } }) + + ctx.on('agent/request-error', async (agent, _turn, _step, error, retryAttempt, signal, next) => { + if (error.code !== CONTEXT_WINDOW_EXCEEDED_CODE + || retryAttempt >= service.config.maxOverflowRetries + || signal.aborted) return next() + + let generation: number + let result: CompactionResult | null + try { + generation = agent.session.surface.replaceGeneration + result = await service.compactIfNeeded(agent, 'context-overflow', signal) + } catch (recoveryError: unknown) { + const message = recoveryError instanceof Error ? recoveryError.message : String(recoveryError) + ctx.logger.warn( + `context-overflow compaction failed: ${message}; preserving the original request error`, + ) + return next() + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while compaction is awaited. + if (signal.aborted || result === null + || agent.session.surface.replaceGeneration <= generation) return next() + logResult(result, 'context overflow recovery') + return { action: 'retry' } + }) } diff --git a/packages/compact/compact-basic/src/config.ts b/packages/compact/compact-basic/src/config.ts index 3169d7f5aa..e4d567da3b 100644 --- a/packages/compact/compact-basic/src/config.ts +++ b/packages/compact/compact-basic/src/config.ts @@ -47,6 +47,7 @@ export function resolveConfig( summarizationModel: '', maxTokens: 8192, compactionRetries: 1, + maxOverflowRetries: 1, auto: true, }, meter) } @@ -56,10 +57,12 @@ export function resolveConfig( summarizationModel: config.summarizationModel ?? '', maxTokens: config.maxTokens ?? 8192, compactionRetries: config.compactionRetries ?? 1, + maxOverflowRetries: config.maxOverflowRetries ?? 1, auto: config.auto ?? true, } assertPositiveInteger('maxTokens', resolved.maxTokens) assertNonNegativeInteger('compactionRetries', resolved.compactionRetries) + assertNonNegativeInteger('maxOverflowRetries', resolved.maxOverflowRetries) if (typeof resolved.summarizationModel !== 'string') { throw new Error('BasicCompactConfig: summarizationModel must be a string') } diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index cd4ea5d1d5..d420851166 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -7,10 +7,9 @@ import { Context } from 'cordis' import z from 'schemastery' import { CompactService } from '@deepseek-ai/dsh-compact' -import type { CompactionResult } from '@deepseek-ai/dsh-compact' -import { canonicalHeader } from '@deepseek-ai/dsh-session' -import type { EpochHeader, Session } from '@deepseek-ai/dsh-session' -import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' +import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact' +import type { Session } from '@deepseek-ai/dsh-session' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { ModelTokenMeter } from '@deepseek-ai/dsh-token-meter' import type { Agent } from '@deepseek-ai/dsh-agent' import { registerAutomaticCompaction } from './automatic.ts' @@ -36,24 +35,10 @@ function effectiveModel(agent: Agent): string | undefined { return agent.session.requestHeader()?.config.model ?? agent.options.model } -/** - * Build the provisional pre-step request envelope. Prompt and prefix are exact; - * tools and non-model call config come from the latest logged request because - * later request middleware has not run yet. - */ -function provisionalHeader( - model: string, - session: Session, - fullSystemPrompt: string, - sessionPrefix: readonly Message[], -): EpochHeader { - const latest = session.requestHeader() - return canonicalHeader({ - config: latest === undefined ? { model } : { ...latest.config, model }, - ...fullSystemPrompt.length === 0 ? {} : { system: fullSystemPrompt }, - ...latest?.tools === undefined ? {} : { tools: latest.tools }, - ...sessionPrefix.length === 0 ? {} : { messagePrefix: [...sessionPrefix] }, - }) +/** Resolve the exact model durably routed for the latest provider request. */ +function routedModel(session: Session): string | undefined { + const model = session.requestHeader()?.config.model + return model === undefined || model.length === 0 ? undefined : model } /** @@ -75,6 +60,7 @@ export class BasicCompactService extends CompactService { summarizationModel: z.string().default(''), maxTokens: z.number().step(1).min(1).default(8192), compactionRetries: z.number().step(1).min(0).default(1), + maxOverflowRetries: z.number().step(1).min(0).default(1), auto: z.boolean().default(true), }) @@ -106,29 +92,33 @@ export class BasicCompactService extends CompactService { } /** - * Check replayed pressure for the provisional pre-step envelope and compact - * a tool-balanced head until it falls below the effective model threshold. - * A genuinely model-less router-first step skips this provisional check; - * naming an unconfigured model throws the token meter's typed error. - * @param agent - agent whose session and provisional model are measured. - * @param fullSystemPrompt - current assembled system prompt override. - * @param sessionPrefix - current request-only prefix override. - * @param signal - live step cancellation signal forwarded to summarization. + * Compact for replayed post-step pressure or one provider-confirmed context + * overflow. Both triggers price the latest durable routed request model; + * overflow bypasses the normal threshold and retained-tail policy so it can + * force one useful balanced reduction. + * @param agent - agent whose latest durable routed request is measured. + * @param trigger - normal post-step pressure or context-overflow recovery. + * @param signal - live turn cancellation signal forwarded to summarization. * @returns the latest compaction result, or `null` when no check/work applies. */ override async compactIfNeeded( agent: Agent, - fullSystemPrompt: string, - sessionPrefix: readonly Message[], + trigger: CompactionTrigger, signal: AbortSignal, ): Promise { - const model = effectiveModel(agent) - if (model === undefined || model.length === 0) return null + const model = routedModel(agent.session) + if (model === undefined) return null const meter = this.ctx.tokenMeter.resolve(model) const policy = this._modelConfig(meter) - const requestHeader = provisionalHeader(model, agent.session, fullSystemPrompt, sessionPrefix) + if (trigger === 'context-overflow') { + const surface = meter.measureSurface(agent.session) + const range = selectCompactableRange(agent.session, surface, 0) + if (range === null) return null + return this.compactRegion(agent.session, range.start, range.end, agent, signal) + } + const threshold = Math.floor(policy.contextWindow * policy.thresholdRatio) - let measurement = meter.measure(agent.session, requestHeader) + let measurement = meter.measure(agent.session) if (measurement.totalTokens < threshold) return null let result: CompactionResult | null = null @@ -147,7 +137,7 @@ export class BasicCompactService extends CompactService { break } result = await this.compactRegion(agent.session, range.start, range.end, agent, signal) - measurement = meter.measure(agent.session, requestHeader) + measurement = meter.measure(agent.session) if (measurement.totalTokens < threshold) return result } diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts index 359421f0f5..78730cc1a6 100644 --- a/packages/compact/compact-basic/src/summarizer.ts +++ b/packages/compact/compact-basic/src/summarizer.ts @@ -106,7 +106,7 @@ export async function summarizeWithLlm( if (!summary.some(block => block.text.trim().length > 0)) { throw new Error('summarization produced no text summary content') } - return { summary, model, maxTokens: config.maxTokens } + return { summary, model: options.model, maxTokens: config.maxTokens } } /** diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index 44ff06435d..4a3f372e08 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -22,7 +22,9 @@ export interface BasicCompactConfig { maxTokens?: number /** Extra attempts after the first compaction when pressure remains above threshold. Defaults to `1`. */ compactionRetries?: number - /** Enable the automatic `agent/pre-step` pressure listener. Defaults to `true`. */ + /** Maximum retries after canonical context overflow; `0` disables recovery. Defaults to `1`. */ + maxOverflowRetries?: number + /** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */ auto?: boolean } @@ -32,6 +34,7 @@ export interface ResolvedConfig { readonly summarizationModel: string readonly maxTokens: number readonly compactionRetries: number + readonly maxOverflowRetries: number readonly auto: boolean } diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 98b4b2f15b..163c2b6b45 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -6,9 +6,10 @@ import BasicCompactService, { } from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts' +import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' import type { CompactionResult } from '@deepseek-ai/dsh-compact' -import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, Message, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import TokenMeterService, { TOKEN_METER_MODEL_UNCONFIGURED, @@ -43,6 +44,12 @@ function conversation(turns = 4, text = 'fixture'): Session { source: { kind: 'user' }, }, { surfaceOp: 'append' }) session.append('step/start', { turn, step: 1 }) + if (turn === 1) { + session.append('request/header', { + header: { config: { model: MODEL } }, + reason: 'initial', + }) + } session.append('assistant/message', { turn, step: 1, @@ -68,6 +75,12 @@ function toolConversation(): Session { source: { kind: 'user' }, }, { surfaceOp: 'append' }) session.append('step/start', { turn, step: 1 }) + if (turn === 1) { + session.append('request/header', { + header: { config: { model: MODEL } }, + reason: 'initial', + }) + } session.append('assistant/message', { turn, step: 1, @@ -120,11 +133,10 @@ function service( async function compactIfNeeded( compact: BasicCompactService, session: Session, + trigger: 'pressure' | 'context-overflow' = 'pressure', model: string | undefined = MODEL, - system = '', - prefix: readonly Message[] = [], ): Promise { - return compact.compactIfNeeded(agent(session, model), system, prefix, SIGNAL) + return compact.compactIfNeeded(agent(session, model), trigger, SIGNAL) } describe('compact configuration and defaults', () => { @@ -140,6 +152,7 @@ describe('compact configuration and defaults', () => { summarizationModel: '', maxTokens: 8192, compactionRetries: 1, + maxOverflowRetries: 1, auto: true, }) expect(resolveModelConfig(resolved, ctx.tokenMeter.resolve(MODEL))).toEqual({ @@ -176,6 +189,7 @@ describe('compact configuration and defaults', () => { const bad = [ [{ maxTokens: 0 }, /maxTokens/], [{ compactionRetries: -1 }, /compactionRetries/], + [{ maxOverflowRetries: -1 }, /maxOverflowRetries/], [{ auto: 'yes' }, /auto must be a boolean/], [{ summarizationModel: 1 }, /summarizationModel must be a string/], [{ models: null }, /models must be an object/], @@ -207,19 +221,57 @@ describe('pressure measurement and retention', () => { models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } }, } - it('skips the provisional check only when no routed or fallback model exists', async () => { + it('skips when no durable routed model exists instead of using AgentOptions fallback', async () => { const compact = service(compactConfig) - const session = conversation() - expect(await compact.compactIfNeeded(agent(session), '', [], SIGNAL)).toBeNull() + const session = new Session(SessionId('headerless')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await expect(compact.compactIfNeeded(agent(session, MODEL), 'pressure', SIGNAL)) + .resolves.toBeNull() expect(compact.calls).toHaveLength(0) }) it('throws for a named unconfigured model instead of swallowing it', async () => { const compact = service(compactConfig) - await expect(compactIfNeeded(compact, conversation(), 'missing')) + const session = conversation() + session.append('request/header', { + header: { config: { model: 'missing' } }, + reason: 'resume', + }) + await expect(compactIfNeeded(compact, session)) .rejects.toMatchObject({ code: TOKEN_METER_MODEL_UNCONFIGURED, model: 'missing' }) }) + it('declines forced overflow when the whole surface is one indivisible tool pair', async () => { + const compact = service(compactConfig) + const session = new Session(SessionId('single-tool-pair')) + const callId = CallId('single-call') + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('request/header', { + header: { config: { model: MODEL } }, + reason: 'initial', + }) + session.append('assistant/message', { + turn: 1, + step: 1, + content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }], + }, { surfaceOp: 'append' }) + session.append('tool/call', { turn: 1, step: 1, callId, name: 'read', arguments: '{}' }) + session.append('tool/result', { + turn: 1, + step: 1, + callId, + content: [{ type: 'text', text: 'result' }], + isError: false, + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + const generation = session.surface.replaceGeneration + + await expect(compactIfNeeded(compact, session, 'context-overflow')).resolves.toBeNull() + expect(session.surface.replaceGeneration).toBe(generation) + expect(session.events.some(event => event.type === 'compact/start')).toBe(false) + }) + it('does nothing below threshold and compacts a priced head above threshold', async () => { const compact = service(compactConfig) expect(await compactIfNeeded(compact, conversation(2))).toBeNull() @@ -231,7 +283,7 @@ describe('pressure measurement and retention', () => { expect(session.surface.nodes.length).toBeLessThan(8) }) - it('counts the current prompt and request prefix without putting either on the surface', async () => { + it('counts the durable routed request envelope without putting its prefix on the surface', async () => { const compact = service({ auto: false, models: { [MODEL]: { thresholdRatio: 0.7, retainTokens: 9 } }, @@ -239,11 +291,16 @@ describe('pressure measurement and retention', () => { const session = conversation(2, 'x'.repeat(2_000)) expect(await compactIfNeeded(compact, session)).toBeNull() - const prefix: Message[] = [{ - role: 'user', - content: [{ type: 'text', text: 'p'.repeat(10_000) }], - }] - const result = await compactIfNeeded(compact, session, MODEL, 's'.repeat(5_000), prefix) + const prefix = [{ role: 'user' as const, content: [{ type: 'text' as const, text: 'p'.repeat(10_000) }] }] + session.append('request/header', { + header: { + config: { model: MODEL }, + system: 's'.repeat(5_000), + messagePrefix: prefix, + }, + reason: 'resume', + }) + const result = await compactIfNeeded(compact, session) expect(result).not.toBeNull() expect(prefix).toHaveLength(1) expect(session.events.some(event => event.type === 'context/message')).toBe(false) @@ -264,17 +321,26 @@ describe('pressure measurement and retention', () => { reason: 'initial', }) - const result = await compactIfNeeded(compact, session, 'fallback') + const result = await compactIfNeeded(compact, session, 'pressure', 'fallback') expect(result).not.toBeNull() }) it('declines when envelope pressure is high but the surface has no compactable range', async () => { const compact = service(compactConfig) const empty = new Session(SessionId('empty')) - expect(await compactIfNeeded(compact, empty, MODEL, 'x'.repeat(100_000))).toBeNull() + empty.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + empty.append('request/header', { + header: { config: { model: MODEL }, system: 'x'.repeat(100_000) }, + reason: 'initial', + }) + expect(await compactIfNeeded(compact, empty)).toBeNull() const retained = conversation(1) - expect(await compactIfNeeded(compact, retained, MODEL, 'x'.repeat(100_000))).toBeNull() + retained.append('request/header', { + header: { config: { model: MODEL }, system: 'x'.repeat(100_000) }, + reason: 'resume', + }) + expect(await compactIfNeeded(compact, retained)).toBeNull() }) it('detects scalar/surface revision disagreement', async () => { @@ -587,7 +653,19 @@ describe('compaction region transaction', () => { it('requires a conversation model for pricing', async () => { const compact = service() - const session = conversation(1) + const session = new Session(SessionId('model-less-region')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { + content: [{ type: 'text', text: 'history' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + turn: 1, + step: 1, + content: [{ type: 'text', text: 'answer' }], + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) const nodes = session.surface.nodes await expect(compact.compactRegion( session, @@ -679,6 +757,25 @@ describe('default one-shot summarizer', () => { expect(adapter.lastOptions?.model).toBe('routed') }) + it('records the model actually dispatched after one-shot stream routing', async () => { + const { ctx, compact } = await summarizerHarness([{ type: 'text', text: 'unused' }]) + const routedAdapter = new ScriptedAdapter([{ type: 'text', text: 'routed summary' }]) + ctx.llm.registerAdapter(['routed-summary-model'], routedAdapter) + ctx.on('llm/stream', (options, next) => { + options.model = 'routed-summary-model' + return next() + }) + + const session = conversation(3, 'large history '.repeat(500)) + const nodes = session.surface.nodes + await compact.compactRegion(session, nodes[0]!.seq, nodes[3]!.seq, agent(session, MODEL), SIGNAL) + expect(session.events.findLast(event => event.type === 'compact/summary')?.data).toMatchObject({ + summary: [{ type: 'text', text: 'routed summary' }], + model: 'routed-summary-model', + }) + expect(routedAdapter.lastOptions?.model).toBe('routed-summary-model') + }) + it('fails clearly when no summarization model can be resolved', async () => { const ctx = new Context() await ctx.plugin(LlmService) @@ -717,21 +814,36 @@ describe('default one-shot summarizer', () => { }) describe('automatic listener and loader composition', () => { - function preStep(ctx: Context, owner: Agent): Promise { - return ctx.serial('agent/pre-step', owner, 1, 1, '', [], SIGNAL) + function postStep(ctx: Context, owner: Agent, signal = SIGNAL): Promise { + return ctx.serial('agent/post-step', owner, 1, 1, signal) } - it('compacts above threshold and remains idle below it', async () => { + function recover( + ctx: Context, + owner: Agent, + error: Error & { code?: string }, + retryAttempt = 0, + signal = SIGNAL, + next: () => Promise<{ action: 'fail' | 'retry' }> = () => Promise.resolve({ action: 'fail' }), + ): Promise<{ action: 'fail' | 'retry' }> { + return ctx.waterfall('agent/request-error', owner, 1, 1, error, retryAttempt, signal, next) + } + + function overflow(message = 'provider overflow'): Error & { code: string } { + return Object.assign(new Error(message), { code: CONTEXT_WINDOW_EXCEEDED_CODE }) + } + + it('compacts post-step above threshold using the durable routed model and remains idle below it', async () => { const ctx = createContext() const compact = new TestCompactService(ctx, { models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } }, }) const pressured = conversation(4) - await preStep(ctx, agent(pressured, MODEL)) + await postStep(ctx, agent(pressured, 'unconfigured-agent-fallback')) expect(pressured.events.some(event => event.type === 'compact/summary')).toBe(true) const small = conversation(1) - await preStep(ctx, agent(small, MODEL)) + await postStep(ctx, agent(small, MODEL)) expect(small.events.some(event => event.type === 'compact/start')).toBe(false) expect(compact.calls).toHaveLength(1) }) @@ -746,7 +858,7 @@ describe('automatic listener and loader composition', () => { compact.error = 'temporary failure' const session = conversation(4) - await expect(preStep(ctx, agent(session, MODEL))).resolves.toBeUndefined() + await expect(postStep(ctx, agent(session, MODEL))).resolves.toBeUndefined() expect(warnings).toContainEqual(expect.stringContaining('temporary failure')) expect(session.events.some(event => event.type === 'compact/summary')).toBe(false) }) @@ -754,21 +866,210 @@ describe('automatic listener and loader composition', () => { it('propagates a named unknown-model configuration failure', async () => { const ctx = createContext() void new TestCompactService(ctx) - await expect(preStep(ctx, agent(conversation(4), 'missing'))).rejects.toMatchObject({ + const session = conversation(4) + session.append('request/header', { + header: { config: { model: 'missing' } }, + reason: 'resume', + }) + await expect(postStep(ctx, agent(session, MODEL))).rejects.toMatchObject({ code: TOKEN_METER_MODEL_UNCONFIGURED, model: 'missing', }) }) - it('auto:false installs no listener', async () => { + it('force-compacts below normal pressure for canonical overflow and retries only after replacement', async () => { + const ctx = createContext() + void new TestCompactService(ctx, { + models: { [MODEL]: { thresholdRatio: 1, retainTokens: 90 } }, + }) + const session = conversation(3) + const beforeGeneration = session.surface.replaceGeneration + const retainedSeq = session.surface.nodes.at(-1)!.seq + const threshold = 100 + expect(ctx.tokenMeter.resolve(MODEL).measure(session).totalTokens).toBeLessThan(threshold) + const decision = await recover(ctx, agent(session, 'unconfigured-agent-fallback'), overflow()) + + expect(decision).toEqual({ action: 'retry' }) + expect(session.surface.replaceGeneration).toBe(beforeGeneration + 1) + expect(session.events.some(event => event.type === 'compact/summary')).toBe(true) + expect(session.surface.nodes.some(node => node.seq === retainedSeq)).toBe(true) + }) + + it('preserves the newest whole tool-call/result pair during forced overflow compaction', async () => { + const ctx = createContext() + void new TestCompactService(ctx, { + models: { [MODEL]: { thresholdRatio: 1, retainTokens: 90 } }, + }) + const session = toolConversation() + const newestAssistant = session.surface.nodes.at(-2)! + const newestResult = session.surface.nodes.at(-1)! + + expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' }) + const currentAssistant = session.surface.nodes.find(node => node.seq === newestAssistant.seq) + const currentResult = session.surface.nodes.find(node => node.seq === newestResult.seq) + expect(currentAssistant).toBeDefined() + expect(currentResult).toBeDefined() + expect(toolPairingBalancedBefore(session, currentAssistant!)).toBe(true) + expect(toolPairingBalancedAfter(session, currentResult!)).toBe(true) + }) + + it('does not retry when a backend reports success without replacing the surface', async () => { + const ctx = createContext() + const compact = new TestCompactService(ctx) + const session = conversation(2) + const fakeResult: CompactionResult = { + startSeq: 1, + summarySeq: 2, + endSeq: 3, + summary: [{ type: 'text', text: 'fake' }], + shadowedRange: { start: 1, end: 2 }, + shadowedSeqs: [1, 2], + shadowedTokenCount: 10, + } + vi.spyOn(compact, 'compactIfNeeded').mockResolvedValue(fakeResult) + + expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' }) + expect(session.surface.replaceGeneration).toBe(0) + }) + + it('delegates downstream exactly once when no replacement is available', async () => { + const ctx = createContext() + const compact = new TestCompactService(ctx) + vi.spyOn(compact, 'compactIfNeeded').mockResolvedValue(null) + const downstream = new Error('downstream recovery failed') + let calls = 0 + + await expect(recover( + ctx, + agent(conversation(2), MODEL), + overflow(), + 0, + SIGNAL, + () => { + calls += 1 + return Promise.reject(downstream) + }, + )).rejects.toBe(downstream) + expect(calls).toBe(1) + }) + + it('preserves the original provider error when recovery throws', async () => { + const ctx = createContext() + const warnings: string[] = [] + ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn + const compact = new TestCompactService(ctx) + compact.error = new Error('summary unavailable') + const original = overflow('original provider overflow') + + expect(await recover(ctx, agent(conversation(3), MODEL), original)).toEqual({ action: 'fail' }) + expect(original).toMatchObject({ + message: 'original provider overflow', + code: CONTEXT_WINDOW_EXCEEDED_CODE, + }) + expect(warnings).toContainEqual(expect.stringContaining('preserving the original request error')) + }) + + it('delegates once when overflow recovery throws a non-Error value', async () => { + const ctx = createContext() + const warnings: string[] = [] + ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn + const compact = new TestCompactService(ctx) + compact.error = 'non-error recovery failure' + const session = conversation(3) + const generation = session.surface.replaceGeneration + const original = overflow('original provider failure') + let delegations = 0 + + const decision = await recover(ctx, agent(session, MODEL), original, 0, SIGNAL, () => { + delegations += 1 + return Promise.resolve({ action: 'fail' }) + }) + + expect(decision).toEqual({ action: 'fail' }) + expect(delegations).toBe(1) + expect(session.surface.replaceGeneration).toBe(generation) + expect(original).toMatchObject({ + message: 'original provider failure', + code: CONTEXT_WINDOW_EXCEEDED_CODE, + }) + expect(warnings).toContainEqual(expect.stringContaining('non-error recovery failure')) + }) + + it('delegates once and preserves the original overflow for an unknown routed meter model', async () => { + const ctx = createContext() + void new TestCompactService(ctx) + const session = conversation(2) + session.append('request/header', { + header: { config: { model: 'unknown-routed-model' } }, + reason: 'resume', + }) + const original = overflow('original unknown-model overflow') + let delegations = 0 + + const decision = await recover(ctx, agent(session, MODEL), original, 0, SIGNAL, () => { + delegations += 1 + return Promise.resolve({ action: 'fail' }) + }) + expect(decision).toEqual({ action: 'fail' }) + expect(delegations).toBe(1) + expect(original).toMatchObject({ + message: 'original unknown-model overflow', + code: CONTEXT_WINDOW_EXCEEDED_CODE, + }) + }) + + it('honors retry caps, non-context failures, and cancellation', async () => { + const ctx = createContext() + const compact = new TestCompactService(ctx, { maxOverflowRetries: 1 }) + const compactSpy = vi.spyOn(compact, 'compactIfNeeded') + const owner = agent(conversation(3), MODEL) + expect(await recover(ctx, owner, Object.assign(new Error('rate limit'), { code: 'RATE_LIMIT' }))) + .toEqual({ action: 'fail' }) + expect(await recover(ctx, owner, overflow(), 1)).toEqual({ action: 'fail' }) + + const controller = new AbortController() + controller.abort('cancelled') + expect(await recover(ctx, owner, overflow(), 0, controller.signal)).toEqual({ action: 'fail' }) + expect(compactSpy).not.toHaveBeenCalled() + }) + + it('does not retry when cancellation lands during an awaited compaction', async () => { + const ctx = createContext() + const compact = new TestCompactService(ctx) + const controller = new AbortController() + compact.mutateDuringSummary = () => { controller.abort('cancelled during summary') } + const session = conversation(3) + const generation = session.surface.replaceGeneration + + expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal)) + .toEqual({ action: 'fail' }) + expect(session.surface.replaceGeneration).toBe(generation + 1) + }) + + it('maxOverflowRetries:0 disables recovery without disabling post-step pressure', async () => { + const ctx = createContext() + void new TestCompactService(ctx, { + maxOverflowRetries: 0, + models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } }, + }) + const session = conversation(4) + await postStep(ctx, agent(session, MODEL)) + const summaries = session.events.filter(event => event.type === 'compact/summary').length + expect(summaries).toBe(1) + expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' }) + expect(session.events.filter(event => event.type === 'compact/summary')).toHaveLength(summaries) + }) + + it('auto:false installs neither automatic listener', async () => { const ctx = createContext() void new TestCompactService(ctx, { auto: false, models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } }, }) const session = conversation(4) - await preStep(ctx, agent(session, MODEL)) + await postStep(ctx, agent(session, MODEL)) expect(session.events.some(event => event.type === 'compact/start')).toBe(false) + expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' }) }) it('loads and disposes the real zero-config service stack', async () => { @@ -797,8 +1098,9 @@ describe('automatic listener and loader composition', () => { await fiber.dispose() const session = conversation(4) - await preStep(ctx, agent(session, MODEL)) + await postStep(ctx, agent(session, MODEL)) expect(session.events.some(event => event.type === 'compact/start')).toBe(false) + expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' }) }) }) diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index cc1111c5f1..2fe9725d77 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' -import LlmService from '@deepseek-ai/dsh-llm' +import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' @@ -53,6 +53,45 @@ class StepwiseToolAdapter extends LlmAdapter { } } +/** First conversation request overflows, then the rebuilt retry succeeds. */ +class OverflowRecoveryAdapter extends LlmAdapter { + readonly conversationRequests: GenerateOptions[] = [] + readonly summaryRequests: GenerateOptions[] = [] + + constructor(private readonly delivery: 'thrown' | 'in-band') { + super() + } + + override async * stream(options: GenerateOptions): AsyncIterable { + if (options.system?.includes('You are a compaction engine')) { + this.summaryRequests.push(options) + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: 'RECOVERY CHECKPOINT' } } + yield { type: 'finish', reason: { kind: 'stop' } } + return + } + + this.conversationRequests.push(options) + if (this.conversationRequests.length === 1) { + if (this.delivery === 'thrown') { + throw new LlmError('request too large for model context', CONTEXT_WINDOW_EXCEEDED_CODE, 400) + } + yield { + type: 'finish', + reason: { + kind: 'error', + message: 'request too large for model context', + code: CONTEXT_WINDOW_EXCEEDED_CODE, + }, + } + return + } + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: 'recovered' } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + async function harness(toolSteps: number): Promise<{ ctx: Context; compact: ReproCompactService }> { const ctx = new Context() await ctx.plugin(LlmService) @@ -98,6 +137,53 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { } describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => { + it('uses the model actually routed by agent/request for post-step pressure', async () => { + const { ctx } = await harness(8) + ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, model: 'mock' })) + try { + const agent = ctx.agentLoop.create(AgentId('routed-pressure'), { + model: 'unconfigured-agent-fallback', + }) + agent.send([{ type: 'text', text: 'do a routed multi-step task' }]) + await waitForIdle(ctx, agent) + + expect(agent.session.requestHeader()?.config.model).toBe('mock') + expect(agent.session.events.some(event => event.type === 'compact/summary')).toBe(true) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'completed' } }, + }) + } finally { + await ctx.fiber.dispose() + } + }) + + it('runs automatic pressure after the current tool result and before step/end', async () => { + const { ctx } = await harness(4) + try { + const agent = ctx.agentLoop.create(AgentId('post-step-order'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'do tool work' }]) + await waitForIdle(ctx, agent) + + const events = [...agent.session.events] + const compactStart = events.find(event => event.type === 'compact/start') + expect(compactStart).toBeDefined() + const precedingResult = events.findLast(event => + event.type === 'tool/result' && event.seq < compactStart!.seq, + ) + if (precedingResult?.type !== 'tool/result') throw new Error('expected a durable tool result before compaction') + const stepEnd = events.find(event => + event.type === 'step/end' + && event.data.step === precedingResult.data.step + && event.seq > compactStart!.seq, + ) + expect(precedingResult.seq).toBeLessThan(compactStart!.seq) + expect(compactStart!.seq).toBeLessThan(stepEnd!.seq) + } finally { + await ctx.fiber.dispose() + } + }) + it('the head checkpoint the loop lands is a balanced cut on both sides', async () => { const { ctx } = await harness(8) try { @@ -130,3 +216,91 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () } }) }) + +describe('context-overflow recovery across the real loop and compact-basic', () => { + it.each(['thrown', 'in-band'] as const)( + 'force-compacts a %s overflow between failed and retry steps', + async (delivery) => { + const ctx = new Context() + const adapter = new OverflowRecoveryAdapter(delivery) + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(Invariants) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(TokenMeterService, { + models: { mock: { contextWindow: 128, charsPerToken: 4 } }, + }) + ctx.llm.registerAdapter(['mock'], adapter) + ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, model: 'mock' })) + await ctx.plugin(BasicCompactService, { + models: { mock: { thresholdRatio: 1, retainTokens: 100 } }, + maxTokens: 64, + compactionRetries: 0, + maxOverflowRetries: 1, + }) + + try { + const agent = ctx.agentLoop.create(AgentId(`overflow-${delivery}`), { + model: 'unconfigured-agent-fallback', + }) + for (let turn = 1; turn <= 2; turn += 1) { + const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY' + agent.session.append('turn/start', { + turn, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + agent.session.append('user/message', { + content: [{ type: 'text', text: `${sentinel} ${'old context '.repeat(200)}` }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + agent.session.append('step/start', { turn, step: 1 }) + agent.session.append('assistant/message', { + turn, + step: 1, + content: [{ type: 'text', text: `historical response ${turn} ${'detail '.repeat(200)}` }], + }, { surfaceOp: 'append' }) + agent.session.append('step/end', { turn, step: 1 }) + agent.session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + + agent.send([{ type: 'text', text: 'continue from history' }]) + await agent.whenIdle() + + expect(adapter.conversationRequests).toHaveLength(2) + expect(adapter.summaryRequests).toHaveLength(1) + expect(JSON.stringify(adapter.conversationRequests[0]!.messages)).toContain('OLD HISTORY SENTINEL') + const retry = JSON.stringify(adapter.conversationRequests[1]!.messages) + expect(retry).toContain('RECOVERY CHECKPOINT') + expect(retry).not.toContain('OLD HISTORY SENTINEL') + + const events = [...agent.session.events] + const failedEnd = events.find(event => + event.type === 'step/end' && event.data.turn === 3 && event.data.step === 1, + )! + const retryStart = events.find(event => + event.type === 'step/start' && event.data.turn === 3 && event.data.step === 2, + )! + const compaction = events.filter(event => + event.type === 'compact/start' + || event.type === 'compact/summary' + || event.type === 'compact/end', + ) + expect(compaction.map(event => event.type)).toEqual([ + 'compact/start', + 'compact/summary', + 'compact/end', + ]) + expect(compaction.every(event => event.seq > failedEnd.seq && event.seq < retryStart.seq)).toBe(true) + expect(events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'completed' } }, + }) + } finally { + await ctx.fiber.dispose() + } + }, + ) +}) diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 89aacbb09b..a4c94f6c3a 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -18,7 +18,7 @@ Both methods are **abstract** — the backend owns trigger policy, retention, ev | Member | Semantics | |---|---| -| `compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-step` checkpoint supplies the agent, assembled `fullSystemPrompt`, composed `sessionPrefix` (request-only messages every request carries but the derived history omits — the pressure estimate must count them), and turn `signal`. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. | +| `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`. | | `compactRegion(session, start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. The agent must own the exact target (`session === agent.session`); a backend rejects mismatch before model resolution, lock acquisition, summarization, or log mutation. **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. | `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. @@ -72,6 +72,5 @@ Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and ## 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. -- **Single-unit overflow is out of contract** — one retained unit (a closed step or a large pasted `user/message`) alone exceeding the budget cannot be compacted; the call may go out over-budget. -- **A session prefix that alone approaches the window is a configuration error no backend fixes** — compaction shrinks derived history, never the prefix. -- **Request context injected by downstream `agent/request` listeners sits outside pressure accounting** — `compactIfNeeded` counts prefix, derived history, and system prompt only. +- **Single-unit overflow is out of contract** — one indivisible unit (a closed tool pair or a large pasted `user/message`) alone exceeding the budget 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/src/index.ts b/packages/compact/compact/src/index.ts index 7361d38ef3..80058f6145 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -8,7 +8,6 @@ */ import { Context, Service } from 'cordis' -import type { Message } from '@deepseek-ai/dsh-llm' import type { Session } from '@deepseek-ai/dsh-session' import type { CompactionResult } from './types.ts' @@ -16,6 +15,9 @@ export type { CompactionResult } from './types.ts' export { renderContentBlocks, renderTranscript } from './render.ts' export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts' +/** Why automatic policy is asking a backend to consider compaction. */ +export type CompactionTrigger = 'pressure' | 'context-overflow' + /** Minimal agent context compaction needs without depending on the agent package. */ export interface CompactAgentContext { session: Session @@ -41,24 +43,20 @@ export abstract class CompactService extends Service { } /** - * Check token pressure and compact if the conversation is too large. - * Estimate the next request, including its session prefix, derived history, - * and system prompt. Above threshold, compact a head-anchored range ending at - * a balanced tool boundary and reconsolidate any prior automatic checkpoint. - * Return `null` when no compaction is needed or an open tail leaves no safe - * cutoff. A single oversized retained unit or prefix cannot be repaired here. + * Consider automatic compaction for one explicit trigger. Pressure policy + * uses the latest durable routed request, while context-overflow policy may + * force a useful balanced reduction even below the normal threshold. Return + * `null` when no safe range can be compacted. A single oversized retained + * unit or request envelope cannot be repaired through surface compaction. * * @param agent - agent context owning the session surface and model options. - * @param fullSystemPrompt - assembled system prompt, counted toward the estimate. - * @param sessionPrefix - the instance's composed session prefix, counted toward the - * estimate. + * @param trigger - normal pressure or provider-confirmed context overflow. * @param signal - cancellation signal; model-backed implementations must forward it. * @returns the compaction result, or `null` if no compaction was needed. */ abstract compactIfNeeded( agent: CompactAgentContext, - fullSystemPrompt: string, - sessionPrefix: readonly Message[], + trigger: CompactionTrigger, signal: AbortSignal, ): Promise diff --git a/packages/compact/compact/tests/compact.spec.ts b/packages/compact/compact/tests/compact.spec.ts index c4daa8cc5a..946b2e8b83 100644 --- a/packages/compact/compact/tests/compact.spec.ts +++ b/packages/compact/compact/tests/compact.spec.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CompactService } from '@deepseek-ai/dsh-compact' -import type { CompactionResult } from '@deepseek-ai/dsh-compact' -import type { Message } from '@deepseek-ai/dsh-llm' +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' @@ -18,8 +17,7 @@ class StubCompactService extends CompactService { override async compactIfNeeded( _agent: CompactAgentContext, - _fullSystemPrompt: string, - _sessionPrefix: readonly Message[], + _trigger: CompactionTrigger, signal: AbortSignal, ): Promise { this.lastSignal = signal @@ -80,7 +78,7 @@ describe('CompactService seam', () => { const ctx = new Context() const svc = new StubCompactService(ctx) const session = new Session(SessionId('s')) - expect(await svc.compactIfNeeded(stubAgent(session), '', [], new AbortController().signal)).toBeNull() + expect(await svc.compactIfNeeded(stubAgent(session), 'pressure', new AbortController().signal)).toBeNull() }) it('compact/* events merge into SessionEventMap and are log-only', async () => { @@ -109,7 +107,7 @@ describe('CompactService seam', () => { await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'), controller.signal) expect(svc.lastSignal).toBe(controller.signal) - await svc.compactIfNeeded(stubAgent(session), '', [], controller.signal) + await svc.compactIfNeeded(stubAgent(session), 'context-overflow', controller.signal) expect(svc.lastSignal).toBe(controller.signal) }) }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7aafb3ce8a..fad2094fa9 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -108,7 +108,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'compact', summary: 'Abstract compaction service.', methods: [ - 'abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise', + 'abstract compactIfNeeded( agent: CompactAgentContext, trigger: CompactionTrigger, signal: AbortSignal, ): Promise', 'abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise', ], }, @@ -289,8 +289,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent/pre-step', mode: 'serial', - signature: '\'agent/pre-step\'(this: Scoped, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise | void', - summary: 'Awaited serial checkpoint for session-surface mutation after prompt assembly and before `step/start`; appends land outside the pending step.', + signature: '\'agent/pre-step\'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void', + summary: 'Awaited serial checkpoint before `step/start`; appends land outside the pending step and are included when the loop derives request history.', }, { name: 'agent/prompt-submit', @@ -656,6 +656,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CompactionResult', declaration: 'export interface CompactionResult {\n startSeq: number;\n summarySeq: number;\n endSeq: number;\n summary: ContentBlock[];\n shadowedRange: {\n start: number;\n end: number;\n };\n shadowedSeqs: number[];\n shadowedTokenCount: number;\n}', }, + { + name: 'CompactionTrigger', + declaration: 'export type CompactionTrigger = \'pressure\' | \'context-overflow\';', + }, { name: 'ConfinedArgv', declaration: 'export interface ConfinedArgv {\n argv: string[];\n enforcement: SandboxEnforcement;\n denialSignatures: readonly string[];\n runnerFailureSignatures: readonly string[];\n}', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 864ff95e8c..70d7c16cf0 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -58,7 +58,7 @@ Plugin failure ends the current turn, not the loop. Only final adapter dispatch/ Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy: - Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute` → `tools/execute` → `tools/post-execute` → `tools/result` pipeline; exact signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md) -- Compaction: `agent/pre-step` +- Compaction: pressure on `agent/post-step`; canonical context overflow on `agent/request-error` - Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation - Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while child streaming/progress and background/poll collection remain deferred. - Persistence: `session/event` + `session/flush` diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index e749018f55..620cdd9fbc 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -276,7 +276,7 @@ async function runTurn( const abort = new AbortController() handle.setAbort(abort) - // Assemble once before pre-step so pressure checks and the request share the same prompt. + // Assemble once before pre-step so listener work and the request share one prompt value. const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent)) const fullSystemPrompt = renderPrompt(assembly) @@ -287,9 +287,9 @@ async function runTurn( break } - // Compose the request-only prefix once per loop instance before pressure - // checks. It precedes all derived history and is recorded only in the - // request header, not as session history. + // Compose the request-only prefix once per loop instance before the first + // request boundary. It precedes all derived history and is recorded only + // in the request header, not as session history. if (transmission.sessionPrefix === undefined) { const emptyPrefix: Message[] = deepFreeze([]) const composed = await events.waterfall( @@ -306,8 +306,8 @@ async function runTurn( transmission.sessionPrefix = deepFreeze(structuredClone(composed)) } - // Await surface mutations outside the step; pressure checks receive the pending prefix. - await events.serial('agent/pre-step', turn, step, fullSystemPrompt, transmission.sessionPrefix, abort.signal) + // Await surface mutations outside the step before snapshotting history. + await events.serial('agent/pre-step', turn, step, abort.signal) // Interruption landing during the pre-step seam: do not open an empty step. if (handle.isCancelled() || handle.isDisposed()) { diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 4b37210993..d89e5b0b0e 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -118,7 +118,7 @@ describe('agent/prompt-submit', () => { it('a prompt-submit rewrite + additionalContext is VISIBLE to the agent/pre-step seam (merged ordering)', async () => { // Prompt rewrites and injected context land before `agent/pre-step`, so a - // compaction listener measures the current surface before the single derive. + // surface listener sees the current state before the single derive. const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -130,8 +130,7 @@ describe('agent/prompt-submit', () => { additionalContext: { content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' } }, })) - // The pre-step seam (where compaction lives) derives the surface it would act - // on. Capture what it sees on the first step. + // Capture the surface visible at the generic pre-step seam on the first step. let preStepDerived: string | undefined ctx.on('agent/pre-step', (subject, _turn, step) => { if (subject === agent && step === 1) preStepDerived = JSON.stringify(subject.session.deriveMessages()) @@ -375,7 +374,7 @@ describe('agent/session-prefix', () => { expect(agent.session.deriveMessages()[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'go' }] }) }) - it('composes before the first pre-step and hands the prefix to the seam (pressure gates see the real value)', async () => { + it('composes before the first pre-step and records the prefix on the request header', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -386,20 +385,15 @@ describe('agent/session-prefix', () => { order.push('compose') return [reminder, ...await next()] }) - const seen: (readonly Message[])[] = [] - ctx.on('agent/pre-step', (_agent, _turn, _step, _system, sessionPrefix) => { + ctx.on('agent/pre-step', () => { order.push('pre-step') - seen.push(sessionPrefix) }) send(agent, 'hi') await waitForIdle(ctx, agent) - // Composition precedes the pre-step seam, and the seam receives THIS - // instance's composed prefix — a token-pressure gate (compaction) counts - // what the request will actually carry, never a stale logged prefix. expect(order).toEqual(['compose', 'pre-step']) - expect(seen[0]).toEqual([reminder]) + expect(agent.session.requestHeader()?.messagePrefix).toEqual([reminder]) }) it('the canonical prepend pattern composes contributions in registration order', async () => { diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index f282301a2b..89d8cc306c 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -486,9 +486,8 @@ describe('agent loop', () => { it('agent/pre-step fires once per step before the step is opened', async () => { // Two steps (a tool call, then a final text turn) → two model calls → two - // pre-step fires, each carrying the assembled full system prompt, BEFORE - // the step is opened and its request is derived (the request the adapter - // sees reflects any surface state at fire time). + // pre-step fires BEFORE the step is opened and its request is derived (the + // request the adapter sees reflects any surface state at fire time). const adapter = new MockAdapter([ toolCallResponse('c1', 'echo', {}, 'calling echo'), textResponse('done'), @@ -500,21 +499,19 @@ describe('agent loop', () => { })) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - const fires: { turn: number; step: number; fullSystemPrompt: string }[] = [] - ctx.on('agent/pre-step', (subject, turn, step, fullSystemPrompt) => { - if (subject === agent) fires.push({ turn, step, fullSystemPrompt }) + const fires: { turn: number; step: number; signal: AbortSignal }[] = [] + ctx.on('agent/pre-step', (subject, turn, step, signal) => { + if (subject === agent) fires.push({ turn, step, signal }) }) send(agent, 'go') await waitForIdle(ctx, agent) - // One fire per step, in order, each with the assembled system prompt - // (here just the loop's own harness-identity section — no persona set). - const HARNESS = 'You are an AI agent powered by the DeepSeek Harness SDK.' - expect(fires).toEqual([ - { turn: 1, step: 1, fullSystemPrompt: HARNESS }, - { turn: 1, step: 2, fullSystemPrompt: HARNESS }, + expect(fires.map(({ turn, step }) => ({ turn, step }))).toEqual([ + { turn: 1, step: 1 }, + { turn: 1, step: 2 }, ]) + expect(fires.every(({ signal }) => signal instanceof AbortSignal)).toBe(true) }) it('agent/pre-step fires BEFORE the step it precedes opens (events land outside the step)', async () => { diff --git a/packages/core/agent-loop/tests/request-recovery.spec.ts b/packages/core/agent-loop/tests/request-recovery.spec.ts index 003c678be0..416ede21b3 100644 --- a/packages/core/agent-loop/tests/request-recovery.spec.ts +++ b/packages/core/agent-loop/tests/request-recovery.spec.ts @@ -219,6 +219,48 @@ describe('agent post-step and request-error lifecycle', () => { }) }) + it('closes the successful step as disposed when disposal lands during post-step', async () => { + const adapter = new FailureScriptAdapter([ + toolCallResponse('dispose-call', 'work', {}), + textResponse('must not continue'), + ]) + const ctx = await harness(adapter) + ctx.tools.register(defineTool({ + name: 'work', + description: 'do work', + parameters: {}, + async execute() { return [{ type: 'text', text: 'worked' }] }, + })) + const agent = ctx.agentLoop.create(AgentId('dispose-post-step'), { model: 'mock' }) + let entered!: () => void + const postStepEntered = new Promise((resolve) => { entered = resolve }) + ctx.on('agent/post-step', async (_agent, turn, step, signal) => { + expect({ turn, step }).toEqual({ turn: 1, step: 1 }) + entered() + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + }) + + send(agent) + await postStepEntered + await ctx.fiber.dispose() + + expect(adapter.requests).toHaveLength(1) + const boundaries = agent.session.events.filter(event => + event.type === 'step/start' || event.type === 'step/end', + ) + expect(boundaries.map(event => event.type)).toEqual(['step/start', 'step/end']) + expect(boundaries.map(event => event.data)).toEqual([ + { turn: 1, step: 1 }, + { turn: 1, step: 1 }, + ]) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'disposed' } }, + }) + }) + it.each([ ['thrown', contextError()], ['in-band', [{ type: 'finish', reason: { kind: 'error', message: 'too large', code: CONTEXT_WINDOW_EXCEEDED_CODE } }] satisfies StreamChunk[]], diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 1eda0df2d7..05f473e07c 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -189,23 +189,17 @@ declare module 'cordis' { // ---- step/request extension seams (serial + waterfall) ---- /** - * Awaited serial checkpoint for session-surface mutation after prompt - * assembly and before `step/start`; appends land outside the pending step. - * The loop derives history once afterward, so compaction records and - * replacements are included without rewriting an assembled request. The - * prompt and prefix are the exact pressure inputs for that request, and + * Awaited serial checkpoint before `step/start`; appends land outside the + * pending step and are included when the loop derives request history. * `signal` cancels listener work. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @param agent - the agent opening the step. * @param turn - the open turn number. * @param step - the pending step number. - * @param fullSystemPrompt - the assembled prompt. - * @param sessionPrefix - the frozen request prefix. * @param signal - the turn abort signal. * @mode serial */ - // TODO: Move prompt-pressure inputs behind a compaction-specific seam if no second consumer appears. - 'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise | void + 'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void /** * Allow, rewrite, or block one drained prompt before it becomes a user * message. Call `next()` for the unchanged default. @@ -233,9 +227,9 @@ declare module 'cordis' { * result is computed once per loop instance, logged on its anchoring request * header, and reused so the provider prefix remains stable. Interrupted * composition is discarded. Composition precedes the first `agent/pre-step` - * and request boundary, so listener appends join the current request and - * pressure accounting sees the composed prefix. Changing context belongs in - * history; contributors should prepend to `await next()` to preserve registration order. + * and request boundary, so listener appends join the current request. + * Changing context belongs in history; contributors should prepend to + * `await next()` to preserve registration order. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @param agent - the agent whose session prefix is being composed. * @param prefix - the frozen seed; return an extended replacement. diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 7af8d97848..af84c569b9 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -849,7 +849,7 @@ describe('scoped-dispatch invariants', () => { ['agent/status', [agent, 'idle']], ['agent/queued', [agent, [], { source: { kind: 'user' }, steering: false }]], ['agent/session-start', [agent, 'startup']], - ['agent/pre-step', [agent, 1, 1, '', new AbortController().signal]], + ['agent/pre-step', [agent, 1, 1, new AbortController().signal]], ['agent/prompt-submit', [agent, [], { kind: 'user' }, () => Promise.resolve({ kind: 'allow' })]], ['agent/request', [agent, 1, 1, { model: 'm' }, () => Promise.resolve({ model: 'm' })]], ['agent/session-prefix', [agent, [], new AbortController().signal, () => Promise.resolve([])]], diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index f248e5c468..0a2874499a 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -371,7 +371,7 @@ describe('approval policy (the approval/policy fold)', () => { } const preStep = (ctx: Context, agent: Agent): Promise => - ctx.serial('agent/pre-step', agent, 1, 1, '', [], new AbortController().signal) + ctx.serial('agent/pre-step', agent, 1, 1, new AbortController().signal) /** Append a `request/header` snapshot whose system text is exactly `system`. */ function appendHeader(session: Session, system: string): void { diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 11168ba000..05547c9b5b 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -228,7 +228,7 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'seam', implementations: ['compact-basic'], consumers: ['compact-basic'], - note: 'The basic backend currently consumes the pre-step event directly; a model-facing compact tool remains deferred.', + note: 'The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred.', }, { key: 'subagents', @@ -841,6 +841,8 @@ function renderLifecycle(): string { '', 'The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set.', '', + '`dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts and `agent/request-error` only for canonical context overflow. Recovery compacts between the closed failed step and a fresh retry step, and returns retry only when the surface replacement generation advances; otherwise the original request error remains authoritative.', + '', 'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors.', '', ...maintenanceFooter(maintenance), diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index b0cea673ad..17bbb787dc 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -134,6 +134,7 @@ { "doc": "docs/core-data-structures/skills.md", "symbol": "Config", "source": "packages/skill/skill/src/index.ts" }, { "doc": "docs/core-data-structures/compaction.md", "symbol": "CompactionResult", "source": "packages/compact/compact/src/types.ts" }, + { "doc": "docs/core-data-structures/compaction.md", "symbol": "CompactionTrigger", "source": "packages/compact/compact/src/index.ts" }, { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentCapabilities", "source": "packages/subagent/subagent/src/types.ts" }, { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStartRequest", "source": "packages/subagent/subagent/src/types.ts" },