From e8d066f7505afe83c08ba3cb0b8693a7126558fc Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 16:03:52 +0800 Subject: [PATCH 01/18] feat(core): add post-step request recovery (PR3 phase 1) --- docs/agent-lifecycle.md | 10 +- docs/architecture.md | 58 ++- docs/cordis-catalog/events.md | 52 ++- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.md | 14 + docs/core-data-structures/llm-streaming.md | 3 +- docs/event-producer-consumer.md | 30 +- .../cordis/tool-cordis/src/api-catalog.ts | 12 + packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/loop.ts | 179 +++++-- packages/core/agent-loop/tests/cancel.spec.ts | 57 ++- .../tests/contract-regressions.spec.ts | 10 + .../agent-loop/tests/request-recovery.spec.ts | 442 ++++++++++++++++++ packages/core/agent/README.md | 2 +- packages/core/agent/src/types.ts | 31 ++ packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 24 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 28 +- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/src/convert.ts | 3 +- packages/llm/llm-pi-ai/tests/convert.spec.ts | 14 +- packages/llm/llm/README.md | 5 +- packages/llm/llm/src/adapter-failure.ts | 34 ++ packages/llm/llm/src/error.ts | 41 ++ packages/llm/llm/src/index.ts | 58 ++- packages/llm/llm/tests/service.spec.ts | 198 +++++++- .../invariants/src/scoped-events.generated.ts | 2 + scripts/gen-doc-graphs.ts | 10 +- scripts/type-equiv.manifest.json | 2 + 29 files changed, 1207 insertions(+), 120 deletions(-) create mode 100644 packages/core/agent-loop/tests/request-recovery.spec.ts create mode 100644 packages/llm/llm/src/adapter-failure.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index b9292aa80e..e4ed315e5b 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -32,14 +32,22 @@ sequenceDiagram LLM-->>Driver: StreamChunk* Driver->>Session: assistant/chunk* Session-->>SDK: session/event assistant/chunk* + alt final adapter or terminal in-band request failure + Driver->>Session: step/end + Driver->>Hooks: agent/request-error waterfall + Hooks-->>Driver: retry in a new step or preserve the original error + else model request succeeded Driver->>Hooks: agent/step-result waterfall Driver->>Session: assistant/message Driver->>Session: tool/call Driver->>Tools: execute through pre and post waterfalls Tools-->>Session: tool-owned events when applicable - Driver->>Session: tool/result and step/end + Driver->>Session: tool/result, post-tool context, and steering + Driver->>Hooks: agent/post-step serial checkpoint + Driver->>Session: step/end Driver->>Hooks: agent/turn-continuation waterfall Driver->>Hooks: agent/turn-stop serial terminal checkpoint + end Driver->>Session: turn/end Driver->>Persistence: session/flush parallel checkpoint Driver-->>SDK: agent/status idle diff --git a/docs/architecture.md b/docs/architecture.md index 1fa0ee377c..01988a43df 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,7 +4,7 @@ The **DeepSeek Harness SDK** builds agent harnesses on Cordis. The principle is ## Overview -A harness is one [Cordis](cordis-primer.md) context. Packages contribute service keys, typed events, and disposable registrations: services expose stable calls (`ctx.llm`, `ctx.tools`, `ctx.sessions`), events provide interception and notifications (`agent/request`, `tools/pre-execute`, `session/event`), and registrations install prompt sections, tools, providers, adapters, or listeners. +A harness is one [Cordis](cordis-primer.md) context. Packages add services (`ctx.llm`, `ctx.tools`, `ctx.sessions`), typed interception and notification events (`agent/request`, `tools/pre-execute`, `session/event`), and disposable registrations for prompts, tools, providers, adapters, and listeners. `packages/core/` groups the default agent flow; surrounding capabilities are equally first-class Cordis plugins. @@ -43,9 +43,9 @@ Events form the service extension API; see the exhaustive [events catalog](cordi ### Event Domains -- **Session events** are durable, replayable facts. Turn and step boundaries, user input, assistant output, tool calls, tool results, steering, compaction records, and tool-owned durable facts append to the session log and flow through `session/event`. -- **Agent events** carry the live `Agent` handle for status, diagnostics, prompt admission, call-config shaping, result validation, and continuation policy. -- **Capability events** belong to the seam that owns the action. `tools/*`, `llm/*`, `system-prompt/*`, `fs/*`, and `subagent/*` let policy and adapters attach without importing the loop. +- **Session events** are durable replay facts: boundaries, messages, tools, steering, compaction, and tool-owned state flow through `session/event`. +- **Agent events** carry the live `Agent` for status, diagnostics, prompt admission, request shaping, result validation, and continuation. +- **Capability events** belong to their action owner. `tools/*`, `llm/*`, `system-prompt/*`, `fs/*`, and `subagent/*` attach policy and adapters without importing the loop. ### Interception Semantics @@ -53,9 +53,9 @@ Waterfall events behave like around-middleware: a listener delegates by calling ## Default Loop Lifecycle -The shipped loop drains work, assembles requests, streams model answers, executes tools, applies continuation policy, and checkpoints state. Every pause is a service call or event available to plugins. +The shipped loop drains work, assembles requests, streams answers, executes tools, applies continuation policy, and checkpoints state through plugin-visible calls and events. -A **session** is one agent's append-only event log. A **turn** drains one queued batch and runs until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. +A **session** is an agent's append-only log; a **turn** drains one queued batch; a **step** is one model request and its tool executions. Quoted names below are durable events, and unquoted event names are extension points ([sequence companion](agent-lifecycle.md)). ### Turn Flow @@ -76,42 +76,50 @@ forever: assemble system prompt and tool schemas agent/session-prefix (first step) agent/pre-step - 'step/start' snapshot the derived messages (the reconstruction boundary) + 'step/start' agent/request (config only) -> log request/header -> llm/stream (frozen) + on final adapter failure or terminal in-band error/aborted finish: + 'step/end' + agent/request-error(original error, consecutive retry attempt, signal) + retry in the next numbered step or preserve the original error + otherwise: 'assistant/chunk' - agent/step-result - 'assistant/message' (transformed content, or an empty successful-call anchor if step-result rejects) - each tool call: - 'tool/call' - tools/pre-execute -> monotonic guards -> tools/execute -> tools/post-execute -> tools/result - 'tool/result' - append post-tool context and steering - 'step/end' - agent/turn-continuation - agent/turn-stop (terminal policy) - stop unless tools or continuation policy ask for another step + agent/step-result + 'assistant/message' (transformed content, or an empty successful-call anchor if step-result rejects) + each tool call: + 'tool/call' + tools/pre-execute -> monotonic guards -> tools/execute -> tools/post-execute -> tools/result + 'tool/result' + append post-tool context and steering + agent/post-step + 'step/end' + agent/turn-continuation + agent/turn-stop (terminal policy) + stop unless tools or continuation policy ask for another step 'turn/end' checkpoint persistence and notify idle/running status ``` -The loop renders one prompt assembly per step. Plugins contribute ordered sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn instead of shipping a hole. `dsh-system-prompt` owns the harness identity and default deployment persona; an agent-scoped persona may shadow the default. The loop supplies `model` and `cwd`. See the [prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). +Each step renders one prompt assembly. Plugins contribute ordered sections, tool schemas, and `{{name}}` variables; missing values fail the turn. `dsh-system-prompt` owns harness identity and the default persona, which an agent-scoped persona may shadow. The loop supplies `model` and `cwd` ([prompt ownership](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. +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. ### Failure Boundaries -The turn is the containment boundary. A throwing listener, adapter error finish, or failed step ends the current turn with an error reason and reports live diagnostics through `agent/error`; it does not kill the driver loop. `cancel()` clears queued and steering work, aborts the active model/tool boundary when possible, and records the appropriate turn end. Disposal stops the loop, awaits quiescence, unregisters the agent, and lets service disposers drain. +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. -Every session event is turn-enclosed. Reloading a crashed session preserves the interrupted tail and closes it with a synthetic `interrupted` turn end. A failure after the durable turn has closed reports through `agent/error` only because no safe in-turn position remains. A turn ends with one `TurnEndReason` (`completed`, `aborted`, `error`, `disposed`, `max-tokens`, `rejected`, or `interrupted`); per-variant semantics are in [session.md § TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). +Prompt, middleware, result, tool, post-step, and continuation failures remain ordinary `agent/error` failures. Cancellation and disposal beat recovery. Durable undispatched tool calls receive synthetic `ABORTED` results, preventing dangling replay. `cancel()` clears queues and aborts active work; disposal awaits quiescence before unregistering. + +Every session event is turn-enclosed. Reload preserves a crashed tail and closes it with synthetic `interrupted`; post-close failures report only through `agent/error`. A turn has one `TurnEndReason` (`completed`, `aborted`, `error`, `disposed`, `max-tokens`, `rejected`, or `interrupted`), detailed in [session.md](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). ### Agent Handles -`ctx.agents` owns live agents and returns an `AgentHandle { agent, dispose() }`. `Agent` is the API other plugins drive: `send()` queues work, `steer()` injects mid-turn content, `inject()` appends context and opens a one-shot injection turn when idle, `cancel()` is the public stop primitive, and `whenIdle()` observes quiescence. The caller fiber and concrete factory provider structurally co-own programmatic lifecycles; a consumer handle is the only non-structural teardown capability, and every owner reaches the same awaited disposer. +`ctx.agents` owns live agents and returns `AgentHandle { agent, dispose() }`. Plugins use `send()`, `steer()`, `inject()`, `cancel()`, and `whenIdle()`. The caller fiber and factory provider structurally co-own programmatic lifecycles; the consumer handle is the sole non-structural teardown capability, and all owners share one awaited disposer. ### Agent Scope -Every live agent owns a scoped `agent.ctx`. Its registrations shadow same-named globals, receive only that agent's dispatches, and unwind with the agent. `CreateAgentOptions.setup(agentCtx)` composes the scope before publication. The [semantic-gates RFC](rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md) defines typed resolvers that derive carrier checks from merged `Events` signatures and `scopeTarget`, eliminating the handwritten event table. See the [agent-scope RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md); subagent composition controls are documented [separately](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). +Each agent owns `agent.ctx`; its registrations shadow globals, receive only that agent's dispatches, and unwind on disposal. `CreateAgentOptions.setup(agentCtx)` composes it before publication. Typed resolvers derive carrier checks from merged events and `scopeTarget` ([semantic gates](rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md), [agent scope](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md), [subagent controls](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)). ## State @@ -152,7 +160,7 @@ New behavior should attach to a documented extension point; changing the shipped | Add command execution | implement and register a `ctx.bash` backend | | Add filesystem access or policy | implement a `ctx.fs` provider or listen on `fs/*` policy events | | Confine spawned processes | a `ctx.sandbox` backend; consumers wrap their argv before spawning | -| Intercept prompts, requests, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` waterfall; use serial `agent/turn-stop` for a monotonic terminal stop | +| Intercept prompts, requests, model completion/failure, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` event; use serial `agent/turn-stop` for a monotonic terminal stop | | Add a session-stable request prefix outside history | compose it on `agent/session-prefix`, once per loop instance; logged on the request header | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | | Add durable session state | add a `SessionEventMap` member and render/replay from the log | diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 5408a63720..7ce58b6140 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -23,7 +23,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:139`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:145`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -35,7 +35,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:148`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,7 +47,19 @@ 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:283`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:314`](../../packages/core/agent/src/types.ts) + +### `agent/post-step` — serial + +Awaited serial checkpoint after the response, tool results, injected context, and steering are durable but before `step/end`. + +```ts cordis-catalog +'agent/post-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/core/agent/src/types.ts:267`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -59,7 +71,7 @@ Awaited serial checkpoint for session-surface mutation after prompt assembly and Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -71,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:212`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -83,7 +95,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already 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:167`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:173`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -95,7 +107,19 @@ 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:224`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) + +### `agent/request-error` — waterfall + +Recover a model-request failure after its failed step has closed. `retry` opens a new numbered step; `fail` preserves the original request error. Call `next()` to delegate to the next recovery listener or the default. + +```ts cordis-catalog +'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, retryAttempt: number, signal: AbortSignal, next: () => Promise): Promise +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/core/agent/src/types.ts:281`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -107,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:239`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:245`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -119,7 +143,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:186`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -131,7 +155,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:157`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -143,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:250`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:256`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -155,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:260`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:291`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -167,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:270`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:301`](../../packages/core/agent/src/types.ts) ## `approval/*` @@ -233,7 +257,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:39`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:41`](../../packages/llm/llm/src/index.ts) ## `session/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 150cd35f7f..990a2bc1bc 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -127,7 +127,7 @@ stream(options: GenerateOptions): AsyncIterable Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:75`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:77`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 36d007eb38..1f8dca8abc 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -381,6 +381,20 @@ type ContinuationDecision = | { action: 'continue'; reason?: HookContext } ``` +`agent/request-error` receives the original `RequestError`, whose optional provider-neutral `code` supports stable routing without message parsing: + +```ts type-equiv +type RequestError = Error & { code?: string } +``` + +It returns a `RequestErrorDecision`; `retry` opens a new numbered step after the recovery listener's durable mutation, while `fail` preserves that error: + +```ts type-equiv +type RequestErrorDecision = { action: 'fail' } | { action: 'retry' } +``` + +`agent/post-step` is the awaited successful-step checkpoint after assistant output, tool results, buffered context, and steering are durable. Its signature is `(agent, turn, step, signal)`; replayable facts remain in the session log rather than a transient payload. + `agent/turn-stop` returns the stop-only `ContinuationStop` subset or `undefined`. The loop calls this serial checkpoint after folding the ordinary decision, its reason, and pending steering; a stop is terminal and discards pending steering. ```ts type-equiv diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index fff329310a..32174f0d03 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -26,6 +26,7 @@ Every adapter MUST obey these, and every consumer may rely on them: - **`usage` before `finish`, nothing after `finish`.** Defer both to the provider's end-of-stream marker so a trailing usage-only chunk can't violate the ordering. - **Tool-call `arguments` stay raw JSON strings end-to-end.** Partial fragments stream via `argumentsDelta`; a provider that hands back parsed objects re-stringifies at `block-end`. - **Two sanctioned error paths.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted'}` (provider in-band errors, for adapters that can't throw mid-stream). Consumers must handle *both*. The agent loop translates a finish-error/aborted into a turn error — it never logs a normal completed assistant message for a failed step. +- **Context overflow has one canonical code.** Both DeepSeek adapters classify explicit provider detail through `isContextWindowExceededError()` and surface `CONTEXT_WINDOW_EXCEEDED`, whether the failure arrives as a thrown HTTP `LlmError` or an in-band finish error. Consumers route on the code, never provider text. - **Every provider HTTP request carries the app-attribution header.** Adapters send `attributionHeaders()` (below) - the `User-Agent` baseline - and prove it with a wire-level test (mock server asserting the received header, or the library's header hook for a library-backed adapter). This contract is why two adapters exist as a deliberate pair: `dsh-llm-deepseek` (hand-rolled fetch/SSE) and `dsh-llm-pi-ai` (the same endpoint through `@earendil-works/pi-ai`). Two independent internals over one contract is what pinned the protocol down — the library-backed adapter can't throw mid-stream, so it exercises the finish-chunk error path the hand-rolled one might not. @@ -44,7 +45,7 @@ interface AppIdentity { ## `TokenUsage` -Per-call token accounting. Counts are **disjoint**: `inputTokens` is uncached input only; cached input is reported separately, and billed input is the sum of the three. Adapters whose providers fold cache hits into a single prompt total (DeepSeek's `prompt_tokens`) subtract them back out. +Per-call token accounting. Counts are **disjoint**: `inputTokens` is uncached input only; cached input is reported separately, and billed input is the sum of the three. Adapters whose providers fold cache hits into a single prompt total (DeepSeek's `prompt_tokens`) subtract them back out. `reasoningTokens`, when present, is informational detail already included in `outputTokens`; totals must not add it again. ```ts type-equiv interface TokenUsage { diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e095aa4b7e..e6872669de 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,24 +7,26 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../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:148`](../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:283`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:202`](../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: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:167`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `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:180`](../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:157`](../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:250`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../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:270`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `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/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/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) | | `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) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:51`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:41`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:46`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | | `session/event` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter) | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 5e4f5f0b69..7aafb3ce8a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -280,6 +280,12 @@ export const EVENT_API: readonly EventApiEntry[] = [ signature: '\'agent/error\'(this: Scoped, agent: Agent, turn: number, step: number, error: Error): void', summary: 'A step or turn errored.', }, + { + name: 'agent/post-step', + mode: 'serial', + signature: '\'agent/post-step\'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void', + summary: 'Awaited serial checkpoint after the response, tool results, injected context, and steering are durable but before `step/end`.', + }, { name: 'agent/pre-step', mode: 'serial', @@ -304,6 +310,12 @@ export const EVENT_API: readonly EventApiEntry[] = [ signature: '\'agent/request\'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise', summary: 'Replace the frozen call configuration.', }, + { + name: 'agent/request-error', + mode: 'waterfall', + signature: '\'agent/request-error\'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, retryAttempt: number, signal: AbortSignal, next: () => Promise): Promise', + summary: 'Recover a model-request failure after its failed step has closed.', + }, { name: 'agent/session-prefix', mode: 'waterfall', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 803a35fdc5..864ff95e8c 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -52,7 +52,7 @@ The driver owns one agent for its lifetime. It records turn, step, request, stre Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history. -Plugin failure ends the current turn, not the loop. Cancellation clears pending work and aborts the current step without leaking to the next prompt. Terminal continuation stops remain authoritative through turn close and durability flush. +Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery observes a closed failed step, and a retry rebuilds the request from the durable log in a new numbered step. Cancellation clears pending work and aborts the current step without leaking to the next prompt; model-requested calls that were already durable receive synthetic aborted results when cancellation prevents dispatch. Terminal continuation stops remain authoritative through turn close and durability flush. ### What belongs to plugins diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 41c5d60646..e749018f55 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -7,37 +7,42 @@ import type { Context } from 'cordis' import type { FinishReason, GenerateOptions, LlmCallConfig, Message, TokenUsage } from '@deepseek-ai/dsh-llm' -import { BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm' +import { BlockAssembler, HarnessError, deepFreeze, isLlmAdapterFailure } from '@deepseek-ai/dsh-llm' import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' -import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' import { canonicalHeader } from '@deepseek-ai/dsh-session' import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' import { createTransmissionLog, recordRequestHeader } from './request-log.ts' import type { TransmissionLog } from './request-log.ts' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' -import type {} from '@deepseek-ai/dsh-tools' +import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { ReactLoopAgent } from './agent.ts' import type { Inbox } from './inbox.ts' -/** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */ -type CodedError = Error & { code?: string } - /** Normalize thrown values while preserving an existing error code. */ -function toError(error: unknown): CodedError { +function toError(error: unknown): RequestError { return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error }) } +/** Distinguishes a terminal failure finish from failures in later step processing. */ +class TerminalModelRequestFailure extends Error { + constructor(readonly requestError: RequestError) { + super(requestError.message, { cause: requestError }) + this.name = 'TerminalModelRequestFailure' + } +} + /** Convert terminal failure finishes into step errors; unknown extensible finishes remain successful. */ -function finishError(finish: FinishReason): CodedError | undefined { +function finishError(finish: FinishReason): RequestError | undefined { switch (finish.kind) { case 'error': { - const error: CodedError = new Error(finish.message) + const error: RequestError = new Error(finish.message) if (finish.code !== undefined) error.code = finish.code return error } case 'aborted': { - const error: CodedError = new Error('model stream aborted') + const error: RequestError = new Error('model stream aborted') error.code = 'ABORTED' return error } @@ -51,10 +56,19 @@ function finishError(finish: FinishReason): CodedError | undefined { * Build the `{ message, code? }` part of an error payload, omitting the * `code` key entirely when absent (exactOptionalPropertyTypes-correct). */ -function errorData(err: CodedError): { message: string; code?: string } { +function errorData(err: RequestError): { message: string; code?: string } { return { message: err.message, ...typeof err.code === 'string' ? { code: err.code } : {} } } +/** Build the durable result for a model-requested call skipped after cancellation. */ +function skippedToolResult(): ToolExecutionResult { + return { + content: [{ type: 'text', text: 'Error: tool call skipped because the step was aborted before execution' }], + isError: true, + error: { name: 'AbortError', code: 'ABORTED' }, + } +} + /** Map a successful max-token finish onto the turn reason; other successful finishes add nothing. */ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined { switch (finish.kind) { @@ -168,6 +182,7 @@ async function runTurn( let reason: TurnEndReason = { kind: 'completed' } let step = 0 + let requestRetryAttempt = 0 let stepOpen = false let errorReported = false let terminalStopped = false @@ -180,7 +195,7 @@ async function runTurn( } // Record the durable turn failure once and contain the live error notification. - const failTurn = (err: CodedError): void => { + const failTurn = (err: RequestError): void => { if (errorReported) return errorReported = true reason = { kind: 'error', step, ...errorData(err) } @@ -322,14 +337,65 @@ async function runTurn( break } - let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error } + let stepOutcome: + | { hadToolCalls: boolean; finish: FinishReason } + | { requestError: RequestError } + | { error: RequestError } try { stepOutcome = await runStep( ctx, events, agent, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal) } catch (error: unknown) { - stepOutcome = { error: toError(error) } - } finally { + if (isLlmAdapterFailure(error)) { + stepOutcome = { requestError: error } + } else if (error instanceof TerminalModelRequestFailure) { + stepOutcome = { requestError: error.requestError } + } else { + stepOutcome = { error: toError(error) } + } + } + + if ('requestError' in stepOutcome) { + // Recovery observes a balanced failed step and the original provider + // error while the failed step's signal remains the active owner. + closeStep() + if (handle.isDisposed() || abort.signal.aborted) { + handle.setAbort(undefined) + reason = handle.isDisposed() + ? { kind: 'disposed' } + : { kind: 'aborted', reason: String(abort.signal.reason) } + break + } + + const defaultDecision: RequestErrorDecision = { action: 'fail' } + let recoveryDecision: RequestErrorDecision = defaultDecision + try { + recoveryDecision = await events.waterfall( + 'agent/request-error', turn, step, stepOutcome.requestError, + requestRetryAttempt, abort.signal, + () => Promise.resolve(defaultDecision), + ) + } catch (recoveryError: unknown) { + ctx.logger.warn( + `agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${toError(recoveryError).message}`, + ) + } handle.setAbort(undefined) + + // Cancellation and disposal always win over either a recovery decision + // or a recovery-listener failure. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (handle.isDisposed() || abort.signal.aborted) { + reason = handle.isDisposed() + ? { kind: 'disposed' } + : { kind: 'aborted', reason: String(abort.signal.reason) } + break + } + if (recoveryDecision.action === 'retry') { + requestRetryAttempt += 1 + continue + } + failTurn(stepOutcome.requestError) + break } if ('error' in stepOutcome) { @@ -337,7 +403,9 @@ async function runTurn( // runLoop re-enqueues it as a queued message, so an abort-then-steer // starts a fresh turn instead of being silently consumed. closeStep() + handle.setAbort(undefined) const { error } = stepOutcome + /* v8 ignore next -- narrow race: disposal while non-request step work throws. */ if (handle.isDisposed()) { reason = { kind: 'disposed' } } else if (abort.signal.aborted) { @@ -349,6 +417,8 @@ async function runTurn( break } + requestRetryAttempt = 0 + // Preserve max-token completion unless a later disposal, abort, or error wins. const stepReason = stepFinishReason(stepOutcome.finish) if (stepReason) reason = stepReason @@ -356,7 +426,38 @@ async function runTurn( // Steering that arrived during streaming/tool execution. const steered = drainSteering(agent, handle.inbox, turn) + try { + await events.serial('agent/post-step', turn, step, abort.signal) + } catch (error: unknown) { + stepOutcome = { error: toError(error) } + } + + if ('error' in stepOutcome) { + closeStep() + handle.setAbort(undefined) + /* v8 ignore next -- narrow race: disposal while a post-step listener throws. */ + if (handle.isDisposed()) { + reason = { kind: 'disposed' } + } else if (abort.signal.aborted) { + /* v8 ignore next -- signal.reason always set by cancellation or disposal. */ + reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') } + } else { + failTurn(stepOutcome.error) + } + break + } + + if (handle.isDisposed() || abort.signal.aborted) { + reason = handle.isDisposed() + ? { kind: 'disposed' } + : { kind: 'aborted', reason: String(abort.signal.reason) } + closeStep() + handle.setAbort(undefined) + break + } + closeStep() + handle.setAbort(undefined) const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' } let decision: ContinuationDecision @@ -523,7 +624,7 @@ async function runStep( // Normalize failure finish chunks into the same path as thrown stream errors. const stepError = finishError(assembler.finish) - if (stepError) throw stepError + if (stepError) throw new TerminalModelRequestFailure(stepError) if (assembler.finish.kind === 'max-tokens') { let message: Message = withoutToolCalls(assembler.message()) @@ -553,25 +654,30 @@ async function runStep( const toolCalls = message.content.filter(block => block.type === 'tool-call') // Buffer context until all results are appended to preserve call/result adjacency. const pendingContext: HookContext[] = [] + let aborted = signal.aborted for (const call of toolCalls) { - /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ - if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) const callEvent = session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments }) - let parsedArguments: unknown - try { - parsedArguments = call.arguments ? JSON.parse(call.arguments) : {} - } catch { - parsedArguments = call.arguments + let result: ToolExecutionResult + if (aborted || signal.aborted) { + aborted = true + result = skippedToolResult() + } else { + let parsedArguments: unknown + try { + parsedArguments = call.arguments ? JSON.parse(call.arguments) : {} + } catch { + parsedArguments = call.arguments + } + // TODO(pre-tool-input-rewrite): Keep logged history and live presentation aligned; + // see docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md. + result = await ctx.tools.execute({ + callId: call.id, + name: call.name, + arguments: parsedArguments, + agent, + signal, + }) } - // TODO(pre-tool-input-rewrite): Keep logged history and live presentation aligned; - // see docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md. - const result = await ctx.tools.execute({ - callId: call.id, - name: call.name, - arguments: parsedArguments, - agent, - signal, - }) session.append('tool/result', { turn, step, // Correlation comes from the immutable execution input; the result does @@ -584,13 +690,12 @@ async function runStep( ...result.meta !== undefined ? { meta: result.meta } : {}, }, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] }) if (result.additionalContext) pendingContext.push(result.additionalContext) - // The signal may flip while the tool is awaited. - /* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */ - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) - /* v8 ignore stop */ + if (signal.aborted) aborted = true } + /* v8 ignore next -- signal.reason always set by cancellation or disposal. */ + if (aborted) throw new Error(String(signal.reason ?? 'aborted')) + // Append buffered context after the complete result batch. for (const context of pendingContext) { agent.inject(context.content, { source: context.source }) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index cad830e827..80284b64b5 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -12,10 +12,10 @@ import { Context } from 'cordis' import LlmService, { type Message } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { MockAdapter, textResponse } from './mock-adapter.ts' +import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter) { const ctx = new Context() @@ -139,6 +139,59 @@ describe('Agent.cancel()', () => { expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled' }]) }) + it('cancel from an assistant/message observer skips execution but balances replay', async () => { + const adapter = new MockAdapter([ + toolCallResponse('c1', 'danger', {}), + textResponse('recovered after cancellation'), + ]) + const ctx = await harness(adapter) + let executions = 0 + ctx.tools.register(defineTool({ + name: 'danger', + description: 'must not run after cancellation', + parameters: {}, + async execute() { + executions += 1 + return [{ type: 'text', text: 'ran' }] + }, + })) + const agent = ctx.agentLoop.create(AgentId('cancel-after-assistant-message'), { model: 'mock' }) + const dispose = ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'assistant/message') { + agent.cancel('cancelled after assistant message') + } + }) + + const reasons: TurnEndReason[] = [] + ctx.on('session/event', (_session, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + dispose() + + expect(executions).toBe(0) + expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled after assistant message' }]) + const call = agent.session.events.find(event => event.type === 'tool/call') + const result = agent.session.events.find(event => event.type === 'tool/result') + expect(call?.type === 'tool/call' ? call.data.callId : undefined).toBe('c1') + expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({ + callId: 'c1', + isError: true, + error: { name: 'AbortError', code: 'ABORTED' }, + }) + + send(agent, 'continue safely') + await waitForIdle(ctx, agent) + const replayedResult = adapter.requests[1]!.messages + .flatMap(message => message.content) + .find(block => block.type === 'tool-result') + expect(replayedResult).toMatchObject({ toolCallId: 'c1', isError: true }) + expect(reasons).toEqual([ + { kind: 'aborted', reason: 'cancelled after assistant message' }, + { kind: 'completed' }, + ]) + }) + it('a prompt sent AFTER a cancelled turn settles runs normally (marker reset)', async () => { const adapter = new MockAdapter(['hang', textResponse('second reply')]) const ctx = await harness(adapter) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 3907434395..6ca06fc096 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -204,6 +204,16 @@ describe('abort during tool execution ends the turn', () => { expect(executed).toEqual(['aborter']) // second tool never ran expect(adapter.requests).toHaveLength(1) // no follow-up model call expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }]) + const calls = agent.session.events.filter(event => event.type === 'tool/call') + const results = agent.session.events.filter(event => event.type === 'tool/result') + expect(calls.map(event => event.data.callId)).toEqual([CallId('c1'), CallId('c2')]) + expect(results).toHaveLength(2) + expect(results[0]!.data).toMatchObject({ callId: CallId('c1'), isError: false }) + expect(results[1]!.data).toMatchObject({ + callId: CallId('c2'), + isError: true, + error: { name: 'AbortError', code: 'ABORTED' }, + }) }) }) diff --git a/packages/core/agent-loop/tests/request-recovery.spec.ts b/packages/core/agent-loop/tests/request-recovery.spec.ts new file mode 100644 index 0000000000..003c678be0 --- /dev/null +++ b/packages/core/agent-loop/tests/request-recovery.spec.ts @@ -0,0 +1,442 @@ +/** + * Agent-loop coverage for the successful post-step checkpoint and model-request + * recovery. These tests keep the recovery boundary narrower than the whole + * step and pin retry reconstruction, numbering, cancellation, and identity. + */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import LlmService, { + CallId, + CONTEXT_WINDOW_EXCEEDED_CODE, + LlmAdapter, + LlmError, +} from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import type { PostToolDecision } from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts' + +class FailureScriptAdapter extends LlmAdapter { + requests: GenerateOptions[] = [] + + constructor(private readonly entries: (Error | StreamChunk[])[]) { + super() + } + + async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) + const entry = this.entries.shift() + if (entry === undefined) throw new Error('failure script exhausted') + if (entry instanceof Error) throw entry + yield* entry + } +} + +class IteratorConstructionFailureAdapter extends LlmAdapter { + stream(_options: GenerateOptions): AsyncIterable { + return { + [Symbol.asyncIterator](): AsyncIterator { + throw new LlmError('iterator construction failed', 'ITERATOR_CONSTRUCTION') + }, + } + } +} + +class SynchronousDispatchFailureAdapter extends LlmAdapter { + constructor(private readonly error: Error) { + super() + } + + stream(_options: GenerateOptions): AsyncIterable { + throw this.error + } +} + +class IteratorResultGetterFailureAdapter extends LlmAdapter { + constructor( + private readonly field: 'done' | 'value', + private readonly error: Error, + ) { + super() + } + + stream(_options: GenerateOptions): AsyncIterable { + const result = this.field === 'done' ? {} : { done: false } + Object.defineProperty(result, this.field, { get: () => { throw this.error } }) + return { + [Symbol.asyncIterator](): AsyncIterator { + return { next: () => Promise.resolve(result as unknown as IteratorResult) } + }, + } + } +} + +const streamListenerFailureCases: readonly [string, (ctx: Context) => void][] = [ + ['synchronous listener throw', (ctx) => { + ctx.on('llm/stream', () => { throw new Error('synchronous stream listener failed') }) + }], + ['invalid listener iterable', (ctx) => { + ctx.on('llm/stream', () => ({}) as AsyncIterable) + }], + ['listener wrapper iteration failure', (ctx) => { + ctx.on('llm/stream', (_options, next) => (async function * () { + for await (const chunk of next()) { + yield chunk + throw new Error('stream listener wrapper failed') + } + })()) + }], +] + +async function harness(adapter?: LlmAdapter): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + if (adapter) ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} + +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} + +function send(agent: ReactLoopAgent): void { + agent.send([{ type: 'text', text: 'go' }]) +} + +function contextError(message = 'context too large'): LlmError { + return new LlmError(message, CONTEXT_WINDOW_EXCEEDED_CODE, 400) +} + +describe('agent post-step and request-error lifecycle', () => { + it('fires post-step after results, buffered context, and steering but before step/end', async () => { + const twoCalls: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('call-1'), name: 'work', arguments: '{}' } }, + { type: 'block-start', index: 1, blockType: 'tool-call' }, + { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('call-2'), name: 'work', arguments: '{}' } }, + { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] + const adapter = new FailureScriptAdapter([twoCalls, textResponse('done')]) + const ctx = await harness(adapter) + ctx.tools.register(defineTool({ + name: 'work', + description: 'do work', + parameters: {}, + async execute(_args, exec) { + if (exec.callId === CallId('call-2')) { + exec.agent?.steer([{ type: 'text', text: 'steered' }], { source: { kind: 'plugin', plugin: 'test' } }) + } + return [{ type: 'text', text: 'worked' }] + }, + })) + ctx.on('tools/post-execute', async (exec, _result): Promise => ({ + kind: 'accept', + additionalContext: { + content: [{ type: 'text', text: `context for ${exec.callId}` }], + source: { kind: 'plugin', plugin: 'test' }, + }, + })) + const agent = ctx.agentLoop.create(AgentId('post-step-order'), { model: 'mock' }) + const order: string[] = [] + ctx.on('session/event', (_session, event) => { + if ( + event.type === 'assistant/message' || event.type === 'tool/call' + || event.type === 'tool/result' || event.type === 'context/message' + || event.type === 'steering/message' || event.type === 'step/end' + ) { + if (!('step' in event.data) || event.data.step === 1) order.push(event.type) + } + }) + ctx.on('agent/post-step', (subject, turn, step, signal) => { + if (subject !== agent || step !== 1) return + expect({ turn, step, aborted: signal.aborted }).toEqual({ turn: 1, step: 1, aborted: false }) + subject.inject([{ type: 'text', text: 'listener mutation' }], { source: { kind: 'plugin', plugin: 'post-step' } }) + order.push('agent/post-step') + }) + + send(agent) + await waitForIdle(ctx, agent) + + expect(order).toEqual([ + 'assistant/message', + 'tool/call', + 'tool/result', + 'tool/call', + 'tool/result', + 'context/message', + 'context/message', + 'steering/message', + 'context/message', + 'agent/post-step', + 'step/end', + ]) + }) + + it('fires post-step for max-tokens and lets cancellation override that success', async () => { + const adapter = new FailureScriptAdapter([maxTokensResponse('partial')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('cancel-post-step-max-tokens'), { 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) + const idle = waitForIdle(ctx, agent) + await postStepEntered + agent.cancel('cancelled during max-tokens post-step') + await idle + + expect(agent.session.events.find(event => event.type === 'assistant/message')).toMatchObject({ + data: { usage: { inputTokens: 10, outputTokens: 7 } }, + }) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'aborted', reason: 'cancelled during max-tokens post-step' } }, + }) + }) + + it.each([ + ['thrown', contextError()], + ['in-band', [{ type: 'finish', reason: { kind: 'error', message: 'too large', code: CONTEXT_WINDOW_EXCEEDED_CODE } }] satisfies StreamChunk[]], + ] as const)('recovers a %s request failure in a new reconstructable step', async (_style, failure) => { + const adapter = new FailureScriptAdapter([failure, textResponse('recovered')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId(`recover-${_style}`), { model: 'mock' }) + const attempts: number[] = [] + ctx.on('agent/request-error', async (subject, turn, step, error, attempt) => { + expect(subject).toBe(agent) + expect({ turn, step, code: error.code }).toEqual({ turn: 1, step: 1, code: CONTEXT_WINDOW_EXCEEDED_CODE }) + attempts.push(attempt) + subject.session.append('context/message', { + content: [{ type: 'text', text: 'RECOVERY SURFACE MUTATION' }], + source: { kind: 'plugin', plugin: 'test-recovery' }, + }, { surfaceOp: 'append' }) + return { action: 'retry' } + }) + + send(agent) + await waitForIdle(ctx, agent) + + expect(attempts).toEqual([0]) + expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('RECOVERY SURFACE MUTATION') + const starts = agent.session.events.filter(event => event.type === 'step/start') + const ends = agent.session.events.filter(event => event.type === 'step/end') + expect(starts.map(event => event.data.step)).toEqual([1, 2]) + expect(ends.map(event => event.data.step)).toEqual([1, 2]) + const recovery = agent.session.events.find(event => event.type === 'context/message')! + expect(ends[0]!.seq).toBeLessThan(recovery.seq) + expect(recovery.seq).toBeLessThan(starts[1]!.seq) + }) + + it.each(streamListenerFailureCases)('does not offer %s to request recovery', async (_name, install) => { + const ctx = await harness(new FailureScriptAdapter([textResponse('unused')])) + const agent = ctx.agentLoop.create(AgentId(`stream-plugin-${_name.replaceAll(' ', '-')}`), { model: 'mock' }) + let recoveries = 0 + install(ctx) + ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => { + recoveries += 1 + return next() + }) + + send(agent) + await waitForIdle(ctx, agent) + + expect(recoveries).toBe(0) + expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } } }) + }) + + it.each(['prompt-submit', 'prompt-assembly', 'pre-step', 'request'] as const)( + 'does not offer %s middleware failures to request recovery', + async (boundary) => { + const adapter = new FailureScriptAdapter([textResponse('unused')]) + const ctx = await harness(adapter) + if (boundary === 'prompt-submit') { + ctx.on('agent/prompt-submit', () => { throw new Error('prompt submit failed') }) + } else if (boundary === 'prompt-assembly') { + ctx.on('system-prompt/assemble', () => { throw new Error('prompt assembly failed') }) + } else if (boundary === 'pre-step') { + ctx.on('agent/pre-step', () => { throw new Error('pre-step failed') }) + } else { + ctx.on('agent/request', () => { throw new Error('request middleware failed') }) + } + const agent = ctx.agentLoop.create(AgentId(`${boundary}-not-recoverable`), { model: 'mock' }) + let recoveries = 0 + ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => { + recoveries += 1 + return next() + }) + + send(agent) + await waitForIdle(ctx, agent) + + expect(recoveries).toBe(0) + expect(adapter.requests).toHaveLength(0) + expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } } }) + }, + ) + + it('does not offer result, tool, or post-step plugin failures to request recovery', async () => { + for (const failure of ['result', 'tool', 'post-step'] as const) { + const adapter = new FailureScriptAdapter([ + failure === 'tool' ? toolCallResponse(`call-${failure}`, 'work', {}) : textResponse('done'), + ]) + const ctx = await harness(adapter) + if (failure === 'result') ctx.on('agent/step-result', () => { throw new Error('result failed') }) + if (failure === 'post-step') ctx.on('agent/post-step', () => { throw new Error('post-step failed') }) + if (failure === 'tool') { + vi.spyOn(ctx.tools, 'execute').mockRejectedValue(new Error('tool service failed')) + } + const agent = ctx.agentLoop.create(AgentId(`${failure}-not-recoverable`), { model: 'mock' }) + let recoveries = 0 + ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => { + recoveries += 1 + return next() + }) + send(agent) + await waitForIdle(ctx, agent) + expect(recoveries, failure).toBe(0) + } + }) + + it.each([ + ['synchronous dispatch', (error: Error) => new SynchronousDispatchFailureAdapter(error)], + ['done getter', (error: Error) => new IteratorResultGetterFailureAdapter('done', error)], + ['value getter', (error: Error) => new IteratorResultGetterFailureAdapter('value', error)], + ] as const)('preserves original Error identity for adapter %s', async (_name, makeAdapter) => { + const original = contextError(`${_name} overflow`) + const ctx = await harness(makeAdapter(original)) + const agent = ctx.agentLoop.create(AgentId(`identity-${_name.replaceAll(' ', '-')}`), { model: 'mock' }) + let seen: Error | undefined + ctx.on('agent/request-error', async (_agent, _turn, _step, error, _attempt, _signal, next) => { + seen = error + return next() + }) + + send(agent) + await waitForIdle(ctx, agent) + + expect(seen).toBe(original) + }) + + it('classifies iterator construction and explicit NO_ADAPTER as model-request failures', async () => { + for (const scenario of ['iterator', 'no-adapter'] as const) { + const ctx = scenario === 'iterator' ? await harness(new IteratorConstructionFailureAdapter()) : await harness() + const agent = ctx.agentLoop.create(AgentId(`request-boundary-${scenario}`), { model: 'mock' }) + let seen = '' + ctx.on('agent/request-error', async (_agent, _turn, _step, error, _attempt, _signal, next) => { + seen = error.code ?? '' + return next() + }) + send(agent) + await waitForIdle(ctx, agent) + expect(seen).toBe(scenario === 'iterator' ? 'ITERATOR_CONSTRUCTION' : 'NO_ADAPTER') + } + }) + + it('tracks consecutive retry attempts and resets after a successful request', async () => { + const capped = new FailureScriptAdapter([contextError('first overflow'), contextError('second overflow')]) + const cappedCtx = await harness(capped) + const cappedAgent = cappedCtx.agentLoop.create(AgentId('retry-cap'), { model: 'mock' }) + const cappedAttempts: number[] = [] + cappedCtx.on('agent/request-error', async (_agent, _turn, _step, _error, attempt, _signal, next) => { + cappedAttempts.push(attempt) + return attempt < 1 ? { action: 'retry' } : next() + }) + send(cappedAgent) + await waitForIdle(cappedCtx, cappedAgent) + expect(cappedAttempts).toEqual([0, 1]) + + const reset = new FailureScriptAdapter([ + contextError('first overflow'), + toolCallResponse('retry-reset-call', 'work', {}), + contextError('later overflow'), + ]) + const resetCtx = await harness(reset) + resetCtx.tools.register(defineTool({ + name: 'work', + description: 'continue', + parameters: {}, + async execute() { return [{ type: 'text', text: 'worked' }] }, + })) + const resetAgent = resetCtx.agentLoop.create(AgentId('retry-reset'), { model: 'mock' }) + const resetAttempts: { step: number; attempt: number }[] = [] + resetCtx.on('agent/request-error', async (_agent, _turn, step, _error, attempt, _signal, next) => { + resetAttempts.push({ step, attempt }) + return resetAttempts.length === 1 ? { action: 'retry' } : next() + }) + send(resetAgent) + await waitForIdle(resetCtx, resetAgent) + expect(resetAttempts).toEqual([{ step: 1, attempt: 0 }, { step: 3, attempt: 0 }]) + }) + + it('preserves the original provider error when recovery throws', async () => { + const adapter = new FailureScriptAdapter([contextError('original overflow')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('recovery-throws'), { model: 'mock' }) + ctx.on('agent/request-error', () => { throw new Error('recovery exploded') }) + + send(agent) + await waitForIdle(ctx, agent) + + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'error', message: 'original overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE } }, + }) + }) + + it.each(['cancel', 'dispose'] as const)('keeps %s live through request recovery', async (action) => { + const adapter = new FailureScriptAdapter([contextError()]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId(`${action}-recovery`), { model: 'mock' }) + let entered!: () => void + const recoveryEntered = new Promise((resolve) => { entered = resolve }) + ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, signal) => { + entered() + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + return { action: 'retry' } + }) + + send(agent) + const idle = waitForIdle(ctx, agent) + await recoveryEntered + if (action === 'cancel') { + agent.cancel('cancelled during recovery') + await idle + } else { + await ctx.fiber.dispose() + } + + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: action === 'cancel' ? { kind: 'aborted', reason: 'cancelled during recovery' } : { kind: 'disposed' } }, + }) + }) +}) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 5639f30daf..d37851a681 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -31,7 +31,7 @@ The loop plugin registers `AgentFactory`, keeping consumers independent of its c `agent/created` runs after setup and both registry entries; the following `agent/session-start` is the first supported startup injection point. `agent/disposed` means the exact entry left the registry. The loop quiesces its driver first; directly registered custom agents own any stronger ordering. -Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way). +Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: a retry opens a new numbered step after the failed step closes. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way). Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md). diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 3aad65a70e..1eda0df2d7 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -70,6 +70,12 @@ export type ContinuationDecision = | { action: 'stop' } | { action: 'continue'; reason?: HookContext } +/** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()`. */ +export type RequestErrorDecision = { action: 'fail' } | { action: 'retry' } + +/** Model-request failure with an optional machine-routable provider code. */ +export type RequestError = Error & { code?: string } + /** * The terminal subset of {@link ContinuationDecision}. A listener on * `agent/turn-stop` returns this to make the already-composed continuation @@ -248,6 +254,31 @@ declare module 'cordis' { * @mode waterfall */ 'agent/step-result'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise + /** + * Awaited serial checkpoint after the response, tool results, injected + * context, and steering are durable but before `step/end`. + * @param agent - the agent that completed the step. + * @param turn - the open turn number. + * @param step - the completed step number. + * @param signal - the turn abort signal. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode serial + */ + 'agent/post-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void + /** + * Recover a model-request failure after its failed step has closed. `retry` + * opens a new numbered step; `fail` preserves the original request error. + * Call `next()` to delegate to the next recovery listener or the default. + * @param agent - the agent whose request failed. + * @param turn - the open turn number. + * @param step - the failed step number. + * @param error - the original model-request failure. + * @param retryAttempt - zero-based number of prior recovery retries. + * @param signal - the turn abort signal. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode waterfall + */ + 'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, retryAttempt: number, signal: AbortSignal, next: () => Promise): Promise /** * Override whether the turn continues. The default continues after tool * calls or steering and stops otherwise; a continue reason becomes steering. diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 1270ad40a5..bd4228165b 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -36,7 +36,7 @@ Every request carries the shared attribution header from dsh-llm's `attributionH ## Errors -Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `RATE_LIMIT` (429), `INVALID_REQUEST` (400), `SERVER` (5xx), `HTTP_` otherwise. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', code: }` chunks. +Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `RATE_LIMIT` (429), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_` otherwise. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', code: }` chunks. ## Testing diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 30760a8fbc..113cb6f25e 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -5,7 +5,7 @@ * @module dsh-llm-deepseek/adapter */ -import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { serializeRequest } from './serialize.ts' import type { RequestDefaults } from './serialize.ts' @@ -26,12 +26,17 @@ export interface DeepSeekAdapterOptions { /** * Map an HTTP status to a stable LlmError code. * @param status - status of a non-2xx provider response. - * @returns `AUTH` (401/403), `RATE_LIMIT` (429), `INVALID_REQUEST` (400), `SERVER` (5xx), or `HTTP_` for anything else. + * @param error - parsed provider error body, when available. + * @returns the normalized harness error code. */ -export function httpErrorCode(status: number): string { +export function httpErrorCode(status: number, error?: WireError['error']): string { if (status === 401 || status === 403) return 'AUTH' if (status === 429) return 'RATE_LIMIT' - if (status === 400) return 'INVALID_REQUEST' + if (status === 400) { + const detail = [error?.code, error?.type, error?.message].filter(Boolean).join(' ') + if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE + return 'INVALID_REQUEST' + } if (status >= 500) return 'SERVER' return `HTTP_${status}` } @@ -67,16 +72,17 @@ export class DeepSeekAdapter extends LlmAdapter { }) if (!response.ok) { - const code = httpErrorCode(response.status) let message = `DeepSeek API error (HTTP ${response.status})` + let providerError: WireError['error'] try { const parsed = await response.json() as WireError - if (parsed.error?.message) message = parsed.error.message + providerError = parsed.error + if (providerError?.message) message = providerError.message } catch { - // Only swallow error-body parsing: status and code are already captured, - // so malformed gateway JSON must not mask the actionable HTTP failure. + // Only swallow error-body parsing: the HTTP status still identifies the + // failure, so malformed gateway JSON must not mask it. } - throw new LlmError(message, code, response.status) + throw new LlmError(message, httpErrorCode(response.status, providerError), response.status) } if (!response.body) { throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE') diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 46f123a1c7..6cca0dde17 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -2,7 +2,7 @@ import { createServer } from 'node:http' import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import LlmService, { LlmError, userAgent } from '@deepseek-ai/dsh-llm' +import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@deepseek-ai/dsh-llm' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { DeepSeekAdapter, httpErrorCode } from '@deepseek-ai/dsh-llm-deepseek' import { assemble } from './assemble.ts' @@ -173,6 +173,32 @@ describe('DeepSeekAdapter against a mock server', () => { ).resolves.toBe(status) }) + it('classifies a thrown HTTP context-window rejection with the canonical code', async () => { + const server = await mockServer([{ + kind: 'http-error', + status: 400, + body: JSON.stringify({ + error: { + message: 'This model maximum context length is 128000 tokens; your input exceeds that limit.', + type: 'invalid_request_error', + code: 'context_length_exceeded', + }, + }), + }]) + const ctx = await harness(server.url) + const code = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + .catch((error: unknown) => (error as LlmError).code) + expect(code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE) + }) + + it('classifies only context-capacity HTTP 400 details as context overflow', () => { + expect(httpErrorCode(400, { message: 'request too large for model context' })) + .toBe(CONTEXT_WINDOW_EXCEEDED_CODE) + expect(httpErrorCode(400, { message: 'invalid input: temperature exceeds maximum allowed value' })) + .toBe('INVALID_REQUEST') + expect(httpErrorCode(413, { code: 'context_length_exceeded' })).toBe('HTTP_413') + }) + it('keeps the status-line message for JSON error bodies without a message', async () => { const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }]) const ctx = await harness(server.url) diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index dd1cb5b48c..6c8fc35104 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -7,7 +7,7 @@ DeepSeek adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](ht `@deepseek-ai/dsh-llm-deepseek` already talks to the same endpoint. This package is its **design-verification twin**: same models, same wire protocol, completely different internals — a unified LLM library with its own event vocabulary versus hand-rolled fetch/SSE. Anything the harness `StreamChunk` protocol cannot express for BOTH implementations is a core-vocabulary bug. The differences it exercised on purpose: - pi-ai hands tool-call `arguments` around as **parsed objects**; the harness keeps raw JSON strings. The adapter patches replay payloads back to the original raw strings before sending them, and re-stringifies parsed output tool calls at `block-end`. -- pi-ai reports failures as **in-stream error events** (it never throws mid-stream); these map to `finish {kind:'error'|'aborted'}` chunks — the protocol's other sanctioned error path besides throwing (which llm-deepseek uses). +- pi-ai reports failures as **in-stream error events** (it never throws mid-stream); these map to `finish {kind:'error'|'aborted'}` chunks — the protocol's other sanctioned error path besides throwing (which llm-deepseek uses). Context-overflow detail maps to the same canonical `CONTEXT_WINDOW_EXCEEDED` code as the hand-rolled adapter. - pi-ai folds reasoning tokens into `usage.output`; there is no separate reasoning count to map. - pi-ai's options omit some DeepSeek/OpenAI-compatible details; the adapter uses its `onPayload` hook to preserve the harness contract (`stop`, scrubbing pi-ai's own per-tool `strict` default — the hand-rolled twin sends no such field — omitted reasoning effort, raw replayed tool arguments). diff --git a/packages/llm/llm-pi-ai/src/convert.ts b/packages/llm/llm-pi-ai/src/convert.ts index 098b93edb3..88f134c8f2 100644 --- a/packages/llm/llm-pi-ai/src/convert.ts +++ b/packages/llm/llm-pi-ai/src/convert.ts @@ -8,7 +8,7 @@ * @module dsh-llm-pi-ai/convert */ -import { CallId, LlmError } from '@deepseek-ai/dsh-llm' +import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, LlmError } from '@deepseek-ai/dsh-llm' import type { FinishReason, GenerateOptions, Message, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' import type { AssistantMessage, @@ -165,6 +165,7 @@ export function mapUsage(usage: PiUsage): TokenUsage { function classifyPiAiError(message: string): string { if (/\b(?:401|403)\b/.test(message)) return 'AUTH' if (/\b429\b|rate.?limit/i.test(message)) return 'RATE_LIMIT' + if (isContextWindowExceededError(message)) return CONTEXT_WINDOW_EXCEEDED_CODE if (/\b400\b|invalid.?request/i.test(message)) return 'INVALID_REQUEST' if (/\b5\d\d\b/.test(message)) return 'SERVER' return 'PI_AI_ERROR' diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 078d2a4d3b..42576f7fed 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { CallId } from '@deepseek-ai/dsh-llm' +import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai' import { mapStopReason, mapUsage, toPiContext, toStreamChunks } from '@deepseek-ai/dsh-llm-pi-ai' @@ -300,6 +300,18 @@ describe('mapStopReason / mapUsage', () => { .toMatchObject({ kind: 'error', code: 'RATE_LIMIT' }) expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 500: backend down' }))) .toMatchObject({ kind: 'error', code: 'SERVER' }) + expect(mapStopReason(assistant({ + stopReason: 'error', + errorMessage: 'HTTP 400: input exceeds the model context window limit', + }))).toMatchObject({ kind: 'error', code: CONTEXT_WINDOW_EXCEEDED_CODE }) + expect(mapStopReason(assistant({ + stopReason: 'error', + errorMessage: 'HTTP 400: request too large for model context', + }))).toMatchObject({ kind: 'error', code: CONTEXT_WINDOW_EXCEEDED_CODE }) + expect(mapStopReason(assistant({ + stopReason: 'error', + errorMessage: 'HTTP 400: invalid input: temperature exceeds maximum allowed value', + }))).toMatchObject({ kind: 'error', code: 'INVALID_REQUEST' }) }) it('maps cache fields only when nonzero', () => { diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 296fd0c3d3..b7be205a97 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -12,6 +12,8 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `ctx.llm.models(): string[]` — model names with a registered adapter. - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`. +`LlmService` preserves and privately tags errors from final adapter selection, synchronous dispatch, iterator construction, and iteration. `isLlmAdapterFailure(value)` exposes that provenance without classifying `llm/stream` middleware or downstream consumer failures as provider failures, and without replacing the adapter's original coded `Error`. + ### Events | Event | Mode | Purpose | @@ -43,6 +45,7 @@ Every product adapter sends application identity on provider HTTP requests. `att - `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. The agent loop feeds it raw chunks (logging them for replay) while reading the assembled blocks/message for history. - `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams. - `LlmError` — extends `HarnessError`; `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) plus an optional numeric `status` when the failure came from a non-2xx provider response. +- `CONTEXT_WINDOW_EXCEEDED_CODE` — the provider-neutral code both DeepSeek adapters use when a request exceeds the model context window, regardless of thrown-HTTP versus in-band finish delivery. `isContextWindowExceededError(detail)` is their shared conservative classifier for OpenAI-compatible provider detail. ### Real adapters @@ -54,7 +57,7 @@ None, as this adapter registry forwards an already assembled request without add ## Known Limitations and Deferred Work -- **No retry/caching/rate-limit layer ships** — `llm/stream` is the intended wrap seam and has no production listener, so provider 429/5xx failures surface immediately. +- **No default retry/caching/rate-limit policy ships in this service** — `llm/stream` remains the call-wrapper seam; the agent loop separately offers proven model-request failures to `agent/request-error`, whose default preserves the original failure. - **`GenerateOptions` sampling is `temperature`/`maxTokens`/`stop` only** — no `tool_choice`, `top_p`, or penalty fields; the vocabulary grows when a producer lands ([dropped inert knobs](../../../docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md)). - **Producer-gated variants stay out until produced** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant were pruned as producerless ([RFC](../../../docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)). - **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw. diff --git a/packages/llm/llm/src/adapter-failure.ts b/packages/llm/llm/src/adapter-failure.ts new file mode 100644 index 0000000000..240f934c39 --- /dev/null +++ b/packages/llm/llm/src/adapter-failure.ts @@ -0,0 +1,34 @@ +/** + * Private provider-failure tagging shared by `LlmService` and its consumers. + * + * @module @deepseek-ai/dsh-llm/adapter-failure + */ + +import { HarnessError } from './error.ts' + +/** Errors proven to originate in final adapter dispatch or iteration. */ +const adapterFailures = new WeakSet() + +/** + * Preserve an adapter's Error identity while tagging its provider origin. + * @param value - arbitrary value thrown by adapter dispatch or iteration. + * @returns the original Error, or a coded Error wrapping a non-Error throw. + * @internal + */ +export function markLlmAdapterFailure(value: unknown): Error & { code?: string } { + const error = value instanceof Error + ? value as Error & { code?: string } + : new HarnessError(String(value), 'UNKNOWN', { cause: value }) + adapterFailures.add(error) + return error +} + +/** + * Whether a failure came from final adapter dispatch, iterator construction, + * or iteration rather than from an `llm/stream` waterfall listener. + * @param value - arbitrary failure caught by a model-call consumer. + * @returns true only for errors tagged at the final adapter boundary. + */ +export function isLlmAdapterFailure(value: unknown): value is Error & { code?: string } { + return value instanceof Error && adapterFailures.has(value) +} diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index c1fdbb9ffa..8c1c736492 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -21,6 +21,47 @@ export class HarnessError extends Error { } } +/** Canonical provider-neutral code for a model request rejected because its context window was exceeded. */ +export const CONTEXT_WINDOW_EXCEEDED_CODE = 'CONTEXT_WINDOW_EXCEEDED' + +/** Structured codes and plain phrases that explicitly name a context bound being exceeded. */ +const STRUCTURED_CONTEXT_OVERFLOW = new RegExp( + String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]` + + String.raw`(?:exceed(?:ed|s)?|overflow(?:ed)?|limit[\s_-]exceeded)(?:$|[^a-z0-9])`, + 'i', +) + +/** Request-size wording that ties "too large" directly to model context capacity. */ +const TOO_LARGE_FOR_CONTEXT = new RegExp( + String.raw`\b(?:request|prompt|input|messages?)\s+(?:is\s+|are\s+)?` + + String.raw`too\s+(?:large|long)\s+for\s+(?:(?:this|the)\s+)?` + + String.raw`(?:model(?:'s)?\s+)?context(?:\s+window)?\b`, + 'i', +) + +/** "Exceeds" wording is safe only when its object is explicitly the model context. */ +const EXCEEDS_MODEL_CONTEXT = new RegExp( + String.raw`\b(?:input|prompt|request|messages?)\b.{0,40}` + + String.raw`\b(?:exceed(?:s|ed)?|overflows?|is\s+larger\s+than)\b.{0,40}` + + String.raw`\b(?:the\s+)?(?:model(?:'s)?\s+)?context(?:\s+(?:length|window))?\b`, + 'i', +) + +/** + * Recognize the context-overflow wording used by OpenAI-compatible providers + * and library adapters. Adapters pass all available provider code, type, and + * message text so both thrown and in-band delivery styles share one classifier. + * @param detail - provider error code/type/message text joined into one string. + * @returns true when the detail identifies a request exceeding the model context window. + */ +export function isContextWindowExceededError(detail: string): boolean { + return STRUCTURED_CONTEXT_OVERFLOW.test(detail) + || /\b(?:maximum|max)(?:\s+(?:allowed|supported))?\s+context\s+(?:length|window)\b/i.test(detail) + || TOO_LARGE_FOR_CONTEXT.test(detail) + || /\b(?:input|prompt|request)\s+(?:is\s+)?too\s+(?:long|large)\s+for\s+(?:this|the)\s+model\b/i.test(detail) + || EXCEEDS_MODEL_CONTEXT.test(detail) +} + /** * Narrow an arbitrary thrown value to a HarnessError (for `instanceof` at seams). * @param value - the caught value (`unknown` in catch clauses). diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 08f3f54c51..0b933d12a7 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -9,6 +9,7 @@ import { Context, Service } from 'cordis' import type { GenerateOptions, StreamChunk } from './types.ts' import { HarnessError } from './error.ts' +import { markLlmAdapterFailure } from './adapter-failure.ts' export * from './attribution.ts' export * from './brand.ts' @@ -18,6 +19,7 @@ export * from './types.ts' export { BlockAssembler } from './assembler.ts' export { callConfigEquals, deepFreeze } from './call-config.ts' export type { LlmCallConfig } from './call-config.ts' +export { isLlmAdapterFailure } from './adapter-failure.ts' declare module 'cordis' { interface Context { @@ -118,17 +120,65 @@ export class LlmService extends Service { return adapter } + /** + * Final adapter boundary. It tags only failures from adapter selection, + * synchronous dispatch, iterator construction, or iteration while preserving + * the original Error object. Middleware outside this generator remains + * distinguishable as plugin work. Adapter cleanup is best-effort after an + * earlier failure or downstream close and never masks the winning error. + */ + private async * adapterStream(options: GenerateOptions): AsyncGenerator { + let iterator: AsyncIterator + try { + const stream = this.adapter(options.model).stream(options) + iterator = stream[Symbol.asyncIterator]() + } catch (error: unknown) { + throw markLlmAdapterFailure(error) + } + + let completed = false + try { + while (true) { + let value: StreamChunk + try { + const item = await iterator.next() + if (item.done) { + completed = true + return + } + value = item.value + } catch (error: unknown) { + throw markLlmAdapterFailure(error) + } + // End the adapter-owned try before yielding: consumer/middleware + // failures resumed into this generator must remain untagged. + yield value + } + } finally { + if (!completed) { + try { + const close = iterator.return?.bind(iterator) + if (close) await close() + } catch { + // Lookup and invocation are both adapter-owned cleanup following an + // existing failure/downstream close; neither can replace it. + } + } + } + } + /** * Stream one model call as raw chunks (token-level deltas). Throws * `LlmError` with code `NO_ADAPTER` if no adapter is registered for - * `options.model`. Dispatches through the `llm/stream` waterfall. + * `options.model`. Dispatches through the `llm/stream` waterfall. Final + * adapter dispatch/iteration failures retain their original Error identity + * and are tagged so the agent loop can distinguish them from middleware + * failures without widening request recovery to plugin code. * @param options - the full request; `options.model` selects the adapter. * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. */ stream(options: GenerateOptions): AsyncIterable { - return this.ctx.waterfall(this, 'llm/stream', options, () => { - return this.adapter(options.model).stream(options) - }) + return this.ctx.waterfall(this, 'llm/stream', options, () => this.adapterStream(options)) } } diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index f669069c44..8dc46529bd 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { GenerateOptions, LlmAdapter, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { + GenerateOptions, + HarnessError, + isContextWindowExceededError, + isLlmAdapterFailure, + LlmAdapter, + LlmError, + StreamChunk, +} from '@deepseek-ai/dsh-llm' class ScriptedAdapter extends LlmAdapter { constructor(private script: StreamChunk[]) { @@ -19,6 +27,22 @@ const SCRIPT: StreamChunk[] = [ ] describe('LlmService', () => { + it('recognizes structured and model-capacity context-window overflow details', () => { + expect(isContextWindowExceededError('context_length_exceeded maximum context length')).toBe(true) + expect(isContextWindowExceededError('context-window-overflowed')).toBe(true) + expect(isContextWindowExceededError('This model maximum context length is 128000 tokens')).toBe(true) + expect(isContextWindowExceededError('input is too long for this model')).toBe(true) + expect(isContextWindowExceededError('request too large for model context')).toBe(true) + expect(isContextWindowExceededError('input exceeds the model context window limit')).toBe(true) + }) + + it('does not mistake unrelated input validation for context-window overflow', () => { + expect(isContextWindowExceededError('invalid request: malformed tool arguments')).toBe(false) + expect(isContextWindowExceededError('invalid input: temperature exceeds maximum allowed value')).toBe(false) + expect(isContextWindowExceededError('input exceeds maximum allowed value')).toBe(false) + expect(isContextWindowExceededError('context window size must be positive')).toBe(false) + }) + it('routes stream() to the registered adapter', async () => { const ctx = new Context() await ctx.plugin(LlmService) @@ -32,9 +56,177 @@ describe('LlmService', () => { it('throws NO_ADAPTER for unregistered models', async () => { const ctx = new Context() await ctx.plugin(LlmService) - await expect((async () => { + let caught: unknown + try { for await (const _ of ctx.llm.stream({ model: 'nope', messages: [] })) { /* drain */ } - })()).rejects.toThrow('no adapter registered') + } catch (error: unknown) { + caught = error + } + expect(caught).toBeInstanceOf(LlmError) + expect((caught as LlmError).code).toBe('NO_ADAPTER') + expect((caught as LlmError).message).toContain('no adapter registered') + expect(isLlmAdapterFailure(caught)).toBe(true) + }) + + it.each(['done', 'value'] as const)('tags a throwing IteratorResult.%s getter without replacing its Error', async (field) => { + const original = new LlmError(`${field} getter failed`, 'RESULT_GETTER_FAILED') + const result = field === 'done' ? {} : { done: false } + Object.defineProperty(result, field, { get: () => { throw original } }) + const adapter = new class extends LlmAdapter { + stream(_options: GenerateOptions): AsyncIterable { + return { + [Symbol.asyncIterator](): AsyncIterator { + return { next: () => Promise.resolve(result as unknown as IteratorResult) } + }, + } + } + }() + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-model'], adapter) + + let caught: unknown + try { + for await (const _chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) { /* drain */ } + } catch (error: unknown) { + caught = error + } + + expect(caught).toBe(original) + expect(isLlmAdapterFailure(caught)).toBe(true) + }) + + it.each(['dispatch', 'iterator'] as const)('tags synchronous adapter %s failures without replacing their Error', async (boundary) => { + const original = new LlmError(`${boundary} failed`, 'BOUNDARY_FAILED') + const adapter = new class extends LlmAdapter { + stream(_options: GenerateOptions): AsyncIterable { + if (boundary === 'dispatch') throw original + return { [Symbol.asyncIterator]: () => { throw original } } + } + }() + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-model'], adapter) + + let caught: unknown + try { + for await (const _chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) { /* drain */ } + } catch (error: unknown) { + caught = error + } + + expect(caught).toBe(original) + expect(isLlmAdapterFailure(caught)).toBe(true) + }) + + it('tags adapter iteration failures without replacing the original Error or cleanup outcome', async () => { + const original = new LlmError('provider failed', 'PROVIDER_FAILED') + let cleanupCalls = 0 + const adapter = new class extends LlmAdapter { + stream(_options: GenerateOptions): AsyncIterable { + return { + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => Promise.reject(original), + return: () => { + cleanupCalls += 1 + return Promise.reject(new Error('cleanup failed')) + }, + } + }, + } + } + }() + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-model'], adapter) + + let caught: unknown + try { + for await (const _chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) { /* drain */ } + } catch (error: unknown) { + caught = error + } + + expect(caught).toBe(original) + expect(isLlmAdapterFailure(caught)).toBe(true) + expect(cleanupCalls).toBe(1) + }) + + it('contains a throwing iterator.return getter after next fails without replacing the original Error', async () => { + const original = new LlmError('provider failed', 'PROVIDER_FAILED') + let cleanupLookups = 0 + const iterator: AsyncIterator = { next: () => Promise.reject(original) } + Object.defineProperty(iterator, 'return', { + get: () => { + cleanupLookups += 1 + throw new Error('return getter failed') + }, + }) + const adapter = new class extends LlmAdapter { + stream(_options: GenerateOptions): AsyncIterable { + return { [Symbol.asyncIterator]: () => iterator } + } + }() + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-model'], adapter) + + let caught: unknown + try { + for await (const _chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) { /* drain */ } + } catch (error: unknown) { + caught = error + } + + expect(caught).toBe(original) + expect(isLlmAdapterFailure(caught)).toBe(true) + expect(cleanupLookups).toBe(1) + }) + + it('normalizes and tags non-Error adapter failures once', async () => { + const adapter = new class extends LlmAdapter { + stream(_options: GenerateOptions): AsyncIterable { + return { + [Symbol.asyncIterator](): AsyncIterator { + // Third-party adapters can reject with arbitrary values; normalization is under test. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + return { next: () => Promise.reject('plain provider failure') } + }, + } + } + }() + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-model'], adapter) + + let caught: unknown + try { + for await (const _chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) { /* drain */ } + } catch (error: unknown) { + caught = error + } + + expect(caught).toBeInstanceOf(HarnessError) + expect(caught).toMatchObject({ code: 'UNKNOWN', cause: 'plain provider failure' }) + expect(isLlmAdapterFailure(caught)).toBe(true) + }) + + it('does not tag a failure thrown downstream while consuming adapter output', async () => { + const downstream = new Error('consumer failed') + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter(SCRIPT)) + + let caught: unknown + try { + for await (const _chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) throw downstream + } catch (error: unknown) { + caught = error + } + + expect(caught).toBe(downstream) + expect(isLlmAdapterFailure(caught)).toBe(false) }) it('unregisters adapters when the owning fiber is disposed (HMR safety)', async () => { diff --git a/packages/support/invariants/src/scoped-events.generated.ts b/packages/support/invariants/src/scoped-events.generated.ts index 06cca6bf55..36d1721945 100644 --- a/packages/support/invariants/src/scoped-events.generated.ts +++ b/packages/support/invariants/src/scoped-events.generated.ts @@ -30,10 +30,12 @@ const scopedSubjectResolvers = Object.freeze({ 'agent/created': adapt<'agent/created'>(args => args[0]), 'agent/disposed': adapt<'agent/disposed'>(args => args[0]), 'agent/error': adapt<'agent/error'>(args => args[0]), + 'agent/post-step': adapt<'agent/post-step'>(args => args[0]), 'agent/pre-step': adapt<'agent/pre-step'>(args => args[0]), 'agent/prompt-submit': adapt<'agent/prompt-submit'>(args => args[0]), 'agent/queued': adapt<'agent/queued'>(args => args[0]), 'agent/request': adapt<'agent/request'>(args => args[0]), + 'agent/request-error': adapt<'agent/request-error'>(args => args[0]), 'agent/session-prefix': adapt<'agent/session-prefix'>(args => args[0]), 'agent/session-start': adapt<'agent/session-start'>(args => args[0]), 'agent/status': adapt<'agent/status'>(args => args[0]), diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 71fe28fbe0..11168ba000 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -818,14 +818,22 @@ function renderLifecycle(): string { ' LLM-->>Driver: StreamChunk*', ` Driver->>Session: ${mermaidCode('assistant/chunk')}*`, ` Session-->>SDK: ${mermaidCode('session/event')} ${mermaidCode('assistant/chunk')}*`, + ' alt final adapter or terminal in-band request failure', + ` Driver->>Session: ${mermaidCode('step/end')}`, + ` Driver->>Hooks: ${mermaidCode('agent/request-error')} waterfall`, + ' Hooks-->>Driver: retry in a new step or preserve the original error', + ' else model request succeeded', ` Driver->>Hooks: ${mermaidCode('agent/step-result')} waterfall`, ` Driver->>Session: ${mermaidCode('assistant/message')}`, ` Driver->>Session: ${mermaidCode('tool/call')}`, ' Driver->>Tools: execute through pre and post waterfalls', ' Tools-->>Session: tool-owned events when applicable', - ` Driver->>Session: ${mermaidCode('tool/result')} and ${mermaidCode('step/end')}`, + ` Driver->>Session: ${mermaidCode('tool/result')}, post-tool context, and steering`, + ` Driver->>Hooks: ${mermaidCode('agent/post-step')} serial checkpoint`, + ` Driver->>Session: ${mermaidCode('step/end')}`, ` Driver->>Hooks: ${mermaidCode('agent/turn-continuation')} waterfall`, ` Driver->>Hooks: ${mermaidCode('agent/turn-stop')} serial terminal checkpoint`, + ' end', ` Driver->>Session: ${mermaidCode('turn/end')}`, ` Driver->>Persistence: ${mermaidCode('session/flush')} parallel checkpoint`, ` Driver-->>SDK: ${mermaidCode('agent/status')} idle`, diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 54fe328389..b0cea673ad 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -14,6 +14,8 @@ { "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "RequestError", "source": "packages/core/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "RequestErrorDecision", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationStop", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "SessionStartSource", "source": "packages/core/agent/src/types.ts" }, From 12484104c89fd614a247b1a903f3c9c7821aac3d Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 16:50:44 +0800 Subject: [PATCH 02/18] 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" }, From d66c926d7a63d5416f766f2de90b16760ffb5176 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 17:43:33 +0800 Subject: [PATCH 03/18] fix(agent): preserve lifecycle recovery boundaries (PR3 round 2) --- .../compact/compact-basic/src/automatic.ts | 1 + .../compact-basic/tests/compact-basic.spec.ts | 15 +++ packages/core/agent-loop/src/loop.ts | 6 +- .../tests/contract-regressions.spec.ts | 57 ++++++++++- packages/llm/llm/src/index.ts | 19 ++-- packages/llm/llm/tests/service.spec.ts | 95 ++++++++++++++----- 6 files changed, 151 insertions(+), 42 deletions(-) diff --git a/packages/compact/compact-basic/src/automatic.ts b/packages/compact/compact-basic/src/automatic.ts index 16e71c081a..e837e076cc 100644 --- a/packages/compact/compact-basic/src/automatic.ts +++ b/packages/compact/compact-basic/src/automatic.ts @@ -45,6 +45,7 @@ export function registerAutomaticCompaction( _step: number, signal: AbortSignal, ) => { + if (signal.aborted) return try { const result = await service.compactIfNeeded(agent, 'pressure', signal) if (result !== null) logResult(result, 'post-step pressure') diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 163c2b6b45..d5e3246777 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -848,6 +848,21 @@ describe('automatic listener and loader composition', () => { expect(compact.calls).toHaveLength(1) }) + it('skips post-step pressure when the step signal is already aborted', async () => { + const ctx = createContext() + const compact = new TestCompactService(ctx, { + models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } }, + }) + const pressured = conversation(4) + const compactIfNeeded = vi.spyOn(compact, 'compactIfNeeded') + + await expect(postStep(ctx, agent(pressured, MODEL), AbortSignal.abort('step aborted'))) + .resolves.toBeUndefined() + + expect(compactIfNeeded).not.toHaveBeenCalled() + expect(pressured.events.some(event => event.type === 'compact/start')).toBe(false) + }) + it('warns and continues after operational failures, including non-Errors', async () => { const ctx = createContext() const warnings: string[] = [] diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 620cdd9fbc..59b0d26088 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -650,7 +650,8 @@ async function runStep( session, turn, step, message.content, assembler.usage, chunkSeqs, ) - // Tool execution stays sequential; recheck abort around each normalized result. + // Tool execution stays sequential; cancellation latches synthetic results for + // every remaining call while preserving one complete result batch. const toolCalls = message.content.filter(block => block.type === 'tool-call') // Buffer context until all results are appended to preserve call/result adjacency. const pendingContext: HookContext[] = [] @@ -693,9 +694,6 @@ async function runStep( if (signal.aborted) aborted = true } - /* v8 ignore next -- signal.reason always set by cancellation or disposal. */ - if (aborted) throw new Error(String(signal.reason ?? 'aborted')) - // Append buffered context after the complete result batch. for (const context of pendingContext) { agent.inject(context.content, { source: context.source }) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 6ca06fc096..75afb08b0c 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -156,7 +156,7 @@ describe('successful provider completion survives agent/step-result failure', () }) describe('abort during tool execution ends the turn', () => { - it('aborting the in-flight step inside a tool prevents both remaining tools and the next model step', async () => { + it('balances an aborted tool batch through context, steering, and post-step before closing', async () => { const adapter = new MockAdapter([ // model asks for two tool calls in one step [ @@ -175,8 +175,12 @@ describe('abort during tool execution ends the turn', () => { name: 'aborter', description: '', parameters: {}, - async execute() { + async execute(_args, exec) { executed.push('aborter') + exec.agent?.steer( + [{ type: 'text', text: 'steering before abort' }], + { source: { kind: 'plugin', plugin: 'abort-test' } }, + ) // Fire the in-flight step's AbortController directly (the loop registers // it on the agent). This is the bare step-abort path — distinct from // cancel(), which would also clear the inbox; here the subject is the @@ -185,6 +189,13 @@ describe('abort during tool execution ends the turn', () => { return [{ type: 'text', text: 'done' }] }, })) + ctx.on('tools/post-execute', async exec => ({ + kind: 'accept', + additionalContext: { + content: [{ type: 'text', text: `context for ${exec.callId}` }], + source: { kind: 'plugin', plugin: 'abort-test' }, + }, + })) ctx.tools.register(defineTool({ name: 'second', description: '', @@ -196,13 +207,53 @@ describe('abort during tool execution ends the turn', () => { })) const reasons: TurnEndReason[] = [] - ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) + const order: string[] = [] + ctx.on('session/event', (session, event) => { + if (session !== agent.session) return + switch (event.type) { + case 'assistant/message': order.push('assistant/message'); break + case 'tool/call': order.push(`tool/call:${event.data.callId}`); break + case 'tool/result': { + const outcome = event.data.error?.code === 'ABORTED' ? 'synthetic-aborted' : 'real' + order.push(`tool/result:${event.data.callId}:${outcome}`) + break + } + case 'context/message': order.push('context/message'); break + case 'steering/message': order.push('steering/message'); break + case 'step/end': order.push('step/end'); break + case 'turn/end': { + reasons.push(event.data.reason) + order.push(`turn/end:${event.data.reason.kind}`) + break + } + } + }) + let postSteps = 0 + ctx.on('agent/post-step', (subject, turn, step, signal) => { + if (subject !== agent) return + postSteps += 1 + expect({ turn, step, aborted: signal.aborted }).toEqual({ turn: 1, step: 1, aborted: true }) + order.push('agent/post-step') + }) send(agent, 'go') await waitForIdle(ctx, agent) expect(executed).toEqual(['aborter']) // second tool never ran expect(adapter.requests).toHaveLength(1) // no follow-up model call + expect(postSteps).toBe(1) + expect(order).toEqual([ + 'assistant/message', + 'tool/call:c1', + 'tool/result:c1:real', + 'tool/call:c2', + 'tool/result:c2:synthetic-aborted', + 'context/message', + 'steering/message', + 'agent/post-step', + 'step/end', + 'turn/end:aborted', + ]) expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }]) const calls = agent.session.events.filter(event => event.type === 'tool/call') const results = agent.session.events.filter(event => event.type === 'tool/result') diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 0b933d12a7..1be716cbfb 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -124,8 +124,9 @@ export class LlmService extends Service { * Final adapter boundary. It tags only failures from adapter selection, * synchronous dispatch, iterator construction, or iteration while preserving * the original Error object. Middleware outside this generator remains - * distinguishable as plugin work. Adapter cleanup is best-effort after an - * earlier failure or downstream close and never masks the winning error. + * distinguishable as plugin work. An iteration failure skips adapter cleanup + * so it cannot suppress the primary provider error. A downstream close awaits + * adapter cleanup, whose failures remain ordinary untagged work. */ private async * adapterStream(options: GenerateOptions): AsyncGenerator { let iterator: AsyncIterator @@ -137,6 +138,7 @@ export class LlmService extends Service { } let completed = false + let iterationFailed = false try { while (true) { let value: StreamChunk @@ -148,6 +150,7 @@ export class LlmService extends Service { } value = item.value } catch (error: unknown) { + iterationFailed = true throw markLlmAdapterFailure(error) } // End the adapter-owned try before yielding: consumer/middleware @@ -155,14 +158,10 @@ export class LlmService extends Service { yield value } } finally { - if (!completed) { - try { - const close = iterator.return?.bind(iterator) - if (close) await close() - } catch { - // Lookup and invocation are both adapter-owned cleanup following an - // existing failure/downstream close; neither can replace it. - } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the iteration catch sets its latch before entering finally. + if (!completed && !iterationFailed) { + const close = iterator.return?.bind(iterator) + if (close) await close() } } } diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 8dc46529bd..b488002666 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -72,11 +72,21 @@ describe('LlmService', () => { const original = new LlmError(`${field} getter failed`, 'RESULT_GETTER_FAILED') const result = field === 'done' ? {} : { done: false } Object.defineProperty(result, field, { get: () => { throw original } }) + let cleanupLookups = 0 + const iterator: AsyncIterator = { + next: () => Promise.resolve(result as unknown as IteratorResult), + } + Object.defineProperty(iterator, 'return', { + get: () => { + cleanupLookups += 1 + throw new Error('return getter must not run after iteration fails') + }, + }) const adapter = new class extends LlmAdapter { stream(_options: GenerateOptions): AsyncIterable { return { [Symbol.asyncIterator](): AsyncIterator { - return { next: () => Promise.resolve(result as unknown as IteratorResult) } + return iterator }, } } @@ -94,6 +104,7 @@ describe('LlmService', () => { expect(caught).toBe(original) expect(isLlmAdapterFailure(caught)).toBe(true) + expect(cleanupLookups).toBe(0) }) it.each(['dispatch', 'iterator'] as const)('tags synchronous adapter %s failures without replacing their Error', async (boundary) => { @@ -119,7 +130,7 @@ describe('LlmService', () => { expect(isLlmAdapterFailure(caught)).toBe(true) }) - it('tags adapter iteration failures without replacing the original Error or cleanup outcome', async () => { + it('propagates a rejected next promptly without awaiting a non-settling return', async () => { const original = new LlmError('provider failed', 'PROVIDER_FAILED') let cleanupCalls = 0 const adapter = new class extends LlmAdapter { @@ -130,7 +141,49 @@ describe('LlmService', () => { next: () => Promise.reject(original), return: () => { cleanupCalls += 1 - return Promise.reject(new Error('cleanup failed')) + return new Promise>(() => {}) + }, + } + }, + } + } + }() + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-model'], adapter) + + const failure = (async (): Promise => { + try { + for await (const _chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) { /* drain */ } + } catch (error: unknown) { + return error + } + return new Error('expected adapter iteration to fail') + })() + let timer: ReturnType | undefined + const timeout = new Promise((resolve) => { + timer = setTimeout(() => { resolve(new Error('adapter failure did not settle promptly')) }, 100) + }) + const caught = await Promise.race([failure, timeout]) + if (timer !== undefined) clearTimeout(timer) + + expect(caught).toBe(original) + expect(isLlmAdapterFailure(caught)).toBe(true) + expect(cleanupCalls).toBe(0) + }) + + it('awaits one adapter return on downstream close and leaves its rejection unclassified', async () => { + const cleanup = new Error('cleanup failed') + let cleanupCalls = 0 + const adapter = new class extends LlmAdapter { + stream(_options: GenerateOptions): AsyncIterable { + return { + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => Promise.resolve({ done: false, value: SCRIPT[0]! }), + return: () => { + cleanupCalls += 1 + return Promise.reject(cleanup) }, } }, @@ -143,45 +196,37 @@ describe('LlmService', () => { let caught: unknown try { - for await (const _chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) { /* drain */ } + for await (const _chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) break } catch (error: unknown) { caught = error } - expect(caught).toBe(original) - expect(isLlmAdapterFailure(caught)).toBe(true) + expect(caught).toBe(cleanup) + expect(isLlmAdapterFailure(caught)).toBe(false) expect(cleanupCalls).toBe(1) }) - it('contains a throwing iterator.return getter after next fails without replacing the original Error', async () => { - const original = new LlmError('provider failed', 'PROVIDER_FAILED') - let cleanupLookups = 0 - const iterator: AsyncIterator = { next: () => Promise.reject(original) } - Object.defineProperty(iterator, 'return', { - get: () => { - cleanupLookups += 1 - throw new Error('return getter failed') - }, - }) + it('allows downstream close when the adapter iterator has no return method', async () => { const adapter = new class extends LlmAdapter { stream(_options: GenerateOptions): AsyncIterable { - return { [Symbol.asyncIterator]: () => iterator } + return { + [Symbol.asyncIterator](): AsyncIterator { + return { next: () => Promise.resolve({ done: false, value: SCRIPT[0]! }) } + }, + } } }() const ctx = new Context() await ctx.plugin(LlmService) ctx.llm.registerAdapter(['test-model'], adapter) - let caught: unknown - try { - for await (const _chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) { /* drain */ } - } catch (error: unknown) { - caught = error + let chunks = 0 + for await (const _chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) { + chunks += 1 + break } - expect(caught).toBe(original) - expect(isLlmAdapterFailure(caught)).toBe(true) - expect(cleanupLookups).toBe(1) + expect(chunks).toBe(1) }) it('normalizes and tags non-Error adapter failures once', async () => { From d027ea0d1003b22f36c389bf126c7dd47a598981 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 15:10:45 +0800 Subject: [PATCH 04/18] refactor(compact-basic): inline automatic listeners into the service Fold automatic.ts into BasicCompactService as a private _registerAutomaticCompaction method, removing the AutomaticCompactor structural interface the standalone module needed to avoid an import cycle. Listener behavior is unchanged; compactIfNeeded stays dynamically dispatched so subclass overrides are honored at event time. --- .../compact/compact-basic/src/automatic.ts | 85 ------------------- packages/compact/compact-basic/src/index.ts | 68 ++++++++++++++- 2 files changed, 66 insertions(+), 87 deletions(-) delete mode 100644 packages/compact/compact-basic/src/automatic.ts diff --git a/packages/compact/compact-basic/src/automatic.ts b/packages/compact/compact-basic/src/automatic.ts deleted file mode 100644 index e837e076cc..0000000000 --- a/packages/compact/compact-basic/src/automatic.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Automatic post-step pressure and context-overflow recovery listeners. - * - * @module @deepseek-ai/dsh-compact-basic/automatic - */ - -import type { Context } from 'cordis' -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, -} from '@deepseek-ai/dsh-token-meter' -import type { Agent } from '@deepseek-ai/dsh-agent' - -interface AutomaticCompactor { - readonly config: { readonly maxOverflowRetries: number } - compactIfNeeded( - agent: Agent, - trigger: CompactionTrigger, - signal: AbortSignal, - ): Promise -} - -/** - * Register the implementation-owned automatic compaction listener. - * @param ctx - context owning the listener effect and logger. - * @param service - compactor whose public methods remain dynamically dispatched. - */ -export function registerAutomaticCompaction( - ctx: Context, - service: AutomaticCompactor, -): void { - 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, - signal: AbortSignal, - ) => { - if (signal.aborted) return - try { - 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(`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/index.ts b/packages/compact/compact-basic/src/index.ts index d420851166..64f8586bac 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -9,10 +9,14 @@ import z from 'schemastery' import { CompactService } from '@deepseek-ai/dsh-compact' import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact' import type { Session } from '@deepseek-ai/dsh-session' +import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { + TOKEN_METER_MODEL_UNCONFIGURED, + TokenMeterError, +} from '@deepseek-ai/dsh-token-meter' import type { ModelTokenMeter } from '@deepseek-ai/dsh-token-meter' import type { Agent } from '@deepseek-ai/dsh-agent' -import { registerAutomaticCompaction } from './automatic.ts' import { resolveConfig, resolveModelConfig } from './config.ts' import { compactSurfaceRegion, selectCompactableRange } from './region.ts' import { summarizeWithLlm } from './summarizer.ts' @@ -72,7 +76,67 @@ export class BasicCompactService extends CompactService { constructor(ctx: Context, config: BasicCompactConfig = {}) { super(ctx) this.config = resolveConfig(config, ctx.tokenMeter) - if (this.config.auto) registerAutomaticCompaction(ctx, this) + if (this.config.auto) this._registerAutomaticCompaction() + } + + /** + * Register the automatic post-step pressure and context-overflow recovery + * listeners. `compactIfNeeded` stays dynamically dispatched so subclass + * overrides are honored at event time. + */ + private _registerAutomaticCompaction(): void { + const { ctx } = this + 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, + signal: AbortSignal, + ) => { + if (signal.aborted) return + try { + const result = await this.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(`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 >= this.config.maxOverflowRetries + || signal.aborted) return next() + + let generation: number + let result: CompactionResult | null + try { + generation = agent.session.surface.replaceGeneration + result = await this.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' } + }) } /** From 231aeabe55f3d58b5f02cd0f9a1495c529613db9 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 17:11:41 +0800 Subject: [PATCH 05/18] docs(compact): align recovery RFC with singleton meter --- ...compaction-pressure-and-overflow-recovery.i18n.yaml | 4 ++-- ...r-call-compaction-pressure-and-overflow-recovery.md | 10 +++++----- ...all-compaction-pressure-and-overflow-recovery.zh.md | 10 +++++----- 3 files changed, 12 insertions(+), 12 deletions(-) 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 index dbb9c76bbf..7cfb11d29d 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 92167dc7d444a3620abfbaab721260ed1c828db9 -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: fe3b6617b25a58ef2c1c311df088f9c805fe9ef4 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 7d68bc32d3860bf5edd94c4eda76922c91ae6af2 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 2315bd4d9ca9b93eb9a8d4850f917e6aa1bc6476 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 index 92167dc7d4..7d68bc32d3 100644 --- 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 @@ -18,7 +18,7 @@ Successful calls are not the only pressure signal. A provider can reject a reque 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. +`dsh-compact-basic` reads the exact latest routed model from the durable request header only to establish that a completed route exists, then asks the singleton `ctx.tokenMeter` 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; any durable non-empty model name uses the same estimator. Operational measurement or summarization failures warn and continue with full history. ### Request recovery is limited to the final model boundary @@ -32,11 +32,11 @@ If cancellation lands after assistant tool calls are durable but before all call `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 `pressure`, compact-basic applies the service-wide threshold and retained-tail policy to one unified `ctx.tokenMeter.measure()` result. The same singleton meter owns 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. +`maxOverflowRetries` is optional and defaults to `1`; `0` disables overflow recovery without disabling pressure. `auto: false` registers neither automatic listener. Noncanonical errors, exhausted attempts, an already-aborted signal, a missing routed model, no safe range, no generation change, and recovery throws 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. @@ -44,7 +44,7 @@ The default summarizer still resolves explicit configuration, then the latest lo 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. +Compact tests pin low-friction service-wide defaults, actual routed-model selection, unlisted-model measurement, unified pressure-and-retention decisions, 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 @@ -52,7 +52,7 @@ Compact tests pin low-friction defaults, actual routed-model selection, exact un - **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. +- **Fall back to `AgentOptions.model` when no durable route exists** — rejected because automatic policy must describe a completed logged request. Headerless pressure and recovery delegate unchanged. ## Consequences 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 index fe3b6617b2..2315bd4d9c 100644 --- 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 @@ -18,7 +18,7 @@ Status: implemented 循环在 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 失败;操作性的选择或摘要失败则警告并继续使用完整历史。 +`dsh-compact-basic` 从持久请求头读取精确的最新实际路由模型,只用它确认已经存在完整路由,随后让单例 `ctx.tokenMeter` 计量规范日志信封与当前表层。自动压力不会回退到 `AgentOptions.model`。没有请求头的会话尚无已完成路由请求可供判断,因此不执行工作;任意持久记录的非空模型名都使用同一个估算器。操作性的计量或摘要失败会发出警告,并继续使用完整历史。 ### 请求恢复只覆盖最终模型边界 @@ -32,11 +32,11 @@ Status: implemented `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`。 +对于 `pressure`,compact-basic 把服务级阈值与保留尾部策略应用到一次统一的 `ctx.tokenMeter.measure()` 结果。范围定价、来源、被遮蔽 token 数与非缩小摘要拒绝也由同一个单例 meter 完成。通用默认值保持为阈值比例 `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 未变化,以及恢复抛错都会委托给下一个监听器。若没有后续恢复,循环报告原始提供方错误对象与代码。即使恢复工作并发完成,取消或销毁仍具有最终优先级。 +`maxOverflowRetries` 可选且默认为 `1`;`0` 只禁用溢出恢复,不会禁用压力检查。`auto: false` 不注册任何自动监听器。非规范化错误、尝试耗尽、已经中止的 signal、缺失路由模型、没有安全范围、generation 未变化,以及恢复抛错都会委托给下一个监听器。若没有后续恢复,循环报告原始提供方错误对象与代码。即使恢复工作并发完成,取消或销毁仍具有最终优先级。 默认摘要器仍依次解析显式配置、最近记录的路由与 agent options。因为直接 `llm/stream` 中间件可以重新路由该辅助调用,`compact/summary.model` 记录分发后最终可变的 `GenerateOptions.model`,而不是 waterfall 之前的候选值。 @@ -44,7 +44,7 @@ Status: implemented 生命周期测试固定 post-step 位于持久工具、上下文与 steering 工作之后,覆盖无内容与达到 token 上限的成功、最终适配器分发/迭代器/带内边界、重试编号、尝试重置、取消、销毁、合成工具结果与原始错误身份。 -压缩测试固定低摩擦默认值、实际路由模型选择、精确未知模型行为、低于阈值的强制溢出、最新工具配对保留、非缩小拒绝、generation 证明、上限、禁用监听器、单次下游委托与辅助摘要路由来源。真实循环组合同时覆盖抛出式和带内溢出:失败 step 关闭,压缩落在两次尝试之间,下一个编号请求从替换表层重建。 +压缩测试固定低摩擦服务级默认值、实际路由模型选择、未列出模型计量、统一压力与保留决策、低于阈值的强制溢出、最新工具配对保留、非缩小拒绝、generation 证明、上限、禁用监听器、单次下游委托与辅助摘要路由来源。真实循环组合同时覆盖抛出式和带内溢出:失败 step 关闭,压缩落在两次尝试之间,下一个编号请求从替换表层重建。 ## 考虑过的替代方案 @@ -52,7 +52,7 @@ Status: implemented - **重试相同编号的 step**——不予采纳,因为恢复会在失败边界之后追加持久事件。新 step 保持边界配对与可重建性。 - **只要 `compactIfNeeded` 返回结果就重试**——不予采纳,因为自定义后端可能报告成功却没有改变模型可见状态。`replaceGeneration` 才是权威证明。 - **让 compact-basic 解析提供方措辞**——不予采纳,因为分类属于适配器,而且必须同时覆盖抛出式与带内交付。 -- **恢复时使用通用模型/窗口回退**——不予采纳,因为基于错误上下文容量执行破坏性策略可能掩盖原始提供方失败。未知持久路由会原样委托。 +- **没有持久路由时回退到 `AgentOptions.model`**——不予采纳,因为自动策略必须描述已完成且已记录的请求。没有请求头的压力检查与恢复会原样委托。 ## 后果 From 86d97845dc7a3be558a5b494be667b039b72d9b7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:14:37 +0800 Subject: [PATCH 06/18] docs(agent): remove stale pre-step contracts --- packages/core/agent-loop/src/tool-calls.ts | 3 ++- packages/core/agent/README.md | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index 57bfb081b4..13105d3082 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -106,7 +106,8 @@ function parseArguments(raw: string): unknown { * before start; an exclusive reclassification waits for the current pool to * drain and remains for the caller's next barrier. Results and contexts commit * in model order. Abort stops starts, drains and commits started calls, accepts - * their contexts into the owning batch, and throws. + * their contexts into the owning batch, records results for skipped calls, and + * returns an aborted outcome. */ async function runGroup( ctx: Context, diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 8d1870795d..5979b2bb08 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -77,4 +77,3 @@ The handle every plugin programs against: - **No public step-only abort** — `cancel()` clears ALL pending work (queued + steering + in-flight); an abort that preserves queued prompts returns only with a named consumer ([stop-surface RFC](../../../docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md)). - **`HookContext` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable. - **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`). -- **`agent/pre-step`'s `fullSystemPrompt`/`sessionPrefix` parameters are a flagged smell** — compaction is their only consumer; a lazy prompt provider or a compaction-specific pressure seam is the marked revisit. From 7538cef967b745d9817b9502363699d494d6d069 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:35:43 +0800 Subject: [PATCH 07/18] docs(agent): describe cancelled post-step checkpoint --- docs/cordis-catalog/events.md | 12 ++++++------ docs/core-data-structures/core.md | 2 +- docs/event-producer-consumer.md | 10 +++++----- packages/cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/agent/src/types.ts | 9 +++++---- website/zh-CN/api/harness/events.md | 16 ++++++++-------- 6 files changed, 26 insertions(+), 25 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index f7fe327f80..1dd62f0b76 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -47,11 +47,11 @@ 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:310`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial -Awaited serial checkpoint after the response, tool results, injected context, and steering are durable but before `step/end`. +Awaited serial checkpoint after the response, real or synthetic tool results, injected context, and steering are durable but before `step/end`. A cancelled tool batch reaches this checkpoint with an aborted signal. ```ts cordis-catalog 'agent/post-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void @@ -59,7 +59,7 @@ 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:263`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -119,7 +119,7 @@ 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:277`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — 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:287`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:288`](../../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:297`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index c50134550d..15fd4f2942 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -456,7 +456,7 @@ It returns a `RequestErrorDecision`; `retry` opens a new numbered step after the type RequestErrorDecision = { action: 'fail' } | { action: 'retry' } ``` -`agent/post-step` is the awaited successful-step checkpoint after assistant output, tool results, buffered context, and steering are durable. Its signature is `(agent, turn, step, signal)`; replayable facts remain in the session log rather than a transient payload. +`agent/post-step` is awaited after assistant output, real or synthetic tool results, buffered context, and steering are durable but before `step/end`. A cancelled tool batch reaches it with an aborted signal after draining; its signature is `(agent, turn, step, signal)`, and replayable facts remain in the session log rather than a transient payload. `agent/turn-stop` returns the stop-only `ContinuationStop` subset or `undefined`. The loop calls this serial checkpoint after folding the ordinary decision, its reason, and pending steering; a stop is terminal and discards pending steering. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 2e39b77563..abba29627b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -10,19 +10,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:362`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:147`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:156`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:310`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:263`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:264`](../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:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:214`](../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:175`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:277`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:278`](../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:241`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:188`](../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), [`stdio`](../packages/ui/stdio) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:287`](../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:297`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../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:298`](../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:61`](../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:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 116366b910..6555aed2a4 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -323,7 +323,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/post-step', mode: 'serial', signature: '\'agent/post-step\'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void', - summary: 'Awaited serial checkpoint after the response, tool results, injected context, and steering are durable but before `step/end`.', + summary: 'Awaited serial checkpoint after the response, real or synthetic tool results, injected context, and steering are durable but before `step/end`.', }, { name: 'agent/pre-step', diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 0c79c822f2..702861f407 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -251,11 +251,12 @@ declare module 'cordis' { */ 'agent/step-result'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise /** - * Awaited serial checkpoint after the response, tool results, injected - * context, and steering are durable but before `step/end`. - * @param agent - the agent that completed the step. + * Awaited serial checkpoint after the response, real or synthetic tool + * results, injected context, and steering are durable but before `step/end`. + * A cancelled tool batch reaches this checkpoint with an aborted signal. + * @param agent - the agent whose step is settling. * @param turn - the open turn number. - * @param step - the completed step number. + * @param step - the open step number. * @param signal - the turn abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode serial diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 0a7bb0a5f6..bc2a9e7e4b 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w - `step` — the step at which the failure surfaced. - `error` — the failure, verbatim. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L310) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L311) ### agent/post-step @@ -59,14 +59,14 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w 'agent/post-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void ``` -Awaited serial checkpoint after the response, tool results, injected context, and steering are durable but before `step/end`. +Awaited serial checkpoint after the response, real or synthetic tool results, injected context, and steering are durable but before `step/end`. A cancelled tool batch reaches this checkpoint with an aborted signal. -- `agent` — the agent that completed the step. +- `agent` — the agent whose step is settling. - `turn` — the open turn number. -- `step` — the completed step number. +- `step` — the open step number. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L263) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L264) ### agent/pre-step @@ -151,7 +151,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens - `retryAttempt` — zero-based number of prior recovery retries. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L277) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L278) ### agent/session-prefix @@ -230,7 +230,7 @@ Override whether the turn continues. The default continues after tool calls or s - `turn` — the turn being continued or stopped. - `defaultDecision` — what the loop would do absent an override. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L287) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L288) ### agent/turn-stop @@ -245,7 +245,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a - `agent` — the agent whose composed continuation outcome may be stopped. - `turn` — the turn at its terminal-stop checkpoint. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L297) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L298) ## agent-loop/* From 85ae6848875f96c3199367ccf33454a46a3e39a9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:40:56 +0800 Subject: [PATCH 08/18] docs: sync type-equivalent JSDoc --- docs/core-data-structures/compaction.md | 3 ++- docs/core-data-structures/core.md | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index eae8d2e727..f6cdfd5b8b 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -54,7 +54,8 @@ interface CompactionResult { Automatic callers state why policy is running; implementations may treat confirmed overflow more aggressively than ordinary pressure. ```ts type-equiv -export type CompactionTrigger = 'pressure' | 'context-overflow' +/** Why automatic policy is asking a backend to consider compaction. */ +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: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 4d32b7796f..0eaede9345 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -446,12 +446,14 @@ type ContinuationDecision = `agent/request-error` receives the original `RequestError`, whose optional provider-neutral `code` supports stable routing without message parsing: ```ts type-equiv +/** Model-request failure with an optional machine-routable provider code. */ type RequestError = Error & { code?: string } ``` It returns a `RequestErrorDecision`; `retry` opens a new numbered step after the recovery listener's durable mutation, while `fail` preserves that error: ```ts type-equiv +/** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()`. */ type RequestErrorDecision = { action: 'fail' } | { action: 'retry' } ``` From 954c2ad2b5a2c9cedae72b3e0aa6e4efacd2a106 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:03:15 +0800 Subject: [PATCH 09/18] docs(llm-pi-ai): track overflow classification gap --- packages/llm/llm-pi-ai/src/stream.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/llm/llm-pi-ai/src/stream.ts b/packages/llm/llm-pi-ai/src/stream.ts index c3aae46c44..f7a0d6ee7f 100644 --- a/packages/llm/llm-pi-ai/src/stream.ts +++ b/packages/llm/llm-pi-ai/src/stream.ts @@ -30,6 +30,9 @@ export function mapUsage(usage: PiUsage): TokenUsage { function classifyPiAiError(message: string): string { if (/\b(?:401|403)\b/.test(message)) return 'AUTH' if (/\b429\b|rate.?limit/i.test(message)) return 'RATE_LIMIT' + // TODO: Classify the full message with pi-ai's isContextOverflow() and the + // resolved model's contextWindow so provider-specific and usage-based overflows + // reach automatic compaction. if (isContextWindowExceededError(message)) return CONTEXT_WINDOW_EXCEEDED_CODE if (/\b400\b|invalid.?request/i.test(message)) return 'INVALID_REQUEST' if (/\b5\d\d\b/.test(message)) return 'SERVER' From edaaa50fe375cf1a058367289d0bd553880af293 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:03:31 +0800 Subject: [PATCH 10/18] test(acp): snapshot cancelled queued tool calls --- examples/acp-agent/tests/acp.snapshot.ts | 1 + .../snapshots/cancel-tool-calls/input.json | 11 +++++++++ .../cancel-tool-calls/replay.override.json | 15 ++++++++++++ .../snapshots/cancel-tool-calls/session.jsonl | 20 ++++++++++++++++ .../cancel-tool-calls/stdout.golden.jsonl | 7 ++++++ packages/support/acp-snapshot/src/harness.ts | 24 ++++++++----------- .../tests/fixtures/fake-acp-agent.ts | 20 +++++++++++++++- .../acp-snapshot/tests/harness.spec.ts | 10 ++++++++ 8 files changed, 93 insertions(+), 15 deletions(-) create mode 100644 examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json create mode 100644 examples/acp-agent/tests/snapshots/cancel-tool-calls/replay.override.json create mode 100644 examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.golden.jsonl diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 124117afef..d60d8b9724 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -103,6 +103,7 @@ const SCENARIOS: Scenario[] = [ configPath: WORKSPACE_CONTEXT_CONFIG, }, { name: 'cancel', hasModelTurn: true, recorded: false, overridden: true }, + { name: 'cancel-tool-calls', hasModelTurn: true, recorded: false, overridden: true }, { name: 'subagent-spawn', hasModelTurn: true, recorded: true }, { name: 'subagent-multi', hasModelTurn: true, recorded: true }, { name: 'subagent-fork', hasModelTurn: true, recorded: true }, diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json b/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json new file mode 100644 index 0000000000..3610e1f436 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json @@ -0,0 +1,11 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { + "op": "promptAndCancel", + "text": "Run two shell commands: wait for cancellation, then write skipped.txt.", + "afterUpdate": "tool_call" + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/replay.override.json b/examples/acp-agent/tests/snapshots/cancel-tool-calls/replay.override.json new file mode 100644 index 0000000000..c0aa7730d7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/replay.override.json @@ -0,0 +1,15 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_wait", "name": "bash", "argumentsDelta": "{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_wait", "name": "bash", "arguments": "{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}" } }, + { "type": "block-start", "index": 1, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 1, "id": "call_skipped", "name": "bash", "argumentsDelta": "{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}" }, + { "type": "block-end", "index": 1, "block": { "type": "tool-call", "id": "call_skipped", "name": "bash", "arguments": "{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 10 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + } +] diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl new file mode 100644 index 0000000000..e6d18515a6 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl @@ -0,0 +1,20 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":1784437195072,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784437195072,"data":{"content":[{"type":"text","text":"Run two shell commands: wait for cancellation, then write skipped.txt."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1784437195076,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1784437195076,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_wait","name":"bash","argumentsDelta":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}} +{"type":"assistant/chunk","seq":6,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":8,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_skipped","name":"bash","argumentsDelta":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}}} +{"type":"assistant/chunk","seq":9,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}}}} +{"type":"assistant/chunk","seq":10,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":10}}}} +{"type":"assistant/chunk","seq":11,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":12,"time":1784437195078,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"},{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":10}},"sourceEventSeqs":[4,5,6,7,8,9,10,11],"surfaceOp":"append"} +{"type":"tool/call","seq":13,"time":1784437195078,"data":{"turn":1,"step":1,"callId":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}} +{"type":"tool/result","seq":14,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_wait","content":[{"type":"text","text":"Error: command aborted"}],"isError":true},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}} +{"type":"tool/result","seq":16,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","content":[{"type":"text","text":"Error: tool call skipped because the step was aborted before execution"}],"isError":true,"error":{"name":"AbortError","code":"ABORTED"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1784437195090,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":18,"time":1784437195090,"data":{"turn":1,"reason":{"kind":"aborted","reason":"session/cancel"}}} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.golden.jsonl new file mode 100644 index 0000000000..11178b9bc7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.golden.jsonl @@ -0,0 +1,7 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"node -e \"setInterval(() => {}, 1000)\"","kind":"execute","status":"in_progress","rawInput":"node -e \"setInterval(() => {}, 1000)\"","content":[{"type":"content","content":{"type":"text","text":"Wait until cancellation"}}]}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_wait","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: command aborted\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skipped","title":"printf skipped > skipped.txt","kind":"execute","status":"in_progress","rawInput":"printf skipped > skipped.txt","content":[{"type":"content","content":{"type":"text","text":"Write skipped marker"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skipped","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: tool call skipped because the step was aborted before execution\n```"}}]}}} diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index e3efca8da9..ca00056d93 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -37,11 +37,10 @@ export type { AgentUnderTest } from './launcher.ts' * (random) session id into a `{{sessionId}}` variable that later steps * reference, since a committed file cannot know the id in advance. * - * `promptAndCancel` sends a prompt WITHOUT awaiting its response, waits until - * the client observes the first streamed `agent_message_chunk` (so the emitted - * frames deterministically precede the cancellation), then cancels the turn — - * the only way to exercise a cancel deterministically (a plain `prompt` step - * awaits the response, which a cancel/hang scenario would block on forever). + * `promptAndCancel` starts a prompt without awaiting completion, waits until + * the client observes the selected update (`agent_message_chunk` by default), + * then cancels and awaits completion. This keeps update/cancel order + * deterministic for fixtures that a plain `prompt` cannot drive. */ export type InputStep = | { op: 'initialize'; terminalOutput?: boolean } @@ -49,7 +48,7 @@ export type InputStep = | { op: 'newSessionExpectError'; additionalDirectories?: string[] } | { op: 'prompt'; text: string } | { op: 'promptExpectError'; text: string } - | { op: 'promptAndCancel'; text: string } + | { op: 'promptAndCancel'; text: string; afterUpdate?: 'agent_message_chunk' | 'tool_call' } | { op: 'cancel' } | { op: 'setConfigOption'; configId: string; value: string } | { op: 'setConfigOptionExpectError'; configId: string; value: string } @@ -342,15 +341,12 @@ async function runStep( case 'promptAndCancel': { const sessionId = getSessionId() if (sessionId === undefined) throw new Error('snapshot-harness: promptAndCancel before newSession') - // Dispatch the prompt WITHOUT awaiting (a hang fixture never resolves on - // its own). To pin frame order deterministically, wait until the client - // has OBSERVED the hang's streamed agent_message_chunk before cancelling — - // so those update frames always precede the cancelled prompt response in - // the transcript (without this, the late chunk and the response race). - // Then cancel and await the prompt, which the bridge settles as - // `cancelled` once the abort propagates. + // Dispatch without awaiting because the fixture does not settle on its + // own. Waiting for the selected update pins it before cancellation and + // the cancelled prompt response in the transcript. const promptDone = client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] }) - await waitForUpdate(u => u.sessionUpdate === 'agent_message_chunk') + const afterUpdate = step.afterUpdate ?? 'agent_message_chunk' + await waitForUpdate(u => u.sessionUpdate === afterUpdate) await client.cancel({ sessionId }) await promptDone return diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts index 5dd5524ed0..dd8ca9ce92 100644 --- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts +++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts @@ -33,6 +33,8 @@ interface Behavior { rejectExtraDirs?: boolean /** How `session/prompt` settles: a clean response, a JSON-RPC error, or a hang until `session/cancel`. */ prompt?: 'respond' | 'error' | 'hang-until-cancel' + /** Emit a tool call instead of a message chunk before parking a cancellable prompt. */ + cancelAtToolCall?: boolean /** Before responding to a prompt, send a `session/request_permission` request and echo its outcome as a chunk. */ permissionProbe?: boolean /** Echo the `DSH_SNAPSHOT_*` env the harness set as a chunk (spec-side env-plumbing assertions). */ @@ -126,7 +128,23 @@ async function handlePrompt(id: number | string): Promise { params: { sessionId, update: { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'mulling' } } }, }) } - chunk('thinking about it') + if (behavior.cancelAtToolCall === true) { + send({ + method: 'session/update', + params: { + sessionId, + update: { + sessionUpdate: 'tool_call', + toolCallId: 'call_fake_1', + title: 'fake tool', + kind: 'execute', + status: 'in_progress', + }, + }, + }) + } else { + chunk('thinking about it') + } if (behavior.echoEnv === true) { chunk(`env:${JSON.stringify({ mode: process.env.DSH_SNAPSHOT, diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 2b9d6e032f..be03b27c78 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -334,6 +334,16 @@ describe('runScenario', () => { expect(result.rawStdout.indexOf('thinking about it')).toBeLessThan(result.rawStdout.indexOf('cancelled')) }) + it('promptAndCancel can wait for a tool call before cancelling', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({ prompt: 'hang-until-cancel', cancelAtToolCall: true }) + const result = await runScenario( + { steps: [...boot, { op: 'promptAndCancel', text: 'hang', afterUpdate: 'tool_call' }] }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ) + expect(result.rawStdout).toContain('"sessionUpdate":"tool_call"') + expect(result.rawStdout.indexOf('"sessionUpdate":"tool_call"')).toBeLessThan(result.rawStdout.indexOf('cancelled')) + }) + it('promptExpectError swallows a model-error response as the expected outcome', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ prompt: 'error' }) const result = await runScenario( From d709a8a1a4aab517ffdf75059f681b6ad5cc8e91 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:09:16 +0800 Subject: [PATCH 11/18] test(acp): await cancelled tool updates --- .../snapshots/cancel-tool-calls/input.json | 3 ++- packages/support/acp-snapshot/src/harness.ts | 16 +++++++++++++--- .../tests/fixtures/fake-acp-agent.ts | 15 +++++++++++++++ .../support/acp-snapshot/tests/harness.spec.ts | 18 +++++++++++++++--- 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json b/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json index 3610e1f436..0f40e9d8b6 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json @@ -5,7 +5,8 @@ { "op": "promptAndCancel", "text": "Run two shell commands: wait for cancellation, then write skipped.txt.", - "afterUpdate": "tool_call" + "afterUpdate": "tool_call", + "waitForToolCallUpdate": "call_skipped" } ] } diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index ca00056d93..542b6d6df3 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -39,8 +39,8 @@ export type { AgentUnderTest } from './launcher.ts' * * `promptAndCancel` starts a prompt without awaiting completion, waits until * the client observes the selected update (`agent_message_chunk` by default), - * then cancels and awaits completion. This keeps update/cancel order - * deterministic for fixtures that a plain `prompt` cannot drive. + * then cancels and awaits completion. A named `waitForToolCallUpdate` keeps the + * step open for a terminal tool update that may follow the prompt response. */ export type InputStep = | { op: 'initialize'; terminalOutput?: boolean } @@ -48,7 +48,12 @@ export type InputStep = | { op: 'newSessionExpectError'; additionalDirectories?: string[] } | { op: 'prompt'; text: string } | { op: 'promptExpectError'; text: string } - | { op: 'promptAndCancel'; text: string; afterUpdate?: 'agent_message_chunk' | 'tool_call' } + | { + op: 'promptAndCancel' + text: string + afterUpdate?: 'agent_message_chunk' | 'tool_call' + waitForToolCallUpdate?: string + } | { op: 'cancel' } | { op: 'setConfigOption'; configId: string; value: string } | { op: 'setConfigOptionExpectError'; configId: string; value: string } @@ -347,8 +352,13 @@ async function runStep( const promptDone = client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] }) const afterUpdate = step.afterUpdate ?? 'agent_message_chunk' await waitForUpdate(u => u.sessionUpdate === afterUpdate) + // Arm this before cancellation so a fast tool drain cannot outrun the waiter. + const toolCallUpdateDone = step.waitForToolCallUpdate === undefined + ? undefined + : waitForUpdate(u => u.sessionUpdate === 'tool_call_update' && u.toolCallId === step.waitForToolCallUpdate) await client.cancel({ sessionId }) await promptDone + if (toolCallUpdateDone !== undefined) await toolCallUpdateDone return } case 'cancel': { diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts index dd8ca9ce92..43b5ee3fb3 100644 --- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts +++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts @@ -35,6 +35,8 @@ interface Behavior { prompt?: 'respond' | 'error' | 'hang-until-cancel' /** Emit a tool call instead of a message chunk before parking a cancellable prompt. */ cancelAtToolCall?: boolean + /** Emit the parked tool call's terminal update after answering cancellation. */ + cancelToolCallUpdate?: boolean /** Before responding to a prompt, send a `session/request_permission` request and echo its outcome as a chunk. */ permissionProbe?: boolean /** Echo the `DSH_SNAPSHOT_*` env the harness set as a chunk (spec-side env-plumbing assertions). */ @@ -247,6 +249,19 @@ function handleFrame(frame: Record): void { const parked = parkedPromptId parkedPromptId = null respond(parked, { stopReason: 'cancelled' }) + if (behavior.cancelToolCallUpdate === true) { + send({ + method: 'session/update', + params: { + sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'call_fake_1', + status: 'failed', + }, + }, + }) + } } return default: diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index be03b27c78..3d174b3c3e 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -334,14 +334,26 @@ describe('runScenario', () => { expect(result.rawStdout.indexOf('thinking about it')).toBeLessThan(result.rawStdout.indexOf('cancelled')) }) - it('promptAndCancel can wait for a tool call before cancelling', { timeout: 20_000 }, async () => { - const { fixtureFile } = await scenario({ prompt: 'hang-until-cancel', cancelAtToolCall: true }) + it('promptAndCancel can bracket cancellation with tool-call updates', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({ + prompt: 'hang-until-cancel', + cancelAtToolCall: true, + cancelToolCallUpdate: true, + }) const result = await runScenario( - { steps: [...boot, { op: 'promptAndCancel', text: 'hang', afterUpdate: 'tool_call' }] }, + { + steps: [...boot, { + op: 'promptAndCancel', + text: 'hang', + afterUpdate: 'tool_call', + waitForToolCallUpdate: 'call_fake_1', + }], + }, { agent: AGENT, mode: 'replay', fixtureFile }, ) expect(result.rawStdout).toContain('"sessionUpdate":"tool_call"') expect(result.rawStdout.indexOf('"sessionUpdate":"tool_call"')).toBeLessThan(result.rawStdout.indexOf('cancelled')) + expect(result.rawStdout.indexOf('cancelled')).toBeLessThan(result.rawStdout.indexOf('"sessionUpdate":"tool_call_update"')) }) it('promptExpectError swallows a model-error response as the expected outcome', { timeout: 20_000 }, async () => { From 40ad78ee0bdcddea49acd4adabe92da94c0e0280 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:13:36 +0800 Subject: [PATCH 12/18] docs: condense merged architecture contract --- docs/architecture.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 32df16a3cd..193dbf56bf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,7 +83,7 @@ forever: snapshot the derived messages (the reconstruction boundary) 'step/start' agent/request (config only) -> log request/header -> llm/stream (frozen) - on final adapter failure or terminal in-band error/aborted finish: + on final adapter-path or terminal in-band failure: 'step/end' agent/request-error(original error, consecutive retry attempt, signal) retry in the next numbered step or preserve the original error @@ -108,15 +108,15 @@ forever: Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -Context accepted during tool execution—including async `agent.inject()` notices and post-tool `additionalContexts`—waits for settlement, then follows every recorded result. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering while the step signal remains open. Ordinary leftover steering becomes queued input. Terminal `agent/turn-stop` runs after continuation and steering folding, stays authoritative through turn close and flush, and discards later steering while preserving queued prompts. +Context accepted during tool execution—including async `agent.inject()` notices and post-tool `additionalContexts`—waits for settlement, then follows every recorded result. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering before signal closure. Leftover steering becomes queued input. Terminal `agent/turn-stop` runs after continuation and steering folding, stays authoritative through turn close and flush, and discards later steering while preserving queued prompts. -`dsh-compact-basic` uses those checkpoints for routed-envelope pressure and canonical-overflow recovery; only a tool-balanced surface replacement authorizes retry ([RFC](rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)). +`dsh-compact-basic` handles pressure and canonical overflow at these checkpoints; retry requires a balanced surface replacement ([RFC](rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)). ### Failure Boundaries -The turn is the containment boundary. Final adapter selection, dispatch, iteration, and terminal in-band failures close the step before `agent/request-error`; retry reconstructs a new numbered step, while the default preserves the provider error. Attempts reset after success. +The turn is the containment boundary. Final adapter-path and terminal in-band failures close the step before `agent/request-error`; retry starts a new numbered step, while the default preserves the provider error. Attempts reset on success. -Other plugin and step failures use `agent/error`. Cancellation and disposal beat recovery; undispatched model tool calls receive synthetic `tool/call` and `ABORTED` result pairs before turn closure. `cancel()` clears queues and aborts active work; disposal awaits quiescence before unregistering. +Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched model tool calls receive synthetic `tool/call` and `ABORTED` result pairs before turn closure. `cancel()` clears queues and aborts active work; disposal awaits quiescence before unregistering. Every session event is turn-enclosed. Reloading preserves an interrupted tail and closes it with a synthetic `interrupted` turn end. Failures after durable turn close report only through `agent/error` because no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants. From 4791b40b269b19a3c40daae5ed412ebc5d1b6d42 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:18:13 +0800 Subject: [PATCH 13/18] fix(llm-pi-ai): classify usage-based context overflow Pass each resolved catalog model capacity into pi-ai stream conversion so the upstream full-message classifier can recognize provider-specific, silent, and length-stop overflow signals. Retain the harness text fallback for legacy provider wording and cover the catalog-resolution path with a mock-provider regression. --- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/src/adapter.ts | 2 +- packages/llm/llm-pi-ai/src/stream.ts | 34 +++++++++++++++----- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 26 ++++++++++++++- packages/llm/llm-pi-ai/tests/convert.spec.ts | 28 ++++++++++++++++ 5 files changed, 81 insertions(+), 11 deletions(-) diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index e2d56e28a5..1deae8f039 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -43,7 +43,7 @@ If a listener rewrites assembled assistant content, the loop drops replay state ## Vocabulary differences - pi-ai tool-call arguments are parsed objects; the harness stores raw JSON strings. The adapter parses input and re-stringifies output. -- pi-ai reports failures as in-stream error events; these map to `finish {kind:'error'|'aborted'}` chunks, with recognized context overflow normalized to `CONTEXT_WINDOW_EXCEEDED`. +- pi-ai reports failures as in-stream error events; these map to `finish {kind:'error'|'aborted'}` chunks. Provider-specific error text and usage signals evaluated against the resolved model's context window normalize overflow to `CONTEXT_WINDOW_EXCEEDED`. - pi-ai folds reasoning tokens into output usage; there is no separate reasoning count to map. - `GenerateOptions.stop` is rejected with `UNSUPPORTED_OPTION` because pi-ai's common streaming surface cannot guarantee it across providers. diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 26e28ecd1f..7f40c67da3 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -115,7 +115,7 @@ export class PiAiAdapter extends LlmAdapter { // Harness-owned and therefore win collisions. headers: requestHeaders(profile.headers), }) - yield* toStreamChunks(events) + yield* toStreamChunks(events, model.contextWindow) } finally { options.signal?.removeEventListener('abort', onCallerAbort) controller.abort('consumer stopped streaming') diff --git a/packages/llm/llm-pi-ai/src/stream.ts b/packages/llm/llm-pi-ai/src/stream.ts index f7a0d6ee7f..8bc41cded2 100644 --- a/packages/llm/llm-pi-ai/src/stream.ts +++ b/packages/llm/llm-pi-ai/src/stream.ts @@ -10,6 +10,7 @@ import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, LlmError } from '@deepseek-ai/dsh-llm' import type { FinishReason, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' +import { isContextOverflow } from '@earendil-works/pi-ai' import type { AssistantMessage, AssistantMessageEvent, Usage as PiUsage } from '@earendil-works/pi-ai' import { toPiReplayState } from './replay.ts' @@ -30,10 +31,6 @@ export function mapUsage(usage: PiUsage): TokenUsage { function classifyPiAiError(message: string): string { if (/\b(?:401|403)\b/.test(message)) return 'AUTH' if (/\b429\b|rate.?limit/i.test(message)) return 'RATE_LIMIT' - // TODO: Classify the full message with pi-ai's isContextOverflow() and the - // resolved model's contextWindow so provider-specific and usage-based overflows - // reach automatic compaction. - if (isContextWindowExceededError(message)) return CONTEXT_WINDOW_EXCEEDED_CODE if (/\b400\b|invalid.?request/i.test(message)) return 'INVALID_REQUEST' if (/\b5\d\d\b/.test(message)) return 'SERVER' return 'PI_AI_ERROR' @@ -42,9 +39,22 @@ function classifyPiAiError(message: string): string { /** * Map a terminal pi-ai event to the harness finish reason. * @param message - the assistant message carried by the `done` or `error` event. + * @param contextWindow - resolved catalog capacity for usage-based overflow detection. * @returns the harness reason; `error` yields `{kind: 'error'}` with a code classified from the error text. */ -export function mapStopReason(message: AssistantMessage): FinishReason { +export function mapStopReason(message: AssistantMessage, contextWindow?: number): FinishReason { + const piAiOverflow = isContextOverflow(message, contextWindow) + const harnessOverflow = message.stopReason === 'error' + && message.errorMessage !== undefined + && isContextWindowExceededError(message.errorMessage) + if (piAiOverflow || harnessOverflow) { + return { + kind: 'error', + message: message.errorMessage ?? `pi-ai detected context overflow for model "${message.model}"`, + code: CONTEXT_WINDOW_EXCEEDED_CODE, + } + } + switch (message.stopReason) { case 'stop': return { kind: 'stop' } case 'length': return { kind: 'max-tokens' } @@ -62,10 +72,14 @@ export function mapStopReason(message: AssistantMessage): FinishReason { * mid-stream — failures arrive as `error` events, which become error/aborted * `finish` chunks (the harness protocol's other error-delivery style). * @param events - one assistant turn's pi-ai event stream. + * @param contextWindow - resolved catalog capacity for usage-based overflow detection. * @returns the harness chunks, ending with `usage` then `finish`; throws * `LlmError` (`STREAM_CLOSED`) if the source ends without a terminal event. */ -export async function* toStreamChunks(events: AsyncIterable): AsyncGenerator { +export async function* toStreamChunks( + events: AsyncIterable, + contextWindow?: number, +): AsyncGenerator { // pi-ai contentIndex ↔ our block index map 1:1 (both count blocks from 0 // in stream order), but we track ids per index for tool calls. const toolIds = new Map() @@ -128,13 +142,17 @@ export async function* toStreamChunks(events: AsyncIterable { const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) expect(result.finish).toMatchObject({ kind: 'error', code }) }) + + it('uses the resolved catalog context window for usage-based overflow detection', async () => { + const model = getModels('deepseek').find(candidate => candidate.id === 'deepseek-v4-flash') + if (model === undefined) throw new Error('deepseek-v4-flash missing from pi-ai test catalog') + const events = [ + '{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', + JSON.stringify({ + choices: [{ delta: {}, index: 0, finish_reason: 'stop' }], + usage: { prompt_tokens: model.contextWindow + 1, completion_tokens: 0 }, + }), + '[DONE]', + ] + const server = await mockServer([{ events }]) + const ctx = await harness(server.url) + + const result = await assemble(ctx, { model: model.id, messages: [] }) + + expect(result.finish).toEqual({ + kind: 'error', + message: `pi-ai detected context overflow for model "${model.id}"`, + code: CONTEXT_WINDOW_EXCEEDED_CODE, + }) + }) }) describe('provider profile lifecycle', () => { diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 6c58f67bb7..a2f37ce511 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -537,6 +537,34 @@ describe('mapStopReason / mapUsage', () => { }))).toMatchObject({ kind: 'error', code: 'INVALID_REQUEST' }) }) + it('uses pi-ai provider-specific overflow classification without losing rate-limit exclusions', () => { + expect(mapStopReason(assistant({ + stopReason: 'error', + errorMessage: 'prompt is too long: 213462 tokens > 200000 maximum', + }))).toMatchObject({ kind: 'error', code: CONTEXT_WINDOW_EXCEEDED_CODE }) + expect(mapStopReason(assistant({ + stopReason: 'error', + errorMessage: 'ThrottlingException: Too many tokens, rate limit reached', + }))).toMatchObject({ kind: 'error', code: 'RATE_LIMIT' }) + }) + + it('uses the resolved context window for silent and length-stop overflows', () => { + const silent = assistant({ stopReason: 'stop', usage: usage(101, 0) }) + expect(mapStopReason(silent)).toEqual({ kind: 'stop' }) + expect(mapStopReason(silent, 100)).toEqual({ + kind: 'error', + message: 'pi-ai detected context overflow for model "deepseek-v4-flash"', + code: CONTEXT_WINDOW_EXCEEDED_CODE, + }) + + const truncated = assistant({ stopReason: 'length', usage: usage(80, 0, 19) }) + expect(mapStopReason(truncated)).toEqual({ kind: 'max-tokens' }) + expect(mapStopReason(truncated, 100)).toMatchObject({ + kind: 'error', + code: CONTEXT_WINDOW_EXCEEDED_CODE, + }) + }) + it('maps cache fields only when nonzero', () => { expect(mapUsage(usage(10, 5, 8, 2))).toEqual({ inputTokens: 10, From 98e224e45e04308b99751eae4bc227df1c2ba763 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:02:11 +0800 Subject: [PATCH 14/18] refactor(agent): exhaust recovery and compaction decisions Dispatch RequestErrorDecision and CompactionTrigger through explicit discriminant switches. End each closed union with assertNever so new variants fail compilation instead of silently inheriting fail or pressure behavior. This preserves the current retry, fail, pressure, and overflow semantics while aligning the new recovery seams with the repository closed-union contract. --- packages/compact/compact-basic/src/index.ts | 19 +++++++++++++------ packages/core/agent-loop/src/loop.ts | 16 +++++++++++----- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index b77ebebc76..5d325d57ba 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -9,7 +9,7 @@ import z from 'schemastery' import { CompactService } from '@deepseek-ai/dsh-compact' import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact' import type { Session } from '@deepseek-ai/dsh-session' -import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm' +import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import { resolveConfig } from './config.ts' @@ -152,11 +152,18 @@ export class BasicCompactService extends CompactService { const model = routedModel(agent.session) if (model === undefined) return null const meter = this.ctx.tokenMeter - if (trigger === 'context-overflow') { - const measurement = meter.measure(agent.session) - const range = selectCompactableRange(agent.session, measurement, 0) - if (range === null) return null - return this.compactRegion(range.start, range.end, agent, signal) + switch (trigger) { + case 'context-overflow': { + const measurement = meter.measure(agent.session) + const range = selectCompactableRange(agent.session, measurement, 0) + if (range === null) return null + return this.compactRegion(range.start, range.end, agent, signal) + } + case 'pressure': + break + /* v8 ignore next -- closed-union exhaustiveness guard */ + default: + assertNever(trigger, 'compaction trigger') } const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 54cbbc6bdd..08c1ab9412 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm' import { isDeepStrictEqual } from 'node:util' -import { BlockAssembler, HarnessError, deepFreeze, isLlmAdapterFailure } from '@deepseek-ai/dsh-llm' +import { BlockAssembler, HarnessError, assertNever, deepFreeze, isLlmAdapterFailure } from '@deepseek-ai/dsh-llm' import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' import { canonicalHeader } from '@deepseek-ai/dsh-session' @@ -392,11 +392,17 @@ async function runTurn( : { kind: 'aborted', reason: String(abort.signal.reason) } break } - if (recoveryDecision.action === 'retry') { - requestRetryAttempt += 1 - continue + switch (recoveryDecision.action) { + case 'retry': + requestRetryAttempt += 1 + continue + case 'fail': + failTurn(stepOutcome.requestError) + break + /* v8 ignore next -- closed-union exhaustiveness guard */ + default: + assertNever(recoveryDecision, 'agent request-error decision') } - failTurn(stepOutcome.requestError) break } From 8eb9beb31909ae83639c9b5c5df1664c996be0b5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:03:15 +0800 Subject: [PATCH 15/18] docs(rfc): correct recovery translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the repository-mandated 回放 terminology for replayable pressure and replace the literal cancellation-owned rendering with idiomatic Chinese that preserves boundedness, cancellation authority, and monotonic retry behavior. The corresponding English clauses remain accurate. Re-record the English and Chinese pair after checking the corrected text against the implemented recovery contract. --- ...r-call-compaction-pressure-and-overflow-recovery.i18n.yaml | 2 +- ...after-call-compaction-pressure-and-overflow-recovery.zh.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index 9f3f9fcebf..aa45a7ccb2 100644 --- 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 @@ -3,4 +3,4 @@ # 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: abc374d98fed8cac039f10d930fec332515c846e -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 5f6661c9fdc905048aaec56d9b0831a9a5a4548e +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: ef13c64fc4c728e5b4fe9718ae8ef8b8ef9ecdd9 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 index 5f6661c9fd..ef13c64fc4 100644 --- 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 @@ -8,7 +8,7 @@ Status: implemented `agent/pre-step` 运行在最终请求路由之前,也早于 assistant 输出、工具结果、缓冲上下文与 steering 的产生。即使它接收已装配提示词与会话前缀,压力视图仍是临时的,因为 `agent/request` 还可以改变路由或调用配置,工具 schema 也没有与这些输入一同冻结。增加字段无法让调用前状态描述已完成调用,还会把通用 seam 与压缩耦合。 -成功调用也不是唯一的压力信号。提供方可能在返回 usage 之前就因上下文窗口超限拒绝请求,一些成功调用也不提供 usage。因此,系统需要可重放的调用后压力,以及一条狭窄的失败恢复路径;当压缩无法证明取得有效进展时,必须保留原始提供方错误。 +成功调用也不是唯一的压力信号。提供方可能在返回 usage 之前就因上下文窗口超限拒绝请求,一些成功调用也不提供 usage。因此,系统需要可回放的调用后压力,以及一条狭窄的失败恢复路径;当压缩无法证明取得有效进展时,必须保留原始提供方错误。 ## 决策 @@ -54,7 +54,7 @@ Status: implemented ## 后果 -Post-step 压力描述已完成的路由请求,包括持久工具结果与仅请求前缀字段。当成功 usage 锚点不存在时,规范化溢出提供兜底路径。恢复有上限、受取消所有,并保持单调:只有模型可见的表层 generation 变化后才重试。 +Post-step 压力描述已完成的路由请求,包括持久工具结果与仅请求前缀字段。当成功 usage 锚点不存在时,规范化溢出提供兜底路径。恢复有明确上限、以取消为准,并保持单调:只有模型可见的表层 generation 变化后才重试。 代价是成功 step 增加一个串行检查点,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分单个不可分割的超大消息或工具单元。 From 761796347482382699baa71f36b9c8705c75fc01 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:04:51 +0800 Subject: [PATCH 16/18] docs(llm-pi-ai): describe usage overflow mapping Document every context-window path returned by mapStopReason: recognized provider error wording, successful stop usage beyond the resolved window, and zero-output length stops that fill the window. This keeps the public return contract aligned with usage-based overflow classification without duplicating the provider-specific detection table owned by pi-ai. --- packages/llm/llm-pi-ai/src/stream.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/llm/llm-pi-ai/src/stream.ts b/packages/llm/llm-pi-ai/src/stream.ts index 8bc41cded2..c1a85addf0 100644 --- a/packages/llm/llm-pi-ai/src/stream.ts +++ b/packages/llm/llm-pi-ai/src/stream.ts @@ -40,7 +40,9 @@ function classifyPiAiError(message: string): string { * Map a terminal pi-ai event to the harness finish reason. * @param message - the assistant message carried by the `done` or `error` event. * @param contextWindow - resolved catalog capacity for usage-based overflow detection. - * @returns the harness reason; `error` yields `{kind: 'error'}` with a code classified from the error text. + * @returns the mapped harness reason. Recognized error text, `stop` usage above + * `contextWindow`, and zero-output `length` usage that fills the window map + * to `CONTEXT_WINDOW_EXCEEDED`. */ export function mapStopReason(message: AssistantMessage, contextWindow?: number): FinishReason { const piAiOverflow = isContextOverflow(message, contextWindow) From bf93605f8cffea1c977c8957873f5085557b0504 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:12:29 +0800 Subject: [PATCH 17/18] docs: keep architecture within its budget Condense the app-bundle sentence after combining the current-master CLI wiring with the recovery architecture changes. The text still identifies the TUI, line-oriented, headless, and ACP front doors and their output contracts. This restores the enforced architecture.md word ceiling without raising the budget or relocating an architecture-level seam fact. --- docs/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 415d3b2aee..f65a901315 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -156,7 +156,7 @@ Some seams bend the template deliberately: LLM combines interface and consumer b ### Bundles And Apps -`dsh-agent-spine-demo` bundles the default spine ([README](../packages/examples/agent-spine-demo/README.md)). `dsh-stdio-demo` adds a terminal front door that selects `dsh-tui` for interactive terminals and line-oriented `dsh-stdio` for pipes; `dsh-cli-demo` runs one persisted headless turn with format-pure stdout; and `dsh-acp-demo` adds stdout-pure ACP over JSON-RPC ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK supplies its default only without an explicit config channel and drives `dsh-jsonrpc` over line-delimited JSON-RPC ([Python SDK](../python/README.md)). Deployments remain thin leaves with swappable backends and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +`dsh-agent-spine-demo` bundles the default spine ([README](../packages/examples/agent-spine-demo/README.md)). `dsh-stdio-demo` selects `dsh-tui` for interactive terminals and line-oriented `dsh-stdio` for pipes; `dsh-cli-demo` runs one persisted headless turn with format-pure stdout; `dsh-acp-demo` adds stdout-pure ACP over JSON-RPC ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK supplies its default only without an explicit config channel and drives `dsh-jsonrpc` over line-delimited JSON-RPC ([Python SDK](../python/README.md)). Deployments remain thin leaves with swappable backends and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). ### Where New Behavior Goes From ee6b9d081a254279a21452e3f0aa76b3ea907a63 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:14:58 +0800 Subject: [PATCH 18/18] fix(llm): scope adapter failures to model calls Replace the process-wide adapter-failure WeakSet with a per-call scope bound to the exact AsyncIterable returned by LlmService.stream(). Give every call a unique wrapper so waterfall middleware can reuse an iterable without sharing provenance. Move agent-loop recovery classification to the model-stream boundary. Only the final adapter behind that exact call can become an agent/request-error; nested llm/stream calls remain ordinary outer middleware failures while preserving the original Error. Cover nested calls, reused middleware iterables, and end-to-end agent-loop recovery. Update the package and RFC contracts, bilingual pairing record, and generated API and catalog references. --- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 7 +- docs/event-producer-consumer.md | 2 +- ...n-pressure-and-overflow-recovery.i18n.yaml | 4 +- ...mpaction-pressure-and-overflow-recovery.md | 2 +- ...ction-pressure-and-overflow-recovery.zh.md | 2 +- packages/core/agent-loop/src/loop.ts | 24 ++-- .../agent-loop/tests/request-recovery.spec.ts | 36 +++++ packages/llm/llm/README.md | 2 +- packages/llm/llm/src/adapter-failure.ts | 49 +++++-- packages/llm/llm/src/index.ts | 21 ++- packages/llm/llm/tests/service.spec.ts | 124 ++++++++++++++++-- website/zh-CN/api/harness/events.md | 2 +- website/zh-CN/api/harness/llm.md | 12 +- 14 files changed, 233 insertions(+), 56 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 1acfb39a3d..20a92bffb8 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -471,7 +471,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:42`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:43`](../../packages/llm/llm/src/index.ts) ## `session/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 60b6fcbd4e..d07d0c2164 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -461,8 +461,9 @@ async listModels(provider: string): Promise * `options.provider`. Replay state is retained only when the same adapter * instance owns its historical provider and the target provider. Final * adapter selection, dispatch, and iteration failures retain their original - * Error identity and are tagged for narrow agent-loop request recovery; - * middleware failures remain untagged. + * Error identity and are tagged in a call-local scope for narrow agent-loop + * request recovery; middleware and nested-call failures remain untagged for + * the outer call. * @param options - the full request; `options.provider` selects the adapter. * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. */ @@ -471,7 +472,7 @@ stream(options: GenerateOptions): AsyncIterable Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:96`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:97`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b10fd76897..5b5a899b44 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../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:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:42`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:43`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | 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 index aa45a7ccb2..c54a0b344d 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: abc374d98fed8cac039f10d930fec332515c846e -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: ef13c64fc4c728e5b4fe9718ae8ef8b8ef9ecdd9 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: d88d7aaea8ccec30b10bfeb17f1312cfe87a0ce7 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 42e6114304de9c8022ef8f1341035858c0c7d9ec 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 index abc374d98f..d88d7aaea8 100644 --- 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 @@ -22,7 +22,7 @@ The loop fires awaited serial `agent/post-step(agent, turn, step, signal)` after ### Request recovery is limited to the final model boundary -`RequestError`, `RequestErrorDecision`, and the `agent/request-error` waterfall represent failures after the final adapter has been selected. 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. +`RequestError`, `RequestErrorDecision`, and the `agent/request-error` waterfall represent failures after the final adapter has been selected. Each returned stream handle owns a private failure set that preserves the original thrown error identity across dispatch, iterator construction, and iteration without leaking nested-call provenance into an outer call. Terminal in-band `error` or `aborted` finishes enter the same path. Prompt assembly, request middleware, request logging, result processing, tools, post-step listeners, and cleanup remain ordinary failures. 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`. 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 index ef13c64fc4..42e6114304 100644 --- 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 @@ -22,7 +22,7 @@ Status: implemented ### 请求恢复只覆盖最终模型边界 -`RequestError`、`RequestErrorDecision` 与 `agent/request-error` waterfall 表示最终适配器已经选定之后的失败。私有 `WeakSet` 标记在分发、异步迭代器构造与迭代过程中保留原始抛出错误的身份。终止性的带内 `error` 或 `aborted` finish 进入同一路径。提示词装配、请求中间件、请求日志、结果处理、工具、post-step 监听器与清理仍属于普通失败。 +`RequestError`、`RequestErrorDecision` 与 `agent/request-error` waterfall 表示最终适配器已经选定之后的失败。每个返回的流句柄都绑定一个私有失败集合;该集合在分发、异步迭代器构造与迭代过程中保留原始抛出错误的身份,同时防止把嵌套调用的错误来源误归到外层调用。终止性的带内 `error` 或 `aborted` finish 进入同一路径。提示词装配、请求中间件、请求日志、结果处理、工具、post-step 监听器与清理仍属于普通失败。 恢复运行前,失败 step 已经关闭。重试会打开下一个编号 step,并从持久日志重建请求;连续恢复尝试计数只在提供方请求成功后重置。两个 DeepSeek 适配器都把识别出的提供方上下文限制错误规范化为 `CONTEXT_WINDOW_EXCEEDED`。 diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 08c1ab9412..3d34026df0 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -27,7 +27,7 @@ function toError(error: unknown): RequestError { return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error }) } -/** Distinguishes a terminal failure finish from failures in later step processing. */ +/** Distinguishes final model-request failures from failures in later step processing. */ class TerminalModelRequestFailure extends Error { constructor(readonly requestError: RequestError) { super(requestError.message, { cause: requestError }) @@ -347,9 +347,7 @@ async function runTurn( stepOutcome = await runStep( ctx, events, agent, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal) } catch (error: unknown) { - if (isLlmAdapterFailure(error)) { - stepOutcome = { requestError: error } - } else if (error instanceof TerminalModelRequestFailure) { + if (error instanceof TerminalModelRequestFailure) { stepOutcome = { requestError: error.requestError } } else { stepOutcome = { error: toError(error) } @@ -624,12 +622,18 @@ async function runStep( // --- Model call (streaming-first; raw chunks are the replay record) --- const assembler = new BlockAssembler() const chunkSeqs: number[] = [] - for await (const chunk of ctx.llm.stream(request)) { - /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ - if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) - const chunkEvent = session.append('assistant/chunk', { turn, step, chunk }) - chunkSeqs.push(chunkEvent.seq) - assembler.push(chunk) + const stream = ctx.llm.stream(request) + try { + for await (const chunk of stream) { + /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ + if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) + const chunkEvent = session.append('assistant/chunk', { turn, step, chunk }) + chunkSeqs.push(chunkEvent.seq) + assembler.push(chunk) + } + } catch (error: unknown) { + if (isLlmAdapterFailure(stream, error)) throw new TerminalModelRequestFailure(error) + throw error } // Normalize failure finish chunks into the same path as thrown stream errors. diff --git a/packages/core/agent-loop/tests/request-recovery.spec.ts b/packages/core/agent-loop/tests/request-recovery.spec.ts index 442fba7565..bfbcad23ba 100644 --- a/packages/core/agent-loop/tests/request-recovery.spec.ts +++ b/packages/core/agent-loop/tests/request-recovery.spec.ts @@ -307,6 +307,42 @@ describe('agent post-step and request-error lifecycle', () => { expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } } }) }) + it('does not offer a nested model-call failure as the outer request failure', async () => { + const outer = new FailureScriptAdapter([textResponse('outer adapter must not run')]) + const nested = new FailureScriptAdapter([contextError('nested overflow')]) + const ctx = await harness(outer) + ctx.llm.registerAdapter(['nested'], nested) + ctx.on('llm/stream', (options, next) => { + if (options.provider !== 'mock') return next() + return (async function* () { + yield* ctx.llm.stream({ + provider: 'nested', + model: 'nested', + messages: [], + ...options.signal === undefined ? {} : { signal: options.signal }, + }) + yield* next() + })() + }) + const agent = ctx.agentLoop.create(SessionId('nested-stream-not-recoverable'), { provider: 'mock', model: 'mock' }) + let recoveries = 0 + ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => { + recoveries += 1 + return next() + }) + + send(agent) + await waitForIdle(ctx, agent) + + expect(nested.requests).toHaveLength(1) + expect(outer.requests).toHaveLength(0) + expect(recoveries).toBe(0) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'error', message: 'nested overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE } }, + }) + }) + it.each(['prompt-submit', 'prompt-assembly', 'pre-step', 'request'] as const)( 'does not offer %s middleware failures to request recovery', async (boundary) => { diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 83f5259f8d..44e7d80a05 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -13,7 +13,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`. -`LlmService` preserves and privately tags errors from final adapter selection, synchronous dispatch, iterator construction, and iteration. `isLlmAdapterFailure(value)` exposes that provenance without classifying `llm/stream` middleware or downstream consumer failures as provider failures, and without replacing the adapter's original coded `Error`. +`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification does not replace the adapter's original coded `Error`. Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. diff --git a/packages/llm/llm/src/adapter-failure.ts b/packages/llm/llm/src/adapter-failure.ts index 240f934c39..745cbbdc64 100644 --- a/packages/llm/llm/src/adapter-failure.ts +++ b/packages/llm/llm/src/adapter-failure.ts @@ -5,30 +5,63 @@ */ import { HarnessError } from './error.ts' +import type { StreamChunk } from './types.ts' -/** Errors proven to originate in final adapter dispatch or iteration. */ -const adapterFailures = new WeakSet() +/** Errors proven to originate in one model call's final adapter boundary. */ +export type AdapterFailureScope = WeakSet + +/** Call-local failure scopes keyed by the exact stream handle returned to a consumer. */ +const adapterFailureScopes = new WeakMap, AdapterFailureScope>() + +/** + * Bind one call's adapter-failure scope to a unique returned stream handle. + * @param stream - the waterfall-selected stream for this call. + * @param failures - errors tagged by this call's final adapter boundary. + * @returns a unique stream handle that delegates iteration to `stream`. + * @internal + */ +export function bindAdapterFailureScope( + stream: AsyncIterable, + failures: AdapterFailureScope, +): AsyncIterable { + const call = { + [Symbol.asyncIterator](): AsyncIterator { + return stream[Symbol.asyncIterator]() + }, + } + adapterFailureScopes.set(call, failures) + return call +} /** * Preserve an adapter's Error identity while tagging its provider origin. + * @param failures - the call-local final-adapter failure scope. * @param value - arbitrary value thrown by adapter dispatch or iteration. * @returns the original Error, or a coded Error wrapping a non-Error throw. * @internal */ -export function markLlmAdapterFailure(value: unknown): Error & { code?: string } { +export function markLlmAdapterFailure( + failures: AdapterFailureScope, + value: unknown, +): Error & { code?: string } { const error = value instanceof Error ? value as Error & { code?: string } : new HarnessError(String(value), 'UNKNOWN', { cause: value }) - adapterFailures.add(error) + failures.add(error) return error } /** * Whether a failure came from final adapter dispatch, iterator construction, - * or iteration rather than from an `llm/stream` waterfall listener. + * or iteration for the call represented by the exact returned stream handle. + * @param stream - the exact stream returned by the model call being classified. * @param value - arbitrary failure caught by a model-call consumer. - * @returns true only for errors tagged at the final adapter boundary. + * @returns true only for errors tagged at that call's final adapter boundary. */ -export function isLlmAdapterFailure(value: unknown): value is Error & { code?: string } { - return value instanceof Error && adapterFailures.has(value) +export function isLlmAdapterFailure( + stream: AsyncIterable, + value: unknown, +): value is Error & { code?: string } { + const failures = adapterFailureScopes.get(stream) + return value instanceof Error && failures !== undefined && failures.has(value) } diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 135b620f45..ac31738a61 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -10,7 +10,8 @@ import { Context, Service } from 'cordis' import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, Message, StreamChunk } from './types.ts' import { deepFreeze } from './call-config.ts' import { HarnessError } from './error.ts' -import { markLlmAdapterFailure } from './adapter-failure.ts' +import { bindAdapterFailureScope, markLlmAdapterFailure } from './adapter-failure.ts' +import type { AdapterFailureScope } from './adapter-failure.ts' export * from './attribution.ts' export * from './brand.ts' @@ -206,14 +207,17 @@ export class LlmService extends Service { * so it cannot suppress the primary provider error. A downstream close awaits * adapter cleanup, whose failures remain ordinary untagged work. */ - private async * adapterStream(options: GenerateOptions): AsyncGenerator { + private async * adapterStream( + options: GenerateOptions, + failures: AdapterFailureScope, + ): AsyncGenerator { let iterator: AsyncIterator try { const adapter = this.registration(options.provider).adapter const stream = adapter.stream(this.forAdapter(options, adapter)) iterator = stream[Symbol.asyncIterator]() } catch (error: unknown) { - throw markLlmAdapterFailure(error) + throw markLlmAdapterFailure(failures, error) } let completed = false @@ -230,7 +234,7 @@ export class LlmService extends Service { value = item.value } catch (error: unknown) { iterationFailed = true - throw markLlmAdapterFailure(error) + throw markLlmAdapterFailure(failures, error) } // End the adapter-owned try before yielding: consumer/middleware // failures resumed into this generator must remain untagged. @@ -251,13 +255,16 @@ export class LlmService extends Service { * `options.provider`. Replay state is retained only when the same adapter * instance owns its historical provider and the target provider. Final * adapter selection, dispatch, and iteration failures retain their original - * Error identity and are tagged for narrow agent-loop request recovery; - * middleware failures remain untagged. + * Error identity and are tagged in a call-local scope for narrow agent-loop + * request recovery; middleware and nested-call failures remain untagged for + * the outer call. * @param options - the full request; `options.provider` selects the adapter. * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. */ stream(options: GenerateOptions): AsyncIterable { - return this.ctx.waterfall(this, 'llm/stream', options, () => this.adapterStream(options)) + const failures: AdapterFailureScope = new WeakSet() + const stream = this.ctx.waterfall(this, 'llm/stream', options, () => this.adapterStream(options, failures)) + return bindAdapterFailureScope(stream, failures) } } diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 0431d4faba..6e14d749ba 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -30,6 +30,16 @@ class RecordingAdapter extends ScriptedAdapter { } } +class ThrowingAdapter extends LlmAdapter { + constructor(private readonly failure: Error) { + super() + } + + stream(_options: GenerateOptions): AsyncIterable { + throw this.failure + } +} + class CatalogAdapter extends ScriptedAdapter { constructor( private readonly provider: LlmProviderInfo, @@ -83,16 +93,17 @@ describe('LlmService', () => { it('throws NO_ADAPTER for unregistered providers', async () => { const ctx = new Context() await ctx.plugin(LlmService) + const stream = ctx.llm.stream({ provider: 'nope', model: 'any-model', messages: [] }) let caught: unknown try { - for await (const _ of ctx.llm.stream({ provider: 'nope', model: 'any-model', messages: [] })) { /* drain */ } + for await (const _ of stream) { /* drain */ } } catch (error: unknown) { caught = error } expect(caught).toBeInstanceOf(LlmError) expect((caught as LlmError).code).toBe('NO_ADAPTER') expect((caught as LlmError).message).toContain('no adapter registered') - expect(isLlmAdapterFailure(caught)).toBe(true) + expect(isLlmAdapterFailure(stream, caught)).toBe(true) }) it.each(['done', 'value'] as const)('tags a throwing IteratorResult.%s getter without replacing its Error', async (field) => { @@ -122,15 +133,16 @@ describe('LlmService', () => { await ctx.plugin(LlmService) ctx.llm.registerAdapter(['test-model'], adapter) + const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] }) let caught: unknown try { - for await (const _chunk of ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })) { /* drain */ } + for await (const _chunk of stream) { /* drain */ } } catch (error: unknown) { caught = error } expect(caught).toBe(original) - expect(isLlmAdapterFailure(caught)).toBe(true) + expect(isLlmAdapterFailure(stream, caught)).toBe(true) expect(cleanupLookups).toBe(0) }) @@ -146,15 +158,91 @@ describe('LlmService', () => { await ctx.plugin(LlmService) ctx.llm.registerAdapter(['test-model'], adapter) + const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] }) let caught: unknown try { - for await (const _chunk of ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })) { /* drain */ } + for await (const _chunk of stream) { /* drain */ } } catch (error: unknown) { caught = error } expect(caught).toBe(original) - expect(isLlmAdapterFailure(caught)).toBe(true) + expect(isLlmAdapterFailure(stream, caught)).toBe(true) + }) + + it('keeps a nested adapter failure scoped to the nested model call', async () => { + const original = new LlmError('nested provider failed', 'NESTED_FAILED') + const outer = new RecordingAdapter(SCRIPT) + const nested = new ThrowingAdapter(original) + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['outer'], outer) + ctx.llm.registerAdapter(['nested'], nested) + let nestedStream: AsyncIterable | undefined + ctx.on('llm/stream', (options, next) => { + if (options.provider !== 'outer') return next() + return (async function* () { + nestedStream = ctx.llm.stream({ provider: 'nested', model: 'nested', messages: [] }) + yield * nestedStream + })() + }) + + const outerStream = ctx.llm.stream({ provider: 'outer', model: 'outer', messages: [] }) + let caught: unknown + try { + for await (const _chunk of outerStream) { /* drain */ } + } catch (error: unknown) { + caught = error + } + + expect(caught).toBe(original) + expect(nestedStream).toBeDefined() + expect(isLlmAdapterFailure(nestedStream!, caught)).toBe(true) + expect(isLlmAdapterFailure(outerStream, caught)).toBe(false) + expect(outer.lastOptions).toBeUndefined() + }) + + it('keeps call scopes distinct when middleware reuses an iterable', async () => { + const firstFailure = new LlmError('first provider failed', 'FIRST_FAILED') + const secondFailure = new LlmError('second provider failed', 'SECOND_FAILED') + const delegates: AsyncIterable[] = [] + const shared: AsyncIterable = { + [Symbol.asyncIterator](): AsyncIterator { + const delegate = delegates.shift() + if (delegate === undefined) throw new Error('shared stream has no call delegate') + return delegate[Symbol.asyncIterator]() + }, + } + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['first'], new ThrowingAdapter(firstFailure)) + ctx.llm.registerAdapter(['second'], new ThrowingAdapter(secondFailure)) + ctx.on('llm/stream', (_options, next) => { + delegates.push(next()) + return shared + }) + + const firstStream = ctx.llm.stream({ provider: 'first', model: 'first', messages: [] }) + const secondStream = ctx.llm.stream({ provider: 'second', model: 'second', messages: [] }) + const catchFailure = async (stream: AsyncIterable): Promise => { + try { + for await (const _chunk of stream) { /* drain */ } + } catch (error: unknown) { + return error + } + return new Error('expected adapter to fail') + } + + expect(firstStream).not.toBe(secondStream) + const firstCaught = await catchFailure(firstStream) + expect(firstCaught).toBe(firstFailure) + expect(isLlmAdapterFailure(firstStream, firstCaught)).toBe(true) + expect(isLlmAdapterFailure(secondStream, firstCaught)).toBe(false) + const secondCaught = await catchFailure(secondStream) + expect(secondCaught).toBe(secondFailure) + expect(isLlmAdapterFailure(secondStream, secondCaught)).toBe(true) + expect(isLlmAdapterFailure(firstStream, secondCaught)).toBe(false) + expect(delegates).toHaveLength(0) }) it('propagates a rejected next promptly without awaiting a non-settling return', async () => { @@ -179,9 +267,10 @@ describe('LlmService', () => { await ctx.plugin(LlmService) ctx.llm.registerAdapter(['test-model'], adapter) + const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] }) const failure = (async (): Promise => { try { - for await (const _chunk of ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })) { /* drain */ } + for await (const _chunk of stream) { /* drain */ } } catch (error: unknown) { return error } @@ -195,7 +284,7 @@ describe('LlmService', () => { if (timer !== undefined) clearTimeout(timer) expect(caught).toBe(original) - expect(isLlmAdapterFailure(caught)).toBe(true) + expect(isLlmAdapterFailure(stream, caught)).toBe(true) expect(cleanupCalls).toBe(0) }) @@ -221,15 +310,16 @@ describe('LlmService', () => { await ctx.plugin(LlmService) ctx.llm.registerAdapter(['test-model'], adapter) + const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] }) let caught: unknown try { - for await (const _chunk of ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })) break + for await (const _chunk of stream) break } catch (error: unknown) { caught = error } expect(caught).toBe(cleanup) - expect(isLlmAdapterFailure(caught)).toBe(false) + expect(isLlmAdapterFailure(stream, caught)).toBe(false) expect(cleanupCalls).toBe(1) }) @@ -272,16 +362,17 @@ describe('LlmService', () => { await ctx.plugin(LlmService) ctx.llm.registerAdapter(['test-model'], adapter) + const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] }) let caught: unknown try { - for await (const _chunk of ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })) { /* drain */ } + for await (const _chunk of stream) { /* drain */ } } catch (error: unknown) { caught = error } expect(caught).toBeInstanceOf(HarnessError) expect(caught).toMatchObject({ code: 'UNKNOWN', cause: 'plain provider failure' }) - expect(isLlmAdapterFailure(caught)).toBe(true) + expect(isLlmAdapterFailure(stream, caught)).toBe(true) }) it('does not tag a failure thrown downstream while consuming adapter output', async () => { @@ -290,15 +381,20 @@ describe('LlmService', () => { await ctx.plugin(LlmService) ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter(SCRIPT)) + const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] }) let caught: unknown try { - for await (const _chunk of ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })) throw downstream + for await (const _chunk of stream) throw downstream } catch (error: unknown) { caught = error } expect(caught).toBe(downstream) - expect(isLlmAdapterFailure(caught)).toBe(false) + expect(isLlmAdapterFailure(stream, caught)).toBe(false) + expect(isLlmAdapterFailure(new ScriptedAdapter(SCRIPT).stream({ + provider: 'unbound', model: 'unbound', messages: [], + }), caught)).toBe(false) + expect(isLlmAdapterFailure(stream, 'consumer failed')).toBe(false) }) it('unregisters adapters when the owning fiber is disposed (HMR safety)', async () => { diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index bc2a9e7e4b..b7b19dd0e2 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -342,7 +342,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t - `options` — the full request. A LOOP-built request arrives deep-frozen (mutation throws): its content is a pure function of the session log (the reconstructability RFC), so listeners read it, never rewrite it. A hand-built one-shot (compaction summarize) is the caller's own object and stays mutable here. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L42) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L43) ## session/* diff --git a/website/zh-CN/api/harness/llm.md b/website/zh-CN/api/harness/llm.md index 0aebd9afe4..288839eca4 100644 --- a/website/zh-CN/api/harness/llm.md +++ b/website/zh-CN/api/harness/llm.md @@ -6,7 +6,7 @@ The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L96) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L97) ### ctx.llm.registerAdapter(providers, adapter) @@ -21,7 +21,7 @@ Register an adapter for the given provider routes. Throws `LlmError` with code ` **Returns** the disposer that unregisters all of them. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L111) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L112) ### ctx.llm.listProviders() @@ -33,7 +33,7 @@ Describe provider routes with a registered adapter. **Returns** detached provider metadata in registration order. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L142) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L143) ### ctx.llm.listModels(provider) @@ -47,7 +47,7 @@ Discover models advertised by one registered provider. Catalog membership is adv **Returns** detached model metadata in adapter-preferred order. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L152) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L153) ### ctx.llm.stream(options) @@ -55,10 +55,10 @@ Discover models advertised by one registered provider. Catalog membership is adv stream(options: GenerateOptions): AsyncIterable ``` -Stream one model call as raw chunks (token-level deltas). Throws `LlmError` with code `NO_ADAPTER` if no adapter is registered for `options.provider`. Replay state is retained only when the same adapter instance owns its historical provider and the target provider. Final adapter selection, dispatch, and iteration failures retain their original Error identity and are tagged for narrow agent-loop request recovery; middleware failures remain untagged. +Stream one model call as raw chunks (token-level deltas). Throws `LlmError` with code `NO_ADAPTER` if no adapter is registered for `options.provider`. Replay state is retained only when the same adapter instance owns its historical provider and the target provider. Final adapter selection, dispatch, and iteration failures retain their original Error identity and are tagged in a call-local scope for narrow agent-loop request recovery; middleware and nested-call failures remain untagged for the outer call. - `options` — the full request; `options.provider` selects the adapter. **Returns** the chunk stream, possibly wrapped by `llm/stream` listeners. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L259) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L264)