diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md new file mode 100644 index 0000000000..28de5eb97c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -0,0 +1,146 @@ +# Agent Note: Bounded recovery for transient LLM request failures + +Status: implemented + +## Problem + +`dsh-llm` can report provider failures either by throwing during adapter dispatch or iteration or by ending with `finish { kind: 'error' | 'aborted' }`. The final adapter boundary tags thrown failures so `dsh-agent-loop` can distinguish them from middleware and result-processing defects, and the loop normalizes both delivery forms into `agent/request-error` after closing the failed step. The default decision is `fail`; `dsh-compact-basic` is the only shipped recovery listener, and it retries a canonical context-window overflow only after compaction proves that the durable surface shrank. + +That boundary is already safe for another request attempt. Raw `assistant/chunk` events carry the failed `turn` and `step`, message derivation ignores them unless a successful `assistant/message` cites them, tool calls are dispatched only after a successful terminal finish and assembly, and a retry opens a new numbered step from the durable log. The harness therefore does not need a second response lifecycle or tentative-output protocol to keep two attempts separate. + +The prior boundary left three narrower gaps. + +- Provider failures retain only a message and usually a code. HTTP status, retry delay, and provider request id are discarded or recoverable only through provider-specific error objects, so generic recovery cannot make or explain a decision without parsing text. +- Retry ownership differs by adapter. The hand-written DeepSeek adapter makes one attempt, while pi-ai profiles can enable opaque library retries. Combining hidden transport retries with an `agent/request-error` listener would multiply attempts and omit intermediate failures from the session log. +- A recovered failure has no durable status fact. The failed step and chunks remain reconstructable, but an observer cannot tell whether the agent is deliberately backing off, for how long, or why. A long silent wait looks like a stalled loop. + +The goal is bounded recovery from transient failures of the same explicit provider/model request. Provider or model failover, response splicing, and semantic output repair are different problems and have no current consumer. + +## Decision + +### Preserve failure facts without embedding policy + +`@deepseek-ai/dsh-llm` exports one JSON-serializable `LlmFailure` payload: + +```ts ignore-check +type ProviderRequestId = Branded<'ProviderRequestId'> + +interface LlmFailure { + message: string + code: string + status?: number + providerRetryAfterMs?: number + requestId?: ProviderRequestId +} +``` + +`code` remains the provider-neutral machine-routing taxonomy established by `HarnessError`; the new fields are observations from the provider boundary. `ProviderRequestId` is owned and constructed by `dsh-llm`, then serializes as its provider-issued string. The payload deliberately has no `retryable`, `failover`, `partialOutput`, provider, model, phase, or route id fields. Retryability belongs to policy, provider/model are already in the durable request header, and partial output is derived from the failed step's `assistant/chunk` events. + +`LlmError` carries `failure: LlmFailure` and preserves `failure.code === error.code`. `FinishReasonMap.error` and `FinishReasonMap.aborted` carry the same payload instead of parallel failure shapes. An adapter-thrown `Error` keeps its exact object identity: the final-adapter scope associates the normalized facts with that object in call-local sidecar state and rethrows it unchanged; a non-`Error` throw is wrapped as today. `llmFailureOf(stream, error)` retrieves those facts alongside the existing provenance check, while an in-band finish without an error object becomes a new `LlmError`. This preserves listeners that key on error type or identity while giving all final-adapter failures, including unknown SDK exceptions, an `UNKNOWN` terminal payload. + +The agent loop keeps `RequestError` as that exact error object and passes `LlmFailure` as a separate argument to `agent/request-error`; it does not mutate possibly frozen third-party errors. It also uses the payload when converting an in-band finish and when recording an unrecovered `turn/end.reason`. + +Adapters extract structured facts before falling back to message inspection. They validate HTTP status, parse `Retry-After` seconds or dates into a positive finite millisecond delay, brand the provider request id when exposed, and distinguish their own timeout from the caller's abort. Provider-specific codes and messages may refine a mapping, but no recovery listener parses them. + +The initial shared transient-code set is intentionally small: the adapters' existing `RATE_LIMIT` and `SERVER` mappings plus explicit `TIMEOUT` and `TRANSPORT` codes for the two missing remote-failure families. Authentication, quota, invalid request, context overflow, protocol, abort, and unknown failures keep distinct stable codes and are not transient by default. Adding a code requires adapter fixtures and a documented policy decision; it does not require expanding a second failure-class enum. + +### Put retry policy on the existing failed-step seam + +`@deepseek-ai/dsh-llm-retry` is a function plugin that listens to `agent/request-error`. It introduces no service or new loop branch; the agent-loop package changes only the data carried through its existing failed-step recovery control flow. + +The `agent/request-error` seam carries the current `LlmFailure` and an immutable list of prior failures that led to another request attempt in this consecutive recovery sequence. `dsh-llm-retry` counts only prior failures whose codes are in its configured transient set, while `dsh-compact-basic` counts only prior context-overflow failures. A successful model request clears the history. Alternating transient and context-overflow failures therefore consume their owning policy budgets independently; the maximum request count is one plus the sum of the finite budgets of the loaded recovery policies. + +The plugin resolves and validates this deployment configuration at load: + +```ts ignore-check +interface Config { + maxTransientRetries?: number + initialDelayMs?: number + maxDelayMs?: number + jitterRatio?: number + retryableCodes?: string[] +} +``` + +The defaults are two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the four transient codes above. The count and delay bounds match the conservative edge of the inspected implementations: [OpenCode uses two request retries with 500 ms/10 s bounds](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47). Two retries mean at most three provider requests when no other recovery policy applies. `maxTransientRetries` is a non-negative integer, delays are positive finite numbers with `initialDelayMs <= maxDelayMs`, `jitterRatio` is in `[0, 1]`, and codes are non-empty and unique. These are Cordis config fields rather than hidden constants so deployments can choose different cost and latency budgets. + +For an eligible failure with budget remaining, the one-based transient retry count uses bounded exponential backoff. A valid `providerRetryAfterMs` replaces exponential backoff only when it does not exceed `maxDelayMs`; a longer provider delay causes delegation instead of an earlier retry that violates the provider instruction. Local backoff multiplies by an injected random factor in `[1 - jitterRatio, 1 + jitterRatio]` and clamps the final value to `maxDelayMs`; provider delay is not jittered. + +The plugin owns a lifetime `AbortController` and tracks every active backoff callback. Each wait fuses the waterfall's turn signal with that lifetime signal. Effect cleanup first unregisters the listener, then aborts and awaits the active callbacks; a captured callback whose lifetime signal aborts returns `fail` and can neither retry nor enter the rest of its captured waterfall after disposal. This makes HMR disposal quiescent even though Cordis has already captured the listener. + +Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, one-based transient retry number, configured maximum, scheduled delay, and `LlmFailure`. The plugin owns the `SessionEventMap` augmentation; `dsh-session` remains generic persistence and does not absorb the optional policy's vocabulary. The event says what was scheduled, not that the next request completed; cancellation during the delay is subsequently visible on `turn/end`. The event ships only with a production renderer and replay/snapshot coverage, because its purpose is operational state rather than trace collection. + +The listener calls `next()` for a non-transient code, an exhausted policy budget, or an over-cap provider delay. This preserves composition with context-overflow recovery and later policy plugins. It returns `{ action: 'retry' }` only after the delay completes under both signals; turn cancellation and plugin disposal return `fail`, after which the loop's cancellation/disposal checks remain authoritative. + +The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, and ACP example compositions use the same bounded policy. Library consumers retain explicit plugin composition: omitting the plugin leaves `agent/request-error` at its current fail default. + +### Make one layer own visible attempts + +Adapters perform one provider request per `stream()` call. The pi-ai adapter removes public `maxRetries` and `maxRetryDelayMs` profile fields and disables library retries; the hand-written adapter keeps its current single-attempt behavior. This prevents an SDK budget from multiplying the agent budget and ensures every transient retry is represented by a closed failed step plus `llm/retry`. + +`ctx.llm.stream()` remains the raw one-attempt waterfall. Direct callers such as compaction summarization receive the structured failure but do not gain automatic retry, because they have no agent step boundary or general durable place to separate attempts. A future direct-call consumer may justify a buffering helper that retries only before emitting a chunk; this decision adds no such helper. + +### Bound stalled streams where they can be stopped + +Each adapter exposes a validated `streamIdleTimeoutMs` configuration field with the five-minute prior-art default cited above. The interval is capped at Node's maximum timer delay so it cannot be clamped to one millisecond. It covers each outstanding iterator `next()` from demand to the next valid `StreamChunk`; time a consumer spends between `next()` calls is not provider idle time. + +`@deepseek-ai/dsh-timeout` exposes a rearmable idle-watchdog primitive. One stable local `AbortController` is fused with the caller signal and passed to the transport for the whole adapter call; each outstanding `next()` arms the watchdog, resolution disarms it, and the next demand rearms it. Timeout aborts that stable controller with a capability-owned `TimeoutReason`, and `finally` clears the timer. The adapter classifies its watchdog as `TIMEOUT` and an earlier upstream abort as `ABORTED`. The existing one-shot `deadline()` is not presented as a sliding timer. + +Boundary tests prove termination at both actual transports. The hand-written adapter aborts its fetch/reader, and the pi-ai adapter maps the stable signal through the SDK and proves the SDK closes the response. A timer that merely rejects a consumer promise while leaving the request running does not satisfy the contract. + +### Keep attempts separate in the existing log + +A failed attempt may leave `assistant/chunk` events in its closed step, but it never appends `assistant/message` and never dispatches a tool. A retry opens the next numbered step, reconstructs the request from the durable surface, and produces its own chunks. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed step or `turn/end` records terminal failure; message derivation continues to ignore the failed chunks. + +If recovery is exhausted, the final failure is stored once on `turn/end.reason` with the structured facts. If transient recovery continues, `llm/retry` is the durable home for that attempt's failure and delay. No standalone final-error event or response-id vocabulary is added. + +## Out of scope + +- Automatic provider or model failover. Requests already select one explicit provider and model, and the provider registry deliberately has one adapter owner per provider. +- Retrying or continuing after a successful terminal finish, or splicing chunks from two attempts into one assistant message. +- Repairing malformed tool arguments, refusals, content filters, or other semantic model output. +- Unbounded retries, unattended retry-until-cancelled behavior, circuit breakers, shared provider health, or cross-agent retry budgets. +- Changing `llm/stream` into a response lifecycle or adding convenience generation APIs without a production consumer. + +## Alternatives considered + +- **Retry inside `llm/stream` or the provider SDK** — rejected because a raw stream has no durable attempt boundary after emitting chunks, hidden SDK retries multiply budgets, and neither path can record each failed attempt consistently. +- **Add response start, interrupted, discarded, failed, and committed events to `dsh-llm`** — rejected because the agent log already separates raw chunks, successful messages, and numbered attempts. A second state machine would duplicate ownership without enabling the bounded same-route retry. +- **Add logical routes, capability matrices, and failover selection** — rejected because current requests already name provider and model explicitly, one adapter owns each provider, and no current consumer requires automatic fallback or can prove semantic compatibility. +- **Put `retryable` or `failover` on `LlmFailure`** — rejected because adapters report facts while deployment policy decides action. The same 429 may be retried in an interactive bundle and rejected in a cost-capped batch. +- **Retry forever while the caller remains active** — rejected because it gives one request unbounded cost and latency. Visible status makes bounded waiting understandable; it does not make an unlimited budget safe. +- **Log retry status only through the process logger** — rejected because process logs do not reconstruct session behavior and cannot drive replayed UI state. +- **Keep only flat codes** — rejected because retry delay and provider request id are structured provider facts, and HTTP status is necessary for diagnosis when different wire failures share one stable code. + +## Verification + +- `LlmFailure` is the single serializable payload for thrown, error-finish, and aborted-finish final-adapter failures; normalization preserves stable code, status, retry delay, branded provider request id, error cause, and caller-abort versus adapter-timeout classification where available. +- An adapter-thrown `Error` reaches `agent/request-error` as the exact same object while its sidecar `LlmFailure` reaches the adjacent argument; tests retain the existing identity assertion for extensible and frozen third-party errors. +- DeepSeek and pi-ai adapter tests cover representative 400, 401/403, 429, 5xx, connection, malformed/truncated stream, timeout, abort, retry-after seconds/date, request-id, and unknown-SDK-error paths without recovery policy parsing message text. +- Pi-ai pins the SDK option to zero retries and performs one observed wire attempt for a retryable provider response; separate tests make removing either boundary fail. +- `agent/request-error` carries current failure facts plus immutable prior-retried failure facts; a success clears that history, and alternating transient/context-overflow integration tests prove the two policies consume only their own finite budgets. +- `dsh-llm-retry` validates every config field at Loader startup, delegates all ineligible paths with `next()`, and makes at most `maxTransientRetries + 1` provider requests when no other policy applies. +- HMR-during-backoff tests prove disposal unregisters the listener, aborts and awaits its captured callbacks, emits no retry decision after disposal, and leaves no timer or promise alive. +- Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random seams, and abort during backoff. +- Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new step, exhaustion to structured `turn/end.reason`, and composition with `dsh-compact-basic` context-overflow recovery. +- The partial-chunk integration test proves failed chunks remain attributed to the failed step, no assistant message or tool side effect is committed for that step, and the successful retry has distinct provenance. +- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI retraction plus durable discarded-attempt markers in append-only ACP and stdio streams. Keyless snapshots cover scheduling, cancellation, success, and exhaustion. +- Idle-watchdog tests prove the stable signal is rearmed only while `next()` is outstanding, disarmed during consumer think time and in `finally`, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it. +- Direct `ctx.llm.stream()` callers remain single-attempt and receive the same structured failure facts. + +## Consequences + +- Every transient recovery attempt is visible as a closed step plus `llm/retry`, and the bounded policy prevents hidden SDK retries from multiplying cost. A retry can still duplicate provider billing even when no chunk arrived; the finite attempt budget limits but cannot remove that risk. +- Provider SDKs may hide status or retry headers. Those adapters retain the stable facts they expose and otherwise use a coarse code rather than letting recovery policy parse fragile text. +- Durable retry events expand the session protocol and UI state machine. Shipping the event and its consumer together prevents an unused telemetry vocabulary, but later schema changes still require persistence and replay work. +- Clearing a failed step's live chunks can visibly retract output. That is preferable to presenting discarded text or partial tool JSON as committed history, and snapshots pin the transition. +- Adapter-local idle enforcement stops stalled transports without counting consumer think time. Contract tests at each transport boundary guard against SDK drift. +- Multiple recovery plugins add their finite budgets. Their classifiers remain disjoint here; an overlapping classifier would be registration-order policy and must be documented and tested by the plugins that introduce it. + +## Related + +- [Structured error taxonomy](../../implemented/architecture/2026-06-11-structured-error-taxonomy.md) owns stable machine-routable codes and cause chaining. +- [Reconstructable requests](../../implemented/architecture/2026-07-05-reconstructable-requests.md) makes provider/model and complete request inputs durable before dispatch. +- [Timeout deadline library](../../implemented/architecture/2026-07-06-timeout-deadline-library.md) separates shared deadline classification from capability-owned termination. +- [After-call compaction pressure and context-overflow recovery](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md) owns the current closed-step request-recovery seam and bounded overflow retry. +- [Provider-routed LLM adapters](../../implemented/architecture/2026-07-14-provider-routed-llm-adapters.md) owns explicit provider/model routing and the one-adapter-per-provider invariant. diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md index 335581ecff..1c407d777d 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md @@ -18,7 +18,7 @@ Each new external-process or network tool re-derived the same four things — cl ### The library surface -Three functions plus one reason type: +Four functions, one watchdog interface, and one reason type: ```ts ignore-check /** The internal reason attached to a timeout abort, so consumers can classify it after the fact. */ @@ -51,19 +51,34 @@ export function deadline( code: string, ): { signal: AbortSignal; [Symbol.dispose](): void } +/** A stable signal plus one-at-a-time, timer-guarded async-iterator demand. */ +export interface IdleWatchdog { + readonly signal: AbortSignal + next(iterator: AsyncIterator): Promise> + [Symbol.dispose](): void +} + +/** Arm only while one iterator `next()` is outstanding, then rearm on later demand. */ +export function idleWatchdog( + upstream: AbortSignal | undefined, + timeoutMs: number, + code: string, +): IdleWatchdog + /** Recover the TimeoutReason from an aborted signal (or error); `code` scopes the match to this deadline's timer. */ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): TimeoutReason | undefined ``` -`deadline` fuses an upstream signal with a timer through `AbortSignal.any`, adds a typed `TimeoutReason`, and exposes disposable timer cleanup. Non-positive timeouts are an internal no-timeout sentinel for backend-owned background work; external hints pass through `clampTimeout` and must be positive and finite. Without a timer or upstream signal, the function returns a never-aborting signal with the same disposal shape. Providers translate timeout reasons into seam-specific results. `timeoutOf(signal, code)` scopes classification so an outer nested deadline is treated as upstream cancellation rather than the inner capability's timeout. +`deadline` fuses an upstream signal with a one-shot timer through `AbortSignal.any`, adds a typed `TimeoutReason`, and exposes disposable timer cleanup. Non-positive timeouts are an internal no-timeout sentinel for backend-owned background work; external hints pass through `clampTimeout` and must be positive and finite. Without a timer or upstream signal, the function returns a never-aborting signal with the same disposal shape. `idleWatchdog` instead requires a positive finite interval, keeps one stable fused signal for the entire stream, and arms its timer only while one iterator `next()` is outstanding; resolution disarms it, later demand rearms it, concurrent demand fails, and disposal clears the active arm. Providers translate timeout reasons into seam-specific results. `timeoutOf(signal, code)` scopes classification so an outer nested deadline is treated as upstream cancellation rather than the inner capability's timeout. ### The division of labor | Concern | Owner | |---|---| | Validate request hint and clamp default/max | `dsh-timeout` (`clampTimeout`) — pure arithmetic plus the shared positive-finite request contract | -| Arm timer, abort on deadline, carry reason, fuse with upstream cancel | `dsh-timeout` (`deadline`) | -| Clear the timer | `dsh-timeout` (`[Symbol.dispose]`) | +| Arm one-shot timer, abort on deadline, carry reason, fuse with upstream cancel | `dsh-timeout` (`deadline`) | +| Arm and rearm only around outstanding iterator demand | `dsh-timeout` (`idleWatchdog`) | +| Clear the timer | `dsh-timeout` (`[Symbol.dispose]` on either primitive) | | Classify the first abort reason after abort | `dsh-timeout` (`timeoutOf`) | | **Actually terminate the work** | the capability's implementation | | The default/max *values* | the capability's config | @@ -75,6 +90,7 @@ The signal only *notifies*; termination is always the listener's job, and the li - **web_fetch** — the tool stays validate-and-forward; the provider's hand-rolled controller + `setTimeout` + manual listener + `finally` + `signal.reason` recovery is replaced by provider-owned `deadline`/`timeoutOf`. A pre-aborted upstream signal still throws `WEB_ABORTED` up front; otherwise `fetch` runs against the fused `d.signal`, and `translateAbortOrNetwork` classifies a thrown error by the signal (`timeoutOf` → `WEB_FETCH_TIMEOUT`, else aborted → `WEB_ABORTED`, else network → `WEB_PROVIDER_ERROR`). The public error-code contract is unchanged, and `TimeoutReason` never crosses the web seam as the public error. - **bash** — `resolve()` clamps the request into an explicit spec. Foreground `run()` creates the deadline and passes its signal to process execution, whose existing abort listener performs the process-group kill. The executor classifies the first abort as timeout or cancellation. Background starts remain timeout-free and forward only upstream cancellation. +- **LLM adapters** — `dsh-llm-deepseek` and `dsh-llm-pi-ai` wrap actual transport iteration with `idleWatchdog`. The five-minute configured interval covers only outstanding provider demand, not time the downstream consumer spends between chunks. The stable signal reaches `fetch` or the SDK for the whole call, so timeout closes the underlying request and maps to `TIMEOUT`, while an earlier caller abort maps to `ABORTED`. ## Consequences @@ -82,6 +98,7 @@ The signal only *notifies*; termination is always the listener's job, and the li - `SpawnSpec.timeoutMs` and `SpawnOutcome.timedOut`/`aborted` were removed rather than kept as always-zero/always-false vestiges: with `runBash` owning no timer and the executor owning classification, they were read nowhere. This is the one deviation from the literal proposal shape (which passed `timeoutMs: 0` into `runBash`); an always-0 field read by nothing is dead weight under the per-file coverage gate. - web_fetch shed its bespoke controller/timer/listener/reason-recovery; the classifier now keys off the deadline signal (`timeoutOf` + `aborted`) rather than the thrown error's shape, which is robust across both the request-phase reject-with-reason and the read-phase bare-`AbortError`. - `AbortSignal.any` and `using`/`Symbol.dispose` enter the repo for the first time here (Node ≥ 24 baseline, already met). +- Model streams now share one rearmable timer contract without turning a sliding idle interval into a total-call deadline or charging consumer think time. The primitive still only notifies; adapter tests prove their transports observe its stable signal and terminate. Out of scope, named to mark the boundary: `web_search` can gain an optional model-facing `timeout_ms` once its tool-schema/snapshot coverage is planned; future ripgrep-backed fs discovery tools can consume the same provider-owned deadline shape once they exist; a `tools/execute` waterfall middleware could arm a default deadline for every tool call by driving `exec.signal` — that would be a plugin that *consumes* this library and still only notifies, the hard kill remaining each capability's job. diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md index 040a6c2fdc..82da8347a6 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md @@ -50,9 +50,9 @@ The plugin is `@deepseek-ai/dsh-timeout-policy`, a zero-config function/namespac searchTimeoutMs: 30000 ``` -Timeouts live on tool definitions rather than a free-text name map, eliminating misspelled unused policy. `defineTool` validates a positive finite budget. During dispatch the enforcer derives a deadline signal, restores the caller signal afterward, and converts its own expiry into `TOOL_TIMEOUT`; tools without a budget pass through unchanged. +Timeouts live on tool definitions rather than a free-text name map, eliminating misspelled unused policy. `defineTool` validates a positive finite budget. During dispatch the enforcer derives a deadline signal and assigns it to `exec.signal`; the registry fuses that deadline with the original caller signal before the body under the [tool-cancellation contract](2026-07-19-cooperative-tool-cancellation.md). The enforcer restores the caller signal afterward and converts its own expiry into `TOOL_TIMEOUT`; tools without a budget pass through unchanged. -Signal replacement is by **in-place mutation of `exec.signal`**, not by passing a new object to `next()`. Cordis's waterfall `next()` ignores any arguments handed to it and re-invokes downstream listeners with the shared payload array (`vendor/cordis/src/events.ts`), so the documented cordis idiom — mutate the shared object, then delegate — is the only mechanism that reaches dispatch. The plugin restores `exec.signal` to the caller's original in a `finally` so `tools/post-execute` never sees this plugin's (possibly already-aborted) deadline signal. +Signal replacement is by **in-place mutation of `exec.signal`**, not by passing a new object to `next()`. Cordis's waterfall `next()` ignores any arguments handed to it and re-invokes downstream listeners with the shared payload array (`vendor/cordis/src/events.ts`), so mutation is how the wrapper supplies its deadline to the registry. The registry re-fuses the captured caller signal immediately before the body, and the plugin restores `exec.signal` to the caller's original in a `finally` so `tools/post-execute` never sees the plugin's deadline signal. `timeout-policy` owns both uses of the `TOOL_TIMEOUT` code: the internal deadline code passed to `deadline()`/`timeoutOf()` (scoped so a nested outer deadline reads as an ordinary cancel) and the structured tool-result error code. Its replacement result is: @@ -104,6 +104,6 @@ A future model-facing grep/glob tool can be implemented on top of `ctx.bash` wit - `@deepseek-ai/dsh-tools` gains an around-dispatch surface after the interception seams deliberately split pre/post tool hooks. Its contract is narrow — wrap registry dispatch, not replace the pre-gate or post-result policy — and the base `next()` is dispatch-with-normalization so a wrapper never sees a raw tool throw. - Multiple `tools/execute` listeners compose by ordinary Cordis waterfall order: a listener that calls `next()` wraps downstream listeners plus dispatch; one that returns without `next()` short-circuits them. A deployment combining timeout with a future retry/sandbox/metrics wrapper chooses semantics by registration order ("timeout covers the whole retry" vs "timeout covers each attempt"). -- Opt-in by declaration is a deliberate misconfiguration risk: a tool can declare a `timeoutMs` without honoring `exec.signal`, and that tool will not stop on timeout. The plugin contract states that declaring a budget means cooperative; the web tools prove the pattern on tools that already forward the signal. +- Opt-in by declaration is a deliberate misconfiguration risk: a tool can declare a `timeoutMs` without honoring `exec.signal`, and that tool will not stop on timeout. The registry awaits that non-quiescent body rather than racing it, while the plugin contract states that declaring a budget means cooperative; the web tools prove the pattern on tools that already forward the signal. - During the transition `bash` and the migrated web tools use different timeout paths on purpose: `TOOL_TIMEOUT` is the model-facing tool-call budget, while `BASH_TIMEOUT` remains the bash backend timeout used by bash and hooks. - Deviation from the literal proposal, recorded per the implemented-Agent Note rule: the plugin package is `@deepseek-ai/dsh-timeout-policy` (not `tool-timeout`), signal replacement is in-place `exec.signal` mutation before `next()` (not `next({ ...exec, signal })`, which cordis ignores), and the per-tool budget is declared on the `ToolDefinition` (`timeoutMs`, set by the owning tool plugin from its config) rather than mapped by tool name in this plugin's config — so the enforcer is zero-config and a mistyped tool name is impossible. All three are described in `## Decision` above. diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml index 3ed38c1282..3a6b18cecf 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.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-14-provider-routed-llm-adapters.md: b7944bd31fdb5f63894e867d7c1224215d694f11 -2026-07-14-provider-routed-llm-adapters.zh.md: 7dcadf2521bab079e328b5f0d0a45185778b3b8d +2026-07-14-provider-routed-llm-adapters.md: 98205d18d07752e0cdba86d7cba80368d45fd816 +2026-07-14-provider-routed-llm-adapters.zh.md: c35225a86baf4c2d09732b5940abbc8046d365fb diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md index b7944bd31f..98205d18d0 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md @@ -28,7 +28,7 @@ A provider has exactly one adapter owner in a Cordis context. `dsh-llm-deepseek` ### Explicit pi-ai provider profiles -`dsh-llm-pi-ai` takes one non-empty list of provider profiles. Provider names must be unique within the list and present in pi-ai's `getProviders()` result. Each profile contains the provider name plus optional `apiKey`, `baseURL`, headers, reasoning level and budgets, cache retention, transport, timeouts, and retry settings. Credentials are never global: an explicit key applies only to its profile, while an absent key lets pi-ai resolve its standard environment variable, OAuth token, AWS credential chain, Google ADC, or other provider-native ambient authentication. An explicitly empty key is invalid configuration rather than an environment fallback. +`dsh-llm-pi-ai` takes one non-empty list of provider profiles. Provider names must be unique within the list and present in pi-ai's `getProviders()` result. Each profile contains the provider name plus optional `apiKey`, `baseURL`, headers, reasoning level and budgets, cache retention, transport, SDK timeouts, and a Harness stream-idle timeout. Provider retry fields are deliberately absent: the adapter forces pi-ai's `maxRetries` to zero so one `stream()` call makes one visible provider attempt, while `dsh-llm-retry` owns bounded agent-level recovery. Credentials are never global: an explicit key applies only to its profile, while an absent key lets pi-ai resolve its standard environment variable, OAuth token, AWS credential chain, Google ADC, or other provider-native ambient authentication. An explicitly empty key is invalid configuration rather than an environment fallback. The plugin registers all configured provider names against one `PiAiAdapter` in one all-or-nothing call. A request uses its provider to select the matching profile and finds its model in `getModels(provider)` to obtain the catalog descriptor. An unknown provider fails at plugin load; an unknown model fails before network I/O with `UNKNOWN_MODEL`. The catalog object is never mutated. When a profile supplies `baseURL`, the adapter clones the selected descriptor and overrides only `baseUrl`, so a private endpoint can retain pi-ai's API, capabilities, compatibility flags, context limits, and reasoning map. The private endpoint must implement the selected provider's protocol, and the model id must still exist in the installed pi-ai catalog. @@ -75,14 +75,14 @@ The on-disk session format remains the pre-release pinned version `0`, with no c - Provider names are deployment-wide route ownership keys: two providers may use the same model string, but mounting two adapters for one provider fails at load instead of creating fallback order. - Model selection no longer changes the Cordis plugin graph. Catalog-backed adapters can accept any installed catalog model selected after startup, while the native DeepSeek adapter forwards arbitrary DeepSeek model ids. - A custom `baseURL` preserves the selected catalog model's protocol and capabilities; it does not make catalog-external model ids valid. Private endpoints must implement that catalog entry's protocol. -- pi-ai credentials and transport knobs are scoped per provider profile. An omitted key delegates to pi-ai ambient authentication, while an explicitly empty key is invalid. +- pi-ai credentials, transport knobs, SDK timeouts, and the five-minute-default `streamIdleTimeoutMs` watchdog are scoped per provider profile. Hidden provider retries are disabled; bounded retries belong to the separately composed agent recovery policy. - `dsh-llm-pi-ai` rejects stop sequences because pi-ai's common stream API cannot express them; the native DeepSeek adapter retains its stop support. - Replay state is portable only within the adapter instance that owns both the historical and target providers. Cross-provider and cross-model restoration is an adapter responsibility, and another adapter receives provider-neutral history without the opaque state. - Current pre-release session JSONL requires provider/model request headers and assistant provenance. Older shapes remain version `0` but are rejected rather than migrated. ## Testing -- Unit coverage exercises registry conflicts, request reconstruction, session validation, profile resolution, option forwarding, native API selection including OpenAI Responses, conversion, replay validation, error mapping, cancellation, content rewrites, and same-instance versus different-instance replay dispatch. +- Unit coverage exercises registry conflicts, request reconstruction, session validation, profile resolution, single-attempt option forwarding, native API selection including OpenAI Responses, conversion, replay validation, error mapping, caller cancellation, idle-timeout transport termination, content rewrites, and same-instance versus different-instance replay dispatch. - Keyless loop/session tests and ACP snapshots exercise durable provider/model metadata, resume and fork propagation, workflow/subagent overrides, and unchanged user-visible transcripts; the key-gated DeepSeek e2e retains real provider streaming and tool follow-up coverage. - Public JSDoc, package READMEs, architecture and core-data-structure docs, generated catalogs, examples, session fixtures, and Python SDK pairs use provider/model targets consistently and are checked by the repository documentation and type-equivalence gates. diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md index 7dcadf2521..c35225a86b 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md @@ -28,7 +28,7 @@ Status: implemented ### 显式 pi-ai 提供方配置 -`dsh-llm-pi-ai` 接受一个非空的提供方配置列表。列表内的提供方名称必须唯一,并且存在于 pi-ai 的 `getProviders()` 结果中。每项配置包含提供方名称,以及可选的 `apiKey`、`baseURL`、headers、推理级别和预算、缓存保留设置、传输方式、超时和重试设置。凭据不设全局值:显式密钥仅对所属配置生效;未提供密钥时,pi-ai 使用标准环境变量、OAuth token、AWS 凭据链、Google ADC 或其他提供方原生环境认证。显式空密钥属于无效配置,不会回退到环境认证。 +`dsh-llm-pi-ai` 接受一个非空的提供方配置列表。列表内的提供方名称必须唯一,并且存在于 pi-ai 的 `getProviders()` 结果中。每项配置包含提供方名称,以及可选的 `apiKey`、`baseURL`、headers、推理级别和预算、缓存保留设置、传输方式、SDK 超时和 Harness 流空闲超时。配置中有意不提供重试字段:适配器强制将 pi-ai 的 `maxRetries` 设为零,使一次 `stream()` 调用只发起一次可见的提供方请求;有界的 agent 层恢复由 `dsh-llm-retry` 负责。凭据不设全局值:显式密钥仅对所属配置生效;未提供密钥时,pi-ai 使用标准环境变量、OAuth token、AWS 凭据链、Google ADC 或其他提供方原生环境认证。显式空密钥属于无效配置,不会回退到环境认证。 插件通过一次全有或全无调用,将所有已配置的提供方名称注册到同一个 `PiAiAdapter`。请求按 provider 选择对应配置,并在 `getModels(provider)` 中查找模型以取得目录描述符。未知提供方会在插件加载时失败;未知模型会在网络 I/O 前以 `UNKNOWN_MODEL` 失败。适配器不会修改目录对象。当配置提供 `baseURL` 时,适配器复制选中的描述符,仅覆盖 `baseUrl`,使私有端点保留 pi-ai 的 API、能力、兼容标志、上下文限制与推理映射。私有端点必须实现所选提供方的协议,模型 ID 也仍须存在于已安装的 pi-ai 目录中。 @@ -75,14 +75,14 @@ JSON-RPC 运行时显式接收 provider 与 model。仅当 `deepseek` 提供方 - 提供方名称是部署范围内的路由所有权键:两个提供方可以使用相同的模型字符串,但为同一个提供方挂载两个适配器会在加载时失败,不会形成回退顺序。 - 模型选择不再改变 Cordis 插件图。目录型适配器可以接受启动后选择的任意已安装目录模型,原生 DeepSeek 适配器则会转发任意 DeepSeek 模型 ID。 - 自定义 `baseURL` 会保留所选目录模型的协议与能力,但不会让目录外模型 ID 变为有效。私有端点必须实现该目录项对应的协议。 -- pi-ai 凭据与传输选项按提供方配置隔离。省略密钥时委托 pi-ai 使用环境认证;显式空密钥无效。 +- pi-ai 凭据、传输选项、SDK 超时,以及默认五分钟的 `streamIdleTimeoutMs` 空闲超时机制均按提供方配置隔离。系统禁用隐藏的提供方重试;有界重试由单独组合的 agent 恢复策略负责。 - pi-ai 的通用流 API 无法表达停止序列,因此 `dsh-llm-pi-ai` 会拒绝停止序列;原生 DeepSeek 适配器仍支持停止序列。 - 仅当历史提供方与目标提供方归同一个适配器实例所有时,回放状态才可移植。适配器负责跨提供方和跨模型恢复;其他适配器只接收不含不透明状态的提供方无关历史。 - 当前预发布会话 JSONL 要求请求头包含 provider/model,助手消息包含来源信息。旧格式仍使用版本 `0`,但会被拒绝,不执行迁移。 ## 测试 -- 单元测试覆盖注册表冲突、请求重建、会话验证、配置解析、选项转发、包括 OpenAI Responses 在内的原生 API 选择、转换、回放验证、错误映射、取消、内容重写,以及同一实例与不同实例间的回放分发。 +- 单元测试覆盖注册表冲突、请求重建、会话验证、配置解析、单次请求的选项转发、包括 OpenAI Responses 在内的原生 API 选择、转换、回放验证、错误映射、调用方取消、空闲超时导致的传输终止、内容重写,以及同一实例与不同实例间的回放分发。 - 无密钥的 agent loop/会话测试和 ACP 快照覆盖持久化 provider/model 元数据、恢复与 fork 传播、工作流/subagent 覆盖,以及不变的用户可见 transcript(文本记录);密钥门控的 DeepSeek e2e 测试保留真实提供方的流式输出与工具后续调用覆盖率。 - 公共 JSDoc、package README、架构与核心数据结构文档、生成目录、示例、会话 fixture(测试前置数据)和 Python SDK 配对文档统一使用 provider/model 目标,并由仓库文档与类型等价门禁校验。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml new file mode 100644 index 0000000000..b27a40cc7f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.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-19-cooperative-tool-cancellation.md: 559012f10d41963698cc932727125de1b9ccfef7 +2026-07-19-cooperative-tool-cancellation.zh.md: 6af8e57349bba026ab22f257014c084c5c3c3f54 diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md new file mode 100644 index 0000000000..559012f10d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md @@ -0,0 +1,73 @@ +# Agent Note: Cooperative tool cancellation at the registry boundary + +Status: implemented + +English | [中文](2026-07-19-cooperative-tool-cancellation.zh.md) + +## Problem + +Every typed tool invocation needs a caller-owned cancellation signal. An optional `ToolExecutionInput.signal` lets direct callers omit ownership, makes `exec.signal` optional in every tool body, and encourages registry fallbacks that cannot represent the caller's actual lifetime. + +The pipeline also has different mutability needs at different stages. Tool implementations, pre-policy, post-policy, and result observers only borrow cancellation state, while an around-dispatch wrapper must temporarily replace the signal to add a deadline or another lexical cancellation scope. One mutable public type either grants mutation too broadly or prevents that composition. + +Cancellation can arrive before policy, during approval, inside an around-dispatch wait, after a tool body starts, or while post-policy waits. One undifferentiated `ABORTED` result cannot tell durable consumers whether body side effects were possible. Racing a tool promise against cancellation is not a safe fallback because abandoned same-process work continues after the registry reports completion. + +## Decision + +`ToolExecutionInput.signal` is a required readonly `AbortSignal`. `ToolExecution.signal` and `ToolRunContext.signal` are therefore required and readonly as well. Every typed caller supplies the signal it owns; the registry provides no overload, default controller, never-abort sentinel, or convenience execution path. + +`ToolDefinition.execute(args, exec)` keeps its existing signature. `defineTool()` contextually types `exec.signal` as a required `AbortSignal`, so every registered TypeScript tool can observe or forward cancellation without a cast. First-party direct callers and nested Code Mode dispatches pass their current operation signal explicitly. + +The registry trusts this typed same-process contract. It does not perform runtime `AbortSignal` validation or add hostile-input tests for an omitted or malformed signal. Validation remains at parser/config, model/tool JSON, durable/file, worker, process, and wire boundaries; untyped JavaScript that violates the TypeScript interface has no compatibility contract. + +### Mutability follows the pipeline stage + +`ToolDispatchExecution` is identical to `ToolExecution` except that its required `signal` is mutable. Only the `tools/execute` waterfall receives this type. Pre-policy, post-policy, result observers, guards, and tool implementations receive readonly views of a private registry-owned mutable run object. + +An around-dispatch wrapper may replace `exec.signal` for its delegated lifetime but cannot typefully delete it or assign `undefined`. The registry captures the required caller signal outside that mutable object, fuses every wrapper replacement with the caller signal immediately before body invocation, removes dispatch-scoped listeners after settlement, and restores the required upstream signal unconditionally. + +### Cancellation codes record whether dispatch occurred + +`dsh-tools` exports `TOOL_ABORTED = 'ABORTED'` and `TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH'`. The registry records body invocation immediately before calling `ToolDefinition.execute()`. + +`ABORTED_BEFORE_DISPATCH` carries `{ name: 'AbortError' }` and model text `Error: tool call aborted before dispatch`. It applies whenever cancellation prevents body invocation, including pre-aborted entry, cancellation during pre-policy or approval, an aborted wrapper signal, a wrapper success overtaken by caller cancellation before delegation, and agent-loop siblings skipped after turn cancellation. + +`ABORTED` carries model text `Error: tool call aborted` and applies only after the body was invoked, including cancellation while an around wrapper or post-policy listener waits after body completion. A denial, wrapper failure, tool failure, or post-policy failure remains more specific than generic cancellation. A timeout owned by timeout-policy remains `TOOL_TIMEOUT`, and contexts deferred before a successful outcome is replaced remain attached. + +### Pre-aborted entry short-circuits after materialization + +The registry first creates the call token and losslessly snapshots and freezes the arguments. A materialization failure wins even when the caller signal is already aborted. After successful materialization, a pre-aborted signal skips `tools/pre-execute`, approval, `tools/execute`, `tools/post-execute`, and the tool body, then publishes exactly one frozen authoritative `tools/result` with `ABORTED_BEFORE_DISPATCH`. + +### Started work still reaches quiescence + +Once a tool body starts, the registry awaits it. Cancellation reaches the body through the fused signal but never races or abandons its promise. A cooperative implementation stops or forwards cancellation and settles after its owned work reaches quiescence; an uncooperative same-process implementation can keep the registry pending indefinitely. Process, worker, network, and provider layers retain responsibility for their own termination mechanisms. + +This decision requires cancellation at the tool invocation seam only. Making signals required on asynchronous capabilities reachable from tool bodies is a separate migration proposed in [Required cancellation through tool-reachable capability seams](../../proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md). + +## Verification + +[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`code-mode.spec.ts`](../../../../packages/core/tools/tests/code-mode.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/timeout/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership. + +No registry test can prove that arbitrary third-party same-process code observes the signal or stops in bounded time. Capability tests continue to prove cancellation and quiescence at the boundary that owns each side effect. + +## Alternatives considered + +**Keep the signal optional and synthesize a fallback.** Rejected because a registry-owned fallback has no caller lifetime to represent and preserves the exact omission the type should prevent. + +**Validate `AbortSignal` at runtime.** Rejected because this is a typed same-process seam, not a serialization boundary. Runtime checks would duplicate the static contract without making cooperative use enforceable. + +**Add `supportsCancellation` metadata, callback-arity checks, or signal-use linting.** Rejected because none proves that asynchronous work observes or correctly forwards cancellation. Availability is a type contract; behavior remains a tool and capability responsibility. + +**Expose one mutable execution type to every stage.** Rejected because observers and tool implementations only borrow the signal. Stage-specific types make replacement possible only where the pipeline owns that operation. + +**Forbid around wrappers from replacing the signal.** Rejected because deadlines and nested operational scopes need lexical derivation. Capturing and fusing the caller signal preserves composition without allowing detachment. + +**Race the tool promise against cancellation.** Rejected because it reports completion while side effects may remain live, violating the [quiescent-disposal rule](../../../../docs/defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it). + +## Consequences + +- TypeScript rejects every `ToolExecutionInput` that omits `signal`, every tool or observer mutation of a readonly signal, and every around-dispatch attempt to remove the signal. +- Durable consumers can distinguish calls whose body may have produced side effects (`ABORTED`) from calls that never entered the body (`ABORTED_BEFORE_DISPATCH`). +- The change is intentionally breaking under the repository's pre-release stance; no compatibility overload or runtime fallback remains. +- Cooperative tools stop promptly and reach quiescence; an implementation that ignores its signal remains observable as a pending call. +- Downstream capability interfaces remain unchanged until the linked proposed Agent Note is accepted and implemented. diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md new file mode 100644 index 0000000000..6af8e57349 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md @@ -0,0 +1,73 @@ +# Agent Note: 注册表边界上的协作式工具取消 + +Status: implemented + +[English](2026-07-19-cooperative-tool-cancellation.md) | 中文 + +## 问题 + +每次类型化工具调用都需要一个由调用方持有的取消信号。可选的 `ToolExecutionInput.signal` 允许直接调用方不承担所有权,使每个工具主体中的 `exec.signal` 都成为可选值,也会诱使注册表提供无法表达真实调用方生命周期的后备信号。 + +流水线各阶段对可变性的需求也不同。工具实现、前置策略、后置策略和结果观察者只借用取消状态,而环绕调度包装层必须临时替换信号,以加入截止时间或其他词法取消作用域。单一的可变公开类型要么把修改权限授予过多阶段,要么阻止这种组合。 + +取消可能发生在策略之前、审批期间、环绕调度等待期间、工具主体启动之后,或后置策略等待期间。单一的 `ABORTED` 结果无法让持久化结果的使用方判断工具主体是否可能产生过副作用。让工具 promise 与取消竞速也不是安全的后备方案,因为注册表报告完成后,被丢弃的同进程工作仍会继续运行。 + +## 决策 + +`ToolExecutionInput.signal` 是必填且只读的 `AbortSignal`,因此 `ToolExecution.signal` 和 `ToolRunContext.signal` 也都是必填且只读。每个类型化调用方显式提供自己持有的信号;注册表不提供重载、默认控制器、永不中止哨兵或便捷执行路径。 + +`ToolDefinition.execute(args, exec)` 保持现有签名。`defineTool()` 会把 `exec.signal` 上下文推断为必填的 `AbortSignal`,因此每个已注册的 TypeScript 工具都能在无需类型断言的情况下观察或转发取消。所有第一方直接调用方和 Code Mode 嵌套调度都会显式传入当前操作的信号。 + +注册表信任这份类型化同进程契约。它不在运行时校验 `AbortSignal`,也不为缺失或畸形信号添加敌意输入测试。校验仍位于解析器与配置、队列、模型与工具 JSON、持久化与文件、worker、进程和线协议边界;违反 TypeScript 接口的无类型 JavaScript 不享有兼容性契约。 + +### 可变性由流水线阶段决定 + +`ToolDispatchExecution` 与 `ToolExecution` 相同,唯一差异是其必填 `signal` 可修改。只有 `tools/execute` waterfall(瀑布式事件)接收这个类型。前置策略、后置策略、结果观察者、守卫和工具实现接收注册表私有可变运行对象的只读视图。 + +环绕调度包装层可以在委托期间替换 `exec.signal`,但无法通过类型系统删除它或赋值为 `undefined`。注册表在可变对象之外捕获必填的调用方信号,在工具主体调用前把每次包装层替换与调用方信号融合,在完成后移除仅属于本次调度的监听器,并无条件恢复必填的上游信号。 + +### 取消代码记录是否发生过调度 + +`dsh-tools` 导出 `TOOL_ABORTED = 'ABORTED'` 和 `TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH'`。注册表在调用 `ToolDefinition.execute()` 的前一刻记录工具主体已经开始。 + +`ABORTED_BEFORE_DISPATCH` 携带 `{ name: 'AbortError' }` 和模型可见文本 `Error: tool call aborted before dispatch`。凡取消阻止工具主体调用时都使用该结果,包括进入时已中止、前置策略或审批期间取消、包装层信号已中止、包装层在委托前返回的成功结果被调用方取消抢先,以及轮次取消后 agent loop 跳过的同批调用。 + +`ABORTED` 携带模型可见文本 `Error: tool call aborted`,并且只在工具主体已经调用后使用,包括工具主体完成后环绕包装层或后置策略监听器等待期间发生的取消。拒绝、包装层失败、工具失败或后置策略失败比通用取消更具体。timeout-policy 自身拥有的超时仍为 `TOOL_TIMEOUT`,成功结果被取消替换前延后附加的上下文仍会保留。 + +### 进入时已中止会在物化后短路 + +注册表先创建调用 token,并对参数进行无损快照和冻结。即使调用方信号已经中止,参数物化失败仍优先返回。物化成功后,进入时已中止的信号会跳过 `tools/pre-execute`、审批、`tools/execute`、`tools/post-execute` 和工具主体,然后发布且只发布一次冻结的权威 `tools/result`,其代码为 `ABORTED_BEFORE_DISPATCH`。 + +### 已启动工作仍必须完全停稳 + +工具主体一旦启动,注册表就会等待它完成。取消通过融合信号到达工具主体,但注册表不会与其 promise 竞速或丢弃该 promise。协作式实现会停止自身工作或继续转发取消,并在所持有的工作完全停稳后完成;不协作的同进程实现可能让注册表无限期保持等待。进程、worker、网络和提供方层仍负责各自的终止机制。 + +这项决策只要求工具调用接缝携带取消信号。让工具主体可达的异步能力也必须接收信号,属于另一项迁移,见提议中的[工具可达能力接缝中的必填取消](../../proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md)。 + +## 验证 + +[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖未调度同批调用的持久化配对结果。[`code-mode.spec.ts`](../../../../packages/core/tools/tests/code-mode.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/timeout/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。 + +任何注册表测试都无法证明任意第三方同进程代码会观察信号或在有界时间内停止。各能力的测试仍需在拥有相应副作用的边界证明取消与完全停稳。 + +## 考虑过的替代方案 + +**保留可选信号并生成后备值。** 不予采纳,因为注册表持有的后备信号不代表任何调用方生命周期,也会保留类型系统本应阻止的缺失情况。 + +**在运行时校验 `AbortSignal`。** 不予采纳,因为这是类型化同进程接缝,不是序列化边界。运行时检查只会重复静态契约,仍无法强制实现协作式使用信号。 + +**添加 `supportsCancellation` 元数据、回调参数数量检查或信号使用 lint。** 不予采纳,因为这些方法都无法证明异步工作会观察或正确转发取消。信号可用性属于类型契约;具体行为仍由工具和能力负责。 + +**向所有阶段公开同一个可变执行类型。** 不予采纳,因为观察者和工具实现只需要借用信号。按阶段划分类型可以把替换权限限制在流水线拥有该操作的位置。 + +**禁止环绕包装层替换信号。** 不予采纳,因为截止时间和嵌套运行时作用域需要词法派生信号。捕获并融合调用方信号既保留组合能力,也不允许切断调用方取消。 + +**让工具 promise 与取消竞速。** 不予采纳,因为这种方式会在副作用仍可能存活时报告完成,违反[资源释放必须完全停稳的规则](../../../../docs/defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it)。 + +## 后果 + +- TypeScript 会拒绝所有缺少 `signal` 的 `ToolExecutionInput`、工具或观察者对只读信号的修改,以及环绕调度删除信号的尝试。 +- 持久化结果的使用方可以区分工具主体可能产生过副作用的调用(`ABORTED`)和从未进入工具主体的调用(`ABORTED_BEFORE_DISPATCH`)。 +- 根据仓库的预发布原则,这项变更刻意保持破坏性;不保留兼容重载或运行时后备行为。 +- 协作式工具会及时停止并完全停稳;忽略信号的实现会表现为仍在等待的调用。 +- 下游能力接口保持不变,直到关联的提议 Agent Note 被接受并实现。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml index 630a1b05e6..4798f54960 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.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-20-error-cause-chain-diagnostics.md: 2d860d0e966158dd9ec12b45f88e3b031e1cb35a -2026-07-20-error-cause-chain-diagnostics.zh.md: 6eac19dd08d50e4662d53889577b5e3ddabaafdd +2026-07-20-error-cause-chain-diagnostics.md: 391e35997bb1bb050dd2ca620920961d77bb1c46 +2026-07-20-error-cause-chain-diagnostics.zh.md: 90d6559a9410e8a4e5475db9560a2a177ba7a1a7 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md index 2d860d0e96..391e35997b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md @@ -14,7 +14,7 @@ A TUI run against an unreachable DeepSeek endpoint failed with the single notice ## Decision - `dsh-llm` exports `errorChain(value)`: renders a thrown value with its full `cause` chain (`outer: inner: …`) and AggregateError members (`msg [m1; m2]`), with circular-cause and hostile-coercion containment. It is a diagnostic-surface renderer only; routing stays on `HarnessError.code`. -- The DeepSeek adapter wraps a pre-response transport failure in `LlmError('NETWORK')` naming the configured `baseURL` and chaining the original `TypeError` as `cause`. An aborted request keeps its `DOMException` so the loop still classifies it as cancellation, not a provider failure. +- The DeepSeek adapter wraps a pre-response transport failure in `LlmError('TRANSPORT')` naming the configured `baseURL` and chaining the original rejection as `cause`. An aborted request becomes `LlmError('ABORTED')`; because the turn signal is already aborted, the loop still classifies the turn as cancellation rather than recovery. - Every diagnostic seam renders through `errorChain` instead of `error.message`/`String(error)`: the agent-loop's durable `turn/end` error message (`errorData`), its logger warnings, the TUI's `agent/error` notice and startup-failure line, and `dsh-stdio`'s startup-failure log lines. The per-package `renderThrown` copies in `dsh-agent-loop`, `dsh-stdio`, and `dsh-tui` are deleted in favor of the one shared renderer. - `dsh-stdio` renders failure `turn/end` reasons: `[turn failed ] `, `[turn aborted] `, `[turn rejected] `, `[turn interrupted by a previous process exit]`, and the output-token-limit notice. Unknown merge-extended kinds fall through as ordinary turn ends. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md index 6eac19dd08..90d6559a94 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md @@ -14,7 +14,7 @@ TUI 连接不可达的 DeepSeek 端点时,失败只显示一条 `fetch failed` ## Decision - `dsh-llm` 导出 `errorChain(value)`:渲染抛出值及其完整 `cause` 链(`outer: inner: …`)与 AggregateError 成员(`msg [m1; m2]`),并容错循环 cause 和恶意强制转换。它只是诊断表面的渲染器;路由仍然基于 `HarnessError.code`。 -- DeepSeek 适配器把拿到响应之前的传输失败包装成 `LlmError('NETWORK')`,写明配置的 `baseURL` 并把原始 `TypeError` 链为 `cause`。被中止的请求保留其 `DOMException`,使循环仍将其归类为取消而非 provider 失败。 +- DeepSeek 适配器把拿到响应之前的传输失败包装成 `LlmError('TRANSPORT')`,写明配置的 `baseURL` 并把原始拒绝值链为 `cause`。被中止的请求变为 `LlmError('ABORTED')`;由于轮次信号已处于中止状态,循环仍将该轮次归类为取消而非恢复。 - 每个诊断接缝改用 `errorChain` 而非 `error.message`/`String(error)`:agent-loop 的持久化 `turn/end` 错误消息(`errorData`)、其日志警告、TUI 的 `agent/error` 通知与启动失败行、以及 `dsh-stdio` 的启动失败日志行。`dsh-agent-loop`、`dsh-stdio`、`dsh-tui` 里各自的 `renderThrown` 副本被删除,统一使用这一个共享渲染器。 - `dsh-stdio` 渲染失败的 `turn/end` reason:`[turn failed ] `、`[turn aborted] `、`[turn rejected] `、`[turn interrupted by a previous process exit]` 以及输出 token 上限通知。未知的 merge 扩展 kind 按普通 turn 结束处理。 diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md index cedbd693d9..08edfae386 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md @@ -24,7 +24,7 @@ Every call follows `tools/pre-execute` → guards → `tools/execute` → dispat - **`tools/pre-execute`** is the extensible waterfall gate. Its `PreToolDecision` allows, denies, or asks. Deny skips `tools/execute` and core dispatch. Ask resolves through the optional approval seam: only `allowed-once` continues through guards and dispatch; rejection, cancellation, an unavailable channel, a missing approval service, or an agent-less call becomes a normalized denial. Every outcome still reaches post-policy and final observers. - **`ctx.tools.guard()`** installs synchronous scope-aware policy after the whole pre-execute waterfall. A guard may deny or abstain, never force-allow, so listener ordering cannot resurrect an operation that a final invariant forbids. -- **`tools/execute`** is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with `next()`, may add, replace, or remove only `exec.signal` before doing so, and receives the already-normalized result of a thrown or unknown tool; returning its own valid result short-circuits dispatch. +- **`tools/execute`** is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with `next()`, may replace and restore the required `exec.signal` before doing so but cannot remove it, and receives the already-normalized result of a thrown or unknown tool; returning its own valid result short-circuits dispatch. - **`tools/post-execute`** is the inspect/transform waterfall. Its `PostToolDecision` accepts, blocks with feedback, optionally replaces content, or attaches `additionalContexts`. The returned decision is the supported transform channel; after the waterfall, the registry materializes the complete outcome once before final observation. - **`tools/result`** is the synchronous contained notification after every transform, lossless-JSON materialization, and the outer error boundary. It receives the same frozen execution identity and an immutable snapshot of the authoritative result; observer failures are contained per listener and cannot change or reject `ToolRegistry.execute()`'s returned outcome. diff --git a/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.i18n.yaml new file mode 100644 index 0000000000..4edd7b7223 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.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-20-windows-tui-support.md: 6b728486dd50faac067933ce06f883447aae821f +2026-07-20-windows-tui-support.zh.md: 2b53b05ff6231361d79b4304181dc0e6d8e24e68 diff --git a/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md b/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md new file mode 100644 index 0000000000..6b728486dd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md @@ -0,0 +1,33 @@ +# Agent Note: Support the TUI on Windows + +Status: implemented + +English | [中文](2026-07-20-windows-tui-support.zh.md) + +## Problem + +The full-screen TUI delegates raw input, ANSI rendering, resize events, and terminal restoration to pi-tui's `ProcessTerminal`. That dependency contains a native Windows console path, but the repository's real-process smoke used Python's POSIX-only `pty` and `termios` modules. Skipping that smoke on Windows would leave the supported product path without coverage for startup, input, interaction, failure reporting, or restoration. + +The TUI platform contract must follow the runtime shipped to users rather than the portability of one test driver. A platform exclusion is justified only when the product has an unsupported runtime dependency or a demonstrated semantic gap. + +## Decision + +[`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) supports interactive terminals on Windows as well as macOS and Linux. The product continues to use pi-tui's `ProcessTerminal`; on Windows it enables virtual-terminal input after raw mode and avoids the Unix-only `SIGWINCH` refresh. DeepSeek Harness adds no platform rejection or reduced Windows mode. + +The real Loader smoke selects a native pseudo-terminal boundary by host. macOS and Linux retain the Python POSIX PTY driver. Windows uses `node-pty` and ConPTY. Both drivers receive the same launch command, environment, terminal dimensions, marker-gated input actions, timeout, expected exit code, and output assertions, and all three smoke scenarios run on every supported platform. + +`node-pty` is a test-only dependency of the examples workspace. Its reviewed native install script is explicitly enabled in `pnpm-workspace.yaml`; production TUI packages do not acquire a new dependency or subprocess layer. + +## Alternatives considered + +- **Declare the TUI unsupported on Windows** — rejected because the pinned terminal runtime implements Windows console input explicitly and the harness has no POSIX-only production dependency. A documentation-only exclusion would discard an existing product path to accommodate a test harness gap. +- **Run the POSIX driver through MSYS, Cygwin, or WSL** — rejected because that would test a compatibility environment rather than the native Windows console path users run. +- **Use `node-pty` on every host** — rejected because the established POSIX driver already provides the macOS and Linux boundary; replacing it would widen the runtime change without improving those hosts. Platform-specific drivers reserve the `node-pty` runtime path for Windows while sharing one scenario contract. +- **Rely on renderer unit tests and semantic terminal snapshots** — rejected because fake terminals do not prove Loader boot, real raw input, process exit, or terminal restoration at the operating-system boundary. + +## Consequences + +- The Windows artifact lane executes the startup, scripted interaction, resume-failure, and restoration scenarios, and the suite has no supported-platform skip. +- The Windows process proof depends on ConPTY and a pinned `node-pty` release; changing that dependency or its allowed install script requires native-boundary review. +- The two PTY drivers can differ internally, but shared inputs and assertions keep their observable TUI contract aligned. +- Windows support remains bounded by the Node and pi-tui versions shipped by the repository; unsupported historical Windows console environments do not receive a compatibility layer. diff --git a/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.zh.md b/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.zh.md new file mode 100644 index 0000000000..2b53b05ff6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 在 Windows 上支持 TUI + +Status: implemented + +[English](2026-07-20-windows-tui-support.md) | 中文 + +## 问题 + +全屏 TUI 将原始输入、ANSI 渲染、终端尺寸变更事件和终端恢复委托给 pi-tui 的 `ProcessTerminal`。该依赖已实现原生 Windows 控制台路径,但仓库的真实进程冒烟测试此前使用 Python 中仅适用于 POSIX 的 `pty` 和 `termios` 模块。若在 Windows 上跳过该测试,这条受支持的产品路径便会缺少针对启动、输入、交互、失败报告和终端恢复的测试覆盖率。 + +TUI 平台契约必须以交付给用户的运行时为准,而不是取决于某个测试驱动程序的可移植性。只有产品存在不受支持的运行时依赖,或已证实存在语义缺口时,排除某个平台才有依据。 + +## 决策 + +[`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 在 Windows、macOS 和 Linux 上均支持交互式终端。产品继续使用 pi-tui 的 `ProcessTerminal`;在 Windows 上,它会在进入原始模式后启用虚拟终端输入,并避开仅适用于 Unix 的 `SIGWINCH` 刷新。DeepSeek Harness 不增加平台拒绝逻辑,也不采用功能受限的 Windows 模式。 + +真实 Loader 冒烟测试根据宿主选择原生伪终端边界。macOS 和 Linux 继续使用 Python POSIX PTY 驱动,Windows 则使用 `node-pty` 和 ConPTY。两种驱动接收相同的启动命令、环境、终端尺寸、以标记为触发条件的输入动作、超时、预期退出码和输出断言;3 个冒烟场景都会在每个受支持平台上运行。 + +`node-pty` 是 examples 工作区仅供测试使用的依赖。该依赖经评审的原生安装脚本在 `pnpm-workspace.yaml` 中显式启用;生产 TUI 包(package)不会新增依赖或子进程层。 + +## 曾考虑的替代方案 + +- **声明 TUI 不支持 Windows**:不予采纳,因为固定版本的终端运行时已显式实现 Windows 控制台输入,且 harness 没有仅适用于 POSIX 的生产依赖。仅通过文档排除 Windows,等于为迁就测试 harness 的缺口而舍弃现有产品路径。 +- **通过 MSYS、Cygwin 或 WSL 运行 POSIX 驱动**:不予采纳,因为这会测试兼容环境,而不是用户实际运行的原生 Windows 控制台路径。 +- **在所有宿主上使用 `node-pty`**:不予采纳,因为现有 POSIX 驱动已经为 macOS 和 Linux 提供所需边界;替换该驱动会扩大运行时变更范围,却不会给这两个宿主带来改进。按平台选择驱动,仅在 Windows 上启用 `node-pty` 运行时路径,同时共享同一份场景契约。 +- **依赖渲染器单元测试和语义终端快照**:不予采纳,因为模拟终端无法证明 Loader 启动、真实原始输入、进程退出或操作系统边界上的终端恢复。 + +## 后果 + +- Windows 产物 lane 执行启动、脚本化交互、配置恢复失败和终端恢复场景,这套测试不会在任何受支持平台上跳过。 +- Windows 进程级验证依赖 ConPTY 和固定版本的 `node-pty`;变更该依赖或允许执行的安装脚本时,必须进行原生边界评审。 +- 两种 PTY 驱动的内部实现可以不同,但共享的输入和断言会使其可观测 TUI 契约保持一致。 +- Windows 支持范围以仓库交付的 Node 和 pi-tui 版本为界;不受支持的旧版 Windows 控制台环境不会获得兼容层。 diff --git a/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.i18n.yaml new file mode 100644 index 0000000000..bbb2b78989 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.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-19-required-cancellation-through-tool-capability-seams.md: c2cfb09f27222136965058695e9b6b706ac688a9 +2026-07-19-required-cancellation-through-tool-capability-seams.zh.md: f7a1d303212dfab6da27feba2d6e7195ea07bd50 diff --git a/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md b/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md new file mode 100644 index 0000000000..c2cfb09f27 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md @@ -0,0 +1,65 @@ +# Agent Note: Required cancellation through tool-reachable capability seams + +Status: proposed + +English | [中文](2026-07-19-required-cancellation-through-tool-capability-seams.zh.md) + +## Problem + +The implemented [tool registry cancellation contract](../../implemented/architecture/2026-07-19-cooperative-tool-cancellation.md) makes `exec.signal` required in every tool body, but many asynchronous capability interfaces reached from those bodies still accept an optional signal. A tool can therefore satisfy its own type while accidentally dropping cancellation at the next same-process call. + +That gap is transitive. A filesystem tool may call path resolution and I/O, a web tool may call a provider, a bash tool may call an executor, and a composite tool may start or wait for tasks, subagents, or workflows. If any awaited operation controlling tool-owned work accepts omission, TypeScript cannot prove that cancellation remains available at the boundary that owns the side effect. + +Requiring signals on every asynchronous function in the repository would overreach. Some operations are not reachable from tools, some synchronous queries cannot wait or own ongoing work, and explicitly detached work has a new owner after a deliberate handoff. + +## Proposal + +Require an `AbortSignal` on every asynchronous same-process capability operation that is reachable from a tool body while the tool still owns or awaits the operation. The requirement may be a positional parameter or a required readonly request field according to the owning seam's existing shape, but omission must fail TypeScript compilation. + +Each direct caller supplies a signal it owns or propagates from its own required operation context. Implementations may derive a child deadline or cancellation scope, but the derived signal remains linked to the upstream signal for the delegated lifetime. Capability implementations do not synthesize never-abort signals, use ambient async-local cancellation, or validate `AbortSignal` at runtime solely to repeat the typed same-process contract. + +The migration begins with an inventory from every first-party `ToolDefinition.execute()` through the capability calls it awaits. It then changes each coherent interface/implementation/consumer seam together, including tests and generated API documentation. Separate PRs may migrate filesystem, bash/task, web/provider, workflow/subagent, code-runtime, and similar families so each change remains reviewable, but no migrated interface keeps an optional compatibility overload under the repository's pre-release policy. + +### Scope boundary + +The proposal includes asynchronous capability operations whose completion or cancellation remains part of the invoking tool's lifetime, including start operations before ownership transfer, foreground execution, reads and writes, provider requests, waits, and cleanup or disposal that the tool awaits. + +The proposal excludes synchronous registry lookup, availability checks, schema rendering, argument classification, and other operations that cannot retain asynchronous work. It also excludes work after an explicit detached-ownership handoff: once a task, workflow, worker, or child agent has been successfully published to a new lifecycle owner, that owner's controller governs the detached lifetime. The initiating start operation still requires the caller signal until the handoff commits, and any later tool call that waits for detached work requires its own invocation signal. + +Optional cancellation may remain on parser, config, model/tool JSON, durable/file format, worker, process, or wire inputs when the external protocol makes it optional. The owning boundary must resolve that input into a required same-process signal before calling a migrated capability seam. + +## Alternatives considered + +**Leave downstream signals optional because tool bodies now receive one.** Rejected because availability at the outer callback does not make propagation type-safe; omission remains legal at every optional capability call. + +**Enforce propagation with lint rules or callback inspection.** Rejected because syntax checks cannot reliably identify ownership, derived signals, abstraction layers, or correct quiescent settlement. Required interface parameters express the contract where TypeScript can check every caller. + +**Pass `ToolRunContext` through every capability.** Rejected because capabilities need cancellation, not tool identity, agent state, or context deferral. Passing the larger context couples reusable services to the tool registry and obscures the narrow seam. + +**Use an ambient async-local signal.** Rejected because hidden propagation makes ownership and detached handoff difficult to audit, complicates tests, and lets calls silently bind to the wrong lifetime. + +**Add default or never-abort signals at capability implementations.** Rejected because defaults erase the missing owner instead of exposing it at compile time. + +**Migrate every capability in the implemented tool-registry change.** Rejected because the transitive interface changes span independent capability families. Keeping this proposal separate preserves the implemented registry decision and lets each deep seam migrate with focused tests. + +## Acceptance criteria + +- An inventory maps every first-party tool body to the asynchronous capability operations it can reach before ownership handoff. +- Every in-scope capability interface requires `AbortSignal`, and compile-time contract tests prove omission fails. +- Interface, implementation, direct consumer, test helper, example, and generated API references migrate together without compatibility overloads or never-abort production sentinels. +- Derived deadlines and wrapper scopes remain linked to the caller signal, and integration tests prove cancellation reaches the side-effect owner and awaited work reaches quiescence. +- Synchronous queries and explicitly detached post-handoff work remain outside the requirement, with ownership transitions documented and tested where ambiguity exists. +- Runtime validation is added only at an actual untyped boundary, not to repeat a required TypeScript field or parameter. +- The top-level typecheck, coverage, snapshot, documentation, module-graph, build, hygiene, demo, and built-artifact gates pass after each coherent migration. + +## Risks + +**Large transitive blast radius.** A required parameter can expose many direct callers at once. Migrate by coherent capability family and use typecheck failures as the complete caller inventory. + +**Incorrect detached-work classification.** Excluding a start operation too early can detach work before publication is committed; requiring the parent signal forever can let a completed tool cancel legitimately detached work. Each handoff needs an explicit commit point, new owner, rollback behavior, and quiescent failure path. + +**Signal ownership confusion.** A capability that stores a borrowed signal beyond the delegated lifetime can bind work to a stale caller. Interfaces and tests must distinguish borrowed operation signals from controllers owned by long-lived services. + +**Mechanical compliance without cooperation.** A required parameter proves availability, not observation or forwarding. Integration tests at process, worker, socket, provider, and task boundaries remain necessary to prove behavior. + +**Over-scoping synchronous or unrelated APIs.** Requiring cancellation where no asynchronous work exists adds noise and weakens the signal of the contract. The inventory records why each operation is tool-reachable and lifetime-bearing before changing it. diff --git a/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.zh.md b/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.zh.md new file mode 100644 index 0000000000..f7a1d30321 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.zh.md @@ -0,0 +1,65 @@ +# Agent Note: 工具可达能力接缝中的必填取消 + +Status: proposed + +[English](2026-07-19-required-cancellation-through-tool-capability-seams.md) | 中文 + +## 问题 + +已经实现的[工具注册表取消契约](../../implemented/architecture/2026-07-19-cooperative-tool-cancellation.md)让每个工具主体中的 `exec.signal` 成为必填值,但许多由工具主体调用的异步能力接口仍接受可选信号。因此,工具可以满足自身类型,却在下一次同进程调用时意外丢失取消。 + +这项缺口会沿调用链传递。文件系统工具可能调用路径解析和 I/O,Web 工具可能调用提供方,Bash 工具可能调用执行器,组合工具可能启动或等待任务、subagent 或工作流。只要某个控制工具所持有工作的等待操作允许省略信号,TypeScript 就无法证明取消仍能到达拥有副作用的边界。 + +要求仓库中所有异步函数都携带信号会过度扩张。有些操作无法从工具到达,有些同步查询不会等待或持有持续工作,而明确分离的工作在刻意交接后已经拥有新的所有者。 + +## 提议 + +所有能从工具主体到达、且在工具仍持有或等待该操作期间执行的异步同进程能力操作,都必须接收 `AbortSignal`。根据所属接缝的既有形态,这项要求可以表现为位置参数,也可以表现为必填的只读请求字段,但省略信号必须导致 TypeScript 编译失败。 + +每个直接调用方提供自己持有的信号,或从自身必填的操作上下文继续传递信号。实现可以派生子截止时间或取消作用域,但派生信号在委托期间仍须与上游信号关联。能力实现不得生成永不中止信号、使用环境式异步本地取消,也不得仅为重复类型化同进程契约而在运行时校验 `AbortSignal`。 + +迁移首先从每个第一方 `ToolDefinition.execute()` 出发,清点其等待的能力调用;随后把每个内聚的接口、实现和使用方接缝连同测试与生成的 API 文档一起修改。文件系统、Bash 与任务、Web 与提供方、工作流与 subagent、代码运行时等能力族可以通过独立 PR 迁移,以保持每项变更可审查;但根据仓库的预发布原则,已经迁移的接口不得保留可选兼容重载。 + +### 范围边界 + +本提议包含完成或取消仍属于当前工具生命周期的异步能力操作,包括所有权交接前的启动操作、前台执行、读写、提供方请求、等待,以及工具会等待的清理或释放操作。 + +本提议不包含同步注册表查询、可用性检查、schema 渲染、参数分类,以及其他无法保留异步工作的操作。明确交接所有权后的分离工作也不在范围内:任务、工作流、worker 或 subagent 成功发布给新的生命周期所有者后,其分离生命周期由新所有者的控制器管理。发起启动的操作在交接提交前仍须接收调用方信号;之后若另一次工具调用等待该分离工作,则必须使用该次调用自己的信号。 + +若外部协议本身允许省略取消,解析器、配置、模型与工具 JSON、持久化与文件格式、worker、进程或线协议输入仍可保留可选取消。所属边界必须先把该输入解析为必填的同进程信号,再调用已经迁移的能力接缝。 + +## 考虑过的替代方案 + +**因为工具主体已经收到信号,所以继续让下游信号保持可选。** 不予采纳,因为外层回调中存在信号并不能让传递过程具备类型安全;每个可选能力调用仍可合法省略它。 + +**通过 lint 规则或回调检查强制传递。** 不予采纳,因为语法检查无法可靠识别所有权、派生信号、抽象层或正确的完全停稳行为。必填接口参数可以在 TypeScript 能检查每个调用方的位置表达契约。 + +**把 `ToolRunContext` 传入所有能力。** 不予采纳,因为能力需要的是取消,而不是工具身份、agent 状态或上下文延后功能。传递更大的上下文会让可复用服务耦合到工具注册表,也会掩盖狭窄接缝。 + +**使用环境式异步本地信号。** 不予采纳,因为隐藏传递会让所有权和分离交接难以审计,使测试复杂化,并可能让调用静默绑定到错误的生命周期。 + +**在能力实现中加入默认或永不中止信号。** 不予采纳,因为默认值会抹去缺失的所有者,而不是在编译期暴露问题。 + +**在已经实现的工具注册表变更中迁移所有能力。** 不予采纳,因为传递性的接口修改横跨独立能力族。单独保留这项提议既能维持已实现的注册表决策,也能让每个深层接缝通过聚焦测试完成迁移。 + +## 验收标准 + +- 清单把每个第一方工具主体映射到所有权交接前可以到达的异步能力操作。 +- 每个范围内的能力接口都要求 `AbortSignal`,并由编译期契约测试证明省略信号会失败。 +- 接口、实现、直接使用方、测试辅助函数、示例和生成的 API 引用必须一起迁移,不保留兼容重载或生产环境永不中止哨兵。 +- 派生截止时间和包装层作用域仍与调用方信号关联,集成测试证明取消到达副作用所有者,且等待的工作完全停稳。 +- 同步查询和明确交接后的分离工作不受这项要求约束;存在歧义时,需要记录并测试所有权转换。 +- 只有真实的无类型边界才添加运行时校验,不得重复校验 TypeScript 已要求的字段或参数。 +- 每次内聚迁移后,顶层类型检查、覆盖率、快照、文档、模块图、构建、hygiene、演示和构建产物门禁全部通过。 + +## 风险 + +**传递性影响范围较大。** 一个必填参数可能同时暴露大量直接调用方。应按内聚能力族迁移,并把类型检查失败作为完整的调用方清单。 + +**错误划分分离工作。** 过早排除启动操作可能在发布提交前就让工作脱离控制;永久要求父信号又可能让已完成工具取消合法分离的工作。每次交接都需要明确提交点、新所有者、回滚行为和完全停稳的失败路径。 + +**信号所有权混淆。** 能力若在委托生命周期之外保存借用信号,可能让工作绑定到过期调用方。接口和测试必须区分借用的操作信号与长生命周期服务所持有的控制器。 + +**只有机械合规而没有协作行为。** 必填参数只能证明信号可用,不能证明实现会观察或转发它。进程、worker、套接字、提供方和任务边界仍需集成测试证明实际行为。 + +**把同步或无关 API 纳入范围。** 在不存在异步工作的地方要求取消只会增加噪声,并削弱契约的辨识度。修改前,清单需要记录每项操作为何可由工具到达并承载其生命周期。 diff --git a/AGENTS.md b/AGENTS.md index 860f60f1e0..3a11c2398d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,6 +108,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **No hardcoded tunables in plugins**: deployment-varying choices are validated `Config` fields changeable from cordis.yml; a `DEFAULT_*` constant or test seam is not configurability. Protocol constants, external specs, and security invariants stay fixed. - **Misconfiguration fails loud** at load when self-contained, otherwise at the earliest resolvable point; never silently skip a missing referent. - **Opaque cross-boundary ids are branded** (`Branded` from `dsh-brand`), never bare `string`. +- **Trust TypeScript at typed same-process seams.** Do not add runtime validation, fallback behavior, or hostile-input tests solely for values the static interface requires; validate at parser/config, queued, model/tool JSON, durable/file, worker, process, and wire boundaries. - **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement. - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. diff --git a/docs/architecture.md b/docs/architecture.md index 74bb7b9097..f378774f4c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,7 +86,7 @@ forever: agent/request (config only) -> log request/header -> llm/stream (frozen) on final adapter-path or terminal in-band failure: 'step/end' - agent/request-error(original error, consecutive retry attempt, signal) + agent/request-error(original error, failure facts, immutable prior failures, signal) retry in the next numbered step or preserve the original error otherwise: 'assistant/chunk' @@ -111,13 +111,13 @@ Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variab Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles, then follows recorded results. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering before signal closure. Leftovers become queued input. Terminal `agent/turn-stop` runs after continuation and steering folding, stays authoritative through turn close and flush, and discards later steering but preserves queued prompts. -Optional pruning precedes summaries; retry requires durable surface progress; cancellation wins ([decision](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)). +Pruning precedes summaries; overflow retries require durable progress. Bounded transient retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)). ### Failure Boundaries -The turn is the containment boundary. Final adapter-path and terminal in-band failures close the step before `agent/request-error`; retry opens a numbered step; otherwise, the provider error survives. Attempts reset on success. +The turn contains failures. Adapter failures close the step before `agent/request-error`, which receives exact `Error`, `LlmFailure`, and history. Retry opens another step; success clears history; exhaustion stores failure on `turn/end`. Failed chunks commit no message/tool. -Other failures use `agent/error`. Cancellation/disposal beat recovery; undispatched calls receive synthetic pairs before `turn/end`. One explicit `AbortSignal` spans each turn. `cancel()` validates a `user | parent` cause and aborts work; the durable outcome remains `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). +Others use `agent/error`. Cancellation/disposal beat recovery; undispatched calls get synthetic `tool/call` plus `ABORTED_BEFORE_DISPATCH` results before `turn/end`. One turn-wide `AbortSignal` covers stages. `cancel()` validates `user | parent`, clears queues, and aborts it; durability records `aborted`. Disposal quiesces before unregistering ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). 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. @@ -143,7 +143,7 @@ Durability is a plugin concern. Backends buffer synchronous `session/event` noti Messages contain typed blocks (`text`, `reasoning`, `tool-call`, `tool-result`) derived from merge-extensible `ContentBlockMap`; the same pattern types `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`. New block types coordinate adapters, UI bridges, compaction pricing, token metering, and persistence as one repo-wide contract; replay measurement types live in [token-meter.md](core-data-structures/token-meter.md). -Streaming uses raw chunks (`block-start` through `finish`) and `BlockAssembler`. The loop logs and assembles chunks, storing provider/model provenance plus replay state. An `LlmAdapter` implements `stream()`, registers provider routes, and may expose selector metadata; it resolves and validates model ids. Replay state reaches targets only when both routes map to one adapter instance, which owns validation and conversion. The contract lives in [llm-streaming.md](core-data-structures/llm-streaming.md). +Streaming uses raw chunks and `BlockAssembler`. One `LlmAdapter.stream()` is one provider attempt; adapters report facts, while recovery policy lives on `agent/request-error`. The loop logs chunks and successful provenance/replay state. Remote adapters stop stalled transport with per-read idle watchdogs. Replay state reaches targets only when routes share an adapter instance ([contract](core-data-structures/llm-streaming.md)). ## Extension And Composition diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4fba168ed6..675b8df954 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -27,7 +27,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:206`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:207`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` @@ -68,6 +68,8 @@ export interface Config { toolBash?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable + /** Bounded transient model-request retry policy forwarded through agent-core. */ + llmRetry?: NonNullable } ``` @@ -144,6 +146,8 @@ export interface Config { toolBash?: toolBash.Config /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false + /** Bounded transient model-request retry policy. */ + llmRetry?: llmRetry.Config } /** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */ @@ -159,9 +163,9 @@ export interface SkillConfig { } ``` -Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) +Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`llmRetry`](../packages/llm/llm-retry/src/index.ts) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:59`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:60`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -234,6 +238,8 @@ export interface Config { toolBash?: NonNullable /** Generic background-task control-tool config forwarded through agent-spine-demo. */ toolTasks?: NonNullable + /** Bounded transient model-request retry policy forwarded through agent-spine-demo. */ + llmRetry?: NonNullable /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] } @@ -458,6 +464,8 @@ export interface Config { reasoningEffort?: 'high' | 'max' /** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */ models?: DeepSeekCatalogModel[] + /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ + streamIdleTimeoutMs?: number } /** One optional model entry advertised by the hand-written adapter. */ @@ -471,7 +479,7 @@ export interface DeepSeekCatalogModel { } ``` -Source: [`packages/llm/llm-deepseek/src/index.ts:33`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:34`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` @@ -506,16 +514,14 @@ export interface PiAiProviderProfile { timeoutMs?: number /** WebSocket connection timeout in milliseconds. */ websocketConnectTimeoutMs?: number - /** Provider SDK retry count. */ - maxRetries?: number - /** Maximum provider-requested retry delay in milliseconds. */ - maxRetryDelayMs?: number + /** Maximum provider idle time while one stream read is outstanding. */ + streamIdleTimeoutMs?: number } ``` Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `ThinkingLevel` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:40`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:48`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` @@ -561,6 +567,28 @@ export interface ReplayModelConfig { Source: [`packages/support/llm-replay/src/index.ts:375`](../packages/support/llm-replay/src/index.ts) +## `@deepseek-ai/dsh-llm-retry` + +Requires: `agents` + +```ts config-catalog +/** Deployment-owned limits and classification for transient request recovery. */ +export interface Config { + /** Maximum transient retries after the first request (default 2). */ + maxTransientRetries?: number + /** Initial local exponential-backoff delay in milliseconds (default 500). */ + initialDelayMs?: number + /** Maximum accepted or locally scheduled delay in milliseconds (default 10000). */ + maxDelayMs?: number + /** Symmetric random multiplier range around one (default 0.1). */ + jitterRatio?: number + /** Stable failure codes eligible for this policy. */ + retryableCodes?: string[] +} +``` + +Source: [`packages/llm/llm-retry/src/index.ts:39`](../packages/llm/llm-retry/src/index.ts) + ## `@deepseek-ai/dsh-mcp-client` Requires: `tools` @@ -1213,7 +1241,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:382`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:419`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` @@ -1249,7 +1277,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:101`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:102`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 070cc45f93..8dc6c89aea 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.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 -adding-a-tool.md: a45315dc0ec92ab28963c2aca32dffcf5f778dcd -adding-a-tool.zh.md: f574957ddd0e42cedc93ddc0f3270110a8f110c5 +adding-a-tool.md: 9e8fa1287c854f33f62a4c6a1ed93adfccc19471 +adding-a-tool.zh.md: be4e0800036ac9bde949a11d8a35e49cfb92efd7 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index a45315dc0e..9e8fa1287c 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -25,7 +25,7 @@ export function apply(ctx: Context) { async execute(args, exec) { // args is TYPED from the schema: { path: string; limit?: number } // exec carries immutable identity + token; signal is the operational field - return [{ type: 'text', text: await readFile(args.path, 'utf8') }] + return [{ type: 'text', text: await readFile(args.path, { encoding: 'utf8', signal: exec.signal }) }] }, })) } @@ -37,7 +37,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w - **Args are validated for you.** `defineTool` validates the model-generated `arguments` against the `SchemaSpec` before `execute` runs (type, required keys, enum membership, nested objects/arrays — [runtime arg validation](../../.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md)), so inside `execute` the args already match `InferArgs`. You still hand-check value constraints the DSL can't express (non-empty strings, positive numbers, cross-field rules); throw a descriptive Error for those. Raw JSON-Schema tools registered directly (MCP) are NOT validated by the harness — they validate their own input. - **Registration borrows your readonly definition.** A typed same-process contribution is not a serialization boundary; do not mutate its schema or replace callbacks after registration. `schemas()` materializes only the explicit model-facing projection. To hot-swap a tool, dispose its owning effect and register the replacement; mutable state inside the callback's closure remains ordinary plugin state. -- **Execution identity is protected.** The registry materializes `arguments` as detached lossless JSON in one recursive pass, freezes that value before policy starts, and assigns an opaque `exec.token`; `callId`, `name`, `arguments`, `agent`, `token`, and an optional enclosing-transport `parent` token stay immutable through dispatch. `parent` is identity-only and exposes no live outer execution. Treat `args` as readonly input. An around-dispatch wrapper may add, replace, or remove only `exec.signal` to impose cancellation or a deadline. +- **Execution identity is protected.** The registry materializes `arguments` as detached lossless JSON in one recursive pass, freezes that value before policy starts, and assigns an opaque `exec.token`; `callId`, `name`, `arguments`, `agent`, `token`, the required caller-owned `signal`, and an optional enclosing-transport `parent` token stay immutable through dispatch. `parent` is identity-only and exposes no live outer execution. Treat `args` as readonly input. Only an around-dispatch wrapper receives a mutable view, and it may replace and restore the required `exec.signal` to impose a deadline but cannot remove it. - **Throwing or returning non-JSON data means `isError`.** The registry catches throws and materializes the final result before observers run. A malformed or non-JSON result becomes `{ isError: true }`, preventing a live success that cannot be logged. Throw for infrastructure failures; report domain failures in result text when the model must interpret them. - **Honor `exec.signal`.** Cancel in-flight work when it fires. - **Attach durable card data with `meta` (optional).** `execute` may return `{ content, meta }` instead of a bare `ContentBlock[]` — `meta` is a JSON-serializable payload the core treats as opaque, persisted on the `tool/result` event and handed back to your `presentResult` (so a card that needs more than `args`, like `write`/`edit`'s applied-hunk diff, survives a session replay). Keep UI-only data here, never in the model-facing `content`. @@ -45,7 +45,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w ## Long-running work -Gate `run_in_background` with producer config, reject a pre-aborted call, then register through `ctx.tasks.start({ kind, label, owner: exec.agent, run })`. The runtime validates ownership and control-surface availability before `run()` starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup. +Gate `run_in_background` with producer config, then register through `ctx.tasks.start({ kind, label, owner: exec.agent, run })`. The registry skips a pre-aborted invocation before the producer body; the runtime validates ownership and control-surface availability before `run()` starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup. The producer supplies synchronous `cancel`, non-rejecting `done` that settles after resource cleanup, and optional consuming `readOutput` with bounded-output formatting. Once the id is returned, use a task-owned cancellation signal rather than `exec.signal`. See the [background task runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer. diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index f574957ddd..be4e080003 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -25,7 +25,7 @@ export function apply(ctx: Context) { async execute(args, exec) { // args is TYPED from the schema: { path: string; limit?: number } // exec carries immutable identity + token; signal is the operational field - return [{ type: 'text', text: await readFile(args.path, 'utf8') }] + return [{ type: 'text', text: await readFile(args.path, { encoding: 'utf8', signal: exec.signal }) }] }, })) } @@ -37,7 +37,7 @@ export function apply(ctx: Context) { - **参数已为你校验。** `defineTool` 在 `execute` 运行前,会根据 `SchemaSpec` 校验模型生成的 `arguments`(类型、必填键、枚举成员、嵌套对象/数组——见[运行时参数校验](../../.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md)),因此 `execute` 内部的 args 已匹配 `InferArgs`。你仍需手动检查 DSL 无法表达的值约束(非空字符串、正数、跨字段规则),对这些情况抛出描述性 Error。直接注册的原始 JSON-Schema 工具(MCP)不由 harness 校验,它们自行校验输入。 - **注册借用你的只读定义。** 类型化的同进程贡献不是序列化边界;注册后不要修改其 schema 或替换回调。`schemas()` 只物化显式的模型可见投影。如需热替换工具,请 dispose 其所属副作用并注册替代品;回调闭包内的可变状态仍是普通的插件状态。 -- **执行身份受保护。** 注册表在一次递归遍历中将 `arguments` 物化为分离的无损 JSON,在策略开始前冻结该值,并分配一个不透明的 `exec.token`;`callId`、`name`、`arguments`、`agent`、`token` 以及可选的外层传输 `parent` token 在整个分发过程中保持不可变。`parent` 仅用于身份标识,不暴露活跃的外层执行。请将 `args` 视为只读输入。around-dispatch 包装器只能添加、替换或移除 `exec.signal`,以施加取消或截止时间。 +- **执行身份受保护。** 注册表在一次递归遍历中将 `arguments` 物化为分离的无损 JSON,在策略开始前冻结该值,并分配一个不透明的 `exec.token`;`callId`、`name`、`arguments`、`agent`、`token`、必填且由调用方持有的 `signal`,以及可选的外层传输 `parent` token 在整个分发过程中保持不可变。`parent` 仅用于身份标识,不暴露活跃的外层执行。请将 `args` 视为只读输入。只有 around-dispatch 包装器会收到可变视图;它可以替换并恢复必填的 `exec.signal` 以施加截止时间,但不能移除该信号。 - **抛出异常或返回非 JSON 数据意味着 `isError`。** 注册表捕获异常,并在观察者运行前物化最终结果。格式错误或非 JSON 的结果变为 `{ isError: true }`,防止出现无法记录的活跃成功。基础设施故障请抛异常;当模型需要解读领域失败时,请在结果文本中报告。 - **遵守 `exec.signal`。** 信号触发时取消进行中的工作。 - **使用 `meta` 附加持久化的卡片数据(可选)。** `execute` 可以返回 `{ content, meta }` 而非裸的 `ContentBlock[]`。`meta` 是 JSON 可序列化的载荷,核心将其视为不透明数据,持久化在 `tool/result` 事件上并回传给你的 `presentResult`(这样需要 `args` 之外信息的卡片——如 `write`/`edit` 的已应用 hunk diff——在会话回放中依然存活)。仅在此处放 UI 数据,绝不放入模型可见的 `content`。 @@ -45,7 +45,7 @@ export function apply(ctx: Context) { ## 长时间运行的工作 -通过 producer 配置控制 `run_in_background`,拒绝已预先中止的调用,然后使用 `ctx.tasks.start({ kind, label, owner: exec.agent, run })` 注册任务。运行时会在 `run()` 启动工作前校验 owner 和控制面是否可用,随后提供 id、会话围栏、通用控制工具、通知和 owner cleanup。 +通过 producer 配置控制 `run_in_background`,然后使用 `ctx.tasks.start({ kind, label, owner: exec.agent, run })` 注册任务。注册表会在进入 producer 主体前跳过已预先中止的调用;运行时会在 `run()` 启动工作前校验 owner 和控制面是否可用,随后提供 id、会话围栏、通用控制工具、通知和 owner cleanup。 producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 `done`,以及可选的消费式 `readOutput`(负责有界输出的格式化)。返回 id 后,应使用 task 自有的取消信号,而不是 `exec.signal`。流式 producer 的示例和完整契约见[后台 task 运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)与 `dsh-tool-bash`。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 90714ccb3a..9222d61583 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -75,7 +75,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:331`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:332`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -206,17 +206,18 @@ Recover a model-request failure after its failed step has closed. `retry` opens * @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 failure - serializable facts normalized at the final adapter boundary. + * @param priorFailures - immutable failures that already authorized another request in this consecutive sequence. * @param signal - the turn abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ -'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, retryAttempt: number, signal: AbortSignal, next: () => Promise): Promise +'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise): Promise ``` -Types: [Agent](../core-data-structures/core.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:296`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:297`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -329,7 +330,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:307`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:308`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -351,7 +352,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:318`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:319`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` @@ -481,7 +482,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:43`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:44`](../../packages/llm/llm/src/index.ts) ## `session/*` @@ -707,36 +708,41 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:116`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:123`](../../packages/core/tools/src/index.ts) ### `tools/execute` — waterfall -Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a normalized result; wrappers may change only `exec.signal`, while call identity remains immutable. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. +Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a normalized result; wrappers may change only `exec.signal`, while call identity remains immutable. The registry re-fuses the original caller signal before the body, so replacement cannot detach caller cancellation; wrappers must still restore their signal and reach quiescence. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog /** * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns * a normalized result; wrappers may change only `exec.signal`, while call - * identity remains immutable. + * identity remains immutable. The registry re-fuses the original caller + * signal before the body, so replacement cannot detach caller cancellation; + * wrappers must still restore their signal and reach quiescence. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal). * @mode waterfall */ -'tools/execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise +'tools/execute'(this: Scoped, exec: ToolDispatchExecution, next: () => Promise): Promise ``` -Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) +Types: [Scoped](../core-data-structures/scope.md) · [ToolDispatchExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:89`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:93`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall -Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts it unchanged; thrown tools still reach this seam as errors. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. +Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts it unchanged; thrown tools still reach this seam as errors. Async listeners must observe `exec.signal`; after they settle, caller cancellation replaces only a successful accepted outcome with the code selected by whether the tool body was invoked. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog /** * Accept, replace, enrich, or block a normalized dispatch result. `next()` - * accepts it unchanged; thrown tools still reach this seam as errors. + * accepts it unchanged; thrown tools still reach this seam as errors. Async + * listeners must observe `exec.signal`; after they settle, caller + * cancellation replaces only a successful accepted outcome with the code + * selected by whether the tool body was invoked. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @param exec - the call that just ran (name, parsed arguments, caller agent). * @param result - the dispatch outcome a listener may accept, replace, or block. @@ -747,16 +753,18 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:98`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:105`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall -Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. +Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Async gates must observe `exec.signal`; the registry rechecks cancellation after they settle but never abandons their promise. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog /** * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing - * approval support turns `ask` into denial. + * approval support turns `ask` into denial. Async gates must observe + * `exec.signal`; the registry rechecks cancellation after they settle but + * never abandons their promise. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @param exec - the pending call (name, parsed arguments, caller agent). * @mode waterfall @@ -766,7 +774,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:80`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:82`](../../packages/core/tools/src/index.ts) ### `tools/result` — emit @@ -785,7 +793,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:106`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:113`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 396b91a66e..ecd88171fb 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -531,7 +531,7 @@ stream(options: GenerateOptions): AsyncIterable Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:97`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:137`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` @@ -1223,7 +1223,11 @@ executionMode(exec: ToolExecutionInput): ToolExecutionMode * Execute through pre-policy, guards, around-dispatch, post-policy, and final * notification. Tool and listener failures resolve as materialized error * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is - * the same lossless, frozen snapshot final observers receive. + * the same lossless, frozen snapshot final observers receive. Cancellation + * arriving after entry and before final result materialization skips a + * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a + * successful started outcome with `ABORTED`; already-started work is still + * drained and may retain a tool-owned structured error. * @param exec - the typed same-process call input. The registry assigns its * correlation token before policy begins. * @returns the materialized final result. @@ -1233,7 +1237,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:438`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:493`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index f04946384a..fd8a7d3192 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -224,7 +224,7 @@ interface GenerateOptions { } ``` -Why a model response stopped is a merge-extensible reason: +Why a model response stopped is a merge-extensible reason. Terminal provider failures carry the streaming contract's [`LlmFailure`](llm-streaming.md#llmfailure): ```ts type-equiv /** @@ -235,8 +235,8 @@ interface FinishReasonMap { 'stop': { kind: 'stop' } 'tool-calls': { kind: 'tool-calls' } 'max-tokens': { kind: 'max-tokens' } - 'aborted': { kind: 'aborted' } - 'error': { kind: 'error'; message: string; code?: string } + 'aborted': { kind: 'aborted'; failure: LlmFailure } + 'error': { kind: 'error'; failure: LlmFailure } } ``` @@ -458,14 +458,14 @@ type ContinuationDecision = | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } } ``` -`agent/request-error` receives the original `RequestError`, whose optional provider-neutral `code` supports stable routing without message parsing: +`agent/request-error` receives the exact original `RequestError` beside its immutable `LlmFailure`, an immutable list of failures that already authorized another request in the consecutive sequence, the turn signal, and `next()`. Recovery plugins route on `failure.code`, not the live error's message; each policy counts only its own codes, and a successful request clears the history: ```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: +It returns a `RequestErrorDecision`; `retry` opens a new numbered step after the recovery listener's durable mutation, while `fail` retains the structured failure on `turn/end`: ```ts type-equiv /** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()`. */ diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 41ce6427e6..674bff9dc8 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -31,18 +31,40 @@ type StreamChunk = } ``` +## `LlmFailure` + +Every thrown or in-band final-adapter failure normalizes to one serializable provider-neutral payload. `providerRetryAfterMs` is a validated positive delay requested by the provider, not a retry decision; `ProviderRequestId` is an opaque branded string for diagnostics. + +```ts type-equiv +/** Serializable provider-boundary facts; policy decides whether they are retryable. */ +interface LlmFailure { + /** Human-readable provider or transport failure. */ + readonly message: string + /** Stable provider-neutral machine-routing code. */ + readonly code: string + /** HTTP status observed at the provider boundary, when available. */ + readonly status?: number + /** Provider-requested delay in milliseconds, when valid and available. */ + readonly providerRetryAfterMs?: number + /** Opaque provider-issued request identifier for diagnostics. */ + readonly requestId?: ProviderRequestId +} +``` + ## The adapter contract 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 closes the failed step and offers either form to `agent/request-error`; absent recovery it becomes a turn error, and no normal completed assistant message is logged for that request. +- **Two sanctioned error paths, one fact shape.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted', failure}` (provider in-band errors, for adapters that can't throw mid-stream). `LlmError.failure` carries the same `LlmFailure`. The final adapter boundary preserves the exact thrown `Error` object and associates immutable facts with that call; the agent loop closes the failed step and offers the error, facts, and immutable prior-retried facts to `agent/request-error`. Absent recovery the structured failure becomes the turn error, and no normal assistant message or tool side effect is committed for that attempt. +- **One adapter call is one provider attempt.** Adapters disable library retries. Agent-level recovery opens another durable numbered step; direct `ctx.llm.stream()` callers remain single-attempt. +- **Provider stalls are bounded at the transport.** Both shipping remote adapters expose positive finite `streamIdleTimeoutMs` with a five-minute default. The watchdog arms only while iterator `next()` is outstanding, uses one stable signal for the whole request, maps its own expiry to `TIMEOUT`, and keeps an earlier caller abort as `ABORTED`. - **Context overflow has one canonical code.** Both DeepSeek adapters classify explicit provider detail through `isContextWindowExceededError()` and surface `CONTEXT_WINDOW_EXCEEDED`, whether the failure arrives as a thrown HTTP `LlmError` or an in-band finish error. Consumers route on the code, never provider text. - **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). - **Replay state is adapter-owned.** A successful `finish` may carry lossless-JSON state needed to reconstruct a native provider response. The loop stores it with the assembled assistant message unless an `agent/step-result` listener rewrote the content. On a later request, `LlmService` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content and provenance without the private state. -This contract was pinned down by two deliberately independent implementations: `dsh-llm-deepseek` (hand-rolled fetch/SSE) and `dsh-llm-pi-ai` (a generic multi-provider adapter through `@earendil-works/pi-ai`). The library-backed adapter cannot throw mid-stream, so it exercises the finish-chunk error path the hand-rolled one might not. +This contract is pinned down by two deliberately independent implementations: `dsh-llm-deepseek` (hand-rolled fetch/SSE) and `dsh-llm-pi-ai` (a generic multi-provider adapter through `@earendil-works/pi-ai`). The library-backed adapter exercises the finish-chunk error path, while transport-boundary tests prove each idle watchdog stops its actual request. ## `AppIdentity` — app attribution diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 36d385233c..b65d25f7ae 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -430,7 +430,7 @@ declare class Session { - `context/message` → a user-role message carrying its `content` verbatim at its chronological position. Optional JSON `meta` remains in the event log and is never rendered. - `steering/message` → a user-role message carrying its content verbatim at its chronological position. -Everything else (`turn/*`, `step/*`) is structural and does not project into a message. Token usage is observed on `assistant/message.usage` (the step that produced it); an operational error's step number is on `turn/end.reason` for `kind: 'error'`. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data. +Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and does not project into a message. Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message, so their usage chunk is the durable accounting record. An operational error's step number is on `turn/end.reason` for `kind: 'error'`, with normalized `LlmFailure` facts for a final model-request failure and message/code for other live errors. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data. ## Live-session fork API @@ -477,9 +477,13 @@ interface TurnEndReasonMap { * The turn failed: a step threw or the model reported a failure. `step` is the * step number the failure occurred on (the operational error's location — the * single durable record of an in-turn failure; live diagnostics also fire via - * `agent/error`). `code` is the error's code when one was attached. + * `agent/error`). Final model-request failures retain their normalized facts + * as one `failure`; other turn failures retain their live Error message/code. */ - error: { kind: 'error'; step: number; message: string; code?: string } + error: { kind: 'error'; step: number } & ( + | { failure: LlmFailure; message?: never; code?: never } + | { message: string; code?: string; failure?: never } + ) disposed: { kind: 'disposed' } /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 9e3d698440..a4f523fe61 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -11,6 +11,15 @@ A `ToolSchema` (the model-facing fields) plus the `execute` function, host-only ```ts type-equiv /** A registered tool: its schema plus the execution function. */ interface ToolDefinition extends ToolSchema { + /** + * Run one accepted call. Async work must observe or forward `exec.signal` and + * settle only after its owned work reaches quiescence. The registry preserves + * caller cancellation through around-dispatch signal replacement and does + * not abandon this promise, but it cannot hard-kill same-process code. + * @param args - losslessly snapshotted, frozen model arguments. + * @param exec - execution identity, cancellation signal, and context deferral. + * @returns model-facing content plus optional private presentation metadata. + */ execute(args: unknown, exec: ToolRunContext): Promise /** * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline. @@ -138,7 +147,7 @@ interface ToolRestriction { ## Execution: extensible waterfalls plus monotonic policy -`ctx.tools.execute()` accepts a caller-owned `ToolExecutionInput`, materializes its parsed JSON arguments once into a pipeline-owned `ToolExecution`, and runs that call through `tools/pre-execute` (the reorderable allow/deny/ask waterfall) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `tools/post-execute` (inspect/replace the result) → `tools/result` (the immutable authoritative outcome). The outcome is a `ToolExecutionResult`. +`ctx.tools.execute()` accepts a caller-owned `ToolExecutionInput` with a required readonly `signal`, materializes its parsed JSON arguments once into a pipeline-owned `ToolExecution`, and runs that call through `tools/pre-execute` (the reorderable allow/deny/ask waterfall) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `tools/post-execute` (inspect/replace the result) → `tools/result` (the immutable authoritative outcome). Only the `tools/execute` view may replace the required signal. The outcome is a `ToolExecutionResult`. ```ts type-equiv /** Opaque call identity that permits correlation without exposing mutable execution state. */ @@ -161,10 +170,11 @@ interface ToolExecutionInput { /** * Opaque token of the enclosing transport execution, when one exists. Code * Mode sets this on SDK sub-dispatches so commit-style observers can wait for - * the outer `run_code` outcome without receiving its live mutable execution. - */ + * the outer `run_code` outcome without receiving its live mutable execution. + */ readonly parent?: ToolExecutionToken - signal?: AbortSignal + /** Required caller-owned cancellation for this invocation. */ + readonly signal: AbortSignal } ``` @@ -203,9 +213,9 @@ type ToolExecutionMode = /** * One pending tool call inside the registry pipeline. Parsed arguments cross * one lossless-JSON materialization boundary before policy and are deep-frozen; - * call identity and the registry-assigned {@link token} are readonly. An - * around-dispatch wrapper may set, replace, or remove `signal`. The registry - * freezes the complete object before `tools/result` observers run. + * call identity, the caller signal, and the registry-assigned {@link token} are + * readonly. The registry freezes the complete object before `tools/result` + * observers run. */ interface ToolExecution extends ToolExecutionInput { /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ @@ -213,7 +223,19 @@ interface ToolExecution extends ToolExecutionInput { } ``` -`ToolExecutionToken` is an opaque runtime `Symbol` used only for identity comparison. Before policy, `execute()` materializes and freezes arguments, rejects non-JSON input, and assigns the token. Identity fields and the optional parent token remain readonly; only `signal` may change around dispatch. Final observers receive the frozen execution identity. +```ts type-equiv +/** + * Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper + * may replace the signal for its delegated lifetime, but it cannot remove it. + * The registry fuses every replacement with the captured caller signal. + */ +interface ToolDispatchExecution extends Omit { + /** Cancellation signal visible to the next wrapper or tool body. */ + signal: AbortSignal +} +``` + +`ToolExecutionToken` is an opaque runtime `Symbol` used only for identity comparison. Before policy, `execute()` materializes and freezes arguments, rejects non-JSON input, and assigns the token. Identity fields, the required caller signal, and the optional parent token remain readonly. A `ToolDispatchExecution` wrapper may replace but not remove the signal; the registry re-fuses the caller signal before invoking the body. Final observers receive the frozen execution identity. A `ToolGuard` is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 2b11a53716..497c9c950e 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -10,24 +10,24 @@ 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:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:159`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:168`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:332`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | | `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:282`](../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:216`](../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:229`](../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:187`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../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:296`](../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:297`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:258`](../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:200`](../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:177`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`tui`](../packages/ui/tui) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:270`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:307`](../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:318`](../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:308`](../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:319`](../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:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `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) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:44`](../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), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | @@ -38,11 +38,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`acp`](../packages/ui/acp) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:89`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:98`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`workspace-context`](../packages/context/workspace-context) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:80`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:106`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:123`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:93`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:105`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`workspace-context`](../packages/context/workspace-context) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:82`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 84068531fe..fe774feedc 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -18,6 +18,7 @@ flowchart TD pkg_llm["llm"] pkg_llm_deepseek["llm-deepseek"] pkg_llm_pi_ai["llm-pi-ai"] + pkg_llm_retry["llm-retry"] pkg_token_meter["token-meter"] end subgraph group_core["packages/core"] @@ -159,7 +160,9 @@ flowchart TD pkg_scripts --> pkg_app_boot pkg_telemetry --> pkg_brand pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_timeout pkg_llm_pi_ai --> pkg_llm + pkg_llm_pi_ai --> pkg_timeout pkg_session --> pkg_brand pkg_session --> pkg_llm pkg_session --> pkg_scope @@ -197,6 +200,10 @@ flowchart TD pkg_sandbox_local --> pkg_sandbox pkg_sandbox_policy --> pkg_sandbox pkg_sandbox_policy --> pkg_session + pkg_llm_retry --> pkg_agent + pkg_llm_retry --> pkg_llm + pkg_llm_retry --> pkg_session + pkg_llm_retry --> pkg_timeout pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_timeout pkg_fs_local --> pkg_fs @@ -335,6 +342,7 @@ flowchart TD pkg_acp --> pkg_agent pkg_acp --> pkg_bash pkg_acp --> pkg_llm + pkg_acp --> pkg_llm_retry pkg_acp --> pkg_permission pkg_acp --> pkg_sandbox pkg_acp --> pkg_session @@ -397,6 +405,7 @@ flowchart TD pkg_tui --> pkg_agent pkg_tui --> pkg_agent_loop pkg_tui --> pkg_llm + pkg_tui --> pkg_llm_retry pkg_tui --> pkg_session pkg_tui --> pkg_tools pkg_tui --> pkg_user_interaction @@ -405,6 +414,7 @@ flowchart TD pkg_agent_spine_demo --> pkg_home pkg_agent_spine_demo --> pkg_invariants pkg_agent_spine_demo --> pkg_llm + pkg_agent_spine_demo --> pkg_llm_retry pkg_agent_spine_demo --> pkg_session pkg_agent_spine_demo --> pkg_skill pkg_agent_spine_demo --> pkg_skill_local @@ -477,8 +487,8 @@ flowchart TD | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | @@ -498,6 +508,7 @@ flowchart TD | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | +| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) | @@ -531,7 +542,7 @@ flowchart TD | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | +| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | @@ -543,8 +554,8 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 66887ce16a..7f4392709f 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -79,7 +79,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:264`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:333`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:337`](../packages/core/session/src/types.ts) ## Events @@ -151,7 +151,7 @@ Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -167,7 +167,7 @@ Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:235`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/types.ts) ### `compact/*` @@ -246,7 +246,7 @@ Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts) ### `hook/*` @@ -294,6 +294,24 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:19`](../packages/hooks/hook- Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook-protocol/src/types.ts) +### `llm/*` + +#### `llm/retry` — log-only + +```ts persistence-catalog +/** Durable, non-surface record of one transient retry scheduled after a closed failed step. */ +'llm/retry': { + turn: number + step: number + retry: number + maxRetries: number + delayMs: number + failure: LlmFailure +} +``` + +Source: [`packages/llm/llm-retry/src/index.ts:18`](../packages/llm/llm-retry/src/index.ts) + ### `permission/*` #### `permission/preset` — log-only @@ -324,7 +342,7 @@ Source: [`packages/ui/permission/src/index.ts:36`](../packages/ui/permission/src Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:210`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:214`](../packages/core/session/src/types.ts) ### `request/*` @@ -338,7 +356,7 @@ Source: [`packages/core/session/src/types.ts:210`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:260`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:264`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -369,7 +387,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:34`](../packages/s Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts) ### `step/*` @@ -380,7 +398,7 @@ Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:203`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:207`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -389,7 +407,7 @@ Source: [`packages/core/session/src/types.ts:203`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:201`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:205`](../packages/core/session/src/types.ts) ### `todo/*` @@ -402,7 +420,7 @@ Source: [`packages/core/session/src/types.ts:201`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts) ### `tool/*` @@ -419,7 +437,7 @@ Source: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:241`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -463,7 +481,7 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) ### `turn/*` @@ -481,7 +499,7 @@ Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:199`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:203`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -497,7 +515,7 @@ Source: [`packages/core/session/src/types.ts:199`](../packages/core/session/src/ Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:192`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) ### `user/*` @@ -510,4 +528,4 @@ Source: [`packages/core/session/src/types.ts:192`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:205`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:209`](../packages/core/session/src/types.ts) diff --git a/docs/testing.md b/docs/testing.md index 19cd60ebdb..c158c9b922 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -17,6 +17,8 @@ We are DeepSeek — do not ration real-API tests. A no-key test proves plumbing; Mock only the genuinely expensive or non-deterministic boundary (the LLM adapter, the network, the clock); keep everything downstream real. A hand-rolled stand-in proves the bridge moves bytes, not that the shipping tool behaves as asserted — the two drift while the test stays green. Example: bridge tool-call tests run the scripted mock MODEL but the real tool + real executor (`makeBridgeHarness({ withBash: true })` plugs `dsh-bash-local` + `dsh-tool-bash` and runs an actual `echo`). +Recovery tests separate pre/post-chunk failures by step and prove failed chunks derive no message or tool side effect. Cover exhaustion, cancellation, policy composition, persistence, status, wire counts, transport-closing idle timeouts, and shipping Loader composition. + ## Verify the world, not the self-report An e2e assertion re-runs the command or re-reads the file externally; a keyword probe on the agent's own output lets a cheating agent pass. Assert untouched files are byte-identical. e2e tests own their resources: create the harness in the test, dispose in `afterEach` (even on failure/retry/timeout); shared fixtures live in a plain `tests/harness.ts`, never another `*.e2e.ts` (importing a spec re-registers its `describe` and duplicates real API calls). diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl index 65f92c4916..7ba788dcc6 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl @@ -10,7 +10,7 @@ {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} {"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}} -{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-2ef0a5f14624/b5e2b8c5e6a6-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-2c89fd88e86a/76d6f8e72dc8-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl index a993f9aea5..e9b2467385 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl @@ -15,6 +15,6 @@ {"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":"tool/result","seq":16,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","content":[{"type":"text","text":"Error: tool call aborted before dispatch"}],"isError":true,"error":{"name":"AbortError","code":"ABORTED_BEFORE_DISPATCH"}},"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"}}} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl index 11178b9bc7..6061a188bf 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl @@ -4,4 +4,4 @@ {"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```"}}]}}} +{"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 aborted before dispatch\n```"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index c6895dcf2c..fbc6851750 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -10,7 +10,7 @@ {"type":"assistant/chunk","seq":8,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":9,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} {"type":"tool/call","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":11,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"tool/result","seq":11,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":13,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":14,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -20,7 +20,7 @@ {"type":"assistant/chunk","seq":18,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"inspect-tools-event","name":"cordis_inspect","arguments":"{\"what\":\"events\",\"name\":\"tools/pre-execute\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} {"type":"tool/call","seq":20,"time":1784449176734,"data":{"turn":1,"step":2,"callId":"inspect-tools-event","name":"cordis_inspect","arguments":"{\"what\":\"events\",\"name\":\"tools/pre-execute\"}"}} -{"type":"tool/result","seq":21,"time":1784449176734,"data":{"turn":1,"step":2,"callId":"inspect-tools-event","content":[{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}],"isError":false},"sourceEventSeqs":[20],"surfaceOp":"append"} +{"type":"tool/result","seq":21,"time":1784449176734,"data":{"turn":1,"step":2,"callId":"inspect-tools-event","content":[{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}],"isError":false},"sourceEventSeqs":[20],"surfaceOp":"append"} {"type":"step/end","seq":22,"time":1784449176734,"data":{"turn":1,"step":2}} {"type":"step/start","seq":23,"time":1784449176735,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":24,"time":1784449176735,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index 5d14e53a88..33ada3c698 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -1,8 +1,8 @@ {"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":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/session.jsonl b/examples/acp-agent/tests/snapshots/error-finish/session.jsonl index 6b6c44ebff..9773e5aa8c 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/session.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/session.jsonl @@ -4,4 +4,4 @@ {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} {"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"step/end","seq":4,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":5,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"message":"simulated provider error (HTTP 401)","code":"AUTH"}}} +{"type":"turn/end","seq":5,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"simulated provider error (HTTP 401)","code":"AUTH"}}}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl index 540eb2338a..f941121f12 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl @@ -1,3 +1,4 @@ {"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":"agent_message_chunk","content":{"type":"text","text":"\n\n[Model attempt failed; any partial output above is discarded: simulated provider error (HTTP 401)]\n\n"}}}} {"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"Internal error: turn failed: simulated provider error (HTTP 401)"}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 354a014a63..6dee3ebe6d 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -131,8 +131,8 @@ {"type":"assistant/chunk","seq":129,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"} {"type":"tool/call","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"e2d45b4a-ff48-488d-aeb6-8edc9dc5c3de","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"e2d45b4a-ff48-488d-aeb6-8edc9dc5c3de","outcome":"allowed-once"}} +{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"23498617-a99c-49d2-9760-0bf14fad73be","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"23498617-a99c-49d2-9760-0bf14fad73be","outcome":"allowed-once"}} {"type":"tool/result","seq":134,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[131],"surfaceOp":"append"} {"type":"step/end","seq":135,"time":1783962245400,"data":{"turn":1,"step":1}} {"type":"step/start","seq":136,"time":1783962245400,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index ff6d1187b3..73255ef905 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -155,8 +155,8 @@ {"type":"assistant/chunk","seq":153,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153],"surfaceOp":"append"} {"type":"tool/call","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"46c8dba4-52c9-4a6a-b6a6-5f34c95c28df","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"46c8dba4-52c9-4a6a-b6a6-5f34c95c28df","outcome":"rejected"}} +{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"23ecdf35-6e86-4abb-b59a-c0d0b4859ab1","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"23ecdf35-6e86-4abb-b59a-c0d0b4859ab1","outcome":"rejected"}} {"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[155],"surfaceOp":"append"} {"type":"step/end","seq":159,"time":1783962246276,"data":{"turn":1,"step":1}} {"type":"step/start","seq":160,"time":1783962246276,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index 54601f5354..3df66af28b 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -89,8 +89,8 @@ {"type":"assistant/chunk","seq":87,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":88,"time":1784045703780,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87],"surfaceOp":"append"} {"type":"tool/call","seq":89,"time":1784045703780,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} -{"type":"approval/asked","seq":90,"time":1784045703782,"data":{"id":"c37500b3-c252-4a9b-ad0d-9c4349419b30","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} -{"type":"approval/decided","seq":91,"time":1784045703786,"data":{"id":"c37500b3-c252-4a9b-ad0d-9c4349419b30","outcome":"allowed-once"}} +{"type":"approval/asked","seq":90,"time":1784045703782,"data":{"id":"87ed8caf-cd1a-4400-9359-348aafa945af","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} +{"type":"approval/decided","seq":91,"time":1784045703786,"data":{"id":"87ed8caf-cd1a-4400-9359-348aafa945af","outcome":"allowed-once"}} {"type":"tool/result","seq":92,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[89],"surfaceOp":"append"} {"type":"step/end","seq":93,"time":1784045703798,"data":{"turn":1,"step":1}} {"type":"step/start","seq":94,"time":1784045703799,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 247e13a075..3f9d54a308 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -55,8 +55,8 @@ {"type":"tool/call","seq":53,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":54,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":55,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"68f1e09d-f3e5-4e39-8a51-da082ba3ba99","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"68f1e09d-f3e5-4e39-8a51-da082ba3ba99","outcome":"rejected"}} +{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"8ddf3163-76d7-4644-8c2c-81994e3a513e","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"8ddf3163-76d7-4644-8c2c-81994e3a513e","outcome":"rejected"}} {"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":59,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":60,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/cordis-agent/tests/cordis-tools.e2e.ts b/examples/cordis-agent/tests/cordis-tools.e2e.ts index f12f85b6ca..ae3651fdaf 100644 --- a/examples/cordis-agent/tests/cordis-tools.e2e.ts +++ b/examples/cordis-agent/tests/cordis-tools.e2e.ts @@ -4,6 +4,8 @@ import { CallId } from '@deepseek-ai/dsh-llm' import { cordisHarness, waitForIdle } from './harness.ts' import { SessionId } from '@deepseek-ai/dsh-session' +const testToolSignal = new AbortController().signal + /** * With-key smoke for the self-referential cordis tools: a REAL model drives * cordis_mount/cordis_unmount against the live context the test observes. @@ -51,6 +53,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif // the mounted listener through the tagged sandbox console. expect(taggedCalls(log).length).toBeGreaterThan(0) const mid = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('verify-mounted'), name: 'cordis_inspect', arguments: { what: 'dynamic' }, }) expect(resultText(mid)).toContain('dyn-') @@ -59,6 +62,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif await waitForIdle(ctx, agent) const after = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('verify-unmounted'), name: 'cordis_inspect', arguments: { what: 'dynamic' }, }) expect(resultText(after)).toContain('(no dynamic plugins mounted)') @@ -148,6 +152,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif expect(ctx.get('shouter')).toBeUndefined() expect(ctx.tools.get('shout_text')).toBeUndefined() const after = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('verify-parked'), name: 'cordis_inspect', arguments: { what: 'dynamic' }, }) expect(resultText(after)).toContain('waiting for: shouter') diff --git a/examples/package.json b/examples/package.json index 53c392cd4c..f9956c498d 100644 --- a/examples/package.json +++ b/examples/package.json @@ -50,5 +50,8 @@ "@deepseek-ai/dsh-web": "workspace:*", "@deepseek-ai/dsh-web-fetch-local": "workspace:*", "@deepseek-ai/dsh-workflow-workerthread": "workspace:*" + }, + "devDependencies": { + "node-pty": "1.1.0" } } diff --git a/examples/tui-agent/tests/pty-harness.ts b/examples/tui-agent/tests/pty-harness.ts index 21d0b4c9d7..116f7cc9a1 100644 --- a/examples/tui-agent/tests/pty-harness.ts +++ b/examples/tui-agent/tests/pty-harness.ts @@ -2,9 +2,9 @@ import { spawn } from 'node:child_process' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' -const PTY_DRIVER = String.raw` +const POSIX_PTY_DRIVER = String.raw` import errno, json, os, pty, select, signal, sys, time node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds = sys.argv[1:] env = os.environ.copy() @@ -71,9 +71,103 @@ export interface TuiPtySmokeOptions { readonly timeoutMs?: number } +function definedEnv(env: NodeJS.ProcessEnv): Record { + return Object.fromEntries( + Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ) +} + +async function runPosixPtySmoke( + launch: ExampleLaunch, + cwd: string, + options: TuiPtySmokeOptions, + timeoutMs: number, +): Promise { + return await new Promise((resolve, reject) => { + const child = spawn('python3', [ + '-c', + POSIX_PTY_DRIVER, + launch.command, + JSON.stringify(launch.args), + JSON.stringify(launch.env), + cwd, + JSON.stringify(options.actions ?? []), + String(options.expectedExitCode ?? 0), + String(timeoutMs / 1_000), + ], { stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { stdout += chunk }) + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { stderr += chunk }) + const timer = setTimeout(() => { + child.kill('SIGKILL') + reject(new Error(`${options.label} PTY driver did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }, timeoutMs + 5_000) + child.once('error', (error) => { clearTimeout(timer); reject(error) }) + child.once('exit', (code) => { + clearTimeout(timer) + if (code === 0) resolve(stdout) + else reject(new Error(`${options.label} PTY driver exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }) + }) +} + +async function runWindowsPtySmoke( + launch: ExampleLaunch, + cwd: string, + options: TuiPtySmokeOptions, + timeoutMs: number, +): Promise { + const pty = await import('node-pty') + return await new Promise((resolve, reject) => { + const actions = options.actions ?? [] + const expectedExitCode = options.expectedExitCode ?? 0 + let output = '' + let actionIndex = 0 + let timedOut = false + const terminal = pty.spawn(launch.command, launch.args, { + name: 'xterm-256color', + cols: 100, + rows: 30, + cwd, + env: definedEnv({ + ...process.env, + ...launch.env, + COLUMNS: '100', + LINES: '30', + }), + }) + const timer = setTimeout(() => { + timedOut = true + terminal.kill() + }, timeoutMs) + terminal.onData((chunk) => { + output += chunk + while (actionIndex < actions.length && output.includes(actions[actionIndex]!.waitFor)) { + terminal.write(actions[actionIndex]!.send) + actionIndex += 1 + } + }) + terminal.onExit(({ exitCode, signal }) => { + clearTimeout(timer) + if (timedOut) { + reject(new Error(`${options.label} PTY process did not exit before ${String(timeoutMs)}ms. output:\n${output}`)) + } else if (actionIndex !== actions.length) { + reject(new Error(`${options.label} completed ${String(actionIndex)}/${String(actions.length)} PTY actions. output:\n${output}`)) + } else if (exitCode !== expectedExitCode) { + reject(new Error(`${options.label} expected exit ${String(expectedExitCode)}, got ${String(exitCode)} (signal ${String(signal)}). output:\n${output}`)) + } else { + resolve(output) + } + }) + }) +} + /** - * Boot an example in a real pseudo-terminal, drive marker-gated input, and - * return the captured terminal bytes after the expected process exit. + * Boot an example in a real pseudo-terminal (ConPTY on Windows), drive + * marker-gated input, and return captured bytes after the expected process exit. * @param options - launch paths, environment, actions, and expected exit code. * @returns complete pseudo-terminal output. */ @@ -92,35 +186,10 @@ export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise { - const child = spawn('python3', [ - '-c', - PTY_DRIVER, - launch.command, - JSON.stringify(launch.args), - JSON.stringify(launch.env), - cwd, - JSON.stringify(options.actions ?? []), - String(options.expectedExitCode ?? 0), - String(timeoutMs / 1_000), - ], { stdio: ['ignore', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { stdout += chunk }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - const timer = setTimeout(() => { - child.kill('SIGKILL') - reject(new Error(`${options.label} PTY driver did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, timeoutMs + 5_000) - child.once('error', (error) => { clearTimeout(timer); reject(error) }) - child.once('exit', (code) => { - clearTimeout(timer) - if (code === 0) resolve(stdout) - else reject(new Error(`${options.label} PTY driver exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }) - }) + if (process.platform === 'win32') { + return await runWindowsPtySmoke(launch, cwd, options, timeoutMs) + } + return await runPosixPtySmoke(launch, cwd, options, timeoutMs) } finally { await rm(cwd, { recursive: true, force: true }) } diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 21be35eef5..e2fa7377c6 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -8,8 +8,7 @@ const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -// The Python PTY driver imports the POSIX-only pty and termios modules. -describe.skipIf(process.platform === 'win32')('tui-agent keyless smoke (real Loader tree in a PTY)', () => { +describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { it('boots pi-tui, renders the configured banner, accepts /exit, and restores the terminal', async () => { const output = await runTuiPtySmoke({ label: 'tui-agent boot', diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 400436a9f1..70de2a1edd 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -5,7 +5,6 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Plugin export shape:** service packages default-export their service class; function plugins named-export `name` / `inject` / `Config` / `apply` and have no default export. Mixing the forms makes the Loader discard the function plugin's namespace ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). - **Optional services use `ctx.get(name)`.** Reserve `ctx.` for declared injections; the property proxy is topology-sensitive, while strict `ctx.get` reads the global service store ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). - **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md). -- **Typed same-process service and plugin calls are contracts, not serialization boundaries.** Prefer readonly borrowed values; materialize or defensively validate only at parser/config, queued, model/tool JSON, durable/file, worker, process, or wire boundaries. - **Initiator-owned private chains derive, then capture.** Under `ctx.agents.withInitiator()`, recover the Agent at each orchestration entry, derive `agent.session`, and let operation-local helpers close over it. Keep `Agent` and `Session` explicit at lifecycle, session-log, service, authority, worker/process, persistence, and wire interfaces; do not widen a leaf helper from `Session` to `Context` merely to hide a parameter ([rationale](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). - **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence. - **Shape capability interfaces around all current consumers.** Keep tool-schema, Loader, UI, transport, and backend-specific behavior in the consumer or adapter; do not let one consumer dictate the interface ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 0feec1729a..7d1fccde10 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -357,7 +357,7 @@ export function apply(ctx: Context, config: Config = {}): void { agent: exec.agent, callId: exec.callId, toolName: 'bash', - ...exec.signal ? { signal: exec.signal } : {}, + signal: exec.signal, }, ) } @@ -422,8 +422,8 @@ export function apply(ctx: Context, config: Config = {}): void { if (tasks === undefined) { throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') } - // Reject pre-start cancellation; returned tasks use their own lifecycle. - if (exec.signal?.aborted) throw new Error('command aborted') + // The caller owns cancellation until TaskService commits detached ownership. + if (exec.signal.aborted) return [] // Task preflight finishes before the starter can spawn a process. const id = tasks.start({ kind: 'bash', @@ -442,7 +442,7 @@ export function apply(ctx: Context, config: Config = {}): void { } const result = await ctx.bash.run(ctx.bash.resolve({ ...request, - ...exec.signal ? { signal: exec.signal } : {}, + signal: exec.signal, })) if (result.aborted) throw new Error('command aborted') return [{ type: 'text', text: renderResult(result, escalationModes) }] diff --git a/packages/bash/tool-bash/tests/bash-env.spec.ts b/packages/bash/tool-bash/tests/bash-env.spec.ts index 03d29b572b..d988075c5b 100644 --- a/packages/bash/tool-bash/tests/bash-env.spec.ts +++ b/packages/bash/tool-bash/tests/bash-env.spec.ts @@ -7,10 +7,13 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { ToolExecution } from '@deepseek-ai/dsh-tools' import { BashEnvRegistry } from '@deepseek-ai/dsh-tool-bash' +const testToolSignal = new AbortController().signal + afterEach(() => vi.unstubAllEnvs()) function execution(sessionId?: string): ToolExecution { return { + signal: testToolSignal, token: Symbol('bash-env-test') as ToolExecution['token'], callId: CallId('bash-env-call'), name: 'bash', diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 04c18264b1..9c9e06bb31 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -7,7 +7,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -21,6 +21,8 @@ import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { processOutcome } from '../src/background.ts' import { renderProcessRead, renderResult } from '../src/render.ts' +const testToolSignal = new AbortController().signal + const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-')) /** Foreground-only harness: no task runtime (backgrounding fails loud here). */ @@ -67,7 +69,7 @@ function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: un } let callCounter = 0 function call(ctx: Context, name: string, args: unknown, agent?: Agent) { - return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} }) + return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} }) } function text(result: { content: { type: string; text?: string }[] }): string { @@ -179,7 +181,11 @@ async function setupSandboxed(withApproval = false) { return { ctx, bash: ctx.bash as RecordingSandboxExecutor } } -function sandboxAgent(mode?: 'read-only' | 'workspace-write' | 'danger-full-access', ctx?: Context): Agent { +function sandboxAgent( + mode?: 'read-only' | 'workspace-write' | 'danger-full-access', + ctx?: Context, + onAppend?: (type: string) => void, +): Agent { const events: Array<{ type: string; data?: Record }> = [{ type: 'turn/start' }] if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } }) const id = SessionId('sandbox-session') @@ -193,6 +199,7 @@ function sandboxAgent(mode?: 'read-only' | 'workspace-write' | 'danger-full-acce append: (type: string, data: Record) => { const event = { type, data } events.push(event) + onAppend?.(type) return event }, }, @@ -451,7 +458,7 @@ describe('background execution through the task runtime', () => { expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') }) - it('a pre-aborted call refuses to start: isError, no process spawned', async () => { + it('a pre-aborted call is skipped before the process starts', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) @@ -470,7 +477,8 @@ describe('background execution through the task runtime', () => { signal: controller.signal, }) expect(result.isError).toBe(true) - expect(text(result)).toContain('command aborted') + expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) + expect(text(result)).toBe('Error: tool call aborted before dispatch') expect((ctx.bash as CountingStartExecutor).starts).toBe(0) }) @@ -597,6 +605,29 @@ describe('sandbox escalation through the generic task producer', () => { expect(bash.modes).toEqual(['workspace-write', 'workspace-write']) }) + it('does not publish detached work when cancellation follows the escalation grant', async () => { + const { ctx, bash } = await setupSandboxed(true) + const controller = new AbortController() + const agent = sandboxAgent(undefined, ctx, (type) => { + if (type === 'approval/decided') controller.abort() + }) + ctx.agents.register(agent) + ctx.on('approval/request', () => Promise.resolve('allowed-once')) + const start = vi.spyOn(bash, 'start') + + const result = await ctx.tools.execute({ + callId: CallId('cancelled-escalation-background'), + name: 'bash', + arguments: { ...escalate, run_in_background: true }, + agent, + signal: controller.signal, + }) + + expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED }) + expect(text(result)).toBe('Error: tool call aborted') + expect(start).not.toHaveBeenCalled() + }) + it('uses the session override for ordinary calls and evaluates widening against it', async () => { const { ctx, bash } = await setupSandboxed(true) const agent = sandboxAgent('workspace-write') @@ -730,7 +761,7 @@ describe('session-cwd routing (per-session workdir)', () => { it('falls back to the executor default when the agent has no session cwd', async () => { const ctx = await setup() // No exec.agent at all → executor uses its config/process.cwd() default. - const result = await ctx.tools.execute({ callId: CallId('cwd-noagent'), name: 'bash', arguments: { command: 'pwd', description: 'pwd' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('cwd-noagent'), name: 'bash', arguments: { command: 'pwd', description: 'pwd' } }) expect(result.isError).toBe(false) expect(text(result).trim().length).toBeGreaterThan(0) }) @@ -1012,6 +1043,7 @@ describe('the model-facing bash tool builds its request from named args only (no const path = ctx.sessionPersistence.locate(agent.session.header)?.path await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('session-env-fg'), name: 'bash', arguments: { command: 'true', description: 'run command' }, @@ -1032,6 +1064,7 @@ describe('the model-facing bash tool builds its request from named args only (no const path = ctx.sessionPersistence.locate(agent.session.header)?.path await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('session-env-bg'), name: 'bash', arguments: { @@ -1058,6 +1091,7 @@ describe('the model-facing bash tool builds its request from named args only (no const ambient = process.env.DSH_SESSION_ID await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('session-env-id-only'), name: 'bash', arguments: { command: 'true', description: 'run command' }, @@ -1079,6 +1113,7 @@ describe('the model-facing bash tool builds its request from named args only (no for (const [callId, agent] of [['parent', parent], ['child', child]] as const) { await ctx.tools.execute({ + signal: testToolSignal, callId: CallId(`session-env-${callId}`), name: 'bash', arguments: { command: 'true', description: 'run command' }, @@ -1109,6 +1144,7 @@ describe('the model-facing bash tool builds its request from named args only (no // This preserves the request shape; it is not a security boundary because shell syntax can // already set environment variables or feed stdin. await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('no-forward-1'), name: 'bash', arguments: { @@ -1130,6 +1166,7 @@ describe('the model-facing bash tool builds its request from named args only (no it('a background bash call likewise carries no trusted-only fields', async () => { const { ctx, bash } = await setupRecording() const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('no-forward-2'), name: 'bash', arguments: { diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index 01cc2ab6bf..a8844b1820 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -47,6 +47,7 @@ "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index caa1ed9c5c..5fc02964b8 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -95,9 +95,10 @@ export class BasicCompactService extends CompactService { } }) - ctx.on('agent/request-error', async (agent, _turn, _step, error, retryAttempt, signal, next) => { - if (error.code !== CONTEXT_WINDOW_EXCEEDED_CODE - || retryAttempt >= this.config.maxOverflowRetries + ctx.on('agent/request-error', async (agent, _turn, _step, _error, failure, priorFailures, signal, next) => { + const priorOverflowFailures = priorFailures.filter(item => item.code === CONTEXT_WINDOW_EXCEEDED_CODE).length + if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE + || priorOverflowFailures >= this.config.maxOverflowRetries || signal.aborted) return next() const generation = agent.session.surface.replaceGeneration diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts index 62b5f5f5e2..32e308bce4 100644 --- a/packages/compact/compact-basic/src/summarizer.ts +++ b/packages/compact/compact-basic/src/summarizer.ts @@ -141,14 +141,10 @@ export function frameSummary(summary: readonly ContentBlock[]): ContentBlock[] { /** Map a terminal summarization finish to its fail-closed error. */ function finishError(finish: FinishReason): Error | undefined { switch (finish.kind) { - case 'error': { - const error = new Error(finish.message) as Error & { code?: string } - if (finish.code !== undefined) error.code = finish.code - return error - } + case 'error': case 'aborted': { - const error = new Error('summarization stream aborted') as Error & { code?: string } - error.code = 'ABORTED' + const error = new Error(finish.failure.message) as Error & { code?: string } + error.code = finish.failure.code return error } case 'max-tokens': { diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 9098ebef50..d0340e8b0d 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -7,7 +7,7 @@ import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-a import { resolveConfig } from '@deepseek-ai/dsh-compact-basic/src/config.ts' import type { CompactionResult } from '@deepseek-ai/dsh-compact' import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, LlmFailure, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' @@ -872,9 +872,9 @@ describe('default one-shot summarizer', () => { }) it.each([ - [{ kind: 'error', message: 'provider failed', code: 'PROVIDER' }, 'PROVIDER', /provider failed/], - [{ kind: 'error', message: 'opaque' }, undefined, /opaque/], - [{ kind: 'aborted' }, 'ABORTED', /aborted/], + [{ kind: 'error', failure: { message: 'provider failed', code: 'PROVIDER' } }, 'PROVIDER', /provider failed/], + [{ kind: 'error', failure: { message: 'opaque', code: 'UNKNOWN' } }, 'UNKNOWN', /opaque/], + [{ kind: 'aborted', failure: { message: 'summarization aborted', code: 'ABORTED' } }, 'ABORTED', /aborted/], [{ kind: 'max-tokens' }, 'MAX_TOKENS', /token cap/], ] as Array<[(StreamChunk & { type: 'finish' })['reason'], string | undefined, RegExp]>) ( 'rejects terminal finish %#', @@ -912,7 +912,9 @@ describe('automatic listener and loader composition', () => { 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) + const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' } + const priorFailures = Object.freeze(Array.from({ length: retryAttempt }, () => failure)) + return ctx.waterfall('agent/request-error', owner, 1, 1, error, failure, priorFailures, signal, next) } function overflow(message = 'provider overflow'): Error & { code: string } { 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 1327f073c5..e899979f46 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -11,6 +11,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import * as Invariants from '@deepseek-ai/dsh-invariants' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import TokenMeterService from '@deepseek-ai/dsh-token-meter' +import * as LlmRetry from '@deepseek-ai/dsh-llm-retry' import { SessionId, type SurfaceEvent } from '@deepseek-ai/dsh-session' /** @@ -61,7 +62,10 @@ class OverflowRecoveryAdapter extends LlmAdapter { readonly conversationRequests: GenerateOptions[] = [] readonly summaryRequests: GenerateOptions[] = [] - constructor(private readonly delivery: 'thrown' | 'in-band') { + constructor( + private readonly delivery: 'thrown' | 'in-band', + private readonly transientAfterOverflow = false, + ) { super() } @@ -83,12 +87,17 @@ class OverflowRecoveryAdapter extends LlmAdapter { type: 'finish', reason: { kind: 'error', - message: 'request too large for model context', - code: CONTEXT_WINDOW_EXCEEDED_CODE, + failure: { + message: 'request too large for model context', + code: CONTEXT_WINDOW_EXCEEDED_CODE, + }, }, } return } + if (this.transientAfterOverflow && this.conversationRequests.length === 2) { + throw new LlmError('temporary provider outage', 'SERVER') + } 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' } } @@ -134,6 +143,29 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { }) } +function seedOverflowHistory(agent: Agent): void { + 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', { + provenance: { provider: 'mock', model: 'mock' }, + turn, + step: 1, + content: [{ type: 'text', text: `historical response ${turn} ${'detail '.repeat(200)}` }], + }, { surfaceOp: 'append' }) + agent.session.append('step/end', { turn, step: 1 }) + agent.session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } +} + 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) @@ -241,26 +273,7 @@ describe('context-overflow recovery across the real loop and compact-basic', () provider: 'unconfigured-agent-fallback', 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', { - provenance: { provider: 'mock', model: 'mock' }, - turn, - step: 1, - content: [{ type: 'text', text: `historical response ${turn} ${'detail '.repeat(200)}` }], - }, { surfaceOp: 'append' }) - agent.session.append('step/end', { turn, step: 1 }) - agent.session.append('turn/end', { turn, reason: { kind: 'completed' } }) - } + seedOverflowHistory(agent) agent.send([{ type: 'text', text: 'continue from history' }]) await agent.whenIdle() @@ -299,4 +312,47 @@ describe('context-overflow recovery across the real loop and compact-basic', () } }, ) + + it('keeps context-overflow and transient retry budgets independent in one sequence', async () => { + const ctx = new Context() + const adapter = new OverflowRecoveryAdapter('thrown', true) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(Invariants) + await ctx.plugin(LlmRetry, { + maxTransientRetries: 1, + initialDelayMs: 1, + maxDelayMs: 1, + jitterRatio: 0, + }) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(TokenMeterService, { contextWindow: 128 }) + ctx.llm.registerAdapter(['mock'], adapter) + await ctx.plugin(BasicCompactService, { + thresholdRatio: 1, + retainTokens: 100, + maxTokens: 64, + compactionRetries: 0, + maxOverflowRetries: 1, + }) + + try { + const agent = ctx.agentLoop.create(SessionId('alternating-recovery'), { provider: 'mock', model: 'mock' }) + seedOverflowHistory(agent) + agent.send([{ type: 'text', text: 'continue from history' }]) + await agent.whenIdle() + + expect(adapter.conversationRequests).toHaveLength(3) + expect(adapter.summaryRequests).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data)) + .toEqual([expect.objectContaining({ step: 2, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })]) + expect(agent.session.events.filter(event => event.type === 'step/start').slice(-3).map(event => event.data.step)) + .toEqual([1, 2, 3]) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'completed' } }, + }) + } finally { + await ctx.fiber.dispose() + } + }) }) diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index f2e113ddfe..0ab455349a 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -500,7 +500,7 @@ export async function dynamicInstructionContext( { touchedPath, includeBaselineScopes: baselineInstructionStates.has(agent.session), - ...exec.signal === undefined ? {} : { signal: exec.signal }, + signal: exec.signal, }, ) } diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index c7b3812e42..895f715960 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -40,6 +40,8 @@ import { } from '../src/state.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +const testToolSignal = new AbortController().signal + async function tempRepo(): Promise { return mkdtemp(join(tmpdir(), 'dsh-workspace-context-')) } @@ -794,6 +796,7 @@ describe('workspace context request injection', () => { await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({ + signal: testToolSignal, callId: CallId('no-fs-post-execute'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, @@ -829,6 +832,7 @@ describe('workspace context request injection', () => { const agent = stubAgent(root) const exec = stubToolExecution({ + signal: testToolSignal, callId: CallId('read-blocked-post-execute'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, @@ -974,6 +978,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) await write(join(root, 'AGENTS.md'), 'new root rule with more detail') const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-baseline-change'), name: 'read', arguments: { file_path: 'file.txt' }, agent, }) @@ -1002,6 +1007,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) await rm(join(root, 'AGENTS.md')) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-baseline-remove'), name: 'read', arguments: { file_path: 'file.txt' }, agent, }) @@ -1027,6 +1033,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-with-shared-global-root'), name: 'read', arguments: { file_path: 'file.txt' }, agent, }) @@ -1105,6 +1112,25 @@ describe('workspace context request injection', () => { } }) + it('keeps the direct provider API usable without an operation signal', async () => { + const root = '/virtual/no-signal-repo' + const home = '/virtual/no-signal-home' + const ctx = new Context() + try { + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'optional capability signal' }) + + const rendered = await loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 }, fs) + + expect(rendered?.text).toContain('optional capability signal') + expect(fs.signals).toEqual([]) + } finally { + await ctx.fiber.dispose() + } + }) + it('rejects a provider-sized instruction file before reading content', async () => { const root = join(await tempRepo(), 'virtual-repo') const home = join(await tempRepo(), 'virtual-home') @@ -1684,6 +1710,7 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-nested'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -1743,6 +1770,7 @@ describe('dynamic nested workspace context injection', () => { }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-configured-nested-candidate'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -1771,12 +1799,14 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-nested-1'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, agent, }) const second = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-nested-2'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -1809,10 +1839,12 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-before-version-fast-path'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) appendAdditionalContexts(agent, first) const second = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-with-version-fast-path'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) @@ -1844,14 +1876,17 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-before-same-digest-version-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) appendAdditionalContexts(agent, first) fs.entries.set(instructionPath, { type: 'file', content: 'same package rule', version: FsVersion('revision-2') }) const afterVersionChange = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-same-digest-version-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) const afterRefresh = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-version-cache-refresh'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) @@ -1882,9 +1917,11 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-from-first-session'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: stubAgent(root), }) const second = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-from-second-session'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: stubAgent(root), }) @@ -1910,11 +1947,13 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-before-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) appendAdditionalContexts(agent, first) await write(join(root, 'pkg/AGENTS.md'), 'new package rule with more detail') const changed = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) @@ -1950,15 +1989,18 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-before-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) appendAdditionalContexts(agent, first) await rm(join(root, 'pkg/AGENTS.md')) const changed = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) appendAdditionalContexts(agent, changed) const unchanged = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-logged-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) @@ -1989,11 +2031,13 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-before-remove'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) appendAdditionalContexts(agent, first) await rm(join(root, 'pkg/AGENTS.md')) const removed = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-remove'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) @@ -2027,17 +2071,20 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-before-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) appendAdditionalContexts(agent, first) await rm(join(root, 'pkg/AGENTS.md')) const removed = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-to-create-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) appendAdditionalContexts(agent, removed) await write(join(root, 'pkg/AGENTS.md'), 'restored package rule') const restored = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) @@ -2069,11 +2116,13 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-before-provider-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) appendAdditionalContexts(agent, first) fs.throwOnStat.add(join(root, 'pkg/AGENTS.md')) const duringFailure = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-during-provider-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) @@ -2097,6 +2146,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-before-resume'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2109,6 +2159,7 @@ describe('dynamic nested workspace context injection', () => { } const afterResume = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-resume'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2134,6 +2185,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const original = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-before-offline-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: original, }) appendAdditionalContexts(original, first) @@ -2164,6 +2216,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-before-compact'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2171,6 +2224,7 @@ describe('dynamic nested workspace context injection', () => { }) const contextSeq = appendAdditionalContexts(agent, first)! const visibleBeforeCompact = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-while-visible'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2186,6 +2240,7 @@ describe('dynamic nested workspace context injection', () => { }) const afterCompact = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-compact'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2215,6 +2270,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-package'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, @@ -2223,6 +2279,7 @@ describe('dynamic nested workspace context injection', () => { appendAdditionalContexts(agent, first) const second = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-subtree'), name: 'read', arguments: { file_path: 'pkg/sub/file.txt' }, @@ -2250,6 +2307,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-subtree-omitting-parent'), name: 'read', arguments: { file_path: 'pkg/sub/file.txt' }, @@ -2258,6 +2316,7 @@ describe('dynamic nested workspace context injection', () => { appendAdditionalContexts(agent, first) const second = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-parent-after-omit'), name: 'read', arguments: { file_path: 'pkg/other.txt' }, @@ -2319,6 +2378,7 @@ describe('dynamic nested workspace context injection', () => { }, { surfaceOp: 'append' }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-spoofed-state'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2345,12 +2405,14 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const rootResult = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-root-file'), name: 'read', arguments: { file_path: 'root.txt' }, agent, }) const absoluteResult = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-absolute-nested-file'), name: 'read', arguments: { file_path: join(root, 'pkg/deep/file.txt') }, @@ -2384,11 +2446,13 @@ describe('dynamic nested workspace context injection', () => { } const failedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({ + signal: testToolSignal, callId: CallId('provider-stat-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }), result, async () => ({ kind: 'accept' as const })) fs.throwOnStat.clear() fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'directory' }) const mismatchedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({ + signal: testToolSignal, callId: CallId('provider-stat-mismatch'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }), result, async () => ({ kind: 'accept' as const })) @@ -2414,6 +2478,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-with-unreadable-nested-instruction'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2448,6 +2513,7 @@ describe('dynamic nested workspace context injection', () => { })) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-with-downstream'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2492,6 +2558,7 @@ describe('dynamic nested workspace context injection', () => { })) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-blocked-downstream'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2532,6 +2599,7 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const blocked = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('outer-block-first'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2539,6 +2607,7 @@ describe('dynamic nested workspace context injection', () => { }) shouldBlock = false const accepted = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('outer-block-retry'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2579,7 +2648,7 @@ describe('dynamic nested workspace context injection', () => { arguments: { file_path: 'pkg/deep/file.txt' }, ...exec.agent === undefined ? {} : { agent: exec.agent }, parent: exec.token, - ...exec.signal === undefined ? {} : { signal: exec.signal }, + signal: exec.signal, }) for (const context of nested.additionalContexts ?? []) exec.deferContext(context) return nested.content @@ -2596,10 +2665,12 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const blocked = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('composite-first'), name: 'composite-read', arguments: {}, agent, }) shouldBlock = false const accepted = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('composite-retry'), name: 'composite-read', arguments: {}, agent, }) @@ -2623,19 +2694,23 @@ describe('dynamic nested workspace context injection', () => { const plainResult = { callId: CallId('plain'), content: [], isError: false } ctx.emit('tools/result', stubToolExecution({ + signal: testToolSignal, callId: CallId('agentless-child'), name: 'read', arguments: {}, parent, }), plainResult) ctx.emit('tools/result', stubToolExecution({ + signal: testToolSignal, callId: CallId('contextless-child'), name: 'read', arguments: {}, agent, parent, }), { ...plainResult, additionalContexts: [{ content: [], source: { kind: 'plugin', plugin: 'workspace-context' } }] }) ctx.emit('tools/result', stubToolExecution({ + signal: testToolSignal, callId: CallId('first-child'), name: 'read', arguments: {}, agent, parent, }), { ...plainResult, additionalContexts: [workspaceChangeContext('first', 'one')] }) ctx.emit('tools/result', stubToolExecution({ + signal: testToolSignal, callId: CallId('second-child'), name: 'read', arguments: {}, agent, parent, }), { ...plainResult, additionalContexts: [workspaceChangeContext('second', 'two')] }) ctx.emit('tools/result', { - ...stubToolExecution({ callId: CallId('agentless-parent'), name: 'composite', arguments: {} }), + ...stubToolExecution({ signal: testToolSignal, callId: CallId('agentless-parent'), name: 'composite', arguments: {} }), token: parent, }, plainResult) @@ -2671,6 +2746,7 @@ describe('dynamic nested workspace context injection', () => { for (const item of cases) { const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({ + signal: testToolSignal, callId: CallId(`manual-${item.name}-${cases.indexOf(item)}`), name: item.name, arguments: item.arguments, @@ -2695,6 +2771,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 0 }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-with-disabled-budget'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2719,6 +2796,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-missing'), name: 'read', arguments: { file_path: 'pkg/missing.txt' }, @@ -2745,6 +2823,7 @@ describe('dynamic nested workspace context injection', () => { await fiber.dispose() const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-dispose'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 83f278c88a..d568788cac 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -575,7 +575,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async execute(exec: ToolExecutionInput): Promise', - jsDoc: '/**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */', + jsDoc: '/**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */', }, ], }, @@ -695,8 +695,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ { 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', - jsDoc: '/**\n * Recover a model-request failure after its failed step has closed. `retry`\n * opens a new numbered step; `fail` preserves the original request error.\n * Call `next()` to delegate to the next recovery listener or the default.\n * @param agent - the agent whose request failed.\n * @param turn - the open turn number.\n * @param step - the failed step number.\n * @param error - the original model-request failure.\n * @param retryAttempt - zero-based number of prior recovery retries.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + signature: '\'agent/request-error\'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise): Promise', + jsDoc: '/**\n * Recover a model-request failure after its failed step has closed. `retry`\n * opens a new numbered step; `fail` preserves the original request error.\n * Call `next()` to delegate to the next recovery listener or the default.\n * @param agent - the agent whose request failed.\n * @param turn - the open turn number.\n * @param step - the failed step number.\n * @param error - the original model-request failure.\n * @param failure - serializable facts normalized at the final adapter boundary.\n * @param priorFailures - immutable failures that already authorized another request in this consecutive sequence.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Recover a model-request failure after its failed step has closed.', }, { @@ -856,22 +856,22 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'tools/execute', mode: 'waterfall', - signature: '\'tools/execute\'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise', - jsDoc: '/**\n * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns\n * a normalized result; wrappers may change only `exec.signal`, while call\n * identity remains immutable.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).\n * @mode waterfall\n */', + signature: '\'tools/execute\'(this: Scoped, exec: ToolDispatchExecution, next: () => Promise): Promise', + jsDoc: '/**\n * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns\n * a normalized result; wrappers may change only `exec.signal`, while call\n * identity remains immutable. The registry re-fuses the original caller\n * signal before the body, so replacement cannot detach caller cancellation;\n * wrappers must still restore their signal and reach quiescence.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).\n * @mode waterfall\n */', summary: 'Around-dispatch waterfall for timeout, retry, or metrics.', }, { name: 'tools/post-execute', mode: 'waterfall', signature: '\'tools/post-execute\'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise', - jsDoc: '/**\n * Accept, replace, enrich, or block a normalized dispatch result. `next()`\n * accepts it unchanged; thrown tools still reach this seam as errors.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the call that just ran (name, parsed arguments, caller agent).\n * @param result - the dispatch outcome a listener may accept, replace, or block.\n * @mode waterfall\n */', + jsDoc: '/**\n * Accept, replace, enrich, or block a normalized dispatch result. `next()`\n * accepts it unchanged; thrown tools still reach this seam as errors. Async\n * listeners must observe `exec.signal`; after they settle, caller\n * cancellation replaces only a successful accepted outcome with the code\n * selected by whether the tool body was invoked.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the call that just ran (name, parsed arguments, caller agent).\n * @param result - the dispatch outcome a listener may accept, replace, or block.\n * @mode waterfall\n */', summary: 'Accept, replace, enrich, or block a normalized dispatch result.', }, { name: 'tools/pre-execute', mode: 'waterfall', signature: '\'tools/pre-execute\'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise', - jsDoc: '/**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */', + jsDoc: '/**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */', summary: 'Allow, deny, or ask before dispatch.', }, { @@ -1137,7 +1137,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'FinishReasonMap', - declaration: 'export interface FinishReasonMap {\n \'stop\': {\n kind: \'stop\';\n };\n \'tool-calls\': {\n kind: \'tool-calls\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n \'aborted\': {\n kind: \'aborted\';\n };\n \'error\': {\n kind: \'error\';\n message: string;\n code?: string;\n };\n}', + declaration: 'export interface FinishReasonMap {\n \'stop\': {\n kind: \'stop\';\n };\n \'tool-calls\': {\n kind: \'tool-calls\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n \'aborted\': {\n kind: \'aborted\';\n failure: LlmFailure;\n };\n \'error\': {\n kind: \'error\';\n failure: LlmFailure;\n };\n}', }, { name: 'FsDirEntry', @@ -1207,6 +1207,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LlmCallConfig', declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}', }, + { + name: 'LlmFailure', + declaration: 'export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n}', + }, { name: 'LlmModelInfo', declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n}', @@ -1243,6 +1247,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PromptSection', declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}', }, + { + name: 'ProviderRequestId', + declaration: 'export type ProviderRequestId = Branded<\'ProviderRequestId\'>;', + }, { name: 'PrunedEntry', declaration: 'export interface PrunedEntry {\n readonly originalSeq: number;\n readonly replacementSeq: number;\n readonly callId: CallId;\n readonly charsBefore: number;\n readonly charsAfter: number;\n}', @@ -1545,7 +1553,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecutionInput', - declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n}', + declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n}', }, { name: 'ToolExecutionMode', @@ -1597,7 +1605,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TurnEndReasonMap', - declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n };\n error: {\n kind: \'error\';\n step: number;\n message: string;\n code?: string;\n };\n disposed: {\n kind: \'disposed\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n rejected: {\n kind: \'rejected\';\n reason: string;\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}', + declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n };\n error: {\n kind: \'error\';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: \'disposed\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n rejected: {\n kind: \'rejected\';\n reason: string;\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}', }, { name: 'TurnTrigger', diff --git a/packages/cordis/tool-cordis/tests/helpers.ts b/packages/cordis/tool-cordis/tests/helpers.ts index b183a2444f..e945249814 100644 --- a/packages/cordis/tool-cordis/tests/helpers.ts +++ b/packages/cordis/tool-cordis/tests/helpers.ts @@ -6,6 +6,8 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import type { ToolDefinition, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import * as tool from '../src/index.ts' +const testToolSignal = new AbortController().signal + /** * Shared spec helpers: a real `SystemPrompt` + `ToolRegistry` + timer + * tool-cordis tree (only the model is absent — the code strings below stand in @@ -27,7 +29,7 @@ let callCounter = 0 /** Execute a registered tool through the real registry pipeline. */ export function call(ctx: Context, name: string, args: unknown): Promise { - return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args }) + return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args }) } /** Concatenated text blocks of one tool result. */ diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index d8c8adc606..fab6abb97b 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -56,7 +56,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti 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. 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. AgentLoop privately owns one cancellation holder whose explicit signal spans prompt policy, assembly, every step, model and tool work, continuation, terminal stop, turn close, and flush; the next turn gets a fresh signal. `cancel()` strictly normalizes a runtime-only `user | parent` cause, clears pending work, and cooperatively aborts the holder without leaking to the next prompt; durable `turn/end` remains coarse `aborted`, and undispatched model tool calls receive synthetic `tool/call` and aborted result pairs. Disposal wins terminal classification, and work that ignores the signal must settle before quiescence. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. 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 receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. AgentLoop privately owns one cancellation holder whose explicit signal spans prompt policy, assembly, every step, model and tool work, recovery, continuation, terminal stop, turn close, and flush; the next turn gets a fresh signal. `cancel()` strictly normalizes a runtime-only `user | parent` cause, clears pending work, and cooperatively aborts the holder without leaking to the next prompt; durable `turn/end` remains coarse `aborted`, and undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. Disposal wins terminal classification, and work that ignores the signal must settle before quiescence. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. Terminal continuation stops remain authoritative through turn close and durability flush. Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path. @@ -65,6 +65,7 @@ Within a step, exclusive calls form barriers; parallel-safe calls use a bounded 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: pressure on `agent/post-step`; canonical context overflow on `agent/request-error` +- Transient model recovery: `dsh-llm-retry` on `agent/request-error`, with finite code-specific budgets and non-surface `llm/retry` status events - 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 generic [`ctx.tasks`](../../tasks/tasks/) plus [`dsh-tool-subagent`](../../subagent/tool-subagent/) own background collection. - Persistence: `session/event` + `session/flush` @@ -104,7 +105,7 @@ Ordinary history growth is append-only and preserves reusable entries. A surface #### What the model sees -If a later request replays an aborted step, each tool call that cancellation prevented from dispatching has the error result text `Error: tool call skipped because the step was aborted before execution`. +If a later request replays an aborted step, each tool call that cancellation prevented from dispatching has error code `ABORTED_BEFORE_DISPATCH` and result text `Error: tool call aborted before dispatch`. #### Token effect diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 14eb68a9fe..d6eec0a8c8 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -6,9 +6,9 @@ */ import type { Context } from 'cordis' -import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm' import { isDeepStrictEqual } from 'node:util' -import { BlockAssembler, HarnessError, assertNever, deepFreeze, errorChain, isLlmAdapterFailure } from '@deepseek-ai/dsh-llm' +import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf } from '@deepseek-ai/dsh-llm' import { agentEvents, agentInterruptReasonOf, 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' @@ -29,24 +29,29 @@ function toError(error: unknown): RequestError { /** Distinguishes final model-request failures from failures in later step processing. */ class TerminalModelRequestFailure extends Error { - constructor(readonly requestError: RequestError) { - super(requestError.message, { cause: requestError }) + constructor( + readonly requestError: RequestError, + readonly failure: LlmFailure, + ) { + super(failure.message, { cause: requestError }) this.name = 'TerminalModelRequestFailure' } } /** Convert terminal failure finishes into step errors; unknown extensible finishes remain successful. */ -function finishError(finish: FinishReason): RequestError | undefined { +function finishError(finish: FinishReason): { error: RequestError; failure: LlmFailure } | undefined { switch (finish.kind) { - case 'error': { - const error: RequestError = new Error(finish.message) - if (finish.code !== undefined) error.code = finish.code - return error - } + case 'error': case 'aborted': { - const error: RequestError = new Error('model stream aborted') - error.code = 'ABORTED' - return error + const facts = finish.failure + const error = new LlmError(facts.message, facts.code, { + ...facts.status === undefined ? {} : { status: facts.status }, + ...facts.providerRetryAfterMs === undefined + ? {} + : { providerRetryAfterMs: facts.providerRetryAfterMs }, + ...facts.requestId === undefined ? {} : { requestId: facts.requestId }, + }) + return { error, failure: error.failure } } // stop / tool-calls / max-tokens / plugin-added kinds → not a failure. default: @@ -65,6 +70,12 @@ function errorData(err: RequestError): { message: string; code?: string } { return { message: errorChain(err), ...typeof err.code === 'string' ? { code: err.code } : {} } } +/** Preserve cause diagnostics, falling back to adapter-normalized prose for a hostile Error. */ +function durableFailure(err: RequestError, failure: LlmFailure): LlmFailure { + const message = errorChain(err) + return { ...failure, message: message === '' ? failure.message : message } +} + /** Map a successful max-token finish onto the turn reason; other successful finishes add nothing. */ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined { switch (finish.kind) { @@ -241,7 +252,7 @@ async function runTurn( let reason: TurnEndReason = { kind: 'completed' } let step = 0 - let requestRetryAttempt = 0 + let requestFailureHistory: readonly LlmFailure[] = Object.freeze([]) let stepOpen = false let errorReported = false let terminalStopped = false @@ -254,10 +265,12 @@ async function runTurn( } // Record the durable turn failure once and contain the live error notification. - const failTurn = (err: RequestError): void => { + const failTurn = (err: RequestError, failure?: LlmFailure): void => { if (errorReported) return errorReported = true - reason = { kind: 'error', step, ...errorData(err) } + reason = failure === undefined + ? { kind: 'error', step, ...errorData(err) } + : { kind: 'error', step, failure: durableFailure(err, failure) } try { events.emit('agent/error', turn, step, err) } catch { @@ -352,14 +365,14 @@ async function runTurn( let stepOutcome: | { hadToolCalls: boolean; finish: FinishReason } - | { requestError: RequestError } + | { requestError: RequestError; failure: LlmFailure } | { error: RequestError } try { stepOutcome = await runStep( ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, signal) } catch (error: unknown) { if (error instanceof TerminalModelRequestFailure) { - stepOutcome = { requestError: error.requestError } + stepOutcome = { requestError: error.requestError, failure: error.failure } } else { stepOutcome = { error: toError(error) } } @@ -380,7 +393,7 @@ async function runTurn( try { recoveryDecision = await events.waterfall( 'agent/request-error', turn, step, stepOutcome.requestError, - requestRetryAttempt, signal, + stepOutcome.failure, requestFailureHistory, signal, () => Promise.resolve(defaultDecision), ) } catch (recoveryError: unknown) { @@ -397,10 +410,10 @@ async function runTurn( } switch (recoveryDecision.action) { case 'retry': - requestRetryAttempt += 1 + requestFailureHistory = Object.freeze([...requestFailureHistory, stepOutcome.failure]) continue case 'fail': - failTurn(stepOutcome.requestError) + failTurn(stepOutcome.requestError, stepOutcome.failure) break /* v8 ignore next -- closed-union exhaustiveness guard */ default: @@ -421,7 +434,7 @@ async function runTurn( break } - requestRetryAttempt = 0 + requestFailureHistory = Object.freeze([]) // Preserve max-token completion unless a later disposal, abort, or error wins. const stepReason = stepFinishReason(stepOutcome.finish) @@ -605,14 +618,15 @@ async function runStep( assembler.push(chunk) } } catch (error: unknown) { - if (isLlmAdapterFailure(stream, error)) throw new TerminalModelRequestFailure(error) + const failure = llmFailureOf(stream, error) + if (failure !== undefined && error instanceof Error) throw new TerminalModelRequestFailure(error, failure) throw error } interruptionCheckpoint(signal) // Normalize failure finish chunks into the same path as thrown stream errors. const stepError = finishError(assembler.finish) - if (stepError) throw new TerminalModelRequestFailure(stepError) + if (stepError) throw new TerminalModelRequestFailure(stepError.error, stepError.failure) const recordAssistantMessage = ( assembledContent: ContentBlock[], diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index 663dda1b53..64c6c69cf6 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -13,7 +13,7 @@ import type { Context } from 'cordis' import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm' import type { HookContext } from '@deepseek-ai/dsh-agent' import type { Session } from '@deepseek-ai/dsh-session' -import { TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools' +import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools' /** One tool call after argument parsing, ready to schedule. */ interface PlannedCall { @@ -217,9 +217,9 @@ async function runGroup( function appendSkippedToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): void { const callSeq = appendToolCall(session, turn, step, block) appendToolResult(session, turn, step, block, { - content: [{ type: 'text', text: 'Error: tool call skipped because the step was aborted before execution' }], + content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], isError: true, - error: { name: 'AbortError', code: 'ABORTED' }, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, }, callSeq) } diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index 73996047b3..70c02bbeed 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -9,6 +9,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' +const testToolSignal = new AbortController().signal + interface Harness { ctx: Context agentsFiber: Fiber @@ -313,6 +315,7 @@ describe('AgentLoop initiator scope', () => { })) const direct = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('direct'), name: 'agentless-probe', arguments: {}, diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index f4cdc01158..b722a5312d 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -11,7 +11,7 @@ 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, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineTool, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -369,7 +369,7 @@ describe('Agent.cancel()', () => { expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({ callId: 'c1', isError: true, - error: { name: 'AbortError', code: 'ABORTED' }, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, }) send(agent, 'continue safely') diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index c043f429c9..75993be1b3 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, ContentBlock, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineTool, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent' import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' import { prepareReactLoopAgent } from '../src/agent.ts' @@ -258,7 +258,10 @@ describe('abort during tool execution ends the turn', () => { 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' + const outcome = event.data.error?.code === TOOL_ABORTED + || event.data.error?.code === TOOL_ABORTED_BEFORE_DISPATCH + ? 'aborted' + : 'completed' order.push(`tool/result:${event.data.callId}:${outcome}`) break } @@ -289,9 +292,9 @@ describe('abort during tool execution ends the turn', () => { expect(order).toEqual([ 'assistant/message', 'tool/call:c1', - 'tool/result:c1:real', + 'tool/result:c1:aborted', 'tool/call:c2', - 'tool/result:c2:synthetic-aborted', + 'tool/result:c2:aborted', 'context/message', 'agent/post-step', 'step/end', @@ -302,11 +305,16 @@ describe('abort during tool execution ends the turn', () => { 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[0]!.data).toMatchObject({ + callId: CallId('c1'), + content: [{ type: 'text', text: 'Error: tool call aborted' }], + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED }, + }) expect(results[1]!.data).toMatchObject({ callId: CallId('c2'), isError: true, - error: { name: 'AbortError', code: 'ABORTED' }, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, }) }) @@ -923,8 +931,15 @@ describe('discriminated SessionEvent narrows without casts', () => { describe('a finish-error stream chunk ends the turn as error, not completed', () => { it('translates finish {kind:error} into a turn error with a logged error event', async () => { // A finish-error chunk must not produce a completed assistant turn. + const failure = { + message: 'provider 401', + code: 'AUTH', + status: 401, + providerRetryAfterMs: 2_000, + requestId: ProviderRequestId('finish-request-1'), + } const errorStream: StreamChunk[] = [ - { type: 'finish', reason: { kind: 'error', message: 'provider 401', code: 'AUTH' } }, + { type: 'finish', reason: { kind: 'error', failure } }, ] const adapter = new MockAdapter([errorStream]) const ctx = await harness(adapter) @@ -936,20 +951,20 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () send(agent, 'go') await waitForIdle(ctx, agent) - expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'provider 401', code: 'AUTH' }]) + expect(reasons).toEqual([{ kind: 'error', step: 1, failure }]) const events = [...agent.session.events] // The durable failure lives on turn/end.reason (with the failing step), not // a standalone error event. const turnEnd = events.find(event => event.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', step: 1, message: 'provider 401', code: 'AUTH' }) + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', step: 1, failure }) // A failed step must not synthesize an assistant message. expect(events.some(event => event.type === 'assistant/message')).toBe(false) }) it('translates finish {kind:aborted} into a turn error coded ABORTED', async () => { const abortedStream: StreamChunk[] = [ - { type: 'finish', reason: { kind: 'aborted' } }, + { type: 'finish', reason: { kind: 'aborted', failure: { message: 'model stream aborted', code: 'ABORTED' } } }, ] const adapter = new MockAdapter([abortedStream]) const ctx = await harness(adapter) @@ -961,13 +976,13 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () send(agent, 'go') await waitForIdle(ctx, agent) - expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'model stream aborted', code: 'ABORTED' }]) + expect(reasons).toEqual([{ kind: 'error', step: 1, failure: { message: 'model stream aborted', code: 'ABORTED' } }]) expect([...agent.session.events].some(event => event.type === 'assistant/message')).toBe(false) }) it('handles a finish error without a code (code key omitted)', async () => { const errorStream: StreamChunk[] = [ - { type: 'finish', reason: { kind: 'error', message: 'codeless failure' } }, + { type: 'finish', reason: { kind: 'error', failure: { message: 'codeless failure', code: 'UNKNOWN' } } }, ] const adapter = new MockAdapter([errorStream]) const ctx = await harness(adapter) @@ -979,7 +994,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () send(agent, 'go') await waitForIdle(ctx, agent) - expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'codeless failure' }]) + expect(reasons).toEqual([{ kind: 'error', step: 1, failure: { message: 'codeless failure', code: 'UNKNOWN' } }]) }) }) @@ -1099,7 +1114,7 @@ describe('turn and step boundary recovery', () => { }) it('a one-shot turn/end validation failure preserves the earlier turn error on retry', async () => { - const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider failed' } }] + const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider failed', code: 'UNKNOWN' } } }] const adapter = new MockAdapter([errorStream]) const ctx = await balancedHarness(adapter) const agent = ctx.agentLoop.create(SessionId('a-turnend-veto'), { provider: 'mock', model: 'mock' }) @@ -1129,7 +1144,7 @@ describe('turn and step boundary recovery', () => { const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'error', - message: 'provider failed', + failure: { message: 'provider failed', code: 'UNKNOWN' }, }) }) @@ -1165,7 +1180,7 @@ describe('turn and step boundary recovery', () => { it('a throwing agent/error listener during a step-error path still balances the turn, loop survives', async () => { // Listener failure cannot interrupt error finalization or the next turn. - const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }] + const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider 500', code: 'SERVER' } } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) const agent = ctx.agentLoop.create(SessionId('a-errorlistener'), { provider: 'mock', model: 'mock' }) @@ -1181,7 +1196,11 @@ describe('turn and step boundary recovery', () => { expect(c.turnStart).toBe(1) expect(c.turnEnd).toBe(1) expect(c.stepStart).toBe(c.stepEnd) - expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({ kind: 'error', step: 1, message: 'provider 500' }) + expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({ + kind: 'error', + step: 1, + failure: { message: 'provider 500', code: 'SERVER' }, + }) // loop survives: a second turn runs to completion (invariants oracle would // throw on its turn/start if turn 1 had been left open). @@ -1326,7 +1345,7 @@ describe('turn and step boundary recovery', () => { it('a throwing step/end observer cannot interrupt error finalization', async () => { // Observer failure after step/end commit cannot interrupt turn finalization. - const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }] + const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider 500', code: 'SERVER' } } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-stependthrow'), { provider: 'mock', model: 'mock' }) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 1d607c6da5..7d4e79238e 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -187,7 +187,9 @@ describe('toError normalization', () => { // String() of { code: 500 } is '[object Object]' expect(errors[0]!.message).toBe('[object Object]') const turnEnd = agent.session.events.find(e => e.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' && turnEnd.data.reason.code).toBe('UNKNOWN') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' + && ('failure' in turnEnd.data.reason ? turnEnd.data.reason.failure.code : turnEnd.data.reason.code)) + .toBe('UNKNOWN') }) }) @@ -218,7 +220,8 @@ describe('coded error data emission', () => { const turnEnd = agent.session.events.find(e => e.type === 'turn/end') expect(turnEnd).toBeDefined() if (turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error') { - expect(turnEnd.data.reason.code).toBe('RATE_LIMIT') + expect('failure' in turnEnd.data.reason ? turnEnd.data.reason.failure.code : turnEnd.data.reason.code) + .toBe('RATE_LIMIT') } }) }) diff --git a/packages/core/agent-loop/tests/request-recovery.spec.ts b/packages/core/agent-loop/tests/request-recovery.spec.ts index c751b28332..1e6bc14548 100644 --- a/packages/core/agent-loop/tests/request-recovery.spec.ts +++ b/packages/core/agent-loop/tests/request-recovery.spec.ts @@ -3,10 +3,12 @@ import { Context } from 'cordis' import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, + HarnessError, LlmAdapter, LlmError, + ProviderRequestId, } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmFailure, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' @@ -258,16 +260,17 @@ describe('agent post-step and request-error lifecycle', () => { it.each([ ['thrown', contextError()], - ['in-band', [{ type: 'finish', reason: { kind: 'error', message: 'too large', code: CONTEXT_WINDOW_EXCEEDED_CODE } }] satisfies StreamChunk[]], + ['in-band', [{ type: 'finish', reason: { kind: 'error', failure: { message: 'too large', code: CONTEXT_WINDOW_EXCEEDED_CODE, status: 400 } } }] 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(SessionId(`recover-${_style}`), { provider: 'mock', model: 'mock' }) const attempts: number[] = [] - ctx.on('agent/request-error', async (subject, turn, step, error, attempt) => { + ctx.on('agent/request-error', async (subject, turn, step, error, facts, history) => { expect(subject).toBe(agent) expect({ turn, step, code: error.code }).toEqual({ turn: 1, step: 1, code: CONTEXT_WINDOW_EXCEEDED_CODE }) - attempts.push(attempt) + expect(facts.code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE) + attempts.push(history.length) subject.session.append('context/message', { content: [{ type: 'text', text: 'RECOVERY SURFACE MUTATION' }], source: { kind: 'plugin', plugin: 'test-recovery' }, @@ -295,7 +298,7 @@ describe('agent post-step and request-error lifecycle', () => { const agent = ctx.agentLoop.create(SessionId(`stream-plugin-${_name.replaceAll(' ', '-')}`), { provider: 'mock', model: 'mock' }) let recoveries = 0 install(ctx) - ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => { + ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => { recoveries += 1 return next() }) @@ -326,7 +329,7 @@ describe('agent post-step and request-error lifecycle', () => { }) 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) => { + ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => { recoveries += 1 return next() }) @@ -359,7 +362,7 @@ describe('agent post-step and request-error lifecycle', () => { } const agent = ctx.agentLoop.create(SessionId(`${boundary}-not-recoverable`), { provider: 'mock', model: 'mock' }) let recoveries = 0 - ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => { + ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => { recoveries += 1 return next() }) @@ -387,7 +390,7 @@ describe('agent post-step and request-error lifecycle', () => { } const agent = ctx.agentLoop.create(SessionId(`${failure}-not-recoverable`), { provider: 'mock', model: 'mock' }) let recoveries = 0 - ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => { + ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => { recoveries += 1 return next() }) @@ -406,7 +409,7 @@ describe('agent post-step and request-error lifecycle', () => { const ctx = await harness(makeAdapter(original)) const agent = ctx.agentLoop.create(SessionId(`identity-${_name.replaceAll(' ', '-')}`), { provider: 'mock', model: 'mock' }) let seen: Error | undefined - ctx.on('agent/request-error', async (_agent, _turn, _step, error, _attempt, _signal, next) => { + ctx.on('agent/request-error', async (_agent, _turn, _step, error, _failure, _history, _signal, next) => { seen = error return next() }) @@ -417,12 +420,90 @@ describe('agent post-step and request-error lifecycle', () => { expect(seen).toBe(original) }) + it('keeps an adapter error with a hostile message accessor on the recovery path', async () => { + const original = Object.defineProperty(new HarnessError('provider failed', 'SERVER'), 'message', { + get() { throw new Error('SDK message accessor trap') }, + }) + const ctx = await harness(new SynchronousDispatchFailureAdapter(original)) + const agent = ctx.agentLoop.create(SessionId('hostile-message-recovery'), { provider: 'mock', model: 'mock' }) + let seenError: Error | undefined + let seenFailure: LlmFailure | undefined + ctx.on('agent/request-error', async (_agent, _turn, _step, error, failure, _history, _signal, next) => { + seenError = error + seenFailure = failure + return next() + }) + + send(agent) + await waitForIdle(ctx, agent) + + expect(seenError).toBe(original) + expect(seenFailure).toEqual({ message: 'LLM adapter failed', code: 'SERVER' }) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'error', failure: { message: 'LLM adapter failed', code: 'SERVER' } } }, + }) + }) + + it('passes structured facts beside the original Error and records its cause chain on exhaustion', async () => { + const original = new LlmError('provider busy', 'RATE_LIMIT', { + cause: new Error('upstream connection reset'), + status: 429, + providerRetryAfterMs: 2_000, + requestId: ProviderRequestId('req-9'), + }) + Object.freeze(original) + const ctx = await harness(new SynchronousDispatchFailureAdapter(original)) + const agent = ctx.agentLoop.create(SessionId('structured-request-failure'), { provider: 'mock', model: 'mock' }) + let seenError: Error | undefined + let seenFailure: LlmFailure | undefined + let seenHistory: readonly LlmFailure[] | undefined + ctx.on('agent/request-error', async ( + _agent, _turn, _step, error, failure, history, _signal, next, + ) => { + seenError = error + seenFailure = failure + seenHistory = history + return next() + }) + + send(agent) + await waitForIdle(ctx, agent) + + expect(seenError).toBe(original) + expect(seenFailure).toEqual({ + message: 'provider busy', + code: 'RATE_LIMIT', + status: 429, + providerRetryAfterMs: 2_000, + requestId: ProviderRequestId('req-9'), + }) + expect(seenHistory).toEqual([]) + expect(Object.isFrozen(seenHistory)).toBe(true) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { + reason: { + kind: 'error', + step: 1, + failure: { + message: 'provider busy: upstream connection reset', + code: 'RATE_LIMIT', + status: 429, + providerRetryAfterMs: 2_000, + requestId: ProviderRequestId('req-9'), + }, + }, + }, + }) + }) + 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(SessionId(`request-boundary-${scenario}`), { provider: 'mock', model: 'mock' }) let seen = '' - ctx.on('agent/request-error', async (_agent, _turn, _step, error, _attempt, _signal, next) => { + ctx.on('agent/request-error', async (_agent, _turn, _step, error, _failure, _history, _signal, next) => { seen = error.code ?? '' return next() }) @@ -436,14 +517,17 @@ describe('agent post-step and request-error lifecycle', () => { const capped = new FailureScriptAdapter([contextError('first overflow'), contextError('second overflow')]) const cappedCtx = await harness(capped) const cappedAgent = cappedCtx.agentLoop.create(SessionId('retry-cap'), { provider: 'mock', 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() + const cappedHistories: string[][] = [] + cappedCtx.on('agent/request-error', async ( + _agent, _turn, _step, _error, _failure, history, _signal, next, + ) => { + const codes = history.map(entry => entry.code) + cappedHistories.push(codes) + return codes.length < 1 ? { action: 'retry' } : next() }) send(cappedAgent) await waitForIdle(cappedCtx, cappedAgent) - expect(cappedAttempts).toEqual([0, 1]) + expect(cappedHistories).toEqual([[], [CONTEXT_WINDOW_EXCEEDED_CODE]]) const reset = new FailureScriptAdapter([ contextError('first overflow'), @@ -458,14 +542,16 @@ describe('agent post-step and request-error lifecycle', () => { async execute() { return [{ type: 'text', text: 'worked' }] }, })) const resetAgent = resetCtx.agentLoop.create(SessionId('retry-reset'), { provider: 'mock', 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() + const resetHistories: { step: number; codes: string[] }[] = [] + resetCtx.on('agent/request-error', async ( + _agent, _turn, step, _error, _failure, history, _signal, next, + ) => { + resetHistories.push({ step, codes: history.map(entry => entry.code) }) + return resetHistories.length === 1 ? { action: 'retry' } : next() }) send(resetAgent) await waitForIdle(resetCtx, resetAgent) - expect(resetAttempts).toEqual([{ step: 1, attempt: 0 }, { step: 3, attempt: 0 }]) + expect(resetHistories).toEqual([{ step: 1, codes: [] }, { step: 3, codes: [] }]) }) it('preserves the original provider error when recovery throws', async () => { @@ -479,7 +565,7 @@ describe('agent post-step and request-error lifecycle', () => { expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', - data: { reason: { kind: 'error', message: 'original overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE } }, + data: { reason: { kind: 'error', failure: { message: 'original overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE } } }, }) }) @@ -489,7 +575,7 @@ describe('agent post-step and request-error lifecycle', () => { const agent = ctx.agentLoop.create(SessionId(`${action}-recovery`), { provider: 'mock', model: 'mock' }) let entered!: () => void const recoveryEntered = new Promise((resolve) => { entered = resolve }) - ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, signal) => { + ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, signal) => { entered() await new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 011fe87b96..35985c9185 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -9,7 +9,7 @@ import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import LlmService from '@deepseek-ai/dsh-llm' -import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineTool, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -476,12 +476,12 @@ describe('tool-call scheduler: abort handling', () => { isError: e.data.isError, error: e.data.error, }))).toEqual([ - { callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }, - { callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }, + { callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }, + { callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }, ]) }) - it('stops starting siblings when abort fires during ordered pre-execute', async () => { + it('skips dispatch and stops starting siblings when abort fires during ordered pre-execute', async () => { const adapter = new MockAdapter([ multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), textResponse('should never be requested'), @@ -498,18 +498,19 @@ describe('tool-call scheduler: abort handling', () => { }) agent.send([{ type: 'text', text: 'go' }]) - await until(() => gated.started.length === 1) - await new Promise(r => setTimeout(r, 5)) - expect(gated.started).toEqual(['1']) - gated.release('1') await waitForIdle(ctx, agent) + expect(gated.started).toEqual([]) expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId)) .toEqual([CallId('c1'), CallId('c2')]) - expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId)) - .toEqual([CallId('c1'), CallId('c2')]) - expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data) - .toMatchObject({ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }) + expect(events(agent).filter(e => e.type === 'tool/result').map(e => ({ + callId: e.data.callId, + isError: e.data.isError, + error: e.data.error, + }))).toEqual([ + { callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }, + { callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }, + ]) }) it('stops replenishing after abort, commits started results, and drains accepted additional contexts', async () => { @@ -540,8 +541,8 @@ describe('tool-call scheduler: abort handling', () => { .toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')]) expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => e.data)) .toEqual([ - expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }), - expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }), + expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }), + expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }), ]) const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message') expect(settled.map(e => e.type)) @@ -583,6 +584,6 @@ describe('tool-call scheduler: abort handling', () => { expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId)) .toEqual([CallId('c1'), CallId('c2'), CallId('c3')]) expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data) - .toMatchObject({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }) + .toMatchObject({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }) }) }) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index c3d71cd32f..3c5fa3111c 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -44,7 +44,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. -Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `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 [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. +Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `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: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `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 [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. `PromptDecision.additionalContexts` is an array so every injected context keeps its own source and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 28e8e94abc..e1258af3ac 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -7,7 +7,7 @@ import type { Context } from 'cordis' import type { Scoped } from '@deepseek-ai/dsh-scope' -import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/dsh-system-prompt' { @@ -288,12 +288,13 @@ declare module 'cordis' { * @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 failure - serializable facts normalized at the final adapter boundary. + * @param priorFailures - immutable failures that already authorized another request in this consecutive sequence. * @param signal - the turn abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ - 'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, retryAttempt: number, signal: AbortSignal, next: () => Promise): Promise + 'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], 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/core/session/README.md b/packages/core/session/README.md index 0304deadf4..df635f0085 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -60,11 +60,11 @@ Durable values need one accepted representation, not a check followed by a secon ### Session event vocabulary (`types.ts`) -The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token usage and provider/model/replay provenance ride on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`. +The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure. -Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, the hook bridges' `hook/*`); merged members appear in the same catalog. +Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog. -Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). +Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). A final model-request error retains one structured `LlmFailure`; other turn errors retain message/code, and both identify the failed step. An interrupted live turn ends with the coarse `{ kind: 'aborted' }` outcome. Caller identity belongs to the Agent's runtime cancellation signal rather than the durable transcript; disposal remains the separate `{ kind: 'disposed' }` terminal state. diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 542395cc21..6deed3a302 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -1,5 +1,5 @@ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm' import type { JsonValue } from './json.ts' /** Identifies one session in the store (and its persistence artifacts). */ @@ -107,9 +107,13 @@ export interface TurnEndReasonMap { * The turn failed: a step threw or the model reported a failure. `step` is the * step number the failure occurred on (the operational error's location — the * single durable record of an in-turn failure; live diagnostics also fire via - * `agent/error`). `code` is the error's code when one was attached. + * `agent/error`). Final model-request failures retain their normalized facts + * as one `failure`; other turn failures retain their live Error message/code. */ - error: { kind: 'error'; step: number; message: string; code?: string } + error: { kind: 'error'; step: number } & ( + | { failure: LlmFailure; message?: never; code?: never } + | { message: string; code?: string; failure?: never } + ) disposed: { kind: 'disposed' } /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 2cb841d0e2..823060f728 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -20,24 +20,28 @@ tools: - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber. -- `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body; around wrappers may replace only `signal`. +- `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body. Around wrappers may replace only `signal`; the registry re-fuses the original caller signal immediately before the body. - `ctx.tools.executionMode(exec)` returns `parallel` only when the visible definition's `isConcurrencySafe(exec.arguments)` classifier returns exactly `true`; unknown, hidden, undeclared, invalid, or throwing classifications are exclusive. ### Injected services `SystemPrompt` — the registry automatically feeds its tool schemas into the system-prompt assembly via `ctx.systemPrompt.tools()`. The approval seam is consumed opportunistically instead (`ctx.get('approval')`, no static inject): a deployment without it keeps the ask→deny degrade, and the registry stays active either way. +### Cancellation + +Cancellation is cooperative and quiescent. Every typed invocation supplies a caller-owned `AbortSignal`; tool bodies receive it as required readonly `exec.signal`, while only `tools/execute` wrappers may temporarily replace the required signal. The registry preserves caller cancellation through replacement and never races away from a started same-process promise. Cancellation before body invocation is `ABORTED_BEFORE_DISPATCH`; cancellation after invocation can replace only a successful outcome with `ABORTED`. A denial, wrapper failure, tool failure, post-policy failure, or timeout-owned `TOOL_TIMEOUT` remains more specific. A pre-aborted entry materializes and freezes arguments, then skips every policy and dispatch phase and publishes one result. Every async tool must observe or forward the signal and settle only after owned work stops. The [tool-cancellation Agent Note](../../../.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md) owns the full contract and hard-termination limit. + ### Live events The live registry pipeline has three transformable waterfalls followed by the observe-only `tools/result` boundary; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure-containment contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md), while the complete ordering is visualized in the generated [tool execution pipeline](../../../docs/tool-execution-pipeline.md). `tools/result` is live; the similarly named `tool/result` is the durable session event the agent loop appends afterwards. ### Key types -- `ToolDefinition` — `ToolSchema` + `execute(args, exec)`, optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification. -- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token. +- `ToolDefinition` — `ToolSchema` + `execute(args, exec)`, whose async work must cooperatively stop through `exec.signal`, plus optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification. +- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, signal, agent?, parent? }`; `signal` is required and readonly, callers may pass an enclosing execution's opaque token as `parent`, and callers never choose the new execution's own token. - `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary. -- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. -- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws; it never injects immediately. +- `ToolExecution` — the readonly pipeline view: immutable `{ token, callId, name, arguments, signal, agent?, parent? }`; the registry separately retains and re-fuses the original caller signal. `ToolDispatchExecution` is the `tools/execute`-only view whose required signal is mutable, so a wrapper may replace and restore it but cannot delete it. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. +- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws or cancellation wins; it never injects immediately. - `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step. - `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny. - `PostToolDecision` — `{kind:'accept', content?, additionalContexts?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContexts?}` (turn it into an `isError` whose content is the corrective feedback). Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision. @@ -74,7 +78,7 @@ ctx.tools.register(defineTool({ }, async execute(args, exec) { // args is typed: { path: string; offset?: number; limit?: number } - const text = await readFile(args.path, 'utf8') + const text = await readFile(args.path, { encoding: 'utf8', signal: exec.signal }) return [{ type: 'text', text }] }, })) diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 0a65c9e434..01af0e4857 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -160,9 +160,8 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => // (its executor kills on this signal) instead of orphaned, and // queued-unstarted dispatches are abandoned. const runController = new AbortController() - const onOuterAbort = (): void => { runController.abort(exec.signal?.reason) } - if (exec.signal?.aborted) onOuterAbort() - exec.signal?.addEventListener('abort', onOuterAbort, { once: true }) + const onOuterAbort = (): void => { runController.abort(exec.signal.reason) } + exec.signal.addEventListener('abort', onOuterAbort, { once: true }) let dispatches = 0 // The per-run serialization queue: every binding call chains onto the tail, so even @@ -273,7 +272,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => meta, } } finally { - exec.signal?.removeEventListener('abort', onOuterAbort) + exec.signal.removeEventListener('abort', onOuterAbort) } }, // ACP execute cards use the program as their visible title. diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 18fa286a16..086d6a3271 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -72,7 +72,9 @@ declare module 'cordis' { interface Events { /** * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing - * approval support turns `ask` into denial. + * approval support turns `ask` into denial. Async gates must observe + * `exec.signal`; the registry rechecks cancellation after they settle but + * never abandons their promise. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @param exec - the pending call (name, parsed arguments, caller agent). * @mode waterfall @@ -81,15 +83,20 @@ declare module 'cordis' { /** * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns * a normalized result; wrappers may change only `exec.signal`, while call - * identity remains immutable. + * identity remains immutable. The registry re-fuses the original caller + * signal before the body, so replacement cannot detach caller cancellation; + * wrappers must still restore their signal and reach quiescence. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal). * @mode waterfall */ - 'tools/execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise + 'tools/execute'(this: Scoped, exec: ToolDispatchExecution, next: () => Promise): Promise /** * Accept, replace, enrich, or block a normalized dispatch result. `next()` - * accepts it unchanged; thrown tools still reach this seam as errors. + * accepts it unchanged; thrown tools still reach this seam as errors. Async + * listeners must observe `exec.signal`; after they settle, caller + * cancellation replaces only a successful accepted outcome with the code + * selected by whether the tool body was invoked. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @param exec - the call that just ran (name, parsed arguments, caller agent). * @param result - the dispatch outcome a listener may accept, replace, or block. @@ -122,6 +129,15 @@ export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta /** A registered tool: its schema plus the execution function. */ export interface ToolDefinition extends ToolSchema { + /** + * Run one accepted call. Async work must observe or forward `exec.signal` and + * settle only after its owned work reaches quiescence. The registry preserves + * caller cancellation through around-dispatch signal replacement and does + * not abandon this promise, but it cannot hard-kill same-process code. + * @param args - losslessly snapshotted, frozen model arguments. + * @param exec - execution identity, cancellation signal, and context deferral. + * @returns model-facing content plus optional private presentation metadata. + */ execute(args: unknown, exec: ToolRunContext): Promise /** * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline. @@ -203,7 +219,8 @@ export interface ToolExecutionInput { * the outer `run_code` outcome without receiving its live mutable execution. */ readonly parent?: ToolExecutionToken - signal?: AbortSignal + /** Required caller-owned cancellation for this invocation. */ + readonly signal: AbortSignal } /** @@ -217,15 +234,25 @@ export type ToolExecutionMode = /** * One pending tool call inside the registry pipeline. Parsed arguments cross * one lossless-JSON materialization boundary before policy and are deep-frozen; - * call identity and the registry-assigned {@link token} are readonly. An - * around-dispatch wrapper may set, replace, or remove `signal`. The registry - * freezes the complete object before `tools/result` observers run. + * call identity, the caller signal, and the registry-assigned {@link token} are + * readonly. The registry freezes the complete object before `tools/result` + * observers run. */ export interface ToolExecution extends ToolExecutionInput { /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ readonly token: ToolExecutionToken } +/** + * Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper + * may replace the signal for its delegated lifetime, but it cannot remove it. + * The registry fuses every replacement with the captured caller signal. + */ +export interface ToolDispatchExecution extends Omit { + /** Cancellation signal visible to the next wrapper or tool body. */ + signal: AbortSignal +} + /** * Runtime context handed to a tool implementation after the registry has * accepted a {@link ToolExecution}. A composite tool uses @@ -241,6 +268,9 @@ export interface ToolRunContext extends ToolExecution { deferContext(context: HookContext): void } +/** Registry-owned live execution object; public pipeline views stay readonly. */ +type MutableToolRunContext = Omit & { signal: AbortSignal } + /** * Scheduler-only result after ordered pre-execute and guards. A `post-result` * still receives post-execute; a `final-result` bypasses it. @@ -282,6 +312,13 @@ export interface ToolRegistryScheduler { * @internal */ export const TOOL_REGISTRY_SCHEDULER: unique symbol = Symbol('@deepseek-ai/dsh-tools.scheduler') + +/** Canonical error code for cancellation after a tool body was invoked. */ +export const TOOL_ABORTED = 'ABORTED' + +/** Canonical error code for cancellation before a tool body was invoked. */ +export const TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH' + /** Structured error metadata for a failed tool call (alongside the model-facing text). */ export interface ToolErrorInfo { name: string @@ -431,6 +468,24 @@ interface ToolGuardRegistration { guard: ToolGuard } +/** Approval decision plus whether the approval channel reported cancellation. */ +interface ToolAskResolution { + readonly decision: Extract + readonly approvalCancelled: boolean +} + +/** Caller cancellation and dispatch state kept outside the around-wrapper view. */ +interface ToolCancellationState { + readonly callerSignal: AbortSignal + bodyInvoked: boolean +} + +/** One dispatch-scoped fused signal plus listener cleanup after the body settles. */ +interface FusedToolSignal { + readonly signal: AbortSignal + dispose(): void +} + /** * Tool registry and execution pipeline. Scoped registrations shadow globals; * one visibility resolver feeds presentation, lookup, and dispatch. @@ -452,6 +507,8 @@ export class ToolRegistry extends Service { /** Context deferred by a running tool body, keyed by its scheduler-owned execution. */ private deferredContexts = new WeakMap() + /** Original caller cancellation, kept outside the wrapper-mutable execution object. */ + private cancellationStates = new WeakMap() private global = new Map() private scoped = new Map>() /** Compiled restriction filters, per scope (see {@link restrict}). */ @@ -774,7 +831,11 @@ export class ToolRegistry extends Service { * Execute through pre-policy, guards, around-dispatch, post-policy, and final * notification. Tool and listener failures resolve as materialized error * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is - * the same lossless, frozen snapshot final observers receive. + * the same lossless, frozen snapshot final observers receive. Cancellation + * arriving after entry and before final result materialization skips a + * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a + * successful started outcome with `ABORTED`; already-started work is still + * drained and may retain a tool-owned structured error. * @param exec - the typed same-process call input. The registry assigns its * correlation token before policy begins. * @returns the materialized final result. @@ -801,7 +862,7 @@ export class ToolRegistry extends Service { } } - private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: ToolRunContext } { + private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: MutableToolRunContext } { const deferredContexts: HookContext[] = [] const token = createExecutionToken() const callId = exec.callId @@ -813,9 +874,9 @@ export class ToolRegistry extends Service { token, callId, name, + signal, ...agent !== undefined ? { agent } : {}, ...parent !== undefined ? { parent } : {}, - ...signal !== undefined ? { signal } : {}, deferContext(context: HookContext): void { deferredContexts.push(context) }, @@ -825,11 +886,15 @@ export class ToolRegistry extends Service { if (detached === undefined) { throw new TypeError('tool execution arguments must be losslessly JSON-serializable') } - const execution: ToolRunContext = { ...base, arguments: deepFreeze(detached) } + const execution: MutableToolRunContext = { ...base, arguments: deepFreeze(detached) } this.deferredContexts.set(execution, deferredContexts) + this.cancellationStates.set(execution, { + callerSignal: signal, + bodyInvoked: false, + }) return { kind: 'ready', exec: execution } } catch (error: unknown) { - const execution: ToolRunContext = { ...base, arguments: undefined } + const execution: MutableToolRunContext = { ...base, arguments: undefined } return { kind: 'final-result', exec: execution, result: toolErrorResult(error) } } } @@ -851,13 +916,22 @@ export class ToolRegistry extends Service { const created = this.createExecution(input) if (created.kind !== 'ready') return next(created) const exec = created.exec + if (this.callerCancelled(exec)) { + return next({ kind: 'final-result', exec, result: toolAbortedBeforeDispatchResult() }) + } try { const carrier = scopeTarget(this, exec.agent) const gate = await this.ctx.waterfall( carrier, 'tools/pre-execute', exec, () => Promise.resolve({ kind: 'allow' }), ) - const decision = gate.kind === 'ask' ? await this.serviceAsk(exec, gate) : gate + const askResolution: ToolAskResolution = gate.kind === 'ask' + ? await this.serviceAsk(exec, gate) + : { decision: gate, approvalCancelled: false } + const { decision } = askResolution + if (this.callerCancelled(exec) && askResolution.approvalCancelled) { + return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() }) + } const denialReason = decision.kind === 'allow' ? this.guardReason(exec) : decision.reason @@ -871,12 +945,74 @@ export class ToolRegistry extends Service { }, }) } + if (this.callerCancelled(exec)) { + return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() }) + } return await next({ kind: 'dispatch', exec }) } catch (error: unknown) { return next({ kind: 'final-result', exec, result: toolErrorResult(error) }) } } + /** Whether the original caller signal is currently aborted. */ + private callerCancelled(exec: ToolRunContext): boolean { + const state = this.cancellationStates.get(exec) + /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */ + if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state') + return state.callerSignal.aborted + } + + /** Canonical cancellation outcome selected by whether the tool body started. */ + private cancellationResult(exec: ToolRunContext, prior?: ToolExecutionResult): ToolExecutionResult { + const state = this.cancellationStates.get(exec) + /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */ + if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state') + return state.bodyInvoked + ? toolAbortedResult(prior) + : toolAbortedBeforeDispatchResult(prior) + } + + /** + * Dispatch the registered body with the original caller signal fused back + * into any around-wrapper replacement. Cancellation never abandons the body: + * a started promise reaches quiescence before its outcome becomes `ABORTED`. + */ + private async dispatchToolBody(exec: MutableToolRunContext): Promise { + const state = this.cancellationStates.get(exec) + /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */ + if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state') + const wrapperSignal = exec.signal + const fused = fuseToolSignals(state.callerSignal, wrapperSignal) + const signal = fused.signal + + if (isAborted(signal)) { + fused.dispose() + return toolAbortedBeforeDispatchResult() + } + exec.signal = signal + try { + const tool = this.get(exec.name, exec.agent) + if (!tool) throw new ToolNotFoundError(exec.name) + state.bodyInvoked = true + const returned = await tool.execute(exec.arguments, exec) + const content = Array.isArray(returned) ? returned : returned.content + const meta = Array.isArray(returned) ? undefined : returned.meta + const result: ToolExecutionResult = { + content, + isError: false, + ...meta !== undefined ? { meta } : {}, + } + return isAborted(signal) + ? toolAbortedResult(result) + : result + } catch (error: unknown) { + return toolErrorResult(error) + } finally { + fused.dispose() + exec.signal = wrapperSignal + } + } + /** * Run around-dispatch and the tool body. Tool and unknown-tool failures still * receive post-execute; pipeline failures are already final. @@ -886,21 +1022,11 @@ export class ToolRegistry extends Service { */ private async dispatchScheduledExecution(exec: ToolRunContext): Promise { try { + const mutableExec = exec as MutableToolRunContext const carrier = scopeTarget(this, exec.agent) const result = await this.ctx.waterfall( - carrier, 'tools/execute', exec, - async (): Promise => { - try { - const tool = this.get(exec.name, exec.agent) - if (!tool) throw new ToolNotFoundError(exec.name) - const returned = await tool.execute(exec.arguments, exec) - const content = Array.isArray(returned) ? returned : returned.content - const meta = Array.isArray(returned) ? undefined : returned.meta - return { content, isError: false, ...meta !== undefined ? { meta } : {} } - } catch (error: unknown) { - return toolErrorResult(error) - } - }, + carrier, 'tools/execute', mutableExec, + () => this.dispatchToolBody(mutableExec), ) const deferredContexts = this.deferredContexts.get(exec) /* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */ @@ -914,7 +1040,12 @@ export class ToolRegistry extends Service { ...result.additionalContexts ?? [], ], } - return { kind: 'post-result', result: resultWithDeferredContexts } + return { + kind: 'post-result', + result: this.callerCancelled(exec) && !resultWithDeferredContexts.isError + ? this.cancellationResult(exec, resultWithDeferredContexts) + : resultWithDeferredContexts, + } } catch (error: unknown) { return { kind: 'final-result', result: toolErrorResult(error) } } @@ -929,7 +1060,13 @@ export class ToolRegistry extends Service { */ private async finalizeScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): Promise { try { - return this.finishScheduledExecution(exec, await this.postExecute(exec, result)) + const postResult = await this.postExecute(exec, result) + return this.finishScheduledExecution( + exec, + this.callerCancelled(exec) && !postResult.isError + ? this.cancellationResult(exec, postResult) + : postResult, + ) } catch (error: unknown) { return this.finishScheduledExecution(exec, toolErrorResult(error)) } @@ -955,8 +1092,8 @@ export class ToolRegistry extends Service { /** Notify observers without exposing a mutation or error channel into the outcome. */ private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void { - // Freeze the remaining mutable signal slot before observers receive the - // shared WeakMap-keyable execution object. + // Freeze the registry's live object before observers receive its readonly + // WeakMap-keyable view. Object.freeze(exec) const { name: toolName, callId } = exec const reportFailure = (error: unknown): void => { @@ -989,26 +1126,41 @@ export class ToolRegistry extends Service { private async serviceAsk( exec: ToolExecution, ask: Extract, - ): Promise> { + ): Promise { const approval = this.ctx.get('approval') if (approval === undefined) { - return { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` } + return { + decision: { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` }, + approvalCancelled: false, + } } if (exec.agent === undefined) { - return { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` } + return { + decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` }, + approvalCancelled: false, + } } const outcome = await approval.request({ agent: exec.agent, toolName: exec.name, callId: exec.callId, ...ask.reason !== undefined ? { reason: ask.reason } : {}, - ...exec.signal !== undefined ? { signal: exec.signal } : {}, + signal: exec.signal, }) switch (outcome) { - case 'allowed-once': return { kind: 'allow' } - case 'rejected': return { kind: 'deny', reason: `the user rejected tool "${exec.name}"` } - case 'cancelled': return { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` } - case 'unavailable': return { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` } + case 'allowed-once': return { decision: { kind: 'allow' }, approvalCancelled: false } + case 'rejected': return { + decision: { kind: 'deny', reason: `the user rejected tool "${exec.name}"` }, + approvalCancelled: false, + } + case 'cancelled': return { + decision: { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` }, + approvalCancelled: true, + } + case 'unavailable': return { + decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` }, + approvalCancelled: false, + } default: return assertNever(outcome, 'ApprovalOutcome') } } @@ -1074,4 +1226,64 @@ function toolErrorResult(error: unknown): ToolExecutionResult { } } +/** Read live abort state across an await without treating it as synchronously immutable. */ +function isAborted(signal: AbortSignal): boolean { + return signal.aborted +} + +/** + * Fuse caller and wrapper cancellation without nesting `AbortSignal.any`. + * Keeping the relay dispatch-scoped also removes listeners when work settles. + */ +function fuseToolSignals(caller: AbortSignal, wrapper: AbortSignal): FusedToolSignal { + if (caller === wrapper) return { signal: caller, dispose() {} } + + const controller = new AbortController() + let listening = false + const dispose = (): void => { + if (!listening) return + listening = false + caller.removeEventListener('abort', abortFromCaller) + wrapper.removeEventListener('abort', abortFromWrapper) + } + const abortFrom = (source: AbortSignal): void => { + const reason: unknown = source.reason + controller.abort(reason) + dispose() + } + const abortFromCaller = (): void => { abortFrom(caller) } + const abortFromWrapper = (): void => { abortFrom(wrapper) } + + if (wrapper.aborted) abortFromWrapper() + else if (caller.aborted) abortFromCaller() + else { + listening = true + caller.addEventListener('abort', abortFromCaller, { once: true }) + wrapper.addEventListener('abort', abortFromWrapper, { once: true }) + } + return { signal: controller.signal, dispose } +} + +/** Canonical result when cancellation supersedes success after body invocation. */ +function toolAbortedResult(prior?: ToolExecutionResult): ToolExecutionResult { + const additionalContexts = prior?.additionalContexts ?? [] + return { + content: [{ type: 'text', text: 'Error: tool call aborted' }], + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED }, + ...additionalContexts.length > 0 ? { additionalContexts } : {}, + } +} + +/** Canonical result when cancellation prevents tool body invocation. */ +function toolAbortedBeforeDispatchResult(prior?: ToolExecutionResult): ToolExecutionResult { + const additionalContexts = prior?.additionalContexts ?? [] + return { + content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + ...additionalContexts.length > 0 ? { additionalContexts } : {}, + } +} + export default ToolRegistry diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 227ff98129..b8f47cca81 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -6,12 +6,14 @@ import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' -import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineTool } from '@deepseek-ai/dsh-tools' import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEventMap } from '@deepseek-ai/dsh-session' +const testToolSignal = new AbortController().signal + /** * Code Mode unit tier (per the Agent Note's plan): provider contribution per mode, * misconfiguration rejections, the run_code dispatch bridge (serialization, @@ -95,6 +97,7 @@ function fakeAgent(options: { cwd?: string } = { cwd: '/workspace' }): { agent: /** Dispatch run_code through the registry pipeline, as the loop would. */ async function runCode(ctx: Context, code: string, extras: { agent?: Agent; signal?: AbortSignal } = {}): Promise { return ctx.tools.execute({ + signal: testToolSignal, callId: CallId('call-1'), name: RUN_CODE_NAME, arguments: { code }, @@ -357,8 +360,7 @@ describe('the run_code dispatch bridge', () => { const previous = exec.signal exec.signal = new AbortController().signal const result = await next() - if (previous === undefined) delete exec.signal - else exec.signal = previous + exec.signal = previous return result }) ctx.on('tools/result', (exec) => { @@ -574,7 +576,7 @@ describe('the run_code dispatch bridge', () => { seen.push(args.id) await new Promise((resolve) => { const timer = setTimeout(resolve, 500) - exec.signal?.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true }) + exec.signal.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true }) }) return [{ type: 'text' as const, text: args.id }] }, @@ -610,7 +612,7 @@ describe('the run_code dispatch bridge', () => { started() await new Promise((resolve) => { const timer = setTimeout(resolve, 500) - exec.signal?.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true }) + exec.signal.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true }) }) return [{ type: 'text' as const, text: args.id }] }, @@ -838,7 +840,7 @@ describe('the run_code dispatch bridge', () => { expect((result.content[0] as { text: string }).text).toBe('{ n: 42 }') }) - it('reports a pre-aborted outer signal as the run failure without dispatching anything', async () => { + it('short-circuits a pre-aborted outer signal before the code runtime', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) const calls = registerEcho(ctx) runtime.behavior = (request) => { @@ -850,11 +852,16 @@ describe('the run_code dispatch bridge', () => { controller.abort('too-late') const result = await runCode(ctx, 'program', { signal: controller.signal }) expect(result.isError).toBe(true) - expect((result.content[0] as { text: string }).text).toContain('code run failed (abort)') + expect(result).toEqual({ + content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }) + expect(runtime.lastRequest).toBeUndefined() expect(calls).toEqual([]) }) - it('rejects a binding invoked after the run is over without dispatching it', async () => { + it('reports cancellation after rejecting a late binding without dispatching it', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) const calls = registerEcho(ctx) const controller = new AbortController() @@ -865,8 +872,9 @@ describe('the run_code dispatch bridge', () => { return { logs: [], value: message } } const result = await runCode(ctx, 'program', { signal: controller.signal }) - expect(result.isError).toBe(false) - expect((result.content[0] as { text: string }).text).toContain('not dispatched') + expect(result.isError).toBe(true) + expect(result.error).toEqual({ name: 'AbortError', code: 'ABORTED' }) + expect((result.content[0] as { text: string }).text).toBe('Error: tool call aborted') expect(calls).toEqual([]) }) diff --git a/packages/core/tools/tests/execution-mode.spec.ts b/packages/core/tools/tests/execution-mode.spec.ts index 9a12f33a51..054eed65ed 100644 --- a/packages/core/tools/tests/execution-mode.spec.ts +++ b/packages/core/tools/tests/execution-mode.spec.ts @@ -11,6 +11,8 @@ import ToolRegistry, { type ToolExecutionMode, } from '@deepseek-ai/dsh-tools' +const testToolSignal = new AbortController().signal + async function setup() { const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -19,7 +21,7 @@ async function setup() { } function exec(name: string, args: unknown): ToolExecutionInput { - return { callId: CallId('c1'), name, arguments: args } + return { signal: testToolSignal, callId: CallId('c1'), name, arguments: args } } describe('ToolRegistry.executionMode', () => { diff --git a/packages/core/tools/tests/execution-signal-types.spec.ts b/packages/core/tools/tests/execution-signal-types.spec.ts new file mode 100644 index 0000000000..e0e030543f --- /dev/null +++ b/packages/core/tools/tests/execution-signal-types.spec.ts @@ -0,0 +1,100 @@ +import { describe, expectTypeOf, it } from 'vitest' +import type { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { + ToolDispatchExecution, + ToolExecution, + ToolExecutionInput, + ToolRunContext, +} from '@deepseek-ai/dsh-tools' + +function inputAndExecutionContracts( + input: ToolExecutionInput, + execution: ToolExecution, + run: ToolRunContext, +): void { + // @ts-expect-error -- every typed invocation must supply a caller-owned signal. + const missingSignal: ToolExecutionInput = { callId: CallId('missing'), name: 'probe', arguments: {} } + void missingSignal + + // @ts-expect-error -- caller input is readonly after construction. + input.signal = new AbortController().signal + // @ts-expect-error -- required readonly properties cannot be deleted. + delete input.signal + // @ts-expect-error -- required signals cannot become undefined. + input.signal = undefined + + // @ts-expect-error -- pipeline observers receive a readonly execution view. + execution.signal = new AbortController().signal + // @ts-expect-error -- pipeline observers cannot remove the required signal. + delete execution.signal + // @ts-expect-error -- tool bodies receive a readonly run context. + run.signal = new AbortController().signal + // @ts-expect-error -- tool bodies cannot remove the required signal. + delete run.signal + // @ts-expect-error -- tool bodies cannot replace the required signal with undefined. + run.signal = undefined +} +void inputAndExecutionContracts + +function observerContracts(ctx: Context): void { + ctx.on('tools/pre-execute', (exec, next) => { + // @ts-expect-error -- pre-policy sees a readonly signal. + exec.signal = new AbortController().signal + // @ts-expect-error -- pre-policy cannot remove the required signal. + delete exec.signal + // @ts-expect-error -- pre-policy cannot replace the required signal with undefined. + exec.signal = undefined + return next() + }) + ctx.on('tools/post-execute', (exec, _result, next) => { + // @ts-expect-error -- post-policy sees a readonly signal. + exec.signal = new AbortController().signal + // @ts-expect-error -- post-policy sees a readonly signal. + delete exec.signal + // @ts-expect-error -- post-policy cannot replace the required signal with undefined. + exec.signal = undefined + return next() + }) + ctx.on('tools/result', (exec) => { + // @ts-expect-error -- result observers see a readonly signal. + exec.signal = new AbortController().signal + // @ts-expect-error -- result observers cannot remove the required signal. + delete exec.signal + // @ts-expect-error -- result observers see a readonly signal. + exec.signal = undefined + }) + ctx.on('tools/execute', (exec, next) => { + exec.signal = new AbortController().signal + // @ts-expect-error -- around-dispatch may replace but not remove the signal. + delete exec.signal + // @ts-expect-error -- around-dispatch cannot replace the required signal with undefined. + exec.signal = undefined + return next() + }) +} +void observerContracts + +const inferredTool = defineTool({ + name: 'signal-inference', + description: 'Pins contextual signal inference.', + parameters: {}, + async execute(_args, exec) { + expectTypeOf(exec.signal).toEqualTypeOf() + // @ts-expect-error -- defineTool contextually exposes a readonly signal. + exec.signal = new AbortController().signal + return [] + }, +}) +void inferredTool + +describe('tool execution signal types', () => { + it('requires an exact AbortSignal at every readonly tool view', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toBeFunction() + }) +}) diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 843aeb3837..ab3b869953 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -12,6 +12,8 @@ import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' +const testToolSignal = new AbortController().signal + /** Mount the registry (with its systemPrompt dependency) on a fresh context. */ async function mount(): Promise { const ctx = new Context() @@ -43,6 +45,7 @@ function tool(name: string, reply = `ran:${name}`): ToolDefinition { async function run(ctx: Context, name: string, agent?: Agent): Promise { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('c1'), name, arguments: {}, @@ -305,6 +308,7 @@ describe('scoped execution dispatch', () => { expect(await run(ctx, 'danger', key)).toBe('Error: danger denied') const callerArguments = { source: true } const safeResult = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('safe-call'), name: 'safe', arguments: callerArguments, @@ -348,7 +352,7 @@ describe('scoped execution dispatch', () => { if (exec.name === 'parent') parent = exec.token return next() }) - await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} }) + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('parent'), name: 'parent', arguments: {} }) stopCapture() policyCalls = 0 const signal = new AbortController().signal @@ -372,6 +376,7 @@ describe('scoped execution dispatch', () => { signal, }) const subjectlessResult = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('non-cloneable-subjectless'), name: 't', arguments: { invalid: () => undefined }, @@ -414,6 +419,7 @@ describe('scoped execution dispatch', () => { callId: CallId('stateful-parent'), name: 't', arguments: {}, + signal: testToolSignal, get parent(): ToolExecutionToken | undefined { parentReads += 1 return parentReads === 1 ? undefined : forged @@ -438,7 +444,7 @@ describe('scoped execution dispatch', () => { if (exec.name === 'parent') parent = exec.token return next() }) - await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} }) + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('parent'), name: 'parent', arguments: {} }) stopCapture() const acceptedSignal = new AbortController().signal const driftSignal = new AbortController().signal @@ -485,6 +491,7 @@ describe('scoped execution dispatch', () => { const input = { callId: CallId('throwing-arguments'), name: 't', + signal: testToolSignal, get arguments(): unknown { argumentReads += 1 throw new Error('getter exploded') @@ -525,6 +532,7 @@ describe('scoped execution dispatch', () => { }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('bad-arguments'), name: 't', arguments: argumentsValue, }) @@ -545,6 +553,7 @@ describe('scoped execution dispatch', () => { }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue, }) @@ -585,7 +594,7 @@ describe('scoped execution dispatch', () => { ctx.on('tools/result', () => Promise.reject(new Error('async observer failure')) as never) ctx.on('tools/result', (_exec, result) => { seen.push(result.isError) }) - const result = await ctx.tools.execute({ callId: CallId('final'), name: 't', arguments: {}, agent: key }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('final'), name: 't', arguments: {}, agent: key }) await Promise.resolve() expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] }) expect(seen).toEqual([true, true]) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 6c35f4f549..3eb1152a92 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -6,10 +6,13 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' import ToolRegistry, { defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, + TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision, - type ToolExecution, type ToolExecutionResult, + type ToolDispatchExecution, type ToolExecutionResult, } from '@deepseek-ai/dsh-tools' +const testToolSignal = new AbortController().signal + async function setup() { const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -79,7 +82,7 @@ describe('ToolRegistry', () => { it('executes a tool and returns its content', async () => { const ctx = await setup() ctx.tools.register(echoTool) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false }) }) @@ -92,7 +95,7 @@ describe('ToolRegistry', () => { return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] } } }, }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'meta-tool', arguments: {} }) expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false, @@ -109,7 +112,7 @@ describe('ToolRegistry', () => { return { content: [{ type: 'text', text: 'ok' }] } }, }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'no-meta-tool', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'no-meta-tool', arguments: {} }) expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false }) expect('meta' in result).toBe(false) }) @@ -127,6 +130,7 @@ describe('ToolRegistry', () => { }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('bad-meta'), name: 'bad-meta', arguments: {}, }) expect(result.isError).toBe(true) @@ -144,13 +148,13 @@ describe('ToolRegistry', () => { }, }) - const unknown = await ctx.tools.execute({ callId: CallId('c1'), name: 'nope', arguments: {} }) + const unknown = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'nope', arguments: {} }) expect(unknown.isError).toBe(true) expect(unknown.content[0]).toMatchObject({ text: 'Error: unknown tool "nope"' }) // An unknown tool is a routable failure class, same as a tool-thrown one. expect(unknown.error).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }) - const thrown = await ctx.tools.execute({ callId: CallId('c2'), name: 'boom', arguments: {} }) + const thrown = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c2'), name: 'boom', arguments: {} }) expect(thrown.isError).toBe(true) expect(thrown.content[0]).toMatchObject({ text: 'Error: exploded' }) }) @@ -170,6 +174,7 @@ describe('ToolRegistry', () => { }) await expect(ctx.tools.execute({ + signal: testToolSignal, callId: CallId('hostile'), name: 'hostile-throw', arguments: {}, })).resolves.toMatchObject({ isError: true, @@ -195,7 +200,7 @@ describe('ToolRegistry', () => { return next() }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' }) }) @@ -207,7 +212,7 @@ describe('ToolRegistry', () => { ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask', reason: 'needs approval' })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: needs approval' }) }) @@ -218,7 +223,7 @@ describe('ToolRegistry', () => { ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval (not yet supported)' }) }) @@ -269,7 +274,7 @@ describe('ToolRegistry', () => { ctx.on('approval/request', () => Promise.resolve('rejected')) ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: the user rejected tool "echo"' }) }) @@ -279,16 +284,51 @@ describe('ToolRegistry', () => { ctx.on('approval/request', () => Promise.resolve('cancelled')) ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: approval for tool "echo" was cancelled' }) }) + it('returns ABORTED_BEFORE_DISPATCH when caller cancellation overtakes approval', async () => { + const ctx = await approvalSetup() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + let dispatched = 0 + ctx.tools.register({ + ...echoTool, + name: 'approval-probe', + async execute() { dispatched += 1; return [] }, + }) + ctx.on('approval/request', () => { + entered.resolve(undefined) + return release.promise + }) + ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('approval-cancelled'), + name: 'approval-probe', + arguments: {}, + agent: fakeAgent(), + signal: controller.signal, + }) + + await entered.promise + controller.abort('caller cancelled approval') + release.resolve('allowed-once') + + await expect(pending).resolves.toMatchObject({ + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }) + expect(dispatched).toBe(0) + }) + it('denies with the no-channel reason when the seam is mounted but nobody answers', async () => { const ctx = await approvalSetup() ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but no approval channel is available' }) }) @@ -302,7 +342,7 @@ describe('ToolRegistry', () => { }) ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {} }) expect(asked).toBe(false) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but the call has no agent to route it through' }) @@ -317,7 +357,7 @@ describe('ToolRegistry', () => { ctx.provide('approval', { request: () => Promise.resolve('yolo') } as unknown as ApprovalService) ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) const text = result.content[0]?.type === 'text' ? result.content[0].text : '' expect(text).toContain('unreachable') @@ -331,7 +371,7 @@ describe('ToolRegistry', () => { ctx.on('tools/post-execute', async (_exec, _result, _next): Promise => ({ kind: 'accept', content: [{ type: 'text', text: 'rewritten' }] })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(false) expect(result.content[0]).toMatchObject({ text: 'rewritten' }) }) @@ -343,7 +383,7 @@ describe('ToolRegistry', () => { ctx.on('tools/post-execute', async (_exec, _result, _next): Promise => ({ kind: 'block', feedback: [{ type: 'text', text: 'output rejected: try again' }] })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'output rejected: try again' }) }) @@ -359,7 +399,7 @@ describe('ToolRegistry', () => { additionalContexts: [{ content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }], })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'rejected' }) expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }]) @@ -372,7 +412,7 @@ describe('ToolRegistry', () => { ctx.on('tools/post-execute', async (_exec, _result, _next): Promise => ({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }] })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }]) }) @@ -409,7 +449,7 @@ describe('ToolRegistry', () => { } }) - const result = await ctx.tools.execute({ callId: CallId('composite'), name: 'composite', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('composite'), name: 'composite', arguments: {} }) expect(result.additionalContexts?.map(context => context.source)).toEqual([ { kind: 'plugin', plugin: 'nested-1' }, @@ -432,7 +472,7 @@ describe('ToolRegistry', () => { }, })) - const failed = await ctx.tools.execute({ callId: CallId('failed'), name: 'failing-composite', arguments: {} }) + const failed = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('failed'), name: 'failing-composite', arguments: {} }) expect(failed.isError).toBe(true) expect(failed.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'nested' }]) @@ -441,7 +481,7 @@ describe('ToolRegistry', () => { feedback: [{ type: 'text', text: 'blocked' }], additionalContexts: [{ content: [{ type: 'text', text: 'block-only' }], source: { kind: 'plugin', plugin: 'blocker' } }], })) - const blocked = await ctx.tools.execute({ callId: CallId('blocked'), name: 'failing-composite', arguments: {} }) + const blocked = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('blocked'), name: 'failing-composite', arguments: {} }) expect(blocked.isError).toBe(true) expect(blocked.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'blocker' }]) }) @@ -464,7 +504,7 @@ describe('ToolRegistry', () => { return decision }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'x' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'x' } }) expect(result.isError).toBe(false) // pre runs fully (gate) before dispatch, then post runs over the result. expect(order).toEqual(['pre:before', 'pre:after', 'post:before', 'post:after']) @@ -484,7 +524,7 @@ describe('ToolRegistry', () => { })) ctx.on('tools/pre-execute', async (_exec, next) => { order.push('pre'); return next() }) - ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise): Promise => { + ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise): Promise => { order.push('execute:before') const result = await next() order.push('execute:after') @@ -492,24 +532,574 @@ describe('ToolRegistry', () => { }) ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false }) // The around seam wraps dispatch; pre gates before it, post runs over its result. expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post']) }) + it('skips dispatch when caller cancellation arrives while pre-execute awaits', async () => { + const ctx = await setup() + let dispatched = 0 + ctx.tools.register({ + ...echoTool, + name: 'must-not-run', + async execute() { dispatched += 1; return [] }, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('tools/pre-execute', async (_exec, next) => { + entered.resolve(undefined) + await release.promise + return await next() + }) + + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('cancelled-in-pre'), name: 'must-not-run', arguments: {}, signal: controller.signal, + }) + await entered.promise + controller.abort('cancelled in policy') + release.resolve(undefined) + + await expect(pending).resolves.toMatchObject({ + content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }) + expect(dispatched).toBe(0) + }) + + it('preserves a pre-execute denial that settles after cancellation', async () => { + const ctx = await setup() + let dispatched = 0 + ctx.tools.register({ + ...echoTool, + name: 'denied-after-cancel', + async execute() { dispatched += 1; return [] }, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('tools/pre-execute', async () => { + entered.resolve(undefined) + await release.promise + return { kind: 'deny', reason: 'policy denied the call' } + }) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('denied-after-cancel'), name: 'denied-after-cancel', arguments: {}, signal: controller.signal, + }) + + await entered.promise + controller.abort('cancelled while policy decided') + release.resolve(undefined) + + await expect(pending).resolves.toEqual({ + content: [{ type: 'text', text: 'Error: policy denied the call' }], + isError: true, + }) + expect(dispatched).toBe(0) + }) + + it('preserves an async pre-execute failure that settles after cancellation', async () => { + const ctx = await setup() + let dispatched = 0 + ctx.tools.register({ + ...echoTool, + name: 'must-not-run', + async execute() { dispatched += 1; return [] }, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('tools/pre-execute', async () => { + entered.resolve(undefined) + await release.promise + throw new Error('gate interrupted') + }) + + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('cancelled-pre-error'), name: 'must-not-run', arguments: {}, signal: controller.signal, + }) + await entered.promise + controller.abort('cancelled in policy') + release.resolve(undefined) + + await expect(pending).resolves.toEqual({ + content: [{ type: 'text', text: 'Error: gate interrupted' }], + isError: true, + }) + expect(dispatched).toBe(0) + }) + + it('rechecks caller cancellation after an async around-dispatch wrapper delegates', async () => { + const ctx = await setup() + let dispatched = 0 + ctx.tools.register({ + ...echoTool, + name: 'must-not-run', + async execute() { dispatched += 1; return [] }, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const replacement = new AbortController() + ctx.on('tools/execute', async (exec, next) => { + const upstream = exec.signal + exec.signal = replacement.signal + try { + entered.resolve(undefined) + await release.promise + return await next() + } finally { + exec.signal = upstream + } + }) + + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('cancelled-in-around'), name: 'must-not-run', arguments: {}, signal: controller.signal, + }) + await entered.promise + controller.abort('cancelled in wrapper') + release.resolve(undefined) + + await expect(pending).resolves.toMatchObject({ + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }) + expect(dispatched).toBe(0) + }) + + it('skips dispatch when an around wrapper supplies an already-aborted signal', async () => { + const ctx = await setup() + let dispatched = 0 + ctx.tools.register({ + ...echoTool, + name: 'must-not-run', + async execute() { dispatched += 1; return [] }, + }) + const replacement = AbortSignal.abort('wrapper cancelled') + ctx.on('tools/execute', async (exec, next) => { + const upstream = exec.signal + exec.signal = replacement + try { + return await next() + } finally { + exec.signal = upstream + } + }) + + const controller = new AbortController() + const result = await ctx.tools.execute({ + callId: CallId('cancelled-wrapper'), name: 'must-not-run', arguments: {}, signal: controller.signal, + }) + + expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) + expect(dispatched).toBe(0) + }) + + it('uses ABORTED_BEFORE_DISPATCH when cancellation overtakes a wrapper short-circuit', async () => { + const ctx = await setup() + let dispatched = 0 + ctx.tools.register({ + ...echoTool, + name: 'short-circuited', + async execute() { dispatched += 1; return [] }, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('tools/execute', async () => { + entered.resolve(undefined) + await release.promise + return { + content: [{ type: 'text', text: 'wrapper success' }], + isError: false, + additionalContexts: [{ + content: [{ type: 'text', text: 'wrapper context' }], + source: { kind: 'plugin', plugin: 'wrapper' }, + }], + } + }) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('cancelled-short-circuit'), + name: 'short-circuited', + arguments: {}, + signal: controller.signal, + }) + + await entered.promise + controller.abort('cancelled while wrapper waited') + release.resolve(undefined) + + await expect(pending).resolves.toMatchObject({ + content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + additionalContexts: [{ source: { kind: 'plugin', plugin: 'wrapper' } }], + }) + expect(dispatched).toBe(0) + }) + + it('replaces a late wrapper success with ABORTED and preserves deferred contexts', async () => { + const ctx = await setup() + ctx.tools.register({ + ...echoTool, + name: 'completed-before-wrapper', + async execute(_args, exec) { + exec.deferContext({ + content: [{ type: 'text', text: 'completed child work' }], + source: { kind: 'plugin', plugin: 'child' }, + }) + return [{ type: 'text', text: 'body complete' }] + }, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('tools/execute', async (_exec, next) => { + const result = await next() + entered.resolve(undefined) + await release.promise + return result + }) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('cancelled-after-body'), name: 'completed-before-wrapper', arguments: {}, signal: controller.signal, + }) + await entered.promise + controller.abort('cancelled while wrapper settled') + release.resolve(undefined) + + await expect(pending).resolves.toMatchObject({ + content: [{ type: 'text', text: 'Error: tool call aborted' }], + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED }, + additionalContexts: [{ source: { kind: 'plugin', plugin: 'child' } }], + }) + }) + + it('replaces a late post-execute success with ABORTED and preserves contexts', async () => { + const ctx = await setup() + ctx.tools.register({ + ...echoTool, + name: 'completed-before-post', + async execute(_args, exec) { + exec.deferContext({ + content: [{ type: 'text', text: 'completed child work' }], + source: { kind: 'plugin', plugin: 'child' }, + }) + return [{ type: 'text', text: 'body complete' }] + }, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('tools/post-execute', async (_exec, _result, next) => { + const decision = await next() + entered.resolve(undefined) + await release.promise + return { + ...decision, + additionalContexts: [{ + content: [{ type: 'text', text: 'post context' }], + source: { kind: 'plugin', plugin: 'post' }, + }], + } + }) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('cancelled-in-post'), name: 'completed-before-post', arguments: {}, signal: controller.signal, + }) + await entered.promise + controller.abort('cancelled while post policy waits') + release.resolve(undefined) + + await expect(pending).resolves.toMatchObject({ + content: [{ type: 'text', text: 'Error: tool call aborted' }], + isError: true, + error: { name: 'AbortError', code: 'ABORTED' }, + additionalContexts: [ + { source: { kind: 'plugin', plugin: 'child' } }, + { source: { kind: 'plugin', plugin: 'post' } }, + ], + }) + }) + + it('preserves an around-dispatch failure that settles after cancellation', async () => { + const ctx = await setup() + let dispatched = 0 + ctx.tools.register({ + ...echoTool, + name: 'wrapper-failure', + async execute() { dispatched += 1; return [] }, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('tools/execute', async () => { + entered.resolve(undefined) + await release.promise + throw new HarnessError('wrapper failed', 'WRAPPER_FAILURE') + }) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('wrapper-failure'), name: 'wrapper-failure', arguments: {}, signal: controller.signal, + }) + + await entered.promise + controller.abort('cancelled while wrapper failed') + release.resolve(undefined) + + await expect(pending).resolves.toMatchObject({ + content: [{ type: 'text', text: 'Error: wrapper failed' }], + isError: true, + error: { name: 'HarnessError', code: 'WRAPPER_FAILURE' }, + }) + expect(dispatched).toBe(0) + }) + + it('preserves a tool-owned failure after the body observes cancellation', async () => { + const ctx = await setup() + const entered = Promise.withResolvers() + ctx.tools.register({ + ...echoTool, + name: 'tool-failure', + execute(_args, exec) { + entered.resolve(undefined) + return new Promise((_resolve, reject) => { + exec.signal.addEventListener('abort', () => { + reject(new HarnessError('tool failed', 'TOOL_FAILURE')) + }, { once: true }) + }) + }, + }) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('tool-failure'), name: 'tool-failure', arguments: {}, signal: controller.signal, + }) + + await entered.promise + controller.abort('cancelled running body') + + await expect(pending).resolves.toMatchObject({ + content: [{ type: 'text', text: 'Error: tool failed' }], + isError: true, + error: { name: 'HarnessError', code: 'TOOL_FAILURE' }, + }) + }) + + it('preserves a post-policy failure that settles after cancellation', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('tools/post-execute', async () => { + entered.resolve(undefined) + await release.promise + throw new HarnessError('post-policy failed', 'POST_FAILURE') + }) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('post-failure'), name: 'echo', arguments: {}, signal: controller.signal, + }) + + await entered.promise + controller.abort('cancelled while post-policy failed') + release.resolve(undefined) + + await expect(pending).resolves.toMatchObject({ + content: [{ type: 'text', text: 'Error: post-policy failed' }], + isError: true, + error: { name: 'HarnessError', code: 'POST_FAILURE' }, + }) + }) + + it('fuses caller cancellation back into a wrapper replacement for the running body', async () => { + const ctx = await setup() + const entered = Promise.withResolvers() + const replacement = new AbortController() + let bodySignal: AbortSignal | undefined + ctx.tools.register({ + ...echoTool, + name: 'cooperative', + execute(_args, exec) { + bodySignal = exec.signal + entered.resolve(undefined) + if (exec.signal.aborted) return Promise.resolve([]) + return new Promise((resolve) => { + exec.signal.addEventListener('abort', () => { resolve([]) }, { once: true }) + }) + }, + }) + ctx.on('tools/execute', async (exec, next) => { + const upstream = exec.signal + exec.signal = replacement.signal + try { + return await next() + } finally { + exec.signal = upstream + } + }) + + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('cancelled-body'), name: 'cooperative', arguments: {}, signal: controller.signal, + }) + await entered.promise + expect(bodySignal).not.toBe(controller.signal) + expect(bodySignal).not.toBe(replacement.signal) + controller.abort('cancel running body') + + await expect(pending).resolves.toMatchObject({ + isError: true, + error: { name: 'AbortError', code: 'ABORTED' }, + }) + expect(bodySignal?.aborted).toBe(true) + expect(replacement.signal.aborted).toBe(false) + }) + + it('restores the required caller signal after around dispatch', async () => { + const ctx = await setup() + let postSignal: AbortSignal | undefined + ctx.on('tools/execute', async (exec, next) => { + const upstream = exec.signal + exec.signal = new AbortController().signal + try { + return await next() + } finally { + exec.signal = upstream + } + }) + ctx.on('tools/post-execute', async (exec, _result, next) => { + postSignal = exec.signal + return next() + }) + const controller = new AbortController() + + await ctx.tools.execute({ + callId: CallId('restored-signal'), name: 'echo', arguments: {}, signal: controller.signal, + }) + + expect(postSignal).toBe(controller.signal) + }) + + it('waits for an uncooperative started body before returning ABORTED', async () => { + const ctx = await setup() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.tools.register({ + ...echoTool, + name: 'uncooperative', + execute(_args, exec) { + exec.deferContext({ + content: [{ type: 'text', text: 'nested outcome' }], + source: { kind: 'plugin', plugin: 'nested' }, + }) + entered.resolve(undefined) + return release.promise + }, + }) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('drain-body'), name: 'uncooperative', arguments: {}, signal: controller.signal, + }) + await entered.promise + controller.abort('must still drain') + + const state = await Promise.race([ + pending.then(() => 'settled' as const), + Promise.resolve('pending' as const), + ]) + expect(state).toBe('pending') + release.resolve([]) + await expect(pending).resolves.toMatchObject({ + isError: true, + error: { name: 'AbortError', code: 'ABORTED' }, + additionalContexts: [{ source: { kind: 'plugin', plugin: 'nested' } }], + }) + }) + + it('materializes a pre-aborted call and publishes one result without entering pipeline phases', async () => { + const ctx = await setup() + const phases = { pre: 0, around: 0, body: 0, post: 0, result: 0 } + const callerArguments = { nested: { value: 1 } } + const callerSignal = AbortSignal.abort('already cancelled') + let argumentReads = 0 + let observedArguments: unknown + let observedExecution: object | undefined + let observedToken: symbol | undefined + let observedSignal: AbortSignal | undefined + let observedResult: ToolExecutionResult | undefined + ctx.tools.register({ + ...echoTool, + name: 'domain-abort', + async execute() { phases.body += 1; return [] }, + }) + ctx.on('tools/pre-execute', async (_exec, next) => { phases.pre += 1; return next() }) + ctx.on('tools/execute', async (_exec, next) => { phases.around += 1; return next() }) + ctx.on('tools/post-execute', async (_exec, _result, next) => { phases.post += 1; return next() }) + ctx.on('tools/result', (exec, result) => { + phases.result += 1 + observedExecution = exec + observedArguments = exec.arguments + observedToken = exec.token + observedSignal = exec.signal + observedResult = result + }) + + const result = await ctx.tools.execute({ + callId: CallId('pre-aborted'), + name: 'domain-abort', + get arguments() { argumentReads += 1; return callerArguments }, + signal: callerSignal, + }) + + expect(argumentReads).toBe(1) + expect(phases).toEqual({ pre: 0, around: 0, body: 0, post: 0, result: 1 }) + expect(result).toEqual({ + content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }) + expect(observedResult).toBe(result) + expect(Object.isFrozen(observedExecution)).toBe(true) + expect(typeof observedToken).toBe('symbol') + expect(observedSignal).toBe(callerSignal) + expect(Object.isFrozen(result)).toBe(true) + expect(observedArguments).not.toBe(callerArguments) + expect(Object.isFrozen(observedArguments)).toBe(true) + expect(Object.isFrozen((observedArguments as { nested: object }).nested)).toBe(true) + }) + + it('lets argument materialization failure win over a pre-aborted signal', async () => { + const ctx = await setup() + let observed = 0 + ctx.on('tools/result', () => { observed += 1 }) + + const result = await ctx.tools.execute({ + callId: CallId('invalid-pre-aborted'), + name: 'missing', + arguments: { invalid: () => undefined }, + signal: AbortSignal.abort('already cancelled'), + }) + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Error: tool execution arguments must be losslessly JSON-serializable' }], + isError: true, + }) + expect(observed).toBe(1) + }) + it('a pre-execute deny short-circuits before tools/execute (the seam never runs)', async () => { const ctx = await setup() ctx.tools.register(echoTool) let entered = false ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'deny', reason: 'nope' })) - ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise): Promise => { + ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise): Promise => { entered = true return next() }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: nope' }) expect(entered).toBe(false) // a denied call never enters the around-dispatch seam @@ -524,7 +1114,7 @@ describe('ToolRegistry', () => { }) let seen: { isError: boolean; error?: unknown } | undefined - ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise): Promise => { + ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise): Promise => { const result = await next() // The base next() IS dispatch-with-normalization: the wrapper sees the // normalized isError result, never a raw throw from the tool body. @@ -532,7 +1122,7 @@ describe('ToolRegistry', () => { return result }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'boom', arguments: {} }) expect(seen).toEqual({ isError: true, error: { name: 'HarnessError', code: 'BOOM' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' }) @@ -547,19 +1137,19 @@ describe('ToolRegistry', () => { }) let postSaw: boolean | undefined - ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise): Promise => next()) + ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise): Promise => next()) ctx.on('tools/post-execute', async (_exec, result, next) => { postSaw = result.isError return next() }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'boom', arguments: {} }) expect(postSaw).toBe(true) // the normalized isError still flows through post-execute expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: exploded' }) }) - it('a tools/execute listener can replace exec.signal for the dispatched tool (deadline pattern)', async () => { + it('re-fuses the caller signal with an around-dispatch replacement for the body', async () => { const ctx = await setup() let seenSignal: AbortSignal | undefined ctx.tools.register({ @@ -573,7 +1163,7 @@ describe('ToolRegistry', () => { const upstream = new AbortController().signal const replacement = new AbortController().signal - ctx.on('tools/execute', async (exec: ToolExecution, next: () => Promise): Promise => { + ctx.on('tools/execute', async (exec: ToolDispatchExecution, next: () => Promise): Promise => { expect(exec.signal).toBe(upstream) // Cordis next() ignores passed arguments, so a wrapper mutates exec in // place (the documented "mutate the shared object, then delegate" idiom). @@ -582,7 +1172,9 @@ describe('ToolRegistry', () => { }) await ctx.tools.execute({ callId: CallId('c1'), name: 'signal-probe', arguments: {}, signal: upstream }) - expect(seenSignal).toBe(replacement) // dispatch saw the wrapper's replacement, not the upstream + expect(seenSignal).toBeDefined() + expect(seenSignal).not.toBe(upstream) + expect(seenSignal).not.toBe(replacement) }) it('a tools/execute listener can short-circuit dispatch by returning a result without next()', async () => { @@ -594,10 +1186,10 @@ describe('ToolRegistry', () => { async execute() { dispatched = true; return [] }, }) - ctx.on('tools/execute', async (_exec: ToolExecution, _next: () => Promise): Promise => + ctx.on('tools/execute', async (_exec: ToolDispatchExecution, _next: () => Promise): Promise => ({ content: [{ type: 'text', text: 'short-circuited' }], isError: false })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'never-runs', arguments: {} }) expect(dispatched).toBe(false) // returning without next() skips core dispatch expect(result.content[0]).toMatchObject({ text: 'short-circuited' }) }) @@ -615,6 +1207,7 @@ describe('ToolRegistry', () => { })) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('around-context'), name: 'echo', arguments: {}, }) expect(result.additionalContexts).toEqual([{ @@ -628,7 +1221,7 @@ describe('ToolRegistry', () => { ctx.tools.register(echoTool) ctx.on('tools/execute', async () => { throw new Error('wrapper broke') }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'Error: wrapper broke' }], isError: true, @@ -642,7 +1235,7 @@ describe('ToolRegistry', () => { throw new Error('permission hook broke') }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'Error: permission hook broke' }], @@ -657,7 +1250,7 @@ describe('ToolRegistry', () => { throw new Error('post hook broke') }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'Error: post hook broke' }], @@ -672,7 +1265,7 @@ describe('ToolRegistry', () => { throw new HarnessError('denied', 'DENIED') }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toMatchObject({ isError: true, @@ -859,6 +1452,7 @@ describe('defineTool / schema DSL', () => { }]) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('c1'), name: 'typed-echo', arguments: { text: 'hello', uppercase: true }, @@ -913,6 +1507,7 @@ describe('defineTool / schema DSL', () => { // Execution round-trip const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('c1'), name: 'roundtrip', arguments: { req: 'hello' }, @@ -945,6 +1540,7 @@ describe('defineTool / schema DSL', () => { }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('c1'), name: 'raw-tool', arguments: { path: '/tmp' }, @@ -1118,7 +1714,7 @@ describe('schema DSL optional and nested contracts', () => { throw { message: 'denied by object' } }, }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'object-thrower', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'object-thrower', arguments: {} }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: denied by object' }) }) @@ -1133,7 +1729,7 @@ describe('schema DSL optional and nested contracts', () => { throw 'kaboom' }, }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'string-thrower', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'string-thrower', arguments: {} }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' }) }) @@ -1148,7 +1744,7 @@ describe('schema DSL optional and nested contracts', () => { throw { code: 500 } }, }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'object-no-message', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'object-no-message', arguments: {} }) expect(result.isError).toBe(true) const firstContent = result.content[0]! expect(firstContent.type).toBe('text') @@ -1285,7 +1881,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }, })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'reader', arguments: {} }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: invalid arguments: missing required property "path"', @@ -1302,7 +1898,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () return [{ type: 'text', text: `read ${args.path}` }] }, })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'read /x' }], isError: false }) }) @@ -1325,7 +1921,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () return [{ type: 'text', text: args.path }] }, })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'reader', arguments: {} }) expect(result.isError).toBe(true) expect(result.error).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) }) @@ -1340,7 +1936,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () throw new HarnessError('disk full', 'ENOSPC') }, }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'coded', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'coded', arguments: {} }) expect(result.isError).toBe(true) expect(result.error).toEqual({ name: 'HarnessError', code: 'ENOSPC' }) expect(result.content[0]).toMatchObject({ text: 'Error: disk full' }) @@ -1355,7 +1951,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () throw new Error('just a message') }, }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'plain', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'plain', arguments: {} }) expect(result.isError).toBe(true) expect(result.error).toBeUndefined() expect(result.content[0]).toMatchObject({ text: 'Error: just a message' }) @@ -1374,7 +1970,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }) // Missing the "required" path — but raw tools validate their own input, so // this reaches execute rather than being rejected by the harness. - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'raw', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'raw', arguments: {} }) expect(result.isError).toBe(false) }) diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index 82347c1d7e..92d75dec99 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -36,6 +36,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron | `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` | | `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` | | `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` | +| `llmRetry` | owner defaults | bounded transient model-request retry policy routed through `dsh-agent-spine-demo` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | | `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 317f04087b..98ba012829 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -60,6 +60,8 @@ export interface Config { toolBash?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable + /** Bounded transient model-request retry policy forwarded through agent-core. */ + llmRetry?: NonNullable } // Each front door owns a complete, directly readable config schema; extracting @@ -82,6 +84,7 @@ export const Config: z = z.object({ skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), + llmRetry: agentCore.LlmRetryConfigSchema, }) /* jscpd:ignore-end */ diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index ce5a01ff53..5156225490 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -17,6 +17,7 @@ Read this package for the whole plugin tree and its composition order. @deepseek-ai/dsh-skill skill provider registry @deepseek-ai/dsh-skill-local local filesystem skill provider @deepseek-ai/dsh-agent agent registry + initiator scope + agent/* events +@deepseek-ai/dsh-llm-retry bounded transient request retry policy @deepseek-ai/dsh-tasks generic background-task registry @deepseek-ai/dsh-invariants dev-mode event-contract assertions @deepseek-ai/dsh-tool-bash the model-facing bash schema @@ -42,19 +43,21 @@ This is the [interface/implementation/consumer seam](../../../.agents/notes/impl ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks? } +// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, llmRetry? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. +The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. ## Why a code bundle, not a shared YAML include A YAML include can deduplicate config but cannot own a bin or provide front-door defaults. App packages make stdout-safe ACP wiring the default, though a leaf can still add an unsafe logger. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without load-order coupling. +The bounded retry policy may repeat a transiently failed request in a new numbered step. Retry status and failed partial chunks stay outside model history, each provider attempt can still incur billing, front doors derive usage across every logged step, and the reconstructed request preserves the prior prefix for provider cache reuse. + ## Model Experience -Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, and `dsh-tools`, which this bundle mounts without adding model-bound wrapper content. +Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, `dsh-tools`, and `dsh-llm-retry`, which this bundle mounts without adding model-bound wrapper content. #### KV Cache effect diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 772d6c059b..2c73226f3c 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-agent-spine-demo", - "description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + tasks + invariants + tool-bash + workspace-context + tool-skill + tool-tasks + agent-loop)", + "description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + bounded retry + tasks + invariants + tool-bash + workspace-context + tool-skill + tool-tasks + agent-loop)", "version": "0.0.1", "private": true, "type": "module", @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-home": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-workspace-context": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", @@ -48,6 +49,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 2d489435a5..c074fa05c6 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -25,6 +25,7 @@ import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' import * as toolTasks from '@deepseek-ai/dsh-tool-tasks' import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop' +import * as llmRetry from '@deepseek-ai/dsh-llm-retry' import { resolveDshHome } from '@deepseek-ai/dsh-home' export const name = 'agent-spine-demo' @@ -77,6 +78,8 @@ export interface Config { toolBash?: toolBash.Config /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false + /** Bounded transient model-request retry policy. */ + llmRetry?: llmRetry.Config } /** The skill config schema exported for app packages that forward `skills`. */ @@ -93,6 +96,9 @@ export const ToolBashConfigSchema: z = toolBash.Config /** The task-control-tool config schema exported for app packages that forward `toolTasks`. */ export const ToolTasksConfigSchema: z = toolTasks.Config +/** The bounded LLM retry schema exported for app packages that forward `llmRetry`. */ +export const LlmRetryConfigSchema: z = llmRetry.Config + /** Intersect the owners' schemas so validation + defaulting stay identical. */ export const Config = z.intersect([ AgentLoop.Config, @@ -104,7 +110,8 @@ export const Config = z.intersect([ workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), toolBash: ToolBashConfigSchema, toolTasks: z.union([z.const(false), ToolTasksConfigSchema]), - }) as unknown as z>, + llmRetry: LlmRetryConfigSchema, + }) as unknown as z>, ]) as unknown as z /** @@ -123,6 +130,7 @@ export function pickSpineConfig(config: Omit): Omit block.type === 'text' ? block.text : '').join('\n') ?? '' } +class TransientOnceAdapter extends LlmAdapter { + requests = 0 + + async * stream(_options: GenerateOptions): AsyncIterable { + this.requests += 1 + if (this.requests === 1) throw new LlmError('temporary outage', 'SERVER') + yield* textResponse('recovered by bundled policy') + } +} + describe('dsh-agent-spine-demo bundle', () => { it('brings up the full default spine', async () => { const ctx = await mount({ workspaceContext: false }) @@ -117,6 +129,37 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) + it('loads and configures bounded request recovery for every bundled front door', async () => { + const adapter = new TransientOnceAdapter() + const ctx = await mount({ + workspaceContext: false, + llmRetry: { + maxTransientRetries: 1, + initialDelayMs: 1, + maxDelayMs: 1, + jitterRatio: 0, + }, + }) + ctx.llm.registerAdapter(['mock'], adapter) + const handle = await ctx.agents.create({ + sessionId: SessionId('bundled-retry-session'), + meta: { cwd: process.cwd() }, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + + handle.agent.send([{ type: 'text', text: 'recover' }]) + await waitForIdle(ctx, handle.agent) + + expect(adapter.requests).toBe(2) + const retryEvents = handle.agent.session.events.filter(event => event.type === 'llm/retry') + expect(retryEvents).toHaveLength(1) + expect(retryEvents[0]?.data.retry).toBe(1) + expect(retryEvents[0]?.data.maxRetries).toBe(1) + expect(messageText(handle.agent.session.deriveMessages().at(-1))).toBe('recovered by bundled policy') + await handle.dispose() + await ctx.fiber.dispose() + }) + it('includes the skill registry, local provider, and skill tool without builtin skills', async () => { const ctx = await mount({ workspaceContext: false }) @@ -264,6 +307,7 @@ describe('dsh-agent-spine-demo bundle', () => { expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['shared-skill']) const execution: ToolExecution = { + signal: testToolSignal, token: Symbol('agent-core-dsh-home-test') as ToolExecution['token'], callId: CallId('agent-core-dsh-home'), name: 'bash', @@ -335,11 +379,12 @@ describe('dsh-agent-spine-demo bundle', () => { }) const wait = vi.spyOn(ctx.tasks, 'wait') await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('task-config-forwarding'), name: 'task_output', arguments: { task_id: id, wait: true }, }) - expect(wait).toHaveBeenCalledWith(id, 7, undefined, undefined) + expect(wait).toHaveBeenCalledWith(id, 7, undefined, testToolSignal) await ctx.fiber.dispose() }) @@ -370,6 +415,7 @@ describe('dsh-agent-spine-demo bundle', () => { skills: { enabled: false }, toolBash: { enableRunInBackground: false }, toolTasks: false as const, + llmRetry: { maxTransientRetries: 1, jitterRatio: 0 }, } expect(agentCore.pickSpineConfig(appConfig)).toEqual({ @@ -381,6 +427,7 @@ describe('dsh-agent-spine-demo bundle', () => { skills: appConfig.skills, toolBash: appConfig.toolBash, toolTasks: appConfig.toolTasks, + llmRetry: appConfig.llmRetry, }) expect(agentCore.pickSpineConfig({ workspaceContext: false })).toEqual({ workspaceContext: false }) }) diff --git a/packages/examples/agent-spine-demo/tsconfig.json b/packages/examples/agent-spine-demo/tsconfig.json index 89cb2accd8..9b897d1674 100644 --- a/packages/examples/agent-spine-demo/tsconfig.json +++ b/packages/examples/agent-spine-demo/tsconfig.json @@ -47,6 +47,9 @@ { "path": "../../core/agent-loop" }, + { + "path": "../../llm/llm-retry" + }, { "path": "../../support/invariants" }, diff --git a/packages/examples/cli-demo/README.md b/packages/examples/cli-demo/README.md index 958dd4c3bb..60f047101d 100644 --- a/packages/examples/cli-demo/README.md +++ b/packages/examples/cli-demo/README.md @@ -18,6 +18,7 @@ The package mounts no console logger, interactive UI, user-interaction service, | `skills` | owner defaults | skill registry, local provider, and model-facing skill tool | | `toolBash` | owner defaults | model-facing bash config, including this producer's background opt-in | | `toolTasks` | owner defaults | generic `task_output` wait bounds | +| `llmRetry` | owner defaults | bounded transient model-request retry policy | | `persistenceRoot` | `./.sessions` | JSONL session root | | `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | | `workspaceContext` | required | workspace-instruction byte budget, or `false` to disable loading | @@ -41,7 +42,7 @@ Loader configs with bare package specifiers require `node --expose-internals` or ### Output formats - `text` writes the last assistant message containing text, followed by one newline. -- `json` writes one DSH-native result record: `{ type: "result", success, sessionId, turn, result, reason, usage? }`. `usage` sums every model step in the task turn. +- `json` writes one DSH-native result record: `{ type: "result", success, sessionId, turn, result, reason, usage? }`. `usage` sums each model step in the task turn once, including billed failed retry attempts that produced usage without a committed assistant message. - `stream-json` writes each canonical event from the top-level session's task turn as `{ type: "session_event", sessionId, event }`, then the same result record. Child-agent activity appears only through the parent tool events and results. Only `reason.kind === "completed"` exits successfully. Other durable turn endings still emit partial text or a result record, add a stderr diagnostic, and exit nonzero. Argument and boot failures leave stdout empty. SIGINT and SIGTERM cancel active work, await disposal, and exit 130 and 143 respectively. diff --git a/packages/examples/cli-demo/src/cli.ts b/packages/examples/cli-demo/src/cli.ts index 1c017ee8d1..7a6dfee8eb 100644 --- a/packages/examples/cli-demo/src/cli.ts +++ b/packages/examples/cli-demo/src/cli.ts @@ -219,7 +219,7 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise let targetTurn: number | undefined let reason: TurnEndReason | undefined let result = '' - let usage: TokenUsage | undefined + const usageByStep = new Map() let outputError: Error | undefined let resolveTurn!: () => void let rejectTurn!: (error: Error) => void @@ -254,9 +254,14 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise targetTurn = event.data.turn } observe(session.id, event) + if (event.type === 'assistant/chunk' + && event.data.turn === targetTurn + && event.data.chunk.type === 'usage') { + usageByStep.set(event.data.step, event.data.chunk.usage) + } if (event.type === 'assistant/message' && event.data.turn === targetTurn) { result = assistantText(event) ?? result - if (event.data.usage !== undefined) usage = addUsage(usage, event.data.usage) + if (event.data.usage !== undefined) usageByStep.set(event.data.step, event.data.usage) } if (event.type === 'turn/end' && event.data.turn === targetTurn) { reason = event.data.reason @@ -294,6 +299,7 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise } await ctx.sessions.flush(agent.session) if (outputError !== undefined) throw outputError + const usage = [...usageByStep.values()].reduce(addUsage, undefined) return { type: 'result', success: reason.kind === 'completed', @@ -365,7 +371,7 @@ export function formatTurnFailure(reason: TurnEndReason): string { switch (reason.kind) { case 'completed': return 'completed' case 'aborted': return 'was aborted' - case 'error': return `failed at step ${reason.step}: ${reason.message}` + case 'error': return `failed at step ${reason.step}: ${'failure' in reason ? reason.failure.message : reason.message}` case 'disposed': return 'was disposed' case 'max-tokens': return 'reached the model output-token limit' case 'rejected': return `was rejected: ${reason.reason}` diff --git a/packages/examples/cli-demo/src/index.ts b/packages/examples/cli-demo/src/index.ts index d51cc80b23..7f527d857b 100644 --- a/packages/examples/cli-demo/src/index.ts +++ b/packages/examples/cli-demo/src/index.ts @@ -47,6 +47,8 @@ export interface Config { toolBash?: NonNullable /** Generic background-task control-tool config forwarded through agent-spine-demo. */ toolTasks?: NonNullable + /** Bounded transient model-request retry policy forwarded through agent-spine-demo. */ + llmRetry?: NonNullable /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] } @@ -68,6 +70,7 @@ export const Config: z = z.object({ tools: ToolRegistry.Config, toolBash: agentCore.ToolBashConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), + llmRetry: agentCore.LlmRetryConfigSchema, workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), }) /* jscpd:ignore-end */ diff --git a/packages/examples/cli-demo/tests/cli-demo.spec.ts b/packages/examples/cli-demo/tests/cli-demo.spec.ts index c248242ad1..42948b3a22 100644 --- a/packages/examples/cli-demo/tests/cli-demo.spec.ts +++ b/packages/examples/cli-demo/tests/cli-demo.spec.ts @@ -10,6 +10,8 @@ import type { ToolExecution } from '@deepseek-ai/dsh-tools' import { afterEach, describe, expect, it, vi } from 'vitest' import * as cliDemo from '../src/index.ts' +const testToolSignal = new AbortController().signal + const contexts: Context[] = [] async function skillConfig(catalogDescriptionMaxLength?: number): Promise> { @@ -124,6 +126,7 @@ describe('dsh-cli-demo app composition', () => { expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3) const execution: ToolExecution = { + signal: testToolSignal, token: Symbol('cli-demo-dsh-home-test') as ToolExecution['token'], callId: CallId('cli-demo-dsh-home'), name: 'bash', @@ -141,11 +144,12 @@ describe('dsh-cli-demo app composition', () => { }) const wait = vi.spyOn(ctx.tasks, 'wait') await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('cli-demo-task-config'), name: 'task_output', arguments: { task_id: id, wait: true }, }) - expect(wait).toHaveBeenCalledWith(id, 7, undefined, undefined) + expect(wait).toHaveBeenCalledWith(id, 7, undefined, testToolSignal) }) it('accepts false to keep task services without model-facing task controls', async () => { diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index 176fff7f0c..f20697b827 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -70,6 +70,15 @@ function toolResponse(usage: TokenUsage): StreamChunk[] { ] } +function failedResponse(usage: TokenUsage): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'discarded' }, + { type: 'usage', usage }, + { type: 'finish', reason: { kind: 'error', failure: { message: 'temporary', code: 'SERVER' } } }, + ] +} + function reasoningResponse(text: string): StreamChunk[] { return [ { type: 'block-start', index: 0, blockType: 'reasoning' }, @@ -98,6 +107,7 @@ async function harness(script: readonly ScriptEntry[]): Promise { persistenceRoot: root, skills: { local: { dshHome: join(skillHome, '.dsh'), agentsHome: join(skillHome, '.agents') } }, workspaceContext: false, + llmRetry: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, }) await new Promise(resolve => setTimeout(resolve, 80)) ctx.llm.registerAdapter(['mock'], new ScriptedAdapter(script)) @@ -323,6 +333,21 @@ describe('runOneShot and executeCli', () => { }) }) + it('counts a failed retry attempt once even though it has no assistant message', async () => { + const failed = { inputTokens: 11, outputTokens: 2, cacheReadTokens: 3 } + const recovered = { inputTokens: 7, outputTokens: 5, reasoningTokens: 4 } + const { ctx } = await harness([failedResponse(failed), textResponse('done', recovered)]) + + const result = await runOneShot(ctx, { task: 'task' }) + + expect(result.usage).toEqual({ + inputTokens: 18, + outputTokens: 7, + cacheReadTokens: 3, + reasoningTokens: 4, + }) + }) + it('keeps the prior text when a later assistant message has no text blocks', async () => { const { ctx } = await harness([ toolResponse({ inputTokens: 1, outputTokens: 1 }), @@ -463,6 +488,7 @@ describe('formatTurnFailure', () => { [{ kind: 'aborted' }, 'was aborted'], [{ kind: 'aborted' }, 'was aborted'], [{ kind: 'error', step: 2, message: 'bad' }, 'failed at step 2: bad'], + [{ kind: 'error', step: 3, failure: { message: 'provider bad', code: 'SERVER' } }, 'failed at step 3: provider bad'], [{ kind: 'disposed' }, 'was disposed'], [{ kind: 'max-tokens' }, 'output-token limit'], [{ kind: 'rejected', reason: 'policy' }, 'was rejected: policy'], diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 0682c86e35..0eff077fea 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -162,7 +162,7 @@ export async function runRipgrep( command, stdoutMaxBytes: rawOutputMaxBytes, ...cwd !== undefined ? { workdir: cwd } : {}, - ...exec.signal ? { signal: exec.signal } : {}, + signal: exec.signal, }) let result: BashRunResult try { diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts index 36fb2c28e6..7c2ab16705 100644 --- a/packages/fs/tool-fs-search/tests/integration.spec.ts +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -16,10 +16,12 @@ import { join } from 'node:path' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' +const testToolSignal = new AbortController().signal + const hasRg = spawnSync('rg', ['--version'], { encoding: 'utf8' }).status === 0 let dir: string @@ -28,6 +30,7 @@ let ctx: Context let callCounter = 0 function call(name: string, args: unknown, agentObj?: object) { return ctx.tools.execute({ + signal: testToolSignal, callId: CallId(`it-${++callCounter}`), name, arguments: args, @@ -165,8 +168,8 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () }) }) - describe('bash-start infrastructure failures stay in the SEARCH_* taxonomy', () => { - it('a pre-aborted exec.signal (real executor rejects before spawn) is SEARCH_ABORTED', async () => { + describe('pre-dispatch cancellation and bash-start failures', () => { + it('a pre-aborted registry call is ABORTED_BEFORE_DISPATCH', async () => { const controller = new AbortController() controller.abort() const result = await ctx.tools.execute({ @@ -176,7 +179,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () signal: controller.signal, }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) + expect(result.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) }) it('an unusable session cwd (spawn failure) is SEARCH_FAILED', async () => { diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index c11b10556a..d8f92d9f8f 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -14,7 +14,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' @@ -31,6 +31,7 @@ import { toWorkdirRelative, } from '@deepseek-ai/dsh-tool-fs-search' +const testToolSignal = new AbortController().signal const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1' /** A successful run result over the given stdout; overrides script the failure shapes. */ @@ -59,6 +60,7 @@ class FakeBash extends BashExecutor { requests: BashExecRequest[] = [] specs: BashExecSpec[] = [] startCalls = 0 + forwardSignal = true probeResult: BashRunResult = runResult('') probeError?: Error handler: (spec: BashExecSpec) => BashRunResult = () => runResult('') @@ -71,7 +73,7 @@ class FakeBash extends BashExecutor { workdir: request.workdir ?? '/work', timeoutMs: request.timeoutMs ?? 60_000, stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, - signal: request.signal, + ...this.forwardSignal ? { signal: request.signal } : {}, sandboxMode: request.sandboxMode, } } @@ -147,6 +149,7 @@ const agent = (cwd?: string) => ({ session: { header: { id: 'session-1', ...cwd let callCounter = 0 function call(ctx: Context, name: string, args: unknown, options: { agent?: object; signal?: AbortSignal } = {}) { return ctx.tools.execute({ + signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args, @@ -302,16 +305,13 @@ describe('workdir derivation and signal forwarding', () => { expect(bash.requests[1]).not.toHaveProperty('workdir') }) - it('forwards exec.signal into the bash spec (the abort reaches the backend)', async () => { + it('forwards exec.signal into the bash spec', async () => { const { ctx, bash } = await setup() const controller = new AbortController() - controller.abort() - bash.handler = spec => runResult('', { aborted: spec.signal?.aborted === true }) + bash.handler = () => runResult('') const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) expect(bash.specs[0]?.signal).toBe(controller.signal) - expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) - expect(text(result)).toContain('aborted') + expect(result.isError).toBe(false) }) it('reports the bash executor timeout as SEARCH_ABORTED with the budget', async () => { @@ -323,20 +323,46 @@ describe('workdir derivation and signal forwarding', () => { expect(text(result)).toContain('timed out after 1234ms') }) - it('translates a run() rejection under a pre-aborted signal into SEARCH_ABORTED', async () => { - // The seam contract: run() REJECTS for a pre-aborted signal (it never - // spawns). The plain rejection must not escape the SEARCH_* taxonomy. + it('skips a pre-aborted registry call before run()', async () => { const { ctx, bash } = await setup() const controller = new AbortController() controller.abort() bash.handler = () => { throw new Error('aborted before spawn') } const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) + expect(bash.specs).toHaveLength(0) + }) + + it('translates a run() rejection after the forwarded signal aborts', async () => { + const { ctx, bash } = await setup() + const controller = new AbortController() + bash.handler = () => { + controller.abort('cancel search') + throw new Error('executor stopped on abort') + } + + const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) + expect(result.isError).toBe(true) expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) + expect(text(result)).toContain('aborted before completion') + }) + + it('translates an aborted executor result after dispatch starts', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { aborted: true, exitCode: null }) + + const result = await call(ctx, 'glob', { pattern: '*' }) + + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) + expect(text(result)).toContain('aborted before completion') }) it('translates a run() rejection without an abort (unusable workdir) into SEARCH_FAILED', async () => { const { ctx, bash } = await setup() + bash.forwardSignal = false bash.handler = () => { throw new Error('spawn bash ENOENT') } const result = await call(ctx, 'glob', { pattern: '*' }) expect(result.isError).toBe(true) diff --git a/packages/fs/tool-fs/src/sandbox.ts b/packages/fs/tool-fs/src/sandbox.ts index f58f9d6b13..e6cc0a61cd 100644 --- a/packages/fs/tool-fs/src/sandbox.ts +++ b/packages/fs/tool-fs/src/sandbox.ts @@ -106,7 +106,7 @@ export class FsSandboxSurface { agent: exec.agent, callId: exec.callId, toolName, - ...exec.signal ? { signal: exec.signal } : {}, + signal: exec.signal, }, ) } diff --git a/packages/fs/tool-fs/src/session-cwd.ts b/packages/fs/tool-fs/src/session-cwd.ts index 4f98a41a94..65a22bbc06 100644 --- a/packages/fs/tool-fs/src/session-cwd.ts +++ b/packages/fs/tool-fs/src/session-cwd.ts @@ -28,6 +28,6 @@ export function sessionResolveOptions(exec: ToolExecution): { cwd?: string; sign const cwd = sessionCwd(exec) return { ...cwd !== undefined ? { cwd } : {}, - ...exec.signal !== undefined ? { signal: exec.signal } : {}, + signal: exec.signal, } } diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 6a9e6f3568..a0dbc11294 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -12,11 +12,13 @@ import { join } from 'node:path' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +const testToolSignal = new AbortController().signal + let dir: string let ctx: Context let fiber: Awaited> @@ -26,6 +28,7 @@ const session = { header: {} } let callCounter = 0 function call(name: string, args: unknown) { return ctx.tools.execute({ + signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args, @@ -299,6 +302,7 @@ describe('per-session cwd', () => { const callIn = (sessionObj: object, name: string, args: unknown) => ctx.tools.execute({ + signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args, @@ -344,26 +348,25 @@ describe('signal, concurrency, and the fs/observed contract', () => { const callSig = (signal: AbortSignal, name: string, args: unknown) => ctx.tools.execute({ callId: CallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never, signal }) const callOwned = (name: string, args: unknown) => - ctx.tools.execute({ callId: CallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never }) + ctx.tools.execute({ signal: testToolSignal, callId: CallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never }) - it('a pre-aborted signal makes read/write/edit return isError FS_ABORTED', async () => { + it('a pre-aborted registry call skips read/write/edit with ABORTED_BEFORE_DISPATCH', async () => { await writeFile(join(dir, 'a.txt'), 'hello') const read = await callSig(AbortSignal.abort(), 'read', { file_path: 'a.txt' }) expect(read.isError).toBe(true) - expect(read.error).toMatchObject({ code: 'FS_ABORTED' }) + expect(read.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) const write = await callSig(AbortSignal.abort(), 'write', { file_path: 'new.txt', content: 'x' }) expect(write.isError).toBe(true) - expect(write.error).toMatchObject({ code: 'FS_ABORTED' }) + expect(write.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) await expect(readFile(join(dir, 'new.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) // Read first (un-aborted, SAME session owner) so the edit clears the - // observation gate; then the aborted edit fails on the signal, not on - // FS_NOT_OBSERVED. + // observation gate; then the registry skips the aborted edit before its body. expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false) const edit = await callSig(AbortSignal.abort(), 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' }) expect(edit.isError).toBe(true) - expect(edit.error).toMatchObject({ code: 'FS_ABORTED' }) + expect(edit.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello') // unchanged }) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 4d80061584..5de343abe3 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -27,6 +27,8 @@ import type { FileReadOutcome } from '../src/read-render.ts' import ApprovalService from '@deepseek-ai/dsh-user-approval' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +const testToolSignal = new AbortController().signal + /** An in-memory fake provider; a test can arm a rejection on any primitive. */ class FakeFs extends FileSystem { files = new Map() @@ -93,6 +95,7 @@ async function setup() { let callCounter = 0 function call(ctx: Context, name: string, args: unknown, agent?: object) { return ctx.tools.execute({ + signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args, @@ -112,11 +115,11 @@ describe('registration', () => { it('declares read parallel-safe while write/edit remain exclusive', async () => { const { ctx } = await setup() - expect(ctx.tools.executionMode({ callId: CallId('read-safe'), name: 'read', arguments: { file_path: 'a.txt' } })) + expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('read-safe'), name: 'read', arguments: { file_path: 'a.txt' } })) .toEqual({ kind: 'parallel' }) - expect(ctx.tools.executionMode({ callId: CallId('write-exclusive'), name: 'write', arguments: { file_path: 'a.txt', content: 'x' } })) + expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('write-exclusive'), name: 'write', arguments: { file_path: 'a.txt', content: 'x' } })) .toEqual({ kind: 'exclusive' }) - expect(ctx.tools.executionMode({ callId: CallId('edit-exclusive'), name: 'edit', arguments: { file_path: 'a.txt', old_string: 'x', new_string: 'y' } })) + expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('edit-exclusive'), name: 'edit', arguments: { file_path: 'a.txt', old_string: 'x', new_string: 'y' } })) .toEqual({ kind: 'exclusive' }) }) diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index 101c542d5a..b3c90021a5 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -10,6 +10,8 @@ import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard' import type { Config } from '@deepseek-ai/dsh-repeat-tool-guard' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +const testToolSignal = new AbortController().signal + /** * Behavior suite for the repeat-tool-call guard: chain semantics (identical / * different-tracked / untracked-transparent / per-agent / resets), threshold @@ -284,7 +286,7 @@ describe('chain semantics', () => { it('ignores direct executes with no agent (they neither crash nor advance any chain)', async () => { const ctx = await harness({ thresholds: [2] }) - const direct = await ctx.tools.execute({ callId: CallId('d1'), name: 'probe', arguments: { q: 1 } }) + const direct = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('d1'), name: 'probe', arguments: { q: 1 } }) expect(direct.isError).toBe(false) ctx.llm.registerAdapter(['mock'], new MockAdapter([ diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index b59742ff8c..63cd88f8bf 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -231,7 +231,7 @@ export function apply(ctx: Context, config: Config): void { // --- PreToolUse → PreToolDecision. Matcher subject is the tool name. --- ctx.on('tools/pre-execute', async (exec, next): Promise => { const turn = lastTurn(exec.agent) - const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) + const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} } return next() @@ -240,7 +240,7 @@ export function apply(ctx: Context, config: Config): void { // --- PostToolUse → PostToolDecision. Matcher subject is the tool name. --- ctx.on('tools/post-execute', async (exec, result, next): Promise => { const turn = lastTurn(exec.agent) - const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) + const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) const context = contextFrom(merged) if (merged.decision === 'deny') { return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} } diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index 870d369784..f210aa2d12 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -14,6 +14,8 @@ import { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +const testToolSignal = new AbortController().signal + /** Targeted branch coverage for the CC bridge: option arms, warn paths, no-agent * fallbacks, contextFrom-empty, and the detached-listener catch handlers. */ @@ -145,7 +147,7 @@ export function defineCoverageCases(group: CoverageGroup): void { ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) // Call execute() directly with NO agent — the bridge's no-agent/no-turn path. const { CallId } = await import('@deepseek-ai/dsh-llm') - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {} }) expect(ran).toBe(false) expect(result.isError).toBe(true) }) diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 65ddfea657..9dfd171d8e 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -203,7 +203,7 @@ export function apply(ctx: Context, config: Config): void { // PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored). ctx.on('tools/pre-execute', async (exec, next): Promise => { const turn = lastTurn(exec.agent) - const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) + const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) /* jscpd:ignore-end */ if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } return next() @@ -213,7 +213,7 @@ export function apply(ctx: Context, config: Config): void { ctx.on('tools/post-execute', async (exec, result, next): Promise => { const turn = lastTurn(exec.agent) /* jscpd:ignore-start */ - const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) + const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) const context = contextFrom(merged) if (merged.decision === 'deny') { return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} } diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index e02ea52df1..9bf14da46a 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -13,6 +13,8 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +const testToolSignal = new AbortController().signal + const dirs: string[] = [] afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) }) function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hx-cov-')); dirs.push(d); return d } @@ -451,7 +453,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) const { CallId } = await import('@deepseek-ai/dsh-llm') - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) expect(ran).toBe(false) // denied expect(result.isError).toBe(true) }) @@ -462,7 +464,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([])) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { CallId } = await import('@deepseek-ai/dsh-llm') - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) expect(result.isError).toBeFalsy() expect(result.additionalContexts?.[0]?.content.some(b => b.type === 'text' && b.text === 'x')).toBe(true) }) diff --git a/packages/llm/README.md b/packages/llm/README.md index ac08ffafc2..405c2f18f4 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -6,7 +6,8 @@ The LLM seam and its provider adapters. The interface package (`llm`) owns the a |---|---|---| | `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` | | `token-meter/` | Replay-aware request and surface token measurement | `ctx.tokenMeter` | +| `llm-retry/` | Bounded transient request retry policy | (listens to `agent/request-error`) | | `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | | `llm-pi-ai/` | Multi-provider adapter via `@earendil-works/pi-ai` | (registers on `ctx.llm`) | -The interface lives at `llm/llm/`; adapters and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. A new provider adapter joins here and registers one or more provider routes on `ctx.llm` without touching the interface. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the contract-validation origin of the two shipping implementations and the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership. +The interface lives at `llm/llm/`; adapters, retry policy, and reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. A new provider adapter joins here and registers one or more provider routes on `ctx.llm` without touching the interface. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the contract-validation origin of the two shipping implementations and the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership. diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index ee4c705560..d4fa19af3d 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -16,6 +16,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com thinking: enabled # optional; provider default is enabled reasoningEffort: high # optional; high | max — omitted ⇒ not sent + streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default models: # optional; defaults to V4 Flash and V4 Pro - id: deepseek-v4-flash name: DeepSeek V4 Flash @@ -29,6 +30,8 @@ The plugin registers the single provider route `deepseek`. A request selects it `thinking`/`reasoningEffort` are adapter-level request defaults serialized as the official top-level `thinking: {type}` / `reasoning_effort` wire fields. They live in adapter config (not `GenerateOptions`) to keep the core vocabulary provider-neutral. +`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; agent-level retry is a separate plugin policy. + ## App attribution Every request carries the shared attribution header from dsh-llm's `attributionHeaders()` - the mandatory `User-Agent` baseline identifying the harness (see [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts)). Direct DeepSeek requests and OpenAI-compatible gateway requests get no provider-specific app-attribution headers under this adapter contract; OpenRouter app attribution is deferred to a future explicit OpenRouter adapter or mode. @@ -42,11 +45,11 @@ 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), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_` otherwise. A transport failure before any response (DNS, refused connection, TLS, proxy) throws `NETWORK` naming the configured endpoint and chaining fetch's `TypeError: fetch failed` as `cause`, so `errorChain` renders the underlying diagnosis; an abort keeps its `DOMException` so the loop classifies it as cancellation. 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), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. 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', failure}` chunks. ## Testing -Unit suites run against a local `node:http` mock SSE server (no network). Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback. +Unit suites run against a local `node:http` mock SSE server (no network), including structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback. ## Model Experience diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 1461ad0f44..02e8cb85ac 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -23,6 +23,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -30,6 +31,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 66520a83d4..74adafb225 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -5,8 +5,9 @@ * @module dsh-llm-deepseek/adapter */ -import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import { idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' import { serializeRequest } from './serialize.ts' import type { RequestDefaults } from './serialize.ts' import { parseSse } from './sse.ts' @@ -33,6 +34,27 @@ export interface DeepSeekAdapterOptions { defaults?: RequestDefaults /** Advisory models exposed to discovery consumers; requests remain unrestricted. */ models?: readonly DeepSeekCatalogModel[] + /** Maximum provider idle time while one stream read is outstanding. */ + streamIdleTimeoutMs?: number +} + +/** Default maximum idle interval while an adapter stream read is outstanding. */ +export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 +const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT' + +function providerRetryAfterMs(value: string | null): number | undefined { + if (value === null) return undefined + if (/^\d+$/.test(value)) { + const delay = Number(value) * 1_000 + return Number.isFinite(delay) && delay > 0 ? delay : undefined + } + const delay = Date.parse(value) - Date.now() + return Number.isFinite(delay) && delay > 0 ? delay : undefined +} + +function requestId(headers: Headers): ReturnType | undefined { + const value = headers.get('x-request-id') ?? headers.get('x-deepseek-request-id') + return value === null || value.length === 0 ? undefined : ProviderRequestId(value) } /** @@ -43,9 +65,10 @@ export interface DeepSeekAdapterOptions { */ export function httpErrorCode(status: number, error?: WireError['error']): string { if (status === 401 || status === 403) return 'AUTH' + const detail = [error?.code, error?.type, error?.message].filter(Boolean).join(' ') + if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE if (status === 429) return 'RATE_LIMIT' 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' } @@ -57,13 +80,22 @@ export function httpErrorCode(status: number, error?: WireError['error']): strin * The first real `LlmAdapter`. One instance serves every model name it was * registered under (the harness model name IS the wire model name). * - * Abort: `options.signal` is handed to fetch — both the initial request and - * the body stream reject on abort, which surfaces to the loop as a rejected - * step (the loop already contains step errors). + * One stable signal reaches both initial fetch and body reads. Caller aborts + * map to `ABORTED`; the configured per-read idle watchdog maps to `TIMEOUT`. */ export class DeepSeekAdapter extends LlmAdapter { + private readonly streamIdleTimeoutMs: number + constructor(private readonly options: DeepSeekAdapterOptions) { super() + this.streamIdleTimeoutMs = options.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS + if (!Number.isFinite(this.streamIdleTimeoutMs) + || this.streamIdleTimeoutMs <= 0 + || this.streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) { + throw new Error( + `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, + ) + } } override providerInfo(provider: string): LlmProviderInfo { @@ -80,8 +112,50 @@ export class DeepSeekAdapter extends LlmAdapter { } async * stream(options: GenerateOptions): AsyncIterable { + const consumer = new AbortController() + const upstream = options.signal === undefined + ? consumer.signal + : AbortSignal.any([options.signal, consumer.signal]) + using watchdog = idleWatchdog(upstream, this.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE) + const iterator = this.request(options, watchdog.signal)[Symbol.asyncIterator]() + let exhausted = false + try { + while (true) { + const result = await watchdog.next(iterator) + if (result.done) { + exhausted = true + return + } + yield result.value + } + } catch (error: unknown) { + if (timeoutOf(watchdog.signal, STREAM_IDLE_TIMEOUT_CODE) !== undefined) { + throw new LlmError( + `DeepSeek stream idle timeout after ${this.streamIdleTimeoutMs}ms`, + 'TIMEOUT', + { cause: error }, + ) + } + if (options.signal?.aborted) { + throw new LlmError('DeepSeek request aborted by caller', 'ABORTED', { cause: error }) + } + if (error instanceof LlmError) throw error + throw new LlmError(`DeepSeek API stream from ${this.options.baseURL} failed`, 'TRANSPORT', { cause: error }) + } finally { + consumer.abort('DeepSeek stream consumer stopped') + if (!exhausted && iterator.return !== undefined) { + try { + await iterator.return() + } catch (_abortedTransportTeardown) { + // The consumer controller already owns termination; a return-time abort cannot add a second outcome. + } + } + } + } + + private async * request(options: GenerateOptions, signal: AbortSignal): AsyncIterable { const body = serializeRequest(options, this.options.defaults ?? {}) - // Prepared outside the try so the NETWORK label below covers exactly the + // Prepared outside the try so the TRANSPORT label below covers exactly the // transport boundary, never a serialization failure. const payload = JSON.stringify(body) const headers = { @@ -102,19 +176,18 @@ export class DeepSeekAdapter extends LlmAdapter { method: 'POST', headers, body: payload, - ...options.signal ? { signal: options.signal } : {}, + signal, }) } catch (error: unknown) { - // An aborted request rethrows its original rejection (the signal's abort - // reason) so the loop classifies it as cancellation, not a provider failure. - if (options.signal?.aborted) throw error + // The outer stream distinguishes caller cancellation and watchdog expiry. + if (signal.aborted) throw error // fetch wraps every transport failure (DNS, refused connection, TLS, // proxy) in a bare `TypeError: fetch failed` whose actionable detail // lives on `cause`. Wrapping with the endpoint and chaining the cause // lets `errorChain` render the full diagnosis at every reporting seam. throw new LlmError( `DeepSeek API request to ${this.options.baseURL} failed`, - 'NETWORK', + 'TRANSPORT', { cause: error }, ) } @@ -130,7 +203,13 @@ export class DeepSeekAdapter extends LlmAdapter { // Only swallow error-body parsing: the HTTP status still identifies the // failure, so malformed gateway JSON must not mask it. } - throw new LlmError(message, httpErrorCode(response.status, providerError)) + const delay = providerRetryAfterMs(response.headers.get('retry-after')) + const id = requestId(response.headers) + throw new LlmError(message, httpErrorCode(response.status, providerError), { + status: response.status, + ...delay === undefined ? {} : { providerRetryAfterMs: delay }, + ...id === undefined ? {} : { requestId: id }, + }) } if (!response.body) { throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE') diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index f9f223b6ff..c0d0df0f08 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -8,7 +8,8 @@ import type { Context } from 'cordis' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-llm' -import { DeepSeekAdapter } from './adapter.ts' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts' import type { DeepSeekCatalogModel } from './adapter.ts' export { DeepSeekAdapter } from './adapter.ts' @@ -41,6 +42,8 @@ export interface Config { reasoningEffort?: 'high' | 'max' /** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */ models?: DeepSeekCatalogModel[] + /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ + streamIdleTimeoutMs?: number } const catalogModel: z = z.object({ @@ -55,6 +58,7 @@ export const Config: z = z.object({ thinking: z.union(['enabled', 'disabled']), reasoningEffort: z.union(['high', 'max']), models: z.array(catalogModel).default(DEFAULT_MODELS), + streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS), }) /** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */ @@ -92,5 +96,6 @@ export function apply(ctx: Context, config: Config): void { reasoningEffort: config.reasoningEffort, }, models: resolveModels(config.models), + streamIdleTimeoutMs: config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS, })) } diff --git a/packages/llm/llm-deepseek/src/translate.ts b/packages/llm/llm-deepseek/src/translate.ts index c66271246c..f0b5eaf789 100644 --- a/packages/llm/llm-deepseek/src/translate.ts +++ b/packages/llm/llm-deepseek/src/translate.ts @@ -35,7 +35,10 @@ export function mapFinishReason(reason: string): FinishReason { case 'length': return { kind: 'max-tokens' } default: // content_filter, insufficient_system_resource, future additions. - return { kind: 'error', message: `model stopped: ${reason}`, code: reason.toUpperCase() } + return { + kind: 'error', + failure: { message: `model stopped: ${reason}`, code: reason.toUpperCase() }, + } } } diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index b92f96dbda..0b2dae67bf 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -2,7 +2,15 @@ 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, { CONTEXT_WINDOW_EXCEEDED_CODE, errorChain, LlmError, userAgent } from '@deepseek-ai/dsh-llm' +import LlmService, { + CONTEXT_WINDOW_EXCEEDED_CODE, + errorChain, + LlmError, + ProviderRequestId, + QUOTA_EXCEEDED_CODE, + userAgent, +} from '@deepseek-ai/dsh-llm' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { SessionId } from '@deepseek-ai/dsh-session' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek' @@ -12,7 +20,7 @@ import { assemble } from './assemble.ts' /** One scripted behavior for the next request the mock server receives. */ type Behavior = | { kind: 'sse'; events: string[]; delayMs?: number } - | { kind: 'http-error'; status: number; body: string; contentType?: string } + | { kind: 'http-error'; status: number; body: string; contentType?: string; headers?: Record } | { kind: 'close-early'; events: string[] } interface MockServer { @@ -30,6 +38,7 @@ const servers: Server[] = [] afterEach(async () => { await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) vi.unstubAllEnvs() + vi.useRealTimers() }) /** Local chat-completions stand-in: replays scripted behaviors per request. */ @@ -48,7 +57,10 @@ async function mockServer(script: Behavior[]): Promise { return } if (behavior.kind === 'http-error') { - response.writeHead(behavior.status, { 'content-type': behavior.contentType ?? 'application/json' }) + response.writeHead(behavior.status, { + 'content-type': behavior.contentType ?? 'application/json', + ...behavior.headers, + }) response.end(behavior.body) return } @@ -202,6 +214,84 @@ describe('DeepSeekAdapter against a mock server', () => { expect(code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE) }) + it('retains status, Retry-After seconds, and provider request id as structured facts', async () => { + const server = await mockServer([{ + kind: 'http-error', + status: 429, + body: JSON.stringify({ error: { message: 'slow down' } }), + headers: { 'retry-after': '2', 'x-request-id': 'req-429' }, + }]) + const ctx = await harness(server.url) + let thrown: unknown + try { + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + } catch (error: unknown) { + thrown = error + } + expect(thrown).toBeInstanceOf(LlmError) + expect((thrown as LlmError).failure).toEqual({ + message: 'slow down', + code: 'RATE_LIMIT', + status: 429, + providerRetryAfterMs: 2_000, + requestId: ProviderRequestId('req-429'), + }) + }) + + it('parses a future Retry-After HTTP date and the DeepSeek request-id fallback', async () => { + const now = 1_800_000_000_000 + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now) + try { + const server = await mockServer([{ + kind: 'http-error', + status: 503, + body: JSON.stringify({ error: { message: 'come back later' } }), + headers: { + 'retry-after': new Date(now + 3_000).toUTCString(), + 'x-deepseek-request-id': 'deepseek-503', + }, + }]) + const ctx = await harness(server.url) + await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) + .rejects.toMatchObject({ + failure: { + message: 'come back later', + code: 'SERVER', + status: 503, + providerRetryAfterMs: 3_000, + requestId: ProviderRequestId('deepseek-503'), + }, + }) + } finally { + dateNow.mockRestore() + } + }) + + it('omits zero, non-finite, invalid, and past Retry-After values', async () => { + const values = [ + '0', + '9'.repeat(400), + 'not-a-date', + new Date(0).toUTCString(), + ] + for (const value of values) { + const server = await mockServer([{ + kind: 'http-error', + status: 429, + body: JSON.stringify({ error: { message: 'retry later' } }), + headers: { 'retry-after': value }, + }]) + const ctx = await harness(server.url) + let thrown: LlmError | undefined + try { + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + } catch (error: unknown) { + if (error instanceof LlmError) thrown = error + } + expect(thrown?.failure).toEqual({ message: 'retry later', code: 'RATE_LIMIT', status: 429 }) + } + }) + 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) @@ -210,6 +300,12 @@ describe('DeepSeekAdapter against a mock server', () => { expect(httpErrorCode(413, { code: 'context_length_exceeded' })).toBe('HTTP_413') }) + it('distinguishes terminal quota exhaustion from transient HTTP 429 throttling', () => { + expect(httpErrorCode(429, { code: 'insufficient_quota', message: 'account credits exhausted' })) + .toBe(QUOTA_EXCEEDED_CODE) + expect(httpErrorCode(429, { message: 'request rate limit exceeded' })).toBe('RATE_LIMIT') + }) + 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) @@ -228,7 +324,7 @@ describe('DeepSeekAdapter against a mock server', () => { expect(httpErrorCode(418)).toBe('HTTP_418') }) - it('wraps a transport failure in NETWORK with the fetch cause chain in the message', async () => { + it('wraps a transport failure in TRANSPORT with the fetch cause chain in the message', async () => { // Port 1 is reserved/unbound: fetch rejects with `TypeError: fetch failed` // whose actionable detail (ECONNREFUSED) lives on `cause`. const ctx = await harness('http://127.0.0.1:1') @@ -240,14 +336,14 @@ describe('DeepSeekAdapter against a mock server', () => { } expect(caught).toBeInstanceOf(LlmError) const llmError = caught as LlmError - expect(llmError.code).toBe('NETWORK') + expect(llmError.code).toBe('TRANSPORT') expect(llmError.message).toContain('http://127.0.0.1:1') expect(llmError.cause).toBeInstanceOf(TypeError) // The chain renderer reaches the transport diagnosis through the cause. expect(errorChain(llmError)).toMatch(/ECONNREFUSED|EADDRNOTAVAIL|bad port/) }) - it('keeps an abort rejection unwrapped so the loop classifies it as cancellation', async () => { + it('classifies an aborted request without losing the transport rejection', async () => { const controller = new AbortController() controller.abort() const ctx = await harness('http://127.0.0.1:1') @@ -257,8 +353,9 @@ describe('DeepSeekAdapter against a mock server', () => { } catch (error: unknown) { caught = error } - expect(caught).not.toBeInstanceOf(LlmError) - expect((caught as Error).name).toBe('AbortError') + expect(caught).toBeInstanceOf(LlmError) + expect(caught).toMatchObject({ code: 'ABORTED' }) + expect((caught as LlmError).cause).toMatchObject({ name: 'AbortError' }) }) it('throws EMPTY_RESPONSE when the response has no body', async () => { @@ -276,14 +373,20 @@ describe('DeepSeekAdapter against a mock server', () => { } }) - it('rejects with STREAM_CLOSED when the server drops mid-stream', async () => { + it('classifies an abrupt body close as TRANSPORT and retains its cause', async () => { const server = await mockServer([{ kind: 'close-early', events: ['{"choices":[{"delta":{"content":"par"}}]}'], }]) const ctx = await harness(server.url) - await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })) - .rejects.toThrow(/terminated|socket|without \[DONE\]/) + let caught: unknown + try { + await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + } catch (error: unknown) { + caught = error + } + expect(caught).toMatchObject({ code: 'TRANSPORT' }) + expect(errorChain(caught)).toMatch(/terminated|socket|without \[DONE\]/) }) it('aborts mid-stream via the request signal', async () => { @@ -305,7 +408,76 @@ describe('DeepSeekAdapter against a mock server', () => { })() setTimeout(() => { controller.abort() }, 30) - await expect(pending).rejects.toThrow() + await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) + }) + + it('maps connection failures to TRANSPORT without losing the cause', async () => { + const cause = new TypeError('connection refused') + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(cause) + const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' }) + try { + const drain = async (): Promise => { + for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ } + } + await expect(drain()).rejects.toMatchObject({ code: 'TRANSPORT', cause }) + } finally { + fetchSpy.mockRestore() + } + }) + + it('renders a non-Error transport rejection without losing its cause', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => { + const failed = Promise.withResolvers() + failed.reject('offline') + return failed.promise + }) + const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' }) + try { + const drain = async (): Promise => { + for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ } + } + await expect(drain()).rejects.toMatchObject({ + message: 'DeepSeek API request to https://example.invalid failed', + code: 'TRANSPORT', + cause: 'offline', + }) + } finally { + fetchSpy.mockRestore() + } + }) + + it('aborts the underlying body when the stream stays idle past its watchdog', async () => { + vi.useFakeTimers() + let stopped = false + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { + const signal = init?.signal + const body = new ReadableStream({ + start(controller) { + signal?.addEventListener('abort', () => { + stopped = true + controller.error(signal.reason) + }, { once: true }) + }, + }) + return Promise.resolve(new Response(body, { status: 200 })) + }) + const adapter = new DeepSeekAdapter({ + apiKey: 'k', + baseURL: 'https://example.invalid', + streamIdleTimeoutMs: 100, + }) + try { + const drain = (async () => { + for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ } + })() + const rejected = expect(drain).rejects.toMatchObject({ code: 'TIMEOUT' }) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(100) + await rejected + expect(stopped).toBe(true) + } finally { + fetchSpy.mockRestore() + } }) }) @@ -452,4 +624,30 @@ describe('plugin registration and config', () => { expect(adapter).toBeInstanceOf(DeepSeekAdapter) await expect(adapter.listModels('deepseek')).resolves.toEqual([]) }) + + it('rejects invalid idle watchdog bounds for direct and plugin composition', async () => { + expect(() => new DeepSeekAdapter({ + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + streamIdleTimeoutMs: Number.POSITIVE_INFINITY, + })).toThrow(/streamIdleTimeoutMs.*positive finite/) + expect(() => new DeepSeekAdapter({ + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1, + })).toThrow(/streamIdleTimeoutMs.*no greater/) + + const ctx = new Context() + await ctx.plugin(LlmService) + await expect(ctx.plugin(LlmDeepSeek, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + streamIdleTimeoutMs: 0, + })).rejects.toThrow(/streamIdleTimeoutMs/) + await expect(ctx.plugin(LlmDeepSeek, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1, + })).rejects.toThrow(/streamIdleTimeoutMs/) + }) }) diff --git a/packages/llm/llm-deepseek/tests/translate.spec.ts b/packages/llm/llm-deepseek/tests/translate.spec.ts index e62cebc4af..4ae833dc4c 100644 --- a/packages/llm/llm-deepseek/tests/translate.spec.ts +++ b/packages/llm/llm-deepseek/tests/translate.spec.ts @@ -232,8 +232,7 @@ describe('mapFinishReason', () => { (wire) => { expect(mapFinishReason(wire)).toEqual({ kind: 'error', - message: `model stopped: ${wire}`, - code: wire.toUpperCase(), + failure: { message: `model stopped: ${wire}`, code: wire.toUpperCase() }, }) }, ) diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index e9de391ba1..5e427d88b9 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../util/timeout" } ] } diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 06395d701c..bedfc5517d 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -19,7 +19,7 @@ Configure credentials and deployment-specific transport settings per provider. O reasoning: high - provider: anthropic apiKey: !!js process.env.ANTHROPIC_API_KEY - maxRetries: 2 + streamIdleTimeoutMs: 300000 - provider: openrouter apiKey: !!js process.env.OPENROUTER_API_KEY headers: @@ -30,7 +30,9 @@ Each provider name must exist in pi-ai's installed catalog and may appear only o The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. -Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `maxRetries`, and `maxRetryDelayMs`. They map to pi-ai's common stream options. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, and `streamIdleTimeoutMs`. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. + +The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. ## Provider/model routing and replay @@ -43,7 +45,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. Provider-specific error text and usage signals evaluated against the resolved model's context window normalize overflow to `CONTEXT_WINDOW_EXCEEDED`. +- pi-ai reports failures as in-stream error events; these map to `finish {kind:'error'|'aborted', failure}` chunks. Provider-specific error text distinguishes terminal `QUOTA` from transient `RATE_LIMIT`, while 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. @@ -57,7 +59,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata ## Testing -Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. Real-API coverage remains key-gated under `pnpm run test:e2e`. +Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. Real-API coverage remains key-gated under `pnpm run test:e2e`. ## Model Experience @@ -95,3 +97,4 @@ Recorded response content appends to the next request and does not invalidate it - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. - **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override. - **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers; failures expose only stable harness error codes. +- **Retry policy is not an adapter option** — SDK retries are disabled so durable agent steps and `llm/retry` events own every visible attempt; direct `ctx.llm.stream()` calls remain single-attempt. diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index c922467deb..2a5aed1d76 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -23,6 +23,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -32,6 +33,7 @@ "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 7f40c67da3..25240109bb 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -16,7 +16,9 @@ import type { } from '@earendil-works/pi-ai' import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' -import type { PiAiProviderProfile } from './config.ts' +import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { resolveProfiles } from './config.ts' +import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' import { toPiContext } from './context.ts' import { toStreamChunks } from './stream.ts' @@ -48,8 +50,8 @@ function profileOptions(profile: PiAiProviderProfile): SimpleStreamOptions { ...profile.transport === undefined ? {} : { transport: profile.transport }, ...profile.timeoutMs === undefined ? {} : { timeoutMs: profile.timeoutMs }, ...profile.websocketConnectTimeoutMs === undefined ? {} : { websocketConnectTimeoutMs: profile.websocketConnectTimeoutMs }, - ...profile.maxRetries === undefined ? {} : { maxRetries: profile.maxRetries }, - ...profile.maxRetryDelayMs === undefined ? {} : { maxRetryDelayMs: profile.maxRetryDelayMs }, + // The agent recovery layer owns visible attempts; one adapter call is one SDK attempt. + maxRetries: 0, } } @@ -68,11 +70,11 @@ function requestHeaders(headers: Readonly> | undefined): * request, so models need not be registered during the Cordis lifecycle. */ export class PiAiAdapter extends LlmAdapter { - private readonly profiles: ReadonlyMap + private readonly profiles: ReadonlyMap constructor(options: PiAiAdapterOptions) { super() - this.profiles = new Map(options.profiles.map(profile => [profile.provider, profile])) + this.profiles = new Map(resolveProfiles(options.profiles).map(profile => [profile.provider, profile])) } override listModels(provider: string): Promise { @@ -97,12 +99,12 @@ export class PiAiAdapter extends LlmAdapter { } const model = resolveModel(profile, options.model) - // Pi-ai has no iterator-return cancellation hook. Chain an internal signal - // and abort it when this generator exits so early consumers stop the HTTP stream. - const controller = new AbortController() - const onCallerAbort = (): void => { controller.abort(options.signal?.reason) } - if (options.signal?.aborted) controller.abort(options.signal.reason) - else options.signal?.addEventListener('abort', onCallerAbort, { once: true }) + const consumer = new AbortController() + const upstream = options.signal === undefined + ? consumer.signal + : AbortSignal.any([options.signal, consumer.signal]) + const streamIdleTimeoutMs = profile.streamIdleTimeoutMs + using watchdog = idleWatchdog(upstream, streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT') try { const events = streamSimple(model, toPiContext(options), { @@ -110,15 +112,44 @@ export class PiAiAdapter extends LlmAdapter { ...options.temperature === undefined ? {} : { temperature: options.temperature }, ...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens }, ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, - signal: controller.signal, + signal: watchdog.signal, // Profile headers are deployment-owned; attribution names are // Harness-owned and therefore win collisions. headers: requestHeaders(profile.headers), }) - yield* toStreamChunks(events, model.contextWindow) + const iterator = toStreamChunks(events, model.contextWindow)[Symbol.asyncIterator]() + let exhausted = false + try { + while (true) { + const result = await watchdog.next(iterator) + const timeout = timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT') + if (timeout !== undefined) throw timeout + if (result.done) { + exhausted = true + return + } + yield result.value + } + } finally { + if (!exhausted) { + consumer.abort('pi-ai stream consumer stopped') + try { + await iterator.return(undefined) + } catch (_abortedSdkTeardown) { + // The stable signal already owns SDK termination; return-time abort cannot add an outcome. + } + } + } + } catch (error: unknown) { + if (timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT') !== undefined) { + throw new LlmError(`pi-ai stream idle timeout after ${streamIdleTimeoutMs}ms`, 'TIMEOUT', { cause: error }) + } + if (options.signal?.aborted) { + throw new LlmError('pi-ai request aborted by caller', 'ABORTED', { cause: error }) + } + throw error } finally { - options.signal?.removeEventListener('abort', onCallerAbort) - controller.abort('consumer stopped streaming') + consumer.abort('pi-ai stream consumer stopped') } } } diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index f7570aff64..d5b5d70867 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -7,6 +7,10 @@ import { getProviders } from '@earendil-works/pi-ai' import type { CacheRetention, ThinkingBudgets, ThinkingLevel, Transport } from '@earendil-works/pi-ai' import z from 'schemastery' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' + +/** Default maximum idle interval while an adapter stream read is outstanding. */ +export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 /** Configuration for one pi-ai provider route. */ export interface PiAiProviderProfile { @@ -30,10 +34,14 @@ export interface PiAiProviderProfile { timeoutMs?: number /** WebSocket connection timeout in milliseconds. */ websocketConnectTimeoutMs?: number - /** Provider SDK retry count. */ - maxRetries?: number - /** Maximum provider-requested retry delay in milliseconds. */ - maxRetryDelayMs?: number + /** Maximum provider idle time while one stream read is outstanding. */ + streamIdleTimeoutMs?: number +} + +/** Validated profile with every adapter-owned default resolved. */ +export interface ResolvedPiAiProviderProfile extends PiAiProviderProfile { + /** Positive finite provider-idle interval after defaulting. */ + streamIdleTimeoutMs: number } /** Plugin configuration: the non-empty provider profiles this instance owns. */ @@ -60,8 +68,7 @@ const profile = z.object({ transport: z.union(['sse', 'websocket', 'websocket-cached', 'auto']), timeoutMs: z.natural(), websocketConnectTimeoutMs: z.natural(), - maxRetries: z.natural(), - maxRetryDelayMs: z.natural(), + streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS), }) /** Runtime schema for {@link Config}. */ @@ -75,11 +82,18 @@ export const Config: z = z.object({ * @param profiles - configured provider profiles. * @returns validated profiles in configuration order. */ -export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): PiAiProviderProfile[] { +export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): ResolvedPiAiProviderProfile[] { if (profiles.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile') const supported = new Set(getProviders()) const seen = new Set() return profiles.map((source) => { + const legacy = source as PiAiProviderProfile & { + maxRetries?: unknown + maxRetryDelayMs?: unknown + } + if ('maxRetries' in legacy || 'maxRetryDelayMs' in legacy) { + throw new Error('llm-pi-ai: maxRetries and maxRetryDelayMs were removed; compose agent recovery with dsh-llm-retry') + } if (source.provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty') if (!supported.has(source.provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${source.provider}"`) if (seen.has(source.provider)) throw new Error(`llm-pi-ai: duplicate provider profile "${source.provider}"`) @@ -89,9 +103,18 @@ export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): PiAiP if (source.baseURL !== undefined && source.baseURL.length === 0) { throw new Error(`llm-pi-ai: provider "${source.provider}" has an empty baseURL`) } + const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS + if (!Number.isFinite(streamIdleTimeoutMs) + || streamIdleTimeoutMs <= 0 + || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) { + throw new Error( + `llm-pi-ai: provider "${source.provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, + ) + } seen.add(source.provider) return { ...source, + streamIdleTimeoutMs, ...source.headers === undefined ? {} : { headers: { ...source.headers } }, ...source.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...source.thinkingBudgets } }, } diff --git a/packages/llm/llm-pi-ai/src/stream.ts b/packages/llm/llm-pi-ai/src/stream.ts index c1a85addf0..37736af716 100644 --- a/packages/llm/llm-pi-ai/src/stream.ts +++ b/packages/llm/llm-pi-ai/src/stream.ts @@ -8,7 +8,7 @@ * @module dsh-llm-pi-ai/stream */ -import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, LlmError } from '@deepseek-ai/dsh-llm' +import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmError, QUOTA_EXCEEDED_CODE } 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' @@ -30,9 +30,15 @@ export function mapUsage(usage: PiUsage): TokenUsage { function classifyPiAiError(message: string): string { if (/\b(?:401|403)\b/.test(message)) return 'AUTH' + if (isQuotaExceededError(message)) return QUOTA_EXCEEDED_CODE if (/\b429\b|rate.?limit/i.test(message)) return 'RATE_LIMIT' if (/\b400\b|invalid.?request/i.test(message)) return 'INVALID_REQUEST' if (/\b5\d\d\b/.test(message)) return 'SERVER' + if (/\btime(?:d)?\s*out\b|timeout/i.test(message)) return 'TIMEOUT' + if (/\b(?:network|connection|socket|fetch)\b|\bECONN[A-Z]+\b/i.test(message) + || /\b(?:other side closed|HTTP2 request did not get a response|WebSocket closed unexpectedly)\b/i.test(message)) { + return 'TRANSPORT' + } return 'PI_AI_ERROR' } @@ -52,8 +58,10 @@ export function mapStopReason(message: AssistantMessage, contextWindow?: number) if (piAiOverflow || harnessOverflow) { return { kind: 'error', - message: message.errorMessage ?? `pi-ai detected context overflow for model "${message.model}"`, - code: CONTEXT_WINDOW_EXCEEDED_CODE, + failure: { + message: message.errorMessage ?? `pi-ai detected context overflow for model "${message.model}"`, + code: CONTEXT_WINDOW_EXCEEDED_CODE, + }, } } @@ -61,10 +69,13 @@ export function mapStopReason(message: AssistantMessage, contextWindow?: number) case 'stop': return { kind: 'stop' } case 'length': return { kind: 'max-tokens' } case 'toolUse': return { kind: 'tool-calls' } - case 'aborted': return { kind: 'aborted' } + case 'aborted': return { + kind: 'aborted', + failure: { message: message.errorMessage ?? 'pi-ai stream aborted', code: 'ABORTED' }, + } case 'error': { const text = message.errorMessage ?? 'pi-ai stream error' - return { kind: 'error', message: text, code: classifyPiAiError(text) } + return { kind: 'error', failure: { message: text, code: classifyPiAiError(text) } } } } } diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 51f78e7760..1b7445fa56 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -6,6 +6,7 @@ import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@ import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' import { getModels } from '@earendil-works/pi-ai' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { resolveProfiles } from '../src/config.ts' import { assemble } from './assemble.ts' @@ -14,6 +15,8 @@ interface MockServer { paths: string[] requests: unknown[] headers: IncomingMessage['headers'][] + readonly closedResponses: number + responseClosed: Promise } const servers: Server[] = [] @@ -23,11 +26,23 @@ afterEach(async () => { await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) }) -async function mockServer(script: { status?: number; events?: string[]; body?: string; delayMs?: number }[]): Promise { +async function mockServer(script: { + status?: number + events?: string[] + body?: string + delayMs?: number + headers?: Record +}[]): Promise { const paths: string[] = [] const requests: unknown[] = [] const headers: IncomingMessage['headers'][] = [] + let closedResponses = 0 + const responseClosed = Promise.withResolvers() const server = createServer((request: IncomingMessage, response: ServerResponse) => { + response.on('close', () => { + closedResponses += 1 + responseClosed.resolve(undefined) + }) let body = '' request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) request.on('end', () => { @@ -36,7 +51,7 @@ async function mockServer(script: { status?: number; events?: string[]; body?: s headers.push(request.headers) const behavior = script.shift() ?? { status: 500, body: 'script exhausted' } if (behavior.status !== undefined && behavior.status !== 200) { - response.writeHead(behavior.status, { 'content-type': 'application/json' }) + response.writeHead(behavior.status, { 'content-type': 'application/json', ...behavior.headers }) response.end(behavior.body ?? '{}') return } @@ -56,7 +71,14 @@ async function mockServer(script: { status?: number; events?: string[]; body?: s await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) const address = server.address() if (address === null || typeof address === 'string') throw new Error('no port') - return { url: `http://127.0.0.1:${address.port}`, paths, requests, headers } + return { + url: `http://127.0.0.1:${address.port}`, + paths, + requests, + headers, + responseClosed: responseClosed.promise, + get closedResponses() { return closedResponses }, + } } const textEvents = [ @@ -107,8 +129,7 @@ describe('PiAiAdapter provider routing', () => { transport: 'sse', timeoutMs: 5000, websocketConnectTimeoutMs: 3000, - maxRetries: 0, - maxRetryDelayMs: 10, + streamIdleTimeoutMs: 10_000, thinkingBudgets: { high: 2048 }, }) await assemble(ctx, { @@ -161,13 +182,35 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1`, maxRetries: 0 }], + providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }], }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) expect(result.finish.kind).toBe('error') expect(server.paths).toEqual(['/v1/responses']) }) + it('forces one wire request for an SDK-retryable provider failure', async () => { + const server = await mockServer([ + { + status: 429, + headers: { 'retry-after-ms': '1' }, + body: JSON.stringify({ error: { message: 'retryable provider failure' } }), + }, + { status: 500, body: JSON.stringify({ error: { message: 'hidden SDK retry' } }) }, + { status: 500, body: JSON.stringify({ error: { message: 'second hidden SDK retry' } }) }, + ]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }], + }) + + const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) + + expect(result.finish).toMatchObject({ kind: 'error' }) + expect(server.paths).toEqual(['/v1/responses']) + }) + it('uses OpenAI Responses against an Azure project v1 path with its API key header', async () => { const server = await mockServer([{ status: 401, body: JSON.stringify({ error: { message: 'expected mock failure' } }) }]) const ctx = new Context() @@ -178,7 +221,6 @@ describe('PiAiAdapter provider routing', () => { apiKey: 'test-key', baseURL: `${server.url}/api/projects/openai/openai/v1`, headers: { 'api-key': 'test-key', Authorization: '' }, - maxRetries: 0, }], }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-5.5', messages: [] }) @@ -195,9 +237,10 @@ describe('PiAiAdapter provider routing', () => { [500, 'SERVER'], ] as const)('maps HTTP %s failures to %s', async (status, code) => { const server = await mockServer([{ status, body: JSON.stringify({ error: { message: `provider ${status}` } }) }]) - const ctx = await harness(server.url, { maxRetries: 0 }) + const ctx = await harness(server.url) const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toMatchObject({ kind: 'error', code }) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code } }) + expect(server.paths).toEqual(['/chat/completions']) }) it('uses the resolved catalog context window for usage-based overflow detection', async () => { @@ -218,10 +261,29 @@ describe('PiAiAdapter provider routing', () => { expect(result.finish).toEqual({ kind: 'error', - message: `pi-ai detected context overflow for model "${model.id}"`, - code: CONTEXT_WINDOW_EXCEEDED_CODE, + failure: { + message: `pi-ai detected context overflow for model "${model.id}"`, + code: CONTEXT_WINDOW_EXCEEDED_CODE, + }, }) }) + + it('stops the SDK request when the adapter idle watchdog expires', async () => { + const server = await mockServer([{ events: textEvents, delayMs: 200 }]) + const ctx = await harness(server.url, { streamIdleTimeoutMs: 20 }) + + await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) + .rejects.toMatchObject({ code: 'TIMEOUT' }) + await Promise.race([ + server.responseClosed, + new Promise((_resolve, reject) => { + setTimeout(() => { reject(new Error('SDK request did not close after idle timeout')) }, 100) + }), + ]) + + expect(server.paths).toEqual(['/chat/completions']) + expect(server.closedResponses).toBe(1) + }) }) describe('provider profile lifecycle', () => { @@ -280,16 +342,31 @@ describe('provider profile lifecycle', () => { expect(() => resolveProfiles([{ provider: 'openai', baseURL: '' }])).toThrow(/empty baseURL/) }) - it('rejects negative or fractional stream tunables at schema validation', () => { + it.each(['maxRetries', 'maxRetryDelayMs'] as const)( + 'rejects removed profile field %s instead of silently restoring hidden SDK retries', + async (field) => { + const legacy = { provider: 'openai', [field]: 2 } + expect(() => resolveProfiles([legacy as never])).toThrow(/removed.*agent recovery/i) + const ctx = new Context() + await ctx.plugin(LlmService) + await expect(ctx.plugin(LlmPiAi, { providers: [legacy as never] })) + .rejects.toThrow(/removed.*agent recovery/i) + }, + ) + + it('rejects invalid stream tunables at plugin load', async () => { const invalid = [ { timeoutMs: -1 }, { websocketConnectTimeoutMs: -1 }, - { maxRetries: -1 }, - { maxRetries: 0.5 }, - { maxRetryDelayMs: -1 }, + { streamIdleTimeoutMs: 0 }, + { streamIdleTimeoutMs: Number.NaN }, + { streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 }, ] for (const entry of invalid) { - expect(() => new LlmPiAi.Config({ providers: [{ provider: 'openai', ...entry }] })).toThrow() + const ctx = new Context() + await ctx.plugin(LlmService) + await expect(ctx.plugin(LlmPiAi, { providers: [{ provider: 'openai', ...entry }] })) + .rejects.toThrow() } }) @@ -301,11 +378,59 @@ describe('provider profile lifecycle', () => { })()).rejects.toMatchObject({ code: 'NO_ADAPTER' }) expect(new LlmError('x', 'X')).toBeInstanceOf(Error) }) + + it('validates direct-constructor profiles at the embedding boundary', () => { + expect(() => new PiAiAdapter({ + profiles: [{ provider: 'openai', streamIdleTimeoutMs: 0 }], + })).toThrow(/streamIdleTimeoutMs.*positive finite/) + expect(() => new PiAiAdapter({ + profiles: [{ provider: 'openai', streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 }], + })).toThrow(/streamIdleTimeoutMs.*no greater/) + }) }) describe('abort wiring', () => { + it('preserves an unknown pre-dispatch adapter Error exactly', async () => { + const original = new Error('SDK context conversion exploded') + const message = Object.defineProperty({}, 'role', { + get() { throw original }, + }) + const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) + const drain = async (): Promise => { + for await (const _chunk of adapter.stream({ + provider: 'deepseek', + model: 'deepseek-v4-flash', + messages: [message as never], + })) { /* drain */ } + } + + await expect(drain()).rejects.toBe(original) + }) + + it('lets a concurrent caller abort classify a pre-dispatch adapter failure', async () => { + const controller = new AbortController() + const original = new Error('conversion lost its caller') + const message = Object.defineProperty({}, 'role', { + get() { + controller.abort('caller cancelled during conversion') + throw original + }, + }) + const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) + const drain = async (): Promise => { + for await (const _chunk of adapter.stream({ + provider: 'deepseek', + model: 'deepseek-v4-flash', + messages: [message as never], + signal: controller.signal, + })) { /* drain */ } + } + + await expect(drain()).rejects.toMatchObject({ code: 'ABORTED', cause: original }) + }) + it('resolves catalog endpoints without an override before honoring pre-abort', async () => { - const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key', maxRetries: 0 }] }) + const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) const controller = new AbortController() controller.abort('already stopped') const chunks = [] diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index a2f37ce511..15471875d2 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -485,20 +485,32 @@ describe('toStreamChunks', () => { ))) expect(chunks).toEqual([ { type: 'usage', usage: { inputTokens: 1, outputTokens: 0 } }, - { type: 'finish', reason: { kind: 'error', message: 'boom', code: 'PI_AI_ERROR' } }, + { type: 'finish', reason: { kind: 'error', failure: { message: 'boom', code: 'PI_AI_ERROR' } } }, ]) }) it('maps aborted error events to aborted finish', async () => { const error = assistant({ stopReason: 'aborted' }) const chunks = await collect(toStreamChunks(feed({ type: 'error', reason: 'aborted', error }))) - expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'aborted' } }) + expect(chunks.at(-1)).toEqual({ + type: 'finish', + reason: { kind: 'aborted', failure: { message: 'pi-ai stream aborted', code: 'ABORTED' } }, + }) }) it('rejects a stream that ends without done or error', async () => { await expect(collect(toStreamChunks(feed({ type: 'start', partial: assistant() })))) .rejects.toThrow(/without done\/error/) }) + + it('preserves an unknown SDK iterator Error exactly', async () => { + const original = Object.assign(new Error('SDK transport exploded'), { code: 'ECONNRESET' }) + async function* failedSdkStream(): AsyncGenerator { + throw original + } + + await expect(collect(toStreamChunks(failedSdkStream()))).rejects.toBe(original) + }) }) describe('mapStopReason / mapUsage', () => { @@ -506,46 +518,65 @@ describe('mapStopReason / mapUsage', () => { ['stop', { kind: 'stop' }], ['length', { kind: 'max-tokens' }], ['toolUse', { kind: 'tool-calls' }], - ['aborted', { kind: 'aborted' }], + ['aborted', { kind: 'aborted', failure: { message: 'pi-ai stream aborted', code: 'ABORTED' } }], ] as const)('maps %s', (stopReason, expected) => { expect(mapStopReason(assistant({ stopReason }))).toEqual(expected) }) it('defaults the error message when pi-ai omits it', () => { expect(mapStopReason(assistant({ stopReason: 'error' }))) - .toEqual({ kind: 'error', message: 'pi-ai stream error', code: 'PI_AI_ERROR' }) + .toEqual({ kind: 'error', failure: { message: 'pi-ai stream error', code: 'PI_AI_ERROR' } }) }) it('maps routable HTTP-ish error messages to stable codes', () => { expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 401: bad key' }))) - .toMatchObject({ kind: 'error', code: 'AUTH' }) + .toMatchObject({ kind: 'error', failure: { code: 'AUTH' } }) expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 429: rate limit' }))) - .toMatchObject({ kind: 'error', code: 'RATE_LIMIT' }) + .toMatchObject({ kind: 'error', failure: { code: 'RATE_LIMIT' } }) + expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 429: insufficient_quota' }))) + .toMatchObject({ kind: 'error', failure: { code: 'QUOTA' } }) + expect(mapStopReason(assistant({ + stopReason: 'error', + errorMessage: 'OpenAI API error (429): You exceeded your current quota, please check your plan and billing details.', + }))).toMatchObject({ kind: 'error', failure: { code: 'QUOTA' } }) expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 500: backend down' }))) - .toMatchObject({ kind: 'error', code: 'SERVER' }) + .toMatchObject({ kind: 'error', failure: { code: 'SERVER' } }) + expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'provider timed out' }))) + .toMatchObject({ kind: 'error', failure: { code: 'TIMEOUT' } }) + expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'ECONNRESET socket closed' }))) + .toMatchObject({ kind: 'error', failure: { code: 'TRANSPORT' } }) expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 400: input exceeds the model context window limit', - }))).toMatchObject({ kind: 'error', code: CONTEXT_WINDOW_EXCEEDED_CODE }) + }))).toMatchObject({ kind: 'error', failure: { 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 }) + }))).toMatchObject({ kind: 'error', failure: { 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' }) + }))).toMatchObject({ kind: 'error', failure: { code: 'INVALID_REQUEST' } }) + }) + + it.each([ + 'other side closed', + 'HTTP2 request did not get a response', + 'WebSocket closed unexpectedly', + ])('maps pi-ai transport wording %j', (errorMessage) => { + expect(mapStopReason(assistant({ stopReason: 'error', errorMessage }))) + .toMatchObject({ kind: 'error', failure: { code: 'TRANSPORT' } }) }) 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 }) + }))).toMatchObject({ kind: 'error', failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE } }) expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'ThrottlingException: Too many tokens, rate limit reached', - }))).toMatchObject({ kind: 'error', code: 'RATE_LIMIT' }) + }))).toMatchObject({ kind: 'error', failure: { code: 'RATE_LIMIT' } }) }) it('uses the resolved context window for silent and length-stop overflows', () => { @@ -553,15 +584,17 @@ describe('mapStopReason / mapUsage', () => { 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, + failure: { + 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, + failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE }, }) }) diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index 107c12d264..06154a8a5e 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -70,7 +70,7 @@ function textOf(result: AssembledResult): string { function expectFinish(result: AssembledResult, expected: 'stop' | 'tool-calls'): void { if (result.finish.kind === 'error') { - throw new Error(`provider request failed (${result.finish.code ?? 'unknown'}): ${result.finish.message}`) + throw new Error(`provider request failed (${result.finish.failure.code}): ${result.finish.failure.message}`) } expect(result.finish.kind).toBe(expected) } diff --git a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts new file mode 100644 index 0000000000..e85c44c110 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const streamSimple = vi.hoisted(() => vi.fn()) + +vi.mock('@earendil-works/pi-ai', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, streamSimple } +}) + +import { PiAiAdapter } from '../src/adapter.ts' + +afterEach(() => { streamSimple.mockReset() }) + +describe('pi-ai SDK retry boundary', () => { + it('pins one SDK attempt even when the installed provider currently defaults to zero retries', async () => { + const failure = new Error('mock SDK boundary') + streamSimple.mockReturnValue({ + async * [Symbol.asyncIterator](): AsyncGenerator { + throw failure + }, + }) + const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai', apiKey: 'test-key' }] }) + const drain = async (): Promise => { + for await (const _chunk of adapter.stream({ + provider: 'openai', + model: 'gpt-4.1', + messages: [], + })) { /* drain */ } + } + + await expect(drain()).rejects.toBe(failure) + expect(streamSimple).toHaveBeenCalledOnce() + expect(streamSimple.mock.calls[0]?.[2]).toMatchObject({ maxRetries: 0 }) + }) +}) diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index e9de391ba1..5e427d88b9 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../util/timeout" } ] } diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md new file mode 100644 index 0000000000..f84fdba09a --- /dev/null +++ b/packages/llm/llm-retry/README.md @@ -0,0 +1,39 @@ +# `@deepseek-ai/dsh-llm-retry` + +Function plugin that retries selected transient model-request failures on the agent loop's closed-step recovery seam. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered step. + +The default policy permits two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, using bounded exponential backoff from 500 ms to 10 seconds with 10 percent jitter. Delay bounds must fit Node's supported timer range. A valid `providerRetryAfterMs` replaces local backoff when it is within the configured cap; an over-cap instruction delegates to the next recovery policy instead. + +Before waiting, the plugin appends a non-surface `llm/retry` event with the failure and scheduled delay. Cancellation and plugin disposal abort the wait; disposal drains the plugin's active backoffs, and a callback captured before disposal fails closed if invoked afterward. + +```yaml +- name: '@deepseek-ai/dsh-llm-retry' + config: + maxTransientRetries: 2 + initialDelayMs: 500 + maxDelayMs: 10000 + jitterRatio: 0.1 + retryableCodes: [RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT] +``` + +## Model Experience + +### Transient request recovery + +#### What the model sees + +No retry event, delay, or failure prose is model-visible. After a retry, the next numbered step reconstructs the same explicit provider/model request from durable session history; failed chunks never enter derived messages. + +#### Token effect + +Each retry is a new provider request and may repeat input-token billing. The finite budget caps attempts; `llm/retry` itself contributes no tokens. + +#### KV Cache effect + +The reconstructed request preserves the prior prefix and is eligible for provider cache reuse under that provider's rules. The non-surface status event does not change cache identity. + +## Known Limitations and Deferred Work + +- **Agent steps are the only retry boundary** — direct `ctx.llm.stream()` consumers remain single-attempt because a raw stream cannot separate already-emitted chunks durably. +- **Finite plugin budgets add** — this policy counts only configured transient codes; context-overflow compaction counts only its own code. A future policy with overlapping codes must document and test registration-order behavior. +- **`llm/retry` records scheduling, not completion** — later step and turn events establish success, exhaustion, or cancellation. diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json new file mode 100644 index 0000000000..fce40dbe33 --- /dev/null +++ b/packages/llm/llm-retry/package.json @@ -0,0 +1,47 @@ +{ + "name": "@deepseek-ai/dsh-llm-retry", + "description": "Bounded transient LLM request retry policy for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts new file mode 100644 index 0000000000..4edf22d6f2 --- /dev/null +++ b/packages/llm/llm-retry/src/index.ts @@ -0,0 +1,213 @@ +/** + * Bounded transient model-request retry policy on the agent loop's closed-step + * recovery seam. Each scheduled retry is durable before its cancellable wait. + * + * @module @deepseek-ai/dsh-llm-retry + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type { Agent, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' +import type { LlmFailure } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-session' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** Durable, non-surface record of one transient retry scheduled after a closed failed step. */ + 'llm/retry': { + turn: number + step: number + retry: number + maxRetries: number + delayMs: number + failure: LlmFailure + } + } +} + +export const name = 'llm-retry' +export const inject = ['agents'] + +const DEFAULT_MAX_TRANSIENT_RETRIES = 2 +const DEFAULT_INITIAL_DELAY_MS = 500 +const DEFAULT_MAX_DELAY_MS = 10_000 +const DEFAULT_JITTER_RATIO = 0.1 +const DEFAULT_RETRYABLE_CODES = Object.freeze(['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT']) + +/** Deployment-owned limits and classification for transient request recovery. */ +export interface Config { + /** Maximum transient retries after the first request (default 2). */ + maxTransientRetries?: number + /** Initial local exponential-backoff delay in milliseconds (default 500). */ + initialDelayMs?: number + /** Maximum accepted or locally scheduled delay in milliseconds (default 10000). */ + maxDelayMs?: number + /** Symmetric random multiplier range around one (default 0.1). */ + jitterRatio?: number + /** Stable failure codes eligible for this policy. */ + retryableCodes?: string[] +} + +/** Runtime schema for {@link Config}. */ +export const Config: z = z.object({ + maxTransientRetries: z.number().step(1).min(0).default(DEFAULT_MAX_TRANSIENT_RETRIES), + initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS), + maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS), + jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO), + retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]), +}) + +interface ResolvedConfig { + readonly maxTransientRetries: number + readonly initialDelayMs: number + readonly maxDelayMs: number + readonly jitterRatio: number + readonly retryableCodes: ReadonlySet +} + +function resolveConfig(config: Config): ResolvedConfig { + const maxTransientRetries = config.maxTransientRetries ?? DEFAULT_MAX_TRANSIENT_RETRIES + const initialDelayMs = config.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS + const maxDelayMs = config.maxDelayMs ?? DEFAULT_MAX_DELAY_MS + const jitterRatio = config.jitterRatio ?? DEFAULT_JITTER_RATIO + const codes = config.retryableCodes ?? [...DEFAULT_RETRYABLE_CODES] + + if (!Number.isInteger(maxTransientRetries) || maxTransientRetries < 0) { + throw new Error('llm-retry: maxTransientRetries must be a non-negative integer') + } + if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) { + throw new Error(`llm-retry: initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } + if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) { + throw new Error(`llm-retry: maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } + if (initialDelayMs > maxDelayMs) { + throw new Error('llm-retry: initialDelayMs must be less than or equal to maxDelayMs') + } + if (!Number.isFinite(jitterRatio) || jitterRatio < 0 || jitterRatio > 1) { + throw new Error('llm-retry: jitterRatio must be between 0 and 1') + } + if (codes.length === 0) { + throw new Error('llm-retry: retryableCodes must not be empty') + } + if (codes.some(code => code.length === 0)) { + throw new Error('llm-retry: retryableCodes must contain only non-empty strings') + } + if (new Set(codes).size !== codes.length) { + throw new Error('llm-retry: retryableCodes must not contain duplicates') + } + + return Object.freeze({ + maxTransientRetries, + initialDelayMs, + maxDelayMs, + jitterRatio, + retryableCodes: new Set(codes), + }) +} + +/** Non-serializable seams used to make timing policy deterministic in tests. */ +export interface RetryInternals { + /** Random sample in the inclusive zero-to-one range used for jitter. */ + random?: () => number +} + +function localDelay(config: ResolvedConfig, retry: number, random: () => number): number { + const exponent = Math.min(retry - 1, 1024) + const exponential = Math.min(config.initialDelayMs * 2 ** exponent, config.maxDelayMs) + const jitter = 1 - config.jitterRatio + 2 * config.jitterRatio * random() + return Math.min(exponential * jitter, config.maxDelayMs) +} + +function cancellableDelay(delayMs: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(false) + return new Promise((resolve) => { + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, delayMs) + function onAbort(): void { + clearTimeout(timer) + resolve(false) + } + signal.addEventListener('abort', onAbort, { once: true }) + }) +} + +/** + * Install bounded transient request recovery. + * @param ctx - plugin context that owns the listener and active waits. + * @param config - retry budget, delay bounds, jitter, and eligible codes. + * @param internals - non-serializable deterministic seams for tests. + */ +export function apply(ctx: Context, config: Config = {}, internals: RetryInternals = {}): void { + const resolved = resolveConfig(config) + const random = internals.random ?? Math.random + const lifetime = new AbortController() + const active = new Set>() + + async function backoff( + agent: Agent, + turn: number, + step: number, + failure: LlmFailure, + retry: number, + delayMs: number, + signal: AbortSignal, + ): Promise { + const fusedSignal = AbortSignal.any([signal, lifetime.signal]) + if (fusedSignal.aborted) return { action: 'fail' } + agent.session.append('llm/retry', { + turn, + step, + retry, + maxRetries: resolved.maxTransientRetries, + delayMs, + failure, + }) + if (!await cancellableDelay(delayMs, fusedSignal)) return { action: 'fail' } + return { action: 'retry' } + } + + const disposeListener = ctx.on('agent/request-error', ( + agent: Agent, + turn: number, + step: number, + _error: RequestError, + failure: LlmFailure, + priorFailures: readonly LlmFailure[], + signal: AbortSignal, + next: () => Promise, + ) => { + // A waterfall may have captured this callback before its registration was + // removed. Lifetime cancellation must prevent that stale callback from + // entering a downstream policy after disposal. + if (lifetime.signal.aborted) return Promise.resolve({ action: 'fail' }) + if (!resolved.retryableCodes.has(failure.code)) return next() + const priorTransientFailures = priorFailures.filter(item => resolved.retryableCodes.has(item.code)).length + if (priorTransientFailures >= resolved.maxTransientRetries) return next() + + const retry = priorTransientFailures + 1 + let delayMs: number + if (failure.providerRetryAfterMs !== undefined + && Number.isFinite(failure.providerRetryAfterMs) + && failure.providerRetryAfterMs > 0) { + if (failure.providerRetryAfterMs > resolved.maxDelayMs) return next() + delayMs = failure.providerRetryAfterMs + } else { + delayMs = localDelay(resolved, retry, random) + } + + const tracked = backoff(agent, turn, step, failure, retry, delayMs, signal) + .finally(() => active.delete(tracked)) + active.add(tracked) + return tracked + }) + + ctx.effect(() => async () => { + disposeListener() + lifetime.abort(new Error('llm-retry plugin disposed')) + await Promise.allSettled([...active]) + }, 'llm-retry: abort and drain backoffs') +} diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..c00175b20d --- /dev/null +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -0,0 +1,124 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import LlmService, { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as retry from '../src/index.ts' + +let root: string | undefined +let context: Context | undefined + +class TransientOnceAdapter extends LlmAdapter { + requests = 0 + + async * stream(_options: GenerateOptions): AsyncIterable { + this.requests += 1 + if (this.requests === 1) throw new LlmError('temporary outage', 'SERVER') + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'recovered' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: 'recovered' } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +function waitForIdle(ctx: Context, agent: Agent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +async function loadYaml(lines: readonly string[]): Promise { + root = await mkdtemp(join(tmpdir(), 'dsh-llm-retry-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [...lines, ''].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-llm', LlmService], + ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-system-prompt', SystemPrompt], + ['@deepseek-ai/dsh-tools', ToolRegistry], + ['@deepseek-ai/dsh-agent', AgentRegistry], + ['@deepseek-ai/dsh-llm-retry', retry], + ['@deepseek-ai/dsh-agent-loop', AgentLoop], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + return context +} + +describe('real Loader composition', () => { + it('loads the flat policy and records recovery through the shipping loop', async () => { + const loaded = await loadYaml([ + "- name: '@deepseek-ai/dsh-llm'", + "- name: '@deepseek-ai/dsh-session'", + "- name: '@deepseek-ai/dsh-system-prompt'", + "- name: '@deepseek-ai/dsh-tools'", + "- name: '@deepseek-ai/dsh-agent'", + "- name: '@deepseek-ai/dsh-llm-retry'", + ' config:', + ' maxTransientRetries: 1', + ' initialDelayMs: 1', + ' maxDelayMs: 1', + ' jitterRatio: 0', + ' retryableCodes: [RATE_LIMIT, SERVER]', + "- name: '@deepseek-ai/dsh-agent-loop'", + ]) + + const unloaded = [...loaded.loader.entries()] + .filter(entry => entry.fiber === undefined && !entry.disabled) + .map(entry => entry.options.name) + expect(unloaded).toEqual([]) + expect(loaded.agents).toBeInstanceOf(AgentRegistry) + + const adapter = new TransientOnceAdapter() + loaded.llm.registerAdapter(['mock'], adapter) + const agent = loaded.agentLoop.create(SessionId('loader-retry'), { provider: 'mock', model: 'mock' }) + const idle = waitForIdle(loaded, agent) + agent.send([{ type: 'text', text: 'recover' }]) + await idle + + expect(adapter.requests).toBe(2) + expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(1) + expect(agent.session.deriveMessages().at(-1)).toMatchObject({ + role: 'assistant', + content: [{ type: 'text', text: 'recovered' }], + }) + }) +}) diff --git a/packages/llm/llm-retry/tests/persistence.spec.ts b/packages/llm/llm-retry/tests/persistence.spec.ts new file mode 100644 index 0000000000..1668c36d73 --- /dev/null +++ b/packages/llm/llm-retry/tests/persistence.spec.ts @@ -0,0 +1,57 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' +import type {} from '../src/index.ts' + +const dirs: string[] = [] + +afterEach(async () => { + for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }) +}) + +async function backend(kind: 'jsonl' | 'sqlite'): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + if (kind === 'jsonl') { + const root = await mkdtemp(join(tmpdir(), 'dsh-llm-retry-jsonl-')) + dirs.push(root) + await ctx.plugin(SessionPersistenceJsonl, { root }) + } else { + await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' }) + } + return ctx +} + +describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind) => { + it('round-trips the event losslessly without adding a model message', async () => { + const ctx = await backend(kind) + try { + const session = ctx.sessions.create(SessionId(`retry-${kind}`)) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('step/end', { turn: 1, step: 1 }) + const event = session.append('llm/retry', { + turn: 1, + step: 1, + retry: 1, + maxRetries: 2, + delayMs: 750, + failure: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 }, + }) + session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } }) + + expect(session.deriveMessages()).toEqual([]) + await ctx.sessions.flush(session) + const loaded = await ctx.sessionPersistence.load(session.id) + + expect(loaded.events.find(item => item.type === 'llm/retry')).toEqual(event) + } finally { + await ctx.fiber.dispose() + } + }) +}) diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts new file mode 100644 index 0000000000..443256ea2e --- /dev/null +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -0,0 +1,453 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import type { Fiber } from 'cordis' +import LlmService, { CallId, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent, RequestErrorDecision } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import * as retry from '../src/index.ts' + +type ScriptEntry = Error | Iterable | AsyncIterable + +class ScriptedAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + + constructor(private readonly entries: ScriptEntry[]) { + super() + } + + async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) + const entry = this.entries.shift() + if (entry === undefined) throw new Error('retry test script exhausted') + if (entry instanceof Error) throw entry + yield* entry + } +} + +async function* partialToolFailure(error: Error): AsyncGenerator { + const id = CallId('discarded-call') + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'discarded partial output' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: 'discarded partial output' } } + yield { type: 'block-start', index: 1, blockType: 'tool-call' } + yield { type: 'tool-call-delta', index: 1, id, name: 'danger', argumentsDelta: '{}' } + yield { type: 'block-end', index: 1, block: { type: 'tool-call', id, name: 'danger', arguments: '{}' } } + throw error +} + +function textResponse(text: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] +} + +async function harness( + adapter: LlmAdapter, + config: retry.Config = {}, + beforeRetry?: (ctx: Context) => void, + internals: retry.RetryInternals = {}, +): Promise<{ ctx: Context; retryFiber: Fiber }> { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + beforeRetry?.(ctx) + const resolvedConfig = Object.assign({ + maxTransientRetries: 2, + initialDelayMs: 500, + maxDelayMs: 10_000, + jitterRatio: 0, + }, config) + const retryFiber = await ctx.plugin(Object.assign((inner: Context) => { + retry.apply(inner, resolvedConfig, internals) + }, { inject: retry.inject })) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + return { ctx, retryFiber } +} + +function waitForIdle(ctx: Context, agent: Agent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} + +function waitForRetry(ctx: Context, agent: Agent, retryNumber: number): Promise> { + return new Promise((resolve) => { + const dispose = ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'llm/retry' && event.data.retry === retryNumber) { + dispose() + resolve(event) + } + }) + }) +} + +let context: Context | undefined + +afterEach(async () => { + vi.useRealTimers() + await context?.fiber.dispose() + context = undefined +}) + +describe('bounded transient retry policy', () => { + it('records the scheduled delay before opening a fresh request attempt', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('busy', 'RATE_LIMIT', { status: 429 }), + textResponse('done'), + ]) + ;({ ctx: context } = await harness(adapter)) + const agent = context.agentLoop.create(SessionId('retry-success'), { + provider: 'mock', + model: 'mock', + }) + const scheduled = new Promise>((resolve) => { + const dispose = context?.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'llm/retry') { + dispose?.() + resolve(event) + } + }) + }) + + agent.send([{ type: 'text', text: 'go' }]) + const event = await scheduled + + expect(event.data).toEqual({ + turn: 1, + step: 1, + retry: 1, + maxRetries: 2, + delayMs: 500, + failure: { message: 'busy', code: 'RATE_LIMIT', status: 429 }, + }) + expect(adapter.requests).toHaveLength(1) + await vi.advanceTimersByTimeAsync(499) + expect(adapter.requests).toHaveLength(1) + + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(1) + await idle + + expect(adapter.requests).toHaveLength(2) + expect(agent.session.events.filter(item => item.type === 'step/start').map(item => item.data.step)) + .toEqual([1, 2]) + expect(agent.session.deriveMessages().at(-1)).toEqual({ + role: 'assistant', + content: [{ type: 'text', text: 'done' }], + provenance: { provider: 'mock', model: 'mock' }, + }) + }) + + it('leaves partial failed chunks on their step without committing a message or tool side effect', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + partialToolFailure(new LlmError('stream interrupted', 'TRANSPORT')), + textResponse('recovered'), + ]) + ;({ ctx: context } = await harness(adapter)) + let toolExecutions = 0 + context.tools.register(defineTool({ + name: 'danger', + description: 'must not run for a failed provider attempt', + parameters: {}, + async execute() { + toolExecutions += 1 + return [{ type: 'text', text: 'unexpected' }] + }, + })) + const agent = context.agentLoop.create(SessionId('retry-partial'), { provider: 'mock', model: 'mock' }) + const scheduled = waitForRetry(context, agent, 1) + + agent.send([{ type: 'text', text: 'go' }]) + await scheduled + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(500) + await idle + + const failedChunks = agent.session.events.filter(event => + event.type === 'assistant/chunk' && event.data.step === 1, + ) + expect(failedChunks).toHaveLength(6) + expect(agent.session.events.filter(event => event.type === 'assistant/message').map(event => event.data.step)) + .toEqual([2]) + expect(agent.session.events.some(event => event.type === 'tool/call')).toBe(false) + expect(toolExecutions).toBe(0) + expect(agent.session.deriveMessages().at(-1)).toMatchObject({ + role: 'assistant', + content: [{ type: 'text', text: 'recovered' }], + provenance: { provider: 'mock', model: 'mock' }, + }) + }) + + it('applies bounded exponential jitter and stops after the configured budget', async () => { + vi.useFakeTimers() + const samples = [0, 1] + const adapter = new ScriptedAdapter([ + new LlmError('busy one', 'SERVER'), + new LlmError('busy two', 'SERVER'), + new LlmError('busy three', 'SERVER'), + ]) + ;({ ctx: context } = await harness(adapter, { jitterRatio: 0.1 }, undefined, { + random: () => samples.shift() ?? 0.5, + })) + const agent = context.agentLoop.create(SessionId('retry-exhausted'), { provider: 'mock', model: 'mock' }) + const first = waitForRetry(context, agent, 1) + + agent.send([{ type: 'text', text: 'go' }]) + expect((await first).data.delayMs).toBe(450) + + const second = waitForRetry(context, agent, 2) + await vi.advanceTimersByTimeAsync(450) + expect((await second).data.delayMs).toBe(1_100) + + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(1_100) + await idle + + expect(adapter.requests).toHaveLength(3) + expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(2) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'error', failure: { message: 'busy three', code: 'SERVER' } } }, + }) + }) + + it('uses a bounded provider Retry-After verbatim and delegates an over-cap instruction', async () => { + vi.useFakeTimers() + const accepted = new ScriptedAdapter([ + new LlmError('wait', 'RATE_LIMIT', { providerRetryAfterMs: 2_000 }), + textResponse('done'), + ]) + ;({ ctx: context } = await harness(accepted, { jitterRatio: 1 })) + const acceptedAgent = context.agentLoop.create(SessionId('retry-after-accepted'), { provider: 'mock', model: 'mock' }) + const scheduled = waitForRetry(context, acceptedAgent, 1) + acceptedAgent.send([{ type: 'text', text: 'go' }]) + expect((await scheduled).data.delayMs).toBe(2_000) + const acceptedIdle = waitForIdle(context, acceptedAgent) + await vi.advanceTimersByTimeAsync(2_000) + await acceptedIdle + expect(accepted.requests).toHaveLength(2) + + await context.fiber.dispose() + const rejected = new ScriptedAdapter([ + new LlmError('wait too long', 'RATE_LIMIT', { providerRetryAfterMs: 10_001 }), + ]) + ;({ ctx: context } = await harness(rejected)) + const rejectedAgent = context.agentLoop.create(SessionId('retry-after-rejected'), { provider: 'mock', model: 'mock' }) + const rejectedIdle = waitForIdle(context, rejectedAgent) + rejectedAgent.send([{ type: 'text', text: 'go' }]) + await rejectedIdle + expect(rejected.requests).toHaveLength(1) + expect(rejectedAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + }) + + it('delegates non-transient failures without scheduling a timer', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) + ;({ ctx: context } = await harness(adapter)) + const agent = context.agentLoop.create(SessionId('retry-auth'), { provider: 'mock', model: 'mock' }) + const idle = waitForIdle(context, agent) + agent.send([{ type: 'text', text: 'go' }]) + await idle + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(vi.getTimerCount()).toBe(0) + }) + + it('aborts and drains a captured backoff before plugin disposal completes', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('temporary', 'TRANSPORT'), + textResponse('must not run'), + ]) + const mounted = await harness(adapter) + context = mounted.ctx + const agent = context.agentLoop.create(SessionId('retry-hmr'), { provider: 'mock', model: 'mock' }) + const scheduled = waitForRetry(context, agent, 1) + agent.send([{ type: 'text', text: 'go' }]) + await scheduled + const idle = waitForIdle(context, agent) + + await mounted.retryFiber.dispose() + await idle + await vi.advanceTimersByTimeAsync(60_000) + + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not make plugin disposal wait for a delegated recovery policy', async () => { + const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) + const mounted = await harness(adapter) + context = mounted.ctx + const downstream = Promise.withResolvers() + const entered = Promise.withResolvers() + context.on('agent/request-error', () => { + entered.resolve(undefined) + return downstream.promise + }) + const agent = context.agentLoop.create(SessionId('retry-delegated-disposal'), { + provider: 'mock', + model: 'mock', + }) + const idle = waitForIdle(context, agent) + agent.send([{ type: 'text', text: 'go' }]) + await entered.promise + + const disposing = mounted.retryFiber.dispose() + let timer: ReturnType | undefined + const outcome = await Promise.race([ + disposing.then(() => 'disposed' as const), + new Promise<'blocked'>((resolve) => { timer = setTimeout(() => { resolve('blocked') }, 100) }), + ]) + if (timer !== undefined) clearTimeout(timer) + downstream.resolve({ action: 'fail' }) + await disposing + await idle + + expect(outcome).toBe('disposed') + expect(adapter.requests).toHaveLength(1) + }) + + it('fails a captured callback after disposal without entering downstream policy', async () => { + const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) + const captured = Promise.withResolvers() + let invokeCaptured: (() => Promise) | undefined + const mounted = await harness(adapter, {}, (ctx) => { + ctx.on('agent/request-error', (_agent, _turn, _step, _error, _failure, _history, _signal, next) => { + return new Promise((resolve) => { + invokeCaptured = async () => { resolve(await next()) } + captured.resolve(undefined) + }) + }) + }) + context = mounted.ctx + let downstreamCalls = 0 + context.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => { + downstreamCalls += 1 + return next() + }) + const agent = context.agentLoop.create(SessionId('retry-captured-disposal'), { + provider: 'mock', + model: 'mock', + }) + const idle = waitForIdle(context, agent) + agent.send([{ type: 'text', text: 'go' }]) + await captured.promise + + await mounted.retryFiber.dispose() + if (invokeCaptured === undefined) throw new Error('request-error waterfall did not capture retry callback') + await invokeCaptured() + await idle + + expect(downstreamCalls).toBe(0) + expect(adapter.requests).toHaveLength(1) + }) + + it('lets turn cancellation win during backoff without opening another step', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('temporary', 'TIMEOUT'), + textResponse('must not run'), + ]) + ;({ ctx: context } = await harness(adapter)) + const agent = context.agentLoop.create(SessionId('retry-cancel'), { provider: 'mock', model: 'mock' }) + const scheduled = waitForRetry(context, agent, 1) + agent.send([{ type: 'text', text: 'go' }]) + await scheduled + const idle = waitForIdle(context, agent) + agent.cancel({ kind: 'user' }) + await idle + + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'aborted' } }, + }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('lets an earlier recovery listener cancel before retry policy runs', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('temporary', 'SERVER'), + textResponse('must not run'), + ]) + ;({ ctx: context } = await harness(adapter, {}, (ctx) => { + ctx.on('agent/request-error', async (agent, _turn, _step, _error, _failure, _history, _signal, next) => { + agent.cancel({ kind: 'user' }) + return next() + }) + })) + const agent = context.agentLoop.create(SessionId('retry-pre-cancel'), { provider: 'mock', model: 'mock' }) + const idle = waitForIdle(context, agent) + + agent.send([{ type: 'text', text: 'go' }]) + await idle + + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'aborted' } }, + }) + }) + + it('handles synchronous cancellation from the retry status event', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('temporary', 'SERVER'), + textResponse('must not run'), + ]) + ;({ ctx: context } = await harness(adapter)) + const agent = context.agentLoop.create(SessionId('retry-event-cancel'), { provider: 'mock', model: 'mock' }) + context.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'llm/retry') agent.cancel({ kind: 'user' }) + }) + const idle = waitForIdle(context, agent) + + agent.send([{ type: 'text', text: 'go' }]) + await idle + + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it.each([ + [{ maxTransientRetries: -1 }, /maxTransientRetries/], + [{ maxTransientRetries: 1.5 }, /maxTransientRetries/], + [{ initialDelayMs: 0 }, /initialDelayMs/], + [{ maxDelayMs: Number.POSITIVE_INFINITY }, /maxDelayMs/], + [{ initialDelayMs: MAX_TIMER_DELAY_MS + 1 }, /initialDelayMs/], + [{ maxDelayMs: MAX_TIMER_DELAY_MS + 1 }, /maxDelayMs/], + [{ initialDelayMs: 20, maxDelayMs: 10 }, /less than or equal/], + [{ jitterRatio: 1.1 }, /jitterRatio/], + [{ retryableCodes: [] }, /must not be empty/], + [{ retryableCodes: ['SERVER', 'SERVER'] }, /duplicates/], + [{ retryableCodes: [''] }, /non-empty strings/], + ] as const)('fails direct composition for invalid config %#', (config, message) => { + expect(() => { retry.apply(new Context(), config as retry.Config) }).toThrow(message) + }) +}) diff --git a/packages/llm/llm-retry/tsconfig.json b/packages/llm/llm-retry/tsconfig.json new file mode 100644 index 0000000000..44310af6e9 --- /dev/null +++ b/packages/llm/llm-retry/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../util/timeout" + } + ] +} diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 82ca7f901c..a5ccd42b43 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 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`. +`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; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates 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`. @@ -21,12 +21,12 @@ Provider and model metadata is a discovery surface, not a routing whitelist. `re | Event | Mode | Purpose | |---|---|---| -| `llm/stream` | waterfall | Intercept/wrap every streaming model call (retry, caching, routing) | +| `llm/stream` | waterfall | Intercept/wrap every streaming model call for caching, logging, or routing | ### Extension points - Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata; their defaults use the route id as its name and advertise no models. -- Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, retry, logging, rate-limiting, etc. +- Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, logging, or routing. A wrapper that retries after emitting a chunk has no durable attempt boundary; shipped agent retry policy therefore uses `agent/request-error` instead. ### Content-block vocabulary (`types.ts`) @@ -47,9 +47,10 @@ Every product adapter sends application identity on provider HTTP requests. `att - `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`. - `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`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) is the programmatic failure contract. +- `LlmError` — extends `HarnessError`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) matches its frozen serializable `failure.code`. The payload may also retain validated status, `Retry-After`, and branded provider request id facts; policy remains outside the error. - `errorChain(value)` — renders a thrown value with its full `cause` chain and AggregateError members for diagnostic surfaces (UI notices, logger lines, durable `turn/end` messages), so transport wrappers like undici's `TypeError: fetch failed` surface the underlying `ECONNREFUSED`/DNS/TLS detail instead of masking it. Rendering only — route on `code`, never by parsing the result. - `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. +- `QUOTA_EXCEEDED_CODE` — the non-transient provider-neutral code for exhausted account quota, balance, credits, budget, or usage limits. `isQuotaExceededError(detail)` keeps those failures distinct from request-rate limits. ### Real adapters @@ -65,7 +66,7 @@ Pass-through; the registry preserves the assembled request prefix, while the sel ## Known Limitations and Deferred Work -- **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. +- **No default retry/caching/rate-limit policy ships in this service** — `llm/stream` remains a single-attempt call-wrapper seam; the agent loop separately offers proven model-request failures to `agent/request-error`, whose default preserves the original failure. `@deepseek-ai/dsh-llm-retry` is an optional policy plugin loaded by the shared example spine. - **`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](../../../.agents/notes/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 ([Agent Note](../../../.agents/notes/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 index 745cbbdc64..390282327d 100644 --- a/packages/llm/llm/src/adapter-failure.ts +++ b/packages/llm/llm/src/adapter-failure.ts @@ -5,10 +5,10 @@ */ import { HarnessError } from './error.ts' -import type { StreamChunk } from './types.ts' +import type { LlmFailure, StreamChunk } from './types.ts' -/** Errors proven to originate in one model call's final adapter boundary. */ -export type AdapterFailureScope = WeakSet +/** Errors and normalized facts proven to originate in one model call's final adapter boundary. */ +export type AdapterFailureScope = WeakMap /** Call-local failure scopes keyed by the exact stream handle returned to a consumer. */ const adapterFailureScopes = new WeakMap, AdapterFailureScope>() @@ -47,10 +47,71 @@ export function markLlmAdapterFailure( const error = value instanceof Error ? value as Error & { code?: string } : new HarnessError(String(value), 'UNKNOWN', { cause: value }) - failures.add(error) + const carried = error instanceof HarnessError ? ownFailureSnapshot(error) : undefined + const failure = carried !== undefined && carried.code === error.code ? carried : Object.freeze({ + message: errorMessage(error), + code: harnessErrorCode(error), + }) + failures.set(error, failure) return error } +/** Snapshot an own data property without invoking an SDK-defined accessor. */ +function ownFailureSnapshot(error: Error): LlmFailure | undefined { + try { + const descriptor = Object.getOwnPropertyDescriptor(error, 'failure') + return descriptor !== undefined && 'value' in descriptor + ? failureSnapshot(descriptor.value) + : undefined + } catch (_sdkPropertyTrap) { + return undefined + } +} + +/** Validate and detach an arbitrary serializable failure payload. */ +function failureSnapshot(value: unknown): LlmFailure | undefined { + if (typeof value !== 'object' || value === null) return undefined + try { + const candidate = value as Partial + const message = candidate.message + const code = candidate.code + const status = candidate.status + const providerRetryAfterMs = candidate.providerRetryAfterMs + const requestId = candidate.requestId + if (typeof message !== 'string' || message.length === 0 + || typeof code !== 'string' || code.length === 0 + || (status !== undefined && (!Number.isInteger(status) || status < 100 || status > 599)) + || (providerRetryAfterMs !== undefined + && (!Number.isFinite(providerRetryAfterMs) || providerRetryAfterMs <= 0)) + || (requestId !== undefined && (typeof requestId !== 'string' || requestId.length === 0))) return undefined + return Object.freeze({ + message, + code, + ...status === undefined ? {} : { status }, + ...providerRetryAfterMs === undefined ? {} : { providerRetryAfterMs }, + ...requestId === undefined ? {} : { requestId }, + }) + } catch (_sdkFailureGetter) { + return undefined + } +} + +/** Read an SDK error message without letting an accessor replace the primary failure. */ +function errorMessage(error: Error): string { + try { + const message: unknown = error.message + if (typeof message === 'string' && message.length > 0) return message + } catch (_sdkMessageGetter) { + // The fallback below preserves a serializable failure beside the original Error. + } + return 'LLM adapter failed' +} + +/** Trust only Harness-owned codes; third-party SDK codes are not our taxonomy. */ +function harnessErrorCode(error: Error): string { + return error instanceof HarnessError ? error.code : 'UNKNOWN' +} + /** * Whether a failure came from final adapter dispatch, iterator construction, * or iteration for the call represented by the exact returned stream handle. @@ -65,3 +126,18 @@ export function isLlmAdapterFailure( const failures = adapterFailureScopes.get(stream) return value instanceof Error && failures !== undefined && failures.has(value) } + +/** + * Retrieve normalized provider facts only for an Error tagged by this exact + * model call's final adapter boundary. + * @param stream - the exact stream returned to the consumer. + * @param value - the caught failure. + * @returns the immutable facts for that call, or `undefined` for middleware, nested, or consumer failures. + */ +export function llmFailureOf( + stream: AsyncIterable, + value: unknown, +): LlmFailure | undefined { + const failures = adapterFailureScopes.get(stream) + return value instanceof Error ? failures?.get(value) : undefined +} diff --git a/packages/llm/llm/src/brand.ts b/packages/llm/llm/src/brand.ts index ee1cf786b1..259dc49bce 100644 --- a/packages/llm/llm/src/brand.ts +++ b/packages/llm/llm/src/brand.ts @@ -1,5 +1,6 @@ /** - * dsh-llm's owned branded id: `CallId` (tool-call correlation). + * dsh-llm's owned branded ids: tool-call correlation and provider request + * diagnostics. * * The `Branded` primitive itself lives in `@deepseek-ai/dsh-brand` (a * zero-dependency type-only package) so every owner of a cross-boundary id can @@ -25,3 +26,15 @@ export type CallId = Branded<'CallId'> export function CallId(id: string): CallId { return id as CallId } + +/** Provider-issued request identifier retained for diagnostics across package boundaries. */ +export type ProviderRequestId = Branded<'ProviderRequestId'> + +/** + * Brand a provider-issued request identifier. + * @param id - the opaque provider-issued string. + * @returns the same string, branded; no validation is performed. + */ +export function ProviderRequestId(id: string): ProviderRequestId { + return id as ProviderRequestId +} diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index c351060ee5..758e062895 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -24,6 +24,9 @@ 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' +/** Canonical provider-neutral code for an exhausted account quota or balance. */ +export const QUOTA_EXCEEDED_CODE = 'QUOTA' + /** 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_-]` @@ -62,6 +65,20 @@ export function isContextWindowExceededError(detail: string): boolean { || EXCEEDS_MODEL_CONTEXT.test(detail) } +/** + * Recognize provider wording that identifies an exhausted account quota rather + * than a transient request-rate limit. + * @param detail - provider error code/type/message text joined into one string. + * @returns true only for terminal quota, balance, credit, budget, or usage-limit wording. + */ +export function isQuotaExceededError(detail: string): boolean { + return /\binsufficient[\s_-]+(?:quota|balance|credits?)\b/i.test(detail) + || /\b(?:quota|usage[\s_-]+limit)[\s_-]+(?:exceeded|exhausted|reached)\b/i.test(detail) + || /\bexceed(?:ed|s)?[\s_-]+(?:(?:your|the)[\s_-]+)?(?:current[\s_-]+)?quota\b/i.test(detail) + || /\b(?:balance|credits?)[\s_-]+(?:exhausted|depleted)\b/i.test(detail) + || /\bout[\s_-]+of[\s_-]+(?:credits?|budget)\b/i.test(detail) +} + /** * Render a thrown value with its full `cause` chain and AggregateError * members, so transport wrappers like undici's `TypeError: fetch failed` diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index f276aa9f92..fb431a308c 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -7,7 +7,8 @@ */ import { Context, Service } from 'cordis' -import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, Message, StreamChunk } from './types.ts' +import type { GenerateOptions, LlmFailure, LlmModelInfo, LlmProviderInfo, Message, StreamChunk } from './types.ts' +import type { ProviderRequestId } from './brand.ts' import { deepFreeze } from './call-config.ts' import { HarnessError } from './error.ts' import { bindAdapterFailureScope, markLlmAdapterFailure } from './adapter-failure.ts' @@ -21,7 +22,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' +export { isLlmAdapterFailure, llmFailureOf } from './adapter-failure.ts' declare module 'cordis' { interface Context { @@ -44,14 +45,53 @@ declare module 'cordis' { } } +/** Structured provider facts and cause accepted by {@link LlmError}. */ +export interface LlmErrorOptions extends ErrorOptions { + /** Valid HTTP status observed at the provider boundary. */ + status?: number + /** Positive finite provider-requested delay in milliseconds. */ + providerRetryAfterMs?: number + /** Non-empty opaque provider request id. */ + requestId?: ProviderRequestId +} + /** * Typed error for LLM-related failures. Extends {@link HarnessError}, so the * `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy. */ export class LlmError extends HarnessError { - constructor(message: string, code: string, options?: ErrorOptions) { + /** Serializable facts retained beside this live Error. */ + readonly failure: LlmFailure + + /** + * @param message - non-empty human-readable failure summary. + * @param code - non-empty stable provider-neutral machine code. + * @param options - optional cause and validated serializable provider facts. + */ + constructor(message: string, code: string, options?: LlmErrorOptions) { + if (typeof message !== 'string' || message.length === 0) throw new Error('LlmError message must be a non-empty string') + if (typeof code !== 'string' || code.length === 0) throw new Error('LlmError code must be a non-empty string') + if (options?.status !== undefined + && (!Number.isInteger(options.status) || options.status < 100 || options.status > 599)) { + throw new Error('LlmError status must be an integer from 100 through 599') + } + if (options?.providerRetryAfterMs !== undefined + && (!Number.isFinite(options.providerRetryAfterMs) || options.providerRetryAfterMs <= 0)) { + throw new Error('LlmError providerRetryAfterMs must be a positive finite number') + } + if (options?.requestId !== undefined + && (typeof options.requestId !== 'string' || options.requestId.length === 0)) { + throw new Error('LlmError requestId must be a non-empty string') + } super(message, code, options) this.name = 'LlmError' + this.failure = Object.freeze({ + message, + code, + ...options?.status === undefined ? {} : { status: options.status }, + ...options?.providerRetryAfterMs === undefined ? {} : { providerRetryAfterMs: options.providerRetryAfterMs }, + ...options?.requestId === undefined ? {} : { requestId: options.requestId }, + }) } } @@ -262,7 +302,7 @@ export class LlmService extends Service { * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. */ stream(options: GenerateOptions): AsyncIterable { - const failures: AdapterFailureScope = new WeakSet() + const failures: AdapterFailureScope = new WeakMap() const stream = this.ctx.waterfall(this, 'llm/stream', options, () => this.adapterStream(options, failures)) return bindAdapterFailureScope(stream, failures) } diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index b8054c2046..bbe2e09b59 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -5,7 +5,21 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { CallId } from './brand.ts' +import type { CallId, ProviderRequestId } from './brand.ts' + +/** Serializable provider-boundary facts; policy decides whether they are retryable. */ +export interface LlmFailure { + /** Human-readable provider or transport failure. */ + readonly message: string + /** Stable provider-neutral machine-routing code. */ + readonly code: string + /** HTTP status observed at the provider boundary, when available. */ + readonly status?: number + /** Provider-requested delay in milliseconds, when valid and available. */ + readonly providerRetryAfterMs?: number + /** Opaque provider-issued request identifier for diagnostics. */ + readonly requestId?: ProviderRequestId +} /** Plain text visible to the end user. */ export interface TextBlock { @@ -98,8 +112,8 @@ export interface FinishReasonMap { 'stop': { kind: 'stop' } 'tool-calls': { kind: 'tool-calls' } 'max-tokens': { kind: 'max-tokens' } - 'aborted': { kind: 'aborted' } - 'error': { kind: 'error'; message: string; code?: string } + 'aborted': { kind: 'aborted'; failure: LlmFailure } + 'error': { kind: 'error'; failure: LlmFailure } } /** Any known finish reason, derived from {@link FinishReasonMap}; switch on `kind` and fall through unknowns (merge-extensible). */ diff --git a/packages/llm/llm/tests/properties.spec.ts b/packages/llm/llm/tests/properties.spec.ts index 0d65b545d1..31d07c1a47 100644 --- a/packages/llm/llm/tests/properties.spec.ts +++ b/packages/llm/llm/tests/properties.spec.ts @@ -41,7 +41,10 @@ const chunkArb: fc.Arbitrary = indexArb.chain(index => fc.oneof( fc.constant({ type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }), fc.constant({ type: 'finish', reason: { kind: 'stop' } }), fc.constant({ type: 'finish', reason: { kind: 'tool-calls' } }), - fc.string().map((message): StreamChunk => ({ type: 'finish', reason: { kind: 'error', message } })), + fc.string({ minLength: 1 }).map((message): StreamChunk => ({ + type: 'finish', + reason: { kind: 'error', failure: { message, code: 'UNKNOWN' } }, + })), )) /** A stream is an arbitrary list of chunks (we do NOT force a terminal finish). */ diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 67b90d1c2f..9f90a2b7cd 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -5,9 +5,12 @@ import LlmService, { GenerateOptions, HarnessError, isContextWindowExceededError, + isQuotaExceededError, isLlmAdapterFailure, LlmAdapter, LlmError, + llmFailureOf, + ProviderRequestId, StreamChunk, } from '@deepseek-ai/dsh-llm' import type { LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' @@ -81,6 +84,18 @@ describe('LlmService', () => { expect(isContextWindowExceededError('context window size must be positive')).toBe(false) }) + it('distinguishes exhausted account quota from transient rate limiting', () => { + for (const detail of [ + 'insufficient_quota', + 'account balance depleted', + 'usage-limit-exceeded', + 'out of credits', + 'OpenAI API error (429): You exceeded your current quota, please check your plan and billing details.', + ]) expect(isQuotaExceededError(detail)).toBe(true) + expect(isQuotaExceededError('HTTP 429: rate limit reached')).toBe(false) + expect(isQuotaExceededError('quota resets in one minute')).toBe(false) + }) + it('errorChain renders the full cause chain of a wrapped transport failure', () => { const chain = new TypeError('fetch failed', { cause: new Error('connect ECONNREFUSED 127.0.0.1:443') }) expect(errorChain(chain)).toBe('fetch failed: connect ECONNREFUSED 127.0.0.1:443') @@ -213,6 +228,151 @@ describe('LlmService', () => { expect(caught).toBe(original) expect(isLlmAdapterFailure(stream, caught)).toBe(true) + expect(llmFailureOf(stream, caught)).toEqual({ + message: `${boundary} failed`, + code: 'BOUNDARY_FAILED', + }) + }) + + it('keeps structured provider facts beside a frozen third-party Error', async () => { + const original = new LlmError('provider busy', 'RATE_LIMIT', { + status: 429, + providerRetryAfterMs: 1_500, + requestId: ProviderRequestId('req-7'), + }) + Object.freeze(original) + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) + + const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) + let caught: unknown + try { + for await (const _chunk of stream) { /* drain */ } + } catch (error: unknown) { + caught = error + } + + expect(caught).toBe(original) + expect(llmFailureOf(stream, caught)).toEqual({ + message: 'provider busy', + code: 'RATE_LIMIT', + status: 429, + providerRetryAfterMs: 1_500, + requestId: ProviderRequestId('req-7'), + }) + }) + + it('does not trust retry facts carried by an unknown third-party Error', async () => { + const carried = { message: 'busy', code: 'SERVER', status: 503 } + const original = Object.assign(new Error('busy'), { failure: carried }) + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) + + const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) + await expect((async () => { + for await (const _chunk of stream) { /* drain */ } + })()).rejects.toBe(original) + const facts = llmFailureOf(stream, original) + carried.status = 500 + + expect(facts).toEqual({ message: 'busy', code: 'UNKNOWN' }) + expect(Object.isFrozen(facts)).toBe(true) + expect(facts).not.toBe(carried) + }) + + it('keeps an unknown SDK Error exact without trusting its private code or accessors', async () => { + const original = Object.assign(new Error('socket closed'), { code: 'ECONNRESET' }) + Object.defineProperty(original, 'failure', { + get() { throw new Error('SDK failure accessor must not run') }, + }) + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) + + const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) + await expect((async () => { + for await (const _chunk of stream) { /* drain */ } + })()).rejects.toBe(original) + + expect(original.code).toBe('ECONNRESET') + expect(llmFailureOf(stream, original)).toEqual({ message: 'socket closed', code: 'UNKNOWN' }) + }) + + it('keeps an SDK Error exact when its message accessor is hostile', async () => { + const original = Object.defineProperty(new Error(), 'message', { + get() { throw new Error('SDK message accessor trap') }, + }) + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) + const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) + + await expect((async () => { + for await (const _chunk of stream) { /* drain */ } + })()).rejects.toBe(original) + expect(llmFailureOf(stream, original)).toEqual({ message: 'LLM adapter failed', code: 'UNKNOWN' }) + }) + + it('falls back safely when SDK objects trap failure inspection or expose malformed facts', async () => { + const propertyTrap = new Proxy(new HarnessError('descriptor trapped', 'SERVER'), { + getOwnPropertyDescriptor(target, property) { + if (property === 'failure') throw new Error('SDK descriptor trap') + return Reflect.getOwnPropertyDescriptor(target, property) + }, + }) + const throwingFacts = Object.create(null) as Record + Object.defineProperty(throwingFacts, 'message', { + get() { throw new Error('SDK fact getter trap') }, + }) + const carrying = (message: string, failure: unknown): HarnessError => Object.defineProperty( + new HarnessError(message, 'SERVER'), + 'failure', + { value: failure }, + ) + const factGetter = carrying('fact getter failed', throwingFacts) + const malformed = carrying('malformed facts', { message: 'provider busy', code: 'SERVER', requestId: 1 }) + const primitive = carrying('primitive facts', 1) + const nullFacts = carrying('null facts', null) + const mismatched = carrying('mismatched facts', { message: 'busy', code: 'RATE_LIMIT' }) + + for (const [original, expectedMessage] of [ + [propertyTrap, 'descriptor trapped'], + [factGetter, 'fact getter failed'], + [malformed, 'malformed facts'], + [primitive, 'primitive facts'], + [nullFacts, 'null facts'], + [mismatched, 'mismatched facts'], + ] as const) { + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) + const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) + + await expect((async () => { + for await (const _chunk of stream) { /* drain */ } + })()).rejects.toBe(original) + expect(llmFailureOf(stream, original)).toEqual({ message: expectedMessage, code: 'SERVER' }) + } + }) + + it('retains a stable code from a HarnessError without requiring LlmError facts', async () => { + const original = new HarnessError('stable adapter failure', 'ADAPTER_STABLE') + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) + const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) + + await expect((async () => { + for await (const _chunk of stream) { /* drain */ } + })()).rejects.toBe(original) + expect(llmFailureOf(stream, original)).toEqual({ + message: 'stable adapter failure', + code: 'ADAPTER_STABLE', + }) + expect(llmFailureOf(stream, 'not an Error')).toBeUndefined() + expect(llmFailureOf({ [Symbol.asyncIterator]: () => stream[Symbol.asyncIterator]() }, original)).toBeUndefined() }) it('keeps a nested adapter failure scoped to the nested model call', async () => { @@ -631,6 +791,16 @@ describe('LlmService', () => { expect(err.code).toBe('CUSTOM_CODE') }) + it('rejects non-serializable structured failure facts at construction', () => { + expect(() => new LlmError('busy', 'RATE_LIMIT', { status: 42 })).toThrow(/status/) + expect(() => new LlmError('busy', 'RATE_LIMIT', { providerRetryAfterMs: Number.NaN })) + .toThrow(/providerRetryAfterMs/) + expect(() => new LlmError('busy', 'RATE_LIMIT', { requestId: ProviderRequestId('') })).toThrow(/requestId/) + expect(() => new LlmError(1 as never, 'RATE_LIMIT')).toThrow(/message/) + expect(() => new LlmError('busy', 1 as never)).toThrow(/code/) + expect(() => new LlmError('busy', 'RATE_LIMIT', { requestId: 1 as never })).toThrow(/requestId/) + }) + it('LlmError extends the shared HarnessError base', async () => { const { HarnessError, isHarnessError } = await import('@deepseek-ai/dsh-llm') const cause = new Error('root cause') diff --git a/packages/mcp/mcp-client/src/tools.ts b/packages/mcp/mcp-client/src/tools.ts index 7b9217e814..92f3742547 100644 --- a/packages/mcp/mcp-client/src/tools.ts +++ b/packages/mcp/mcp-client/src/tools.ts @@ -165,7 +165,7 @@ function createExecutor( { name: rawName, arguments: argsObj }, undefined, { - ...exec.signal ? { signal: exec.signal } : {}, + signal: exec.signal, timeout: opts.toolCallTimeoutMs, }, ) diff --git a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts index dc783b3edf..803d4d81f9 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts @@ -26,6 +26,8 @@ import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts' import { publicToolName } from '@deepseek-ai/dsh-mcp-client/src/tools.ts' import type { Config } from '@deepseek-ai/dsh-mcp-client' +const testToolSignal = new AbortController().signal + const fixtureServerPath = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) // Resolve package-local .bin for pnpm-hoisted MCP server binaries. @@ -119,6 +121,7 @@ describe('fixture server — controlled scenarios', () => { it('executes the dotted tool via its normalized public name', async () => { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: publicToolName('fixture', 'admin.reset'), arguments: {}, }) expect(result.isError).toBe(false) @@ -127,6 +130,7 @@ describe('fixture server — controlled scenarios', () => { it('executes add(2, 3) → "5"', async () => { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__fixture__add', arguments: { a: 2, b: 3 }, }) expect(result.isError).toBe(false) @@ -135,6 +139,7 @@ describe('fixture server — controlled scenarios', () => { it('executes greet("World") → "Hello, World!"', async () => { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__fixture__greet', arguments: { name: 'World' }, }) expect(result.isError).toBe(false) @@ -143,6 +148,7 @@ describe('fixture server — controlled scenarios', () => { it('executes fail() → isError result', async () => { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__fixture__fail', arguments: {}, }) expect(result.isError).toBe(true) @@ -151,6 +157,7 @@ describe('fixture server — controlled scenarios', () => { it('executes image() → image placeholder', async () => { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__fixture__image', arguments: {}, }) expect(result.isError).toBe(false) @@ -241,6 +248,7 @@ describe('server-everything — official test server', () => { it('executes echo({ message: "hello" }) → "Echo: hello"', async () => { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__everything__echo', arguments: { message: 'hello' }, }) expect(result.isError).toBe(false) @@ -249,6 +257,7 @@ describe('server-everything — official test server', () => { it('executes get-sum({ a: 3, b: 7 }) → contains "10"', async () => { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__everything__get-sum', arguments: { a: 3, b: 7 }, }) expect(result.isError).toBe(false) @@ -257,6 +266,7 @@ describe('server-everything — official test server', () => { it('executes get-tiny-image → image placeholder', async () => { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__everything__get-tiny-image', arguments: {}, }) expect(result.isError).toBe(false) @@ -306,6 +316,7 @@ describe('server-filesystem — real filesystem operations', () => { // Write via MCP tool const writeResult = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__filesystem__write_file', arguments: { path: filePath, content }, }) expect(writeResult.isError).toBe(false) @@ -316,6 +327,7 @@ describe('server-filesystem — real filesystem operations', () => { // Read back via MCP tool const readResult = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__filesystem__read_file', arguments: { path: filePath }, }) expect(readResult.isError).toBe(false) @@ -327,6 +339,7 @@ describe('server-filesystem — real filesystem operations', () => { await writeFile(join(tempDir, 'listed.txt'), 'listed') const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__filesystem__list_directory', arguments: { path: tempDir }, }) expect(result.isError).toBe(false) @@ -418,6 +431,7 @@ describe('streamable-http — in-process MCP server', () => { it('executes ping() → "pong" over HTTP', async () => { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__web__ping', arguments: {}, }) expect(result.isError).toBe(false) @@ -426,6 +440,7 @@ describe('streamable-http — in-process MCP server', () => { it('executes shout({ message }) with args over HTTP', async () => { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__web__shout', arguments: { message: 'quiet' }, }) expect(result.isError).toBe(false) diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index 8fff832434..557379808c 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -7,6 +7,8 @@ import { publicToolName, syncTools, type ToolBridgeOptions } from '@deepseek-ai/ import { createTransport } from '@deepseek-ai/dsh-mcp-client/src/transport.ts' import type { Config } from '@deepseek-ai/dsh-mcp-client' +const testToolSignal = new AbortController().signal + // ---- Mock MCP Client ---- interface MockTool { @@ -122,7 +124,7 @@ describe('syncTools', () => { expect(ctx.tools.get('search')).toBeDefined() expect(ctx.tools.get('mcp__srv__search')).toBeDefined() - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'search', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'search', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: 'native' }) }) @@ -217,7 +219,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__echo', arguments: { msg: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__echo', arguments: { msg: 'hi' } }) expect(result.isError).toBe(false) expect(result.content).toEqual([{ type: 'text', text: 'hello world' }]) @@ -237,7 +239,7 @@ describe('tool execution', () => { await syncTools(client as never, ctx, defaultOpts, new Map()) const publicName = publicToolName('srv', 'admin.reset') - const result = await ctx.tools.execute({ callId: CallId('c1'), name: publicName, arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: publicName, arguments: {} }) expect(result.isError).toBe(false) expect(client.callTool).toHaveBeenCalledWith( @@ -254,7 +256,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__multi', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__multi', arguments: {} }) expect(result.content).toEqual([{ type: 'text', text: 'line1\nline2' }]) }) @@ -266,7 +268,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__img', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__img', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: 'before\n[image: image/png, content discarded]' }) }) @@ -278,7 +280,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__fail', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__fail', arguments: {} }) expect(result.isError).toBe(true) expect(result.content[0]).toEqual({ type: 'text', text: 'Error: something went wrong' }) @@ -308,7 +310,7 @@ describe('tool execution', () => { client.callTool.mockResolvedValue({ toolResult: { key: 'value' } }) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__legacy', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__legacy', arguments: {} }) expect(result.isError).toBe(false) expect(result.content[0]).toEqual({ type: 'text', text: '{"key":"value"}' }) @@ -329,7 +331,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__audio_tool', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__audio_tool', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '[audio: audio/mp3, content discarded]' }) }) @@ -341,7 +343,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__res_tool', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__res_tool', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' }) }) @@ -353,7 +355,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__link_tool', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__link_tool', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' }) }) @@ -365,7 +367,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__unknown_tool', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__unknown_tool', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '[unsupported content type: video]' }) }) @@ -377,7 +379,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__img2', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__img2', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '[image: unknown, content discarded]' }) }) @@ -389,7 +391,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__audio_no_mime', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__audio_no_mime', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '[audio: unknown, content discarded]' }) }) @@ -401,7 +403,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__notext', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__notext', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no text content)' }) }) @@ -413,7 +415,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__empty_tool', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__empty_tool', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no text content)' }) }) @@ -426,7 +428,7 @@ describe('tool execution edge cases', () => { client.callTool.mockResolvedValue({}) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__legacy2', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__legacy2', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '(no output)' }) }) @@ -438,7 +440,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__err_notext', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__err_notext', arguments: {} }) expect(result.isError).toBe(true) expect(result.content[0]).toEqual({ type: 'text', text: 'Error: [image: image/png, content discarded]' }) @@ -575,7 +577,7 @@ describe('tool execution — non-object args fallback', () => { await syncTools(client as never, ctx, defaultOpts, new Map()) // Simulate model emitting `null` as tool arguments (malformed). - await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__coerce', arguments: null }) + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__coerce', arguments: null }) expect(client.callTool).toHaveBeenCalledWith( { name: 'coerce', arguments: {} }, @@ -591,7 +593,7 @@ describe('tool execution — non-object args fallback', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__coerce2', arguments: 'bad' }) + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__coerce2', arguments: 'bad' }) expect(client.callTool).toHaveBeenCalledWith( { name: 'coerce2', arguments: {} }, diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index cab7f4aa02..90d891c20e 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -12,6 +12,8 @@ import SkillService from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' +const testToolSignal = new AbortController().signal + async function tempDir(name: string): Promise { return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`))) } @@ -219,6 +221,7 @@ describe('dsh-tool-skill', () => { const ctx = await setup(home) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('c1'), name: 'skill', arguments: { name: 'project-skill' }, @@ -271,9 +274,9 @@ describe('dsh-tool-skill', () => { content: 'Provider instructions.', }) - const opaque = await ctx.tools.execute({ callId: CallId('c2'), name: 'skill', arguments: { name: 'opaque-skill' } }) - const url = await ctx.tools.execute({ callId: CallId('c3'), name: 'skill', arguments: { name: 'url-skill' } }) - const provider = await ctx.tools.execute({ callId: CallId('c4'), name: 'skill', arguments: { name: 'provider-skill' } }) + const opaque = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c2'), name: 'skill', arguments: { name: 'opaque-skill' } }) + const url = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c3'), name: 'skill', arguments: { name: 'url-skill' } }) + const provider = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c4'), name: 'skill', arguments: { name: 'provider-skill' } }) if (opaque.content[0]?.type !== 'text' || url.content[0]?.type !== 'text' || provider.content[0]?.type !== 'text') { throw new Error('expected text tool results') @@ -295,7 +298,7 @@ describe('dsh-tool-skill', () => { content: 'Rogue instructions.', }) - const result = await ctx.tools.execute({ callId: CallId('c5'), name: 'skill', arguments: { name: 'rogue-resource-skill' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c5'), name: 'skill', arguments: { name: 'rogue-resource-skill' } }) expect(result.isError).toBe(true) const block = result.content[0] @@ -309,9 +312,9 @@ describe('dsh-tool-skill', () => { await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisableModelInvocation: true\n---\n\nHidden instructions.\n') const ctx = await setup(home) - const unknown = await ctx.tools.execute({ callId: CallId('c1'), name: 'skill', arguments: { name: 'missing' } }) - const invalid = await ctx.tools.execute({ callId: CallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } }) - const disabled = await ctx.tools.execute({ callId: CallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } }) + const unknown = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'skill', arguments: { name: 'missing' } }) + const invalid = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } }) + const disabled = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } }) expect(unknown.isError).toBe(true) expect(invalid.isError).toBe(true) diff --git a/packages/spill/spill-policy/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts index 2449f26a8c..b2e1852d20 100644 --- a/packages/spill/spill-policy/tests/spill-policy.spec.ts +++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts @@ -21,6 +21,8 @@ import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy' +const testToolSignal = new AbortController().signal + /** A stub spill backend recording its saves; `fail` exercises the best-effort fallback. */ class StubStore extends SpillStore { saves: SaveTextSpill[] = [] @@ -51,7 +53,7 @@ function textTool(name: string, text: string) { function exec(name: string, session = 's1'): ToolExecution { // Only agent.session.header.id is read by the policy; a structural stub suffices. const agent = { session: { header: { id: SessionId(session) } } } - return { callId: CallId(`call-${name}`), name, arguments: {}, agent } as unknown as ToolExecution + return { callId: CallId(`call-${name}`), name, arguments: {}, agent, signal: testToolSignal } as unknown as ToolExecution } /** @@ -208,7 +210,7 @@ describe('best-effort fallback', () => { const { ctx, spill } = await setup({ maxInlineBytes: 10 }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) ctx.tools.register(textTool('big', 'x'.repeat(1000))) - const result = await ctx.tools.execute({ callId: CallId('c'), name: 'big', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c'), name: 'big', arguments: {} }) expect(textOf(result.content)).toBe('x'.repeat(1000)) expect(spill?.saves).toHaveLength(0) expect(warn).toHaveBeenCalled() diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index d3f2ae1957..2fa3398045 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -16,6 +16,8 @@ import { STRUCTURED_OUTPUT_TOOL, } from '../src/structured.ts' +const testToolSignal = new AbortController().signal + type Script = ConstructorParameters[0] interface CodeRunRequestLike { @@ -640,6 +642,7 @@ describe('in-process structured output', () => { it('a structured_output call from an agent WITHOUT a structured run is UNKNOWN_TOOL (the tool does not exist for it)', async () => { const { ctx, parent } = await setup([]) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: 'x' as never, name: STRUCTURED_OUTPUT_TOOL, arguments: { answer: 1 }, @@ -652,6 +655,7 @@ describe('in-process structured output', () => { it('a structured_output call with NO calling agent at all is UNKNOWN_TOOL', async () => { const { ctx } = await setup([]) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: 'x' as never, name: STRUCTURED_OUTPUT_TOOL, arguments: { answer: 1 }, @@ -684,6 +688,7 @@ describe('in-process structured output', () => { // …and a LATER invalid call (its own body staged nothing) must not // resurrect c1's discarded value: drive the pipeline directly. const invalid = await ctx.tools.execute({ + signal: testToolSignal, callId: 'c2' as never, name: STRUCTURED_OUTPUT_TOOL, arguments: { answer: 'not-a-number' }, @@ -692,6 +697,7 @@ describe('in-process structured output', () => { expect(invalid.isError).toBe(true) // A fresh valid call still captures ITS OWN value. const valid = await ctx.tools.execute({ + signal: testToolSignal, callId: 'c3' as never, name: STRUCTURED_OUTPUT_TOOL, arguments: { answer: 9 }, @@ -722,6 +728,7 @@ describe('in-process structured output', () => { // (invalid args throw before the stage): the discarded value must not ride // its acceptance. const reused = await ctx.tools.execute({ + signal: testToolSignal, callId: 'c1' as never, name: STRUCTURED_OUTPUT_TOOL, arguments: { answer: 'not-a-number' }, @@ -730,6 +737,7 @@ describe('in-process structured output', () => { expect(reused.isError).toBe(true) // Nothing was ever committed: a fresh valid call is still required. const valid = await ctx.tools.execute({ + signal: testToolSignal, callId: 'c1' as never, name: STRUCTURED_OUTPUT_TOOL, arguments: { answer: 5 }, @@ -764,6 +772,7 @@ describe('in-process structured output', () => { return undefined as never }, { prepend: true }) const denied = await ctx.tools.execute({ + signal: testToolSignal, callId: 'c1' as never, name: STRUCTURED_OUTPUT_TOOL, arguments: { answer: 2 }, @@ -774,6 +783,7 @@ describe('in-process structured output', () => { // The discarded value was never promoted: a fresh valid call is required // (and succeeds, proving the runtime is not wedged). const valid = await ctx.tools.execute({ + signal: testToolSignal, callId: 'c1' as never, name: STRUCTURED_OUTPUT_TOOL, arguments: { answer: 5 }, diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index bcd28c6ab1..a84041d290 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -285,9 +285,6 @@ export function apply(ctx: Context, config: Config): void { if (tasks === undefined) { throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') } - // Reject cancellation before spawning; after return, the task-owned - // signal covers both pending startup and the ready child. - if (exec.signal?.aborted) throw new Error('subagent delegation aborted') // Task preflight finishes before the starter can spawn a child. const id = tasks.start({ kind: 'subagent', @@ -315,7 +312,7 @@ export function apply(ctx: Context, config: Config): void { config, args.prompt, parent, - exec.signal ?? new AbortController().signal, + exec.signal, ) const run: SubagentRun = await ctx.subagents.start(config.provider, request) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 9e4d68c08c..8dda16f6df 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { type Agent } from '@deepseek-ai/dsh-agent' import AgentRegistry from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -15,6 +15,8 @@ import * as tool from '../src/index.ts' import { runOutcome, settleRun } from '../src/index.ts' import { SessionId } from '@deepseek-ai/dsh-session' +const testToolSignal = new AbortController().signal + /** * Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real * `ToolRegistry` + `SubagentService`, with a package-local scripted child @@ -45,6 +47,7 @@ function callSubagent(ctx: Context, args: unknown, over: { agent?: Agent | undef // exactOptionalPropertyTypes the key is omitted rather than set to undefined. const agent = 'agent' in over ? over.agent : fakeAgent() return ctx.tools.execute({ + signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name: 'subagent', arguments: args, @@ -100,11 +103,13 @@ describe('dsh-tool-subagent', () => { it('keeps foreground and background calls exclusive', async () => { const ctx = await setup({ provider: 'mock' }) expect(ctx.tools.executionMode({ + signal: testToolSignal, callId: CallId('subagent-foreground'), name: 'subagent', arguments: { description: 'do work', prompt: 'Reply OK' }, })).toEqual({ kind: 'exclusive' }) expect(ctx.tools.executionMode({ + signal: testToolSignal, callId: CallId('subagent-background'), name: 'subagent', arguments: { description: 'do work', prompt: 'Reply OK', run_in_background: true }, @@ -139,8 +144,8 @@ describe('dsh-tool-subagent', () => { const names = ctx.tools.schemas().map(s => s.name).filter(n => n.startsWith('subagent')).sort() expect(names).toEqual(['subagent', 'subagent_acp']) - const viaSpawn = await ctx.tools.execute({ callId: CallId('c-spawn'), name: 'subagent', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() }) - const viaAcp = await ctx.tools.execute({ callId: CallId('c-acp'), name: 'subagent_acp', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() }) + const viaSpawn = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c-spawn'), name: 'subagent', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() }) + const viaAcp = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c-acp'), name: 'subagent_acp', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() }) expect(text(viaSpawn)).toBe('from spawn') expect(text(viaAcp)).toBe('from acp') }) @@ -418,7 +423,7 @@ describe('dsh-tool-subagent', () => { expect(result.isError).toBe(true) }) - it('passes an already-aborted signal so provider startup rejects', async () => { + it('skips provider startup for an already-aborted signal', async () => { const sawAborted = vi.fn() const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -438,8 +443,9 @@ describe('dsh-tool-subagent', () => { const controller = new AbortController() controller.abort() // already aborted BEFORE the tool runs const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal }) - expect(sawAborted).toHaveBeenCalledTimes(1) + expect(sawAborted).not.toHaveBeenCalled() expect(result.isError).toBe(true) + expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) }) it('tools depend on the service: no `subagent` tool without ctx.subagents', async () => { @@ -640,6 +646,7 @@ describe('dsh-tool-subagent background mode', () => { expect(text(start)).toBe('started background subagent task subagent-1') const collected = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('collect-1'), name: 'task_output', arguments: { task_id: 'subagent-1', wait: true }, @@ -649,6 +656,7 @@ describe('dsh-tool-subagent background mode', () => { // Final-output reads are idempotent (not consumed). const again = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('collect-2'), name: 'task_output', arguments: { task_id: 'subagent-1' }, @@ -664,14 +672,15 @@ describe('dsh-tool-subagent background mode', () => { expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks') }) - it('refuses to start when the tool signal is already aborted', async () => { + it('skips background startup when the tool signal is already aborted', async () => { const ctx = await backgroundSetup({ provider: 'mock' }) const parent = ownerAgent(ctx, 'sess-parent') const controller = new AbortController() controller.abort() const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }, { agent: parent, signal: controller.signal }) expect(result.isError).toBe(true) - expect(text(result)).toContain('subagent delegation aborted') + expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) + expect(text(result)).toBe('Error: tool call aborted before dispatch') }) it('settles an asynchronous provider-start failure as a failed task', async () => { @@ -686,6 +695,7 @@ describe('dsh-tool-subagent background mode', () => { tool.apply(ctx, { provider: 'broken-start', toolName: 'subagent_broken' }) const started = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('broken-start'), name: 'subagent_broken', arguments: { description: 'broken', prompt: 'p', run_in_background: true }, @@ -693,6 +703,7 @@ describe('dsh-tool-subagent background mode', () => { }) expect(text(started)).toBe('started background subagent task subagent-1') const output = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('broken-output'), name: 'task_output', arguments: { task_id: 'subagent-1', wait: true }, @@ -715,18 +726,21 @@ describe('dsh-tool-subagent background mode', () => { tool.apply(ctx, { provider: 'pending-start', toolName: 'subagent_pending' }) await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('pending-start'), name: 'subagent_pending', arguments: { description: 'pending', prompt: 'p', run_in_background: true }, agent: parent, }) await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('pending-kill'), name: 'task_kill', arguments: { task_id: 'subagent-1', reason: 'no longer needed' }, agent: parent, }) const output = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('pending-output'), name: 'task_output', arguments: { task_id: 'subagent-1', wait: true }, @@ -764,19 +778,19 @@ describe('dsh-tool-subagent background mode', () => { // Direct apply preserves omitted agentOptions instead of applying schema defaults. tool.apply(ctx, { provider: 'hanging', toolName: 'subagent_hang' }) - const startOne = await ctx.tools.execute({ callId: CallId('h1'), name: 'subagent_hang', arguments: { description: 'one', prompt: 'p', run_in_background: true }, agent: parent }) - const startTwo = await ctx.tools.execute({ callId: CallId('h2'), name: 'subagent_hang', arguments: { description: 'two', prompt: 'p', run_in_background: true }, agent: parent }) + const startOne = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('h1'), name: 'subagent_hang', arguments: { description: 'one', prompt: 'p', run_in_background: true }, agent: parent }) + const startTwo = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('h2'), name: 'subagent_hang', arguments: { description: 'two', prompt: 'p', run_in_background: true }, agent: parent }) expect(text(startOne)).toBe('started background subagent task subagent-1') expect(text(startTwo)).toBe('started background subagent task subagent-2') - const withReason = await ctx.tools.execute({ callId: CallId('k1'), name: 'task_kill', arguments: { task_id: 'subagent-1', reason: 'superseded' }, agent: parent }) - const withoutReason = await ctx.tools.execute({ callId: CallId('k2'), name: 'task_kill', arguments: { task_id: 'subagent-2' }, agent: parent }) + const withReason = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('k1'), name: 'task_kill', arguments: { task_id: 'subagent-1', reason: 'superseded' }, agent: parent }) + const withoutReason = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('k2'), name: 'task_kill', arguments: { task_id: 'subagent-2' }, agent: parent }) expect(text(withReason)).toBe('requested cancellation of task subagent-1') expect(text(withoutReason)).toBe('requested cancellation of task subagent-2') expect(cancels).toEqual(['superseded', 'background subagent task killed']) // The aborted children settle as killed tasks. - const killed = await ctx.tools.execute({ callId: CallId('w1'), name: 'task_output', arguments: { task_id: 'subagent-1', wait: true }, agent: parent }) + const killed = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('w1'), name: 'task_output', arguments: { task_id: 'subagent-1', wait: true }, agent: parent }) expect(text(killed)).toBe('(no new output)\n[status: killed]') }) @@ -870,6 +884,7 @@ describe('background preflight failure (no orphaned child, by construction)', () tool.apply(ctx, { provider: 'probe', toolName: 'subagent_probe' }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('probe-1'), name: 'subagent_probe', arguments: { description: 'd', prompt: 'p', run_in_background: true }, diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index b8a0cef6a9..0409ab3668 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -938,7 +938,7 @@ describe('scoped-dispatch invariants', () => { 'agent/post-step': [agent, 1, 1, signal], 'agent/prompt-submit': [agent, [], { kind: 'user' }, signal, () => Promise.resolve({ kind: 'allow' })], 'agent/request': [agent, 1, 1, config, signal, () => Promise.resolve(config)], - 'agent/request-error': [agent, 1, 1, new Error('request failed'), 0, signal, () => Promise.resolve({ action: 'fail' })], + 'agent/request-error': [agent, 1, 1, new Error('request failed'), { message: 'request failed', code: 'UNKNOWN' }, [], signal, () => Promise.resolve({ action: 'fail' })], 'agent/session-prefix': [agent, [], signal, () => Promise.resolve([])], 'agent/step-result': [agent, 1, 1, message, signal, () => Promise.resolve(message)], 'agent/turn-continuation': [agent, 1, { action: 'stop' }, signal, () => Promise.resolve({ action: 'stop' })], diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 59ffc485db..846085deec 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -142,7 +142,7 @@ describe('deriveReplayScript', () => { it('keeps a finish-error chunk in the derived entry (replays naturally)', () => { const errChunks: StreamChunk[] = [ { type: 'block-start', index: 0, blockType: 'text' }, - { type: 'finish', reason: { kind: 'error', message: 'boom', code: 'X' } }, + { type: 'finish', reason: { kind: 'error', failure: { message: 'boom', code: 'X' } } }, ] const events = errChunks.map((c, i) => chunkEvent(i + 1, 1, 1, c)) expect(deriveReplayScript(events)).toEqual([{ kind: 'chunks', chunks: errChunks }]) diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index 0a2d89431e..4c09845eae 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -11,6 +11,8 @@ import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek- import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { statusLine } from '@deepseek-ai/dsh-tool-tasks' +const testToolSignal = new AbortController().signal + const agentRegistryDisposers = new WeakMap void>() async function setup(config: ToolTasks.Config = {}) { @@ -62,7 +64,7 @@ function producer(overrides: Partial & TaskHooks> = {}) { let callCounter = 0 function call(ctx: Context, name: string, args: unknown, agent?: Agent) { - return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} }) + return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} }) } function text(result: { content: { type: string; text?: string }[] }): string { diff --git a/packages/timeout/timeout-policy/src/index.ts b/packages/timeout/timeout-policy/src/index.ts index e946c2af61..e9fe3a46af 100644 --- a/packages/timeout/timeout-policy/src/index.ts +++ b/packages/timeout/timeout-policy/src/index.ts @@ -54,8 +54,7 @@ export function apply(ctx: Context): void { using d = deadline(exec.signal, timeoutMs, TOOL_TIMEOUT) // Swap the derived deadline onto exec for dispatch, then restore the // caller's own signal so post-execute listeners never see this plugin's - // (possibly already-aborted) timeout signal. `undefined` is not assignable to - // the optional `signal` under exactOptionalPropertyTypes, so branch on it. + // (possibly already-aborted) timeout signal. const upstream = exec.signal exec.signal = d.signal try { @@ -69,8 +68,7 @@ export function apply(ctx: Context): void { } return result } finally { - if (upstream === undefined) delete exec.signal - else exec.signal = upstream + exec.signal = upstream } }) } diff --git a/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts b/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts index bd06ed6e16..77974cd4a0 100644 --- a/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts +++ b/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts @@ -11,10 +11,12 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { CallId, HarnessError } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool, type ToolExecutionInput, type PostToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineTool, TOOL_ABORTED, type ToolExecutionInput, type PostToolDecision } from '@deepseek-ai/dsh-tools' import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy' import { TOOL_TIMEOUT } from '@deepseek-ai/dsh-timeout-policy' +const testToolSignal = new AbortController().signal + /** Mount the registry + the zero-config timeout-policy enforcer. */ async function setup() { const ctx = new Context() @@ -29,8 +31,8 @@ const cooperativeTool = defineTool({ name: 'slow', description: 'stops when aborted', parameters: {}, timeoutMs: 100, execute(_args, exec): Promise<{ type: 'text'; text: string }[]> { const done = [{ type: 'text' as const, text: 'stopped cooperatively' }] - if (exec.signal?.aborted) return Promise.resolve(done) - return new Promise((resolve) => { exec.signal?.addEventListener('abort', () => { resolve(done) }) }) + if (exec.signal.aborted) return Promise.resolve(done) + return new Promise((resolve) => { exec.signal.addEventListener('abort', () => { resolve(done) }) }) }, }) @@ -38,8 +40,8 @@ const cooperativeTool = defineTool({ const abortThrowingTool = defineTool({ name: 'aborter', description: 'throws WEB_ABORTED when aborted', parameters: {}, timeoutMs: 100, execute(_args, exec): Promise { - if (exec.signal?.aborted) return Promise.reject(new HarnessError('web fetch aborted', 'WEB_ABORTED')) - return new Promise((_resolve, reject) => { exec.signal?.addEventListener('abort', () => { reject(new HarnessError('web fetch aborted', 'WEB_ABORTED')) }) }) + if (exec.signal.aborted) return Promise.reject(new HarnessError('web fetch aborted', 'WEB_ABORTED')) + return new Promise((_resolve, reject) => { exec.signal.addEventListener('abort', () => { reject(new HarnessError('web fetch aborted', 'WEB_ABORTED')) }) }) }, }) @@ -59,7 +61,7 @@ describe('timeout-policy delegation (unconfigured / fast)', () => { const ctx = await setup() ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000, async execute() { return [{ type: 'text' as const, text: 'ok' }] } })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'fast', arguments: {} }) expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false }) }) @@ -86,16 +88,6 @@ describe('timeout-policy signal restoration', () => { await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {}, signal: upstream }) expect(postSignal).toBe(upstream) }) - - it('deletes exec.signal again when the caller passed none', async () => { - const ctx = await setup() - ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000, - async execute() { return [{ type: 'text' as const, text: 'ok' }] } })) - let hadSignal: boolean | undefined - ctx.on('tools/post-execute', async (exec, _result, next): Promise => { hadSignal = 'signal' in exec && exec.signal !== undefined; return next() }) - await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} }) - expect(hadSignal).toBe(false) - }) }) describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => { @@ -105,7 +97,7 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => { it('replaces a cooperative tool result with TOOL_TIMEOUT when its own deadline fires', async () => { const ctx = await setup() ctx.tools.register(cooperativeTool) - const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'slow', arguments: {} }) + const pending = ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'slow', arguments: {} }) await vi.advanceTimersByTimeAsync(150) const result = await pending expect(result).toEqual({ @@ -118,7 +110,7 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => { it('replaces a provider-owned abort ERROR result with TOOL_TIMEOUT when the signal was ours', async () => { const ctx = await setup() ctx.tools.register(abortThrowingTool) - const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'aborter', arguments: {} }) + const pending = ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'aborter', arguments: {} }) await vi.advanceTimersByTimeAsync(150) const result = await pending expect(result.isError).toBe(true) @@ -126,16 +118,62 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => { expect(result.content[0]).toMatchObject({ text: 'Error: tool call timed out after 100ms' }) }) - it('does NOT replace when the caller aborts first (upstream cancel, not our timeout)', async () => { + it('preserves registry ABORTED when the caller aborts first (upstream cancel, not our timeout)', async () => { const ctx = await setup() - ctx.tools.register(cooperativeTool) + const entered = Promise.withResolvers() + ctx.tools.register(defineTool({ + name: 'slow', description: 'stops when aborted', parameters: {}, timeoutMs: 100, + execute(_args, exec) { + entered.resolve(undefined) + const done = [{ type: 'text' as const, text: 'stopped cooperatively' }] + if (exec.signal.aborted) return Promise.resolve(done) + return new Promise((resolve) => { + exec.signal.addEventListener('abort', () => { resolve(done) }, { once: true }) + }) + }, + })) const upstream = new AbortController() const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'slow', arguments: {}, signal: upstream.signal }) + await entered.promise upstream.abort('user cancelled') await vi.advanceTimersByTimeAsync(0) const result = await pending - expect(result.isError).toBe(false) - expect(result.content[0]).toMatchObject({ text: 'stopped cooperatively' }) + expect(result.isError).toBe(true) + expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED }) + expect(result.content[0]).toMatchObject({ text: 'Error: tool call aborted' }) + }) + + it('preserves TOOL_TIMEOUT when the deadline wins before a later caller abort', async () => { + const ctx = await setup() + const sawAbort = Promise.withResolvers() + const releaseCleanup = Promise.withResolvers() + ctx.tools.register(defineTool({ + name: 'slow-cleanup', description: 'settles after abort cleanup', parameters: {}, timeoutMs: 100, + async execute(_args, exec) { + if (!exec.signal.aborted) { + await new Promise((resolve) => { + exec.signal.addEventListener('abort', () => { resolve(undefined) }, { once: true }) + }) + } + sawAbort.resolve(undefined) + await releaseCleanup.promise + return [{ type: 'text' as const, text: 'cleanup complete' }] + }, + })) + const upstream = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('timeout-first'), name: 'slow-cleanup', arguments: {}, signal: upstream.signal, + }) + + await vi.advanceTimersByTimeAsync(100) + await sawAbort.promise + upstream.abort('too late to replace timeout') + releaseCleanup.resolve(undefined) + + await expect(pending).resolves.toMatchObject({ + isError: true, + error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, + }) }) }) @@ -183,7 +221,7 @@ describe('dsh-timeout-policy real-load-path guard', () => { const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(timeoutPolicy) as Parameters[0] const fiber = await ctx.plugin(unwrapped) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} } satisfies ToolExecutionInput) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'fast', arguments: {} } satisfies ToolExecutionInput) expect(result.isError).toBe(false) await fiber.dispose() }) diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index 2059bf13e8..47cfc27390 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -10,6 +10,8 @@ import { type Agent } from '@deepseek-ai/dsh-agent' import * as tool from '../src/index.ts' +const testToolSignal = new AbortController().signal + /** * Drives the REAL plugin body: mounts `dsh-tool-todo` on a real `ToolRegistry` * and invokes the registered `todo_write` tool through `ctx.tools.execute`, @@ -36,6 +38,7 @@ let callCounter = 0 function callTodo(ctx: Context, args: unknown, over: { agent?: Agent | undefined } = {}) { const agent = 'agent' in over ? over.agent : agentWithSession() return ctx.tools.execute({ + signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name: 'todo_write', arguments: args, diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 8063964c03..7a1a9fb00e 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -30,7 +30,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: | `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, and replays user, assistant, and tool events | | `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) | | `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) | -| `session/update` | `session/event` | streams user replay, assistant text/reasoning, and tool render intents | +| `session/update` | `session/event` | streams user replay, assistant text/reasoning, retry/failure attempt markers, and tool render intents | | `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice | | `session/request_permission` | `approval/request` listener | answers one-shot allow/reject requests for bridge-owned calls; foreign or call-less requests delegate and fail closed if unanswered — see "Permission prompts" | | `session/set_config_option` | agent-scoped request target / `ctx.permission.set()` | per-session provider+model and permission-preset switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" | @@ -47,6 +47,8 @@ When `ctx.permission` is composed, the bridge also advertises a `permission` sel The shared [`ctx.tasks` runtime](../../tasks/tasks/) fences access to predictable task ids by the owning session; ACP sessions therefore cannot read or stop one another's background work. +ACP updates are append-only, so `llm/retry` emits a visible separator that marks preceding partial model output discarded before the next attempt streams. A terminal model-request failure emits the same discarded-output warning; replay derives both markers from the durable events. + ## Per-session cwd `session/new` records the request's absolute cwd in the session header. Before constructing an agent, `session/load` uses persisted metadata to require an absolute request cwd that matches the stored one. Bash defaults to that workspace; an explicit relative workdir resolves against it, and multiple sessions may use different workspaces. `additionalDirectories` remains unsupported. diff --git a/packages/ui/acp/package.json b/packages/ui/acp/package.json index 84e4dcbda9..94cdd10743 100644 --- a/packages/ui/acp/package.json +++ b/packages/ui/acp/package.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-permission": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -50,6 +51,7 @@ "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index b930635534..b6c8946af1 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -44,6 +44,7 @@ import { } from '@agentclientprotocol/sdk' import type { ContentBlock, LlmCallConfig, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import { assertNever, CallId } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-llm-retry' import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' // Side-effect type import: resolves `ctx.get('permission')` to the service. @@ -481,7 +482,7 @@ export function apply(ctx: Context, config: AcpConfig): void { reason: TurnEndReason, ): void => { if (reason.kind === 'error') { - inflight.reject(internalError(`turn failed: ${reason.message}`)) + inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`)) } else { inflight.resolve(turnEndToStopReason(reason)) } @@ -1036,6 +1037,7 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void { * identical update stream from the same event log. * * - `assistant/chunk` text-delta/reasoning-delta → message/thought chunks + * - `llm/retry` and terminal model failure → visible discarded-attempt markers * - `user/message` → `user_message_chunk` during load replay only — so a * loaded transcript reconstructs the USER side of each turn without echoing * a live `session/prompt` back to the client @@ -1083,6 +1085,13 @@ export function streamSessionEventUpdate( } return } + case 'llm/retry': { + const text = '\n\n[Previous model attempt discarded; retrying ' + + `${event.data.retry}/${event.data.maxRetries} in ${event.data.delayMs}ms: ` + + `${event.data.failure.message}]\n\n` + notify({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } }) + return + } case 'user/message': { if (!includeUserMessages) return // Replay the user's prompt so a loaded session shows both sides of each @@ -1114,7 +1123,13 @@ export function streamSessionEventUpdate( notify({ sessionId, update: { sessionUpdate: 'plan', ...todosToPlan(event.data.todos) } }) return } - // turn/step boundaries, context/message, steering, + case 'turn/end': { + if (event.data.reason.kind !== 'error' || !('failure' in event.data.reason)) return + const text = `\n\n[Model attempt failed; any partial output above is discarded: ${event.data.reason.failure.message}]\n\n` + notify({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } }) + return + } + // non-error turn/step boundaries, context/message, steering, // assistant/message — no direct ACP client update. default: return diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts index 1796cbf868..b8b47b80d5 100644 --- a/packages/ui/acp/tests/harness.ts +++ b/packages/ui/acp/tests/harness.ts @@ -100,7 +100,7 @@ export function errorResponse(message: string): StreamChunk[] { return [ { type: 'block-start', index: 0, blockType: 'text' }, { type: 'text-delta', index: 0, text: 'partial' }, - { type: 'finish', reason: { kind: 'error', message, code: 'PROVIDER_ERROR' } }, + { type: 'finish', reason: { kind: 'error', failure: { message, code: 'PROVIDER_ERROR' } } }, ] } diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index 51a3e3115b..feda449054 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -65,6 +65,37 @@ describe('streamSessionEventUpdate', () => { .toEqual([]) }) + it('marks retry and terminal model failure boundaries but not ordinary turn errors', () => { + expect(updatesFor(evt('llm/retry', { + turn: 1, + step: 1, + retry: 1, + maxRetries: 2, + delayMs: 500, + failure: { message: 'backend busy', code: 'SERVER' }, + }))).toEqual([{ + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: '\n\n[Previous model attempt discarded; retrying 1/2 in 500ms: backend busy]\n\n', + }, + }]) + expect(updatesFor(evt('turn/end', { + turn: 1, + reason: { kind: 'error', step: 2, failure: { message: 'still busy', code: 'SERVER' } }, + }))).toEqual([{ + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: '\n\n[Model attempt failed; any partial output above is discarded: still busy]\n\n', + }, + }]) + expect(updatesFor(evt('turn/end', { + turn: 1, + reason: { kind: 'error', step: 2, message: 'post-step failed' }, + }))).toEqual([]) + }) + it('maps tool/call to an in_progress tool_call with kind other and parsed rawInput (generic fallback, no presenter)', () => { const updates = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' })) expect(updates).toEqual([{ diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts index 8a63cc1755..4ca6775d06 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -49,6 +49,15 @@ describe('acp bridge — turn outcomes', () => { .rejects.toThrow(/turn failed: provider boom/) }) + it('rejects an ordinary plugin turn failure through the same ACP boundary', async () => { + harness = await makeBridgeHarness({ storageDir, script: [textResponse('must not run')] }) + harness.ctx.on('agent/pre-step', () => { throw new Error('plugin pre-step failed') }) + const sessionId = await newSession(harness) + + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) + .rejects.toThrow(/turn failed: plugin pre-step failed/) + }) + it('streams a tool call as tool_call then tool_call_update', async () => { harness = await makeBridgeHarness({ storageDir, diff --git a/packages/ui/acp/tsconfig.json b/packages/ui/acp/tsconfig.json index 387e0d0c53..de4ccd3650 100644 --- a/packages/ui/acp/tsconfig.json +++ b/packages/ui/acp/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../llm/llm-retry" + }, { "path": "../../core/session" }, diff --git a/packages/ui/tool-ask-user/src/index.ts b/packages/ui/tool-ask-user/src/index.ts index 2591b28ddd..773d17bcf6 100644 --- a/packages/ui/tool-ask-user/src/index.ts +++ b/packages/ui/tool-ask-user/src/index.ts @@ -63,7 +63,7 @@ export function apply(ctx: Context): void { ...question.multi_select !== undefined ? { multiSelect: question.multi_select } : {}, })), ...exec.agent !== undefined ? { agent: exec.agent } : {}, - ...exec.signal !== undefined ? { signal: exec.signal } : {}, + signal: exec.signal, }) return [{ type: 'text', text: JSON.stringify(result) }] }, diff --git a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts index ceff7df388..0f03322dcd 100644 --- a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts +++ b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts @@ -7,6 +7,8 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction' import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' +const testToolSignal = new AbortController().signal + interface OptionSchemaShape { properties: { questions: { @@ -75,6 +77,7 @@ describe('ask_user_question tool', () => { }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('ask-1'), name: 'ask_user_question', arguments: { @@ -110,6 +113,7 @@ describe('ask_user_question tool', () => { }) await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('ask-recommended'), name: 'ask_user_question', arguments: { @@ -144,6 +148,7 @@ describe('ask_user_question tool', () => { }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('ask-multi'), name: 'ask_user_question', arguments: { @@ -198,6 +203,7 @@ describe('ask_user_question tool', () => { const agent = { id: 'main' } as unknown as Agent const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('ask-3'), name: 'ask_user_question', arguments: { questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }] }, @@ -212,6 +218,7 @@ describe('ask_user_question tool', () => { const ctx = await setup() const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('ask-no-provider'), name: 'ask_user_question', arguments: { questions: [{ id: 'continue', question: 'Continue?' }] }, @@ -227,6 +234,7 @@ describe('ask_user_question tool', () => { const ctx = await setup() const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('ask-empty'), name: 'ask_user_question', arguments: { questions: [] }, diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index f44644c430..dd6553d09f 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -4,9 +4,11 @@ The interactive terminal front door for DeepSeek Harness agents, built on [`@ear The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [terminal-state snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy. +Interactive terminals on macOS, Linux, and Windows are supported. Windows uses pi-tui's native console VT-input handling, and the [Windows support Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md) owns the platform decision and ConPTY process verification. + This package owns interactive terminal presentation and input only. It injects `agents`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. -The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. Surface replacement events rebuild the transcript so compacted history does not reappear. +The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Surface replacement events rebuild the transcript so compacted history does not reappear. Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling. diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index fd4f187e35..5e7c8d060c 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -25,6 +25,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", @@ -39,6 +40,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 4e15d55dd8..48c2cb2cb0 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -36,7 +36,8 @@ import z from 'schemastery' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-loop' import { errorChain } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-llm-retry' import { SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session' import type { FileDiff, @@ -604,15 +605,38 @@ function formatCwd(cwd: string | undefined): string { return displayText(cwd) } -function sessionTokens(session: Session): { input: number; output: number } { - let input = 0 - let output = 0 - for (const event of session.events) { - if (event.type !== 'assistant/message' || event.data.usage === undefined) continue - input += event.data.usage.inputTokens - output += event.data.usage.outputTokens +interface SessionTokenTotals { + input: number + output: number + readonly byStep: Map +} + +function recordTokenUsage(totals: SessionTokenTotals, turn: number, step: number, usage: TokenUsage): void { + const key = `${turn}:${step}` + const previous = totals.byStep.get(key) + if (previous !== undefined) { + totals.input -= previous.inputTokens + totals.output -= previous.outputTokens } - return { input, output } + totals.byStep.set(key, usage) + totals.input += usage.inputTokens + totals.output += usage.outputTokens +} + +function recordEventUsage(totals: SessionTokenTotals, event: SessionEvent): void { + if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') { + recordTokenUsage(totals, event.data.turn, event.data.step, event.data.chunk.usage) + } else if (event.type === 'assistant/message' && event.data.usage !== undefined) { + recordTokenUsage(totals, event.data.turn, event.data.step, event.data.usage) + } +} + +function sessionTokens(session: Session): SessionTokenTotals { + const totals: SessionTokenTotals = { input: 0, output: 0, byStep: new Map() } + for (const event of session.events) { + recordEventUsage(totals, event) + } + return totals } class FooterComponent implements Component { @@ -891,6 +915,14 @@ export function createTuiChat( return card } + const clearStreaming = (): void => { + if (streaming === undefined) return + const index = chat.children.indexOf(streaming) + /* v8 ignore next -- streaming is assigned only after the same component is added, and every removal clears it. */ + if (index >= 0) chat.children.splice(index, 1) + streaming = undefined + } + const renderEvent = (event: SessionEvent, options: { addHistory: boolean; renderChunks: boolean }): void => { switch (event.type) { case 'user/message': { @@ -933,15 +965,19 @@ export function createTuiChat( } break case 'assistant/message': { - if (streaming !== undefined) { - const index = chat.children.indexOf(streaming) - if (index >= 0) chat.children.splice(index, 1) - streaming = undefined - } + clearStreaming() const component = new AssistantMessageComponent(event.data.content, showReasoning, palette, mdTheme) if (component.children.length > 0) chat.addChild(component) break } + case 'llm/retry': { + clearStreaming() + appendNotice( + `Retrying model request (${event.data.retry}/${event.data.maxRetries}) in ${event.data.delayMs}ms: ${event.data.failure.message}`, + 'warning', + ) + break + } case 'tool/call': chat.addChild(new Spacer(1)) chat.addChild(parsedTool(event)) @@ -962,9 +998,13 @@ export function createTuiChat( todo.update(event.data.todos) break case 'turn/end': + clearStreaming() if (event.data.reason.kind === 'error') { const key = `${event.data.turn}:${event.data.reason.step}` - if (!liveErrors.delete(key)) appendNotice(event.data.reason.message, 'error') + const message = 'failure' in event.data.reason + ? event.data.reason.failure.message + : event.data.reason.message + if (!liveErrors.delete(key)) appendNotice(message, 'error') } else if (event.data.reason.kind === 'aborted') { appendNotice('Turn cancelled.', 'warning') } else if (event.data.reason.kind === 'max-tokens') { @@ -1237,10 +1277,7 @@ export function createTuiChat( const disposeSessionEvents = ctx.on('session/event', (session, event) => { if (session !== agent.session) return - if (event.type === 'assistant/message' && event.data.usage !== undefined) { - tokens.input += event.data.usage.inputTokens - tokens.output += event.data.usage.outputTokens - } + recordEventUsage(tokens, event) if ('surfaceOp' in event && typeof event.surfaceOp === 'object') { rebuildTranscript(false) return diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index c01c87d6ad..65c75dbdbb 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -120,10 +120,11 @@ export function appendAssistant( session: Session, content: ContentBlock[], usage?: { inputTokens: number; outputTokens: number }, + position: { turn: number; step: number } = { turn: 1, step: 0 }, ): void { session.append('assistant/message', { - turn: 1, - step: 0, + turn: position.turn, + step: position.step, provenance: { provider: 'mock', model: 'deepseek-v4-flash' }, content, ...usage === undefined ? {} : { usage }, diff --git a/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt b/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt new file mode 100644 index 0000000000..dff7910ad8 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt @@ -0,0 +1,48 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=15 bufferRow=15 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-95 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 95-95 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 95-95 fg=bright-blue +3| "│ deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-35 dim + style 95-95 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-95 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Start then cancel. " + style 0-0 fg=bright-blue +9| "▌ " + style 0-0 fg=bright-blue +10| +11| " Retrying model request (1/2) in 1000ms: temporary transport failure " + style 1-67 fg=yellow +12| +13| " Turn cancelled. " + style 1-15 fg=yellow +14| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +15| " " + style 1-1 inverse +16| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +17| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" + style 0-24 dim + style 63-95 dim +18-35| diff --git a/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt b/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt new file mode 100644 index 0000000000..b1cf16d42a --- /dev/null +++ b/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt @@ -0,0 +1,45 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=13 bufferRow=13 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-95 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 95-95 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 95-95 fg=bright-blue +3| "│ deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-35 dim + style 95-95 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-95 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Let the bounded policy exhaust. " + style 0-0 fg=bright-blue +9| "▌ " + style 0-0 fg=bright-blue +10| +11| " provider still unavailable " + style 1-26 fg=red +12| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +13| " " + style 1-1 inverse +14| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +15| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" + style 0-24 dim + style 63-95 dim +16-35| diff --git a/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt b/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt new file mode 100644 index 0000000000..7d692e4365 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt @@ -0,0 +1,49 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=16 bufferRow=16 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-95 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 95-95 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 95-95 fg=bright-blue +3| "│ deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-35 dim + style 95-95 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-95 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Recover this request. " + style 0-0 fg=bright-blue +9| "▌ " + style 0-0 fg=bright-blue +10| +11| " Retrying model request (1/2) in 500ms: provider rate limit " + style 1-58 fg=yellow +12| +13| " Assistant " + style 1-9 fg=bright-magenta bold +14| " Recovered on the next bounded attempt. " +15| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +16| " " + style 1-1 inverse +17| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +18| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" + style 0-24 dim + style 63-95 dim +19-35| diff --git a/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt b/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt new file mode 100644 index 0000000000..5cd27db26b --- /dev/null +++ b/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt @@ -0,0 +1,45 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=13 bufferRow=13 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-95 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 95-95 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 95-95 fg=bright-blue +3| "│ deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-35 dim + style 95-95 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-95 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Recover this request. " + style 0-0 fg=bright-blue +9| "▌ " + style 0-0 fg=bright-blue +10| +11| " Retrying model request (1/2) in 500ms: provider rate limit " + style 1-58 fg=yellow +12| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +13| " " + style 1-1 inverse +14| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +15| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" + style 0-24 dim + style 63-95 dim +16-35| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 609574e758..295ceda116 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' import type { Context } from 'cordis' import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-llm-retry' import type { Session } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type ToolDefinition, type ToolResultView } from '@deepseek-ai/dsh-tools' @@ -24,6 +25,10 @@ const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh' const CHECKPOINTS = [ 'conversation-streaming', + 'retry-scheduled', + 'retry-recovered', + 'retry-cancelled', + 'retry-exhausted', 'code-mode-pending', 'dynamic-workflow-pending', 'cordis-tools-pending', @@ -222,6 +227,82 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) + it('pins failed-stream retraction, scheduled retry, and eventual success', async () => { + const harness = await setupSnapshot() + await renderAfter(harness, () => { + appendUser(harness.session, 'Recover this request.') + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'discarded partial output' }, + }) + harness.session.append('llm/retry', { + turn: 1, + step: 1, + retry: 1, + maxRetries: 2, + delayMs: 500, + failure: { message: 'provider rate limit', code: 'RATE_LIMIT', status: 429 }, + }) + }) + await checkpoint('retry-scheduled', harness.terminal, { includeScrollback: true }) + + await renderAfter(harness, () => { + harness.session.append('assistant/message', { + turn: 1, + step: 2, + provenance: { provider: 'mock', model: 'deepseek-v4-flash' }, + content: [{ type: 'text', text: 'Recovered on the next bounded attempt.' }], + }, { surfaceOp: 'append' }) + harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }) + await checkpoint('retry-recovered', harness.terminal, { includeScrollback: true }) + await disposeSnapshot(harness) + }) + + it('pins cancellation during a scheduled retry delay', async () => { + const harness = await setupSnapshot() + await renderAfter(harness, () => { + appendUser(harness.session, 'Start then cancel.') + harness.session.append('llm/retry', { + turn: 1, + step: 1, + retry: 1, + maxRetries: 2, + delayMs: 1_000, + failure: { message: 'temporary transport failure', code: 'TRANSPORT' }, + }) + harness.session.append('turn/end', { + turn: 1, + reason: { kind: 'aborted' }, + }) + }) + await checkpoint('retry-cancelled', harness.terminal, { includeScrollback: true }) + await disposeSnapshot(harness) + }) + + it('pins terminal exhaustion after retracting a failed partial stream', async () => { + const harness = await setupSnapshot() + await renderAfter(harness, () => { + appendUser(harness.session, 'Let the bounded policy exhaust.') + harness.session.append('assistant/chunk', { + turn: 1, + step: 3, + chunk: { type: 'text-delta', index: 0, text: 'discarded terminal partial output' }, + }) + harness.session.append('turn/end', { + turn: 1, + reason: { + kind: 'error', + step: 3, + failure: { message: 'provider still unavailable', code: 'SERVER', status: 503 }, + }, + }) + }) + await checkpoint('retry-exhausted', harness.terminal, { includeScrollback: true }) + await disposeSnapshot(harness) + }) + it('pins Code Mode run_code with its production presenter', async () => { const harness = await setupSnapshot({ configureContext: configureAdvancedTools }) const call = { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 11c75241a3..a73f2481c5 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -7,6 +7,7 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import type {} from '@deepseek-ai/dsh-llm-retry' import { createTuiChat, mountTui, @@ -244,7 +245,12 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('live thought') result.terminal.send('\x12') await tick() - appendAssistant(result.session, [{ type: 'text', text: 'final live answer' }], { inputTokens: 500, outputTokens: 8 }) + appendAssistant( + result.session, + [{ type: 'text', text: 'final live answer' }], + { inputTokens: 500, outputTokens: 8 }, + { turn: 2, step: 0 }, + ) await tick() expect(result.terminal.output).toContain('Working') @@ -276,6 +282,68 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.drainInput).toHaveBeenCalledWith(100, 20) }) + it('counts failed and recovered request usage once per step', async () => { + const result = await setup() + result.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'usage', usage: { inputTokens: 10, outputTokens: 2 } }, + }) + result.session.append('llm/retry', { + turn: 1, + step: 1, + retry: 1, + maxRetries: 2, + delayMs: 500, + failure: { message: 'temporary', code: 'SERVER' }, + }) + result.session.append('assistant/chunk', { + turn: 1, + step: 2, + chunk: { type: 'usage', usage: { inputTokens: 7, outputTokens: 3 } }, + }) + appendAssistant( + result.session, + [{ type: 'text', text: 'recovered' }], + { inputTokens: 7, outputTokens: 3 }, + { turn: 1, step: 2 }, + ) + await tick() + + expect(result.terminal.output).toContain('↑17 ↓5') + await dispose(result) + }) + + it('retracts a failed live stream and renders its durable retry status', async () => { + const result = await setup() + result.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'discarded partial answer' }, + }) + result.session.append('llm/retry', { + turn: 1, + step: 1, + retry: 1, + maxRetries: 2, + delayMs: 500, + failure: { message: 'rate limited', code: 'RATE_LIMIT', status: 429 }, + }) + result.session.append('llm/retry', { + turn: 1, + step: 2, + retry: 2, + maxRetries: 2, + delayMs: 1_000, + failure: { message: 'failed before chunks', code: 'SERVER', status: 503 }, + }) + await tick() + + expect(result.terminal.output).toContain('Retrying model request (1/2) in 500ms: rate limited') + expect(result.terminal.output).toContain('Retrying model request (2/2) in 1000ms: failed before chunks') + await dispose(result) + }) + it('renders the ANSI palette and every markdown/content style', async () => { const result = await setup({ config: { color: true }, @@ -453,11 +521,16 @@ describe('pi-tui chat lifecycle and transcript', () => { events.session.append('turn/end', { turn: 6, reason: { kind: 'max-tokens' } }) events.session.append('turn/end', { turn: 7, reason: { kind: 'rejected', reason: 'policy' } }) events.session.append('turn/end', { turn: 8, reason: { kind: 'interrupted' } }) + events.session.append('turn/end', { + turn: 9, + reason: { kind: 'error', step: 1, failure: { message: 'structured provider failure', code: 'SERVER' } }, + }) events.ctx.emit('agent/disposed', events.agent) await tick() expect(events.terminal.output).toContain('live failure') expect(events.terminal.output).toContain('durable failure') expect(events.terminal.output).toContain('Turn cancelled') + expect(events.terminal.output).toContain('structured provider failure') expect(events.terminal.output).toContain('output-token limit') expect(events.terminal.output).toContain('Turn rejected') expect(events.terminal.output).toContain('previous process ended') diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json index 3a09f80ad8..0c9e89e9e5 100644 --- a/packages/ui/tui/tsconfig.json +++ b/packages/ui/tui/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../llm/llm-retry" + }, { "path": "../../core/tools" }, diff --git a/packages/util/timeout/README.md b/packages/util/timeout/README.md index b5923a3a73..cb6f0aa558 100644 --- a/packages/util/timeout/README.md +++ b/packages/util/timeout/README.md @@ -9,13 +9,15 @@ It is a **library, not a service or plugin**: no `ctx`, registers nothing, holds ## Surface ```ts -import { clampTimeout, deadline, timeoutOf, TimeoutReason } from '@deepseek-ai/dsh-timeout' +import { clampTimeout, deadline, idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf, TimeoutReason } from '@deepseek-ai/dsh-timeout' ``` | Export | Role | |---|---| | `clampTimeout(requested, def, max, name?)` | Validate the caller's optional positive-finite hint, fill from `def`, cap at `max`. Throws (with `name`) on a non-positive/non-finite hint. | | `deadline(upstream, timeoutMs, code)` | Fuse `upstream` cancellation with a timeout into one `AbortSignal` (`AbortSignal.any`); the timeout carries a `TimeoutReason`. `[Symbol.dispose]` clears the timer. | +| `idleWatchdog(upstream, timeoutMs, code)` | Keep one stable fused signal and arm only while its guarded async-iterator `next()` is outstanding. Resolution disarms; later demand rearms; disposal clears; concurrent demand rejects. | +| `MAX_TIMER_DELAY_MS` | Largest delay Node schedules without clamping it to one millisecond (`2_147_483_647`). Timer-owning config must not exceed it. | | `timeoutOf(signal \| { reason }, code?)` | Recover the `TimeoutReason` from an aborted signal/error, else `undefined` — the timeout-vs-cancel classifier. Pass `code` to match only THIS deadline's timer (see nesting below). | | `TimeoutReason` | The internal reason (`code` + `timeoutMs`) stamped on a timeout abort. Not a public error — providers translate it into their own error/field. | @@ -44,6 +46,8 @@ The signal only *notifies* — the caller MUST attach its own termination (`d.si Pass your own `code` to `timeoutOf` so classification composes under nesting: when the `upstream` you were handed is *itself* a deadline signal (a future `tools/execute` middleware arming a per-call deadline), `AbortSignal.any` preserves the outer `TimeoutReason` if the outer timer fires first. Scoping to your `code` makes a foreign timeout read as an ordinary upstream cancel — the correct classification from your capability's view — instead of your own timeout firing when your local timer never expired. +For a streamed transport, create one `idleWatchdog`, pass its stable `signal` into the transport, and call `watchdog.next(iterator)` for each provider read. The interval must be positive, finite, and no greater than `MAX_TIMER_DELAY_MS`; Node otherwise clamps it to one millisecond. It measures only outstanding demand, so no timer runs while downstream code renders or otherwise waits before asking for the next chunk. The primitive still only notifies, so the transport must observe the stable signal; the DeepSeek and pi-ai adapters prove that timeout closes their real response body or SDK request. + ## What does NOT get a timeout Local file `read`/`write`/`edit` take no `timeoutMs`: a syscall is best-effort-abortable at most, a timeout could not force `fsync`/`rename` to stop, and adding one would be an implicit default that violates explicit-over-implicit. See [`fs/`](../../fs/README.md). @@ -61,3 +65,4 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Notification only** — a deadline cannot stop work that ignores its signal; every capability still needs its own socket/process/task termination path. - **`timeoutMs <= 0` is internal vocabulary** — it disables the local timer only after an owning backend has resolved policy, never as a public model/plugin knob. - **The first abort reason wins classification** — when an upstream cancellation beats the local timer, this layer cannot later report that its own timeout would also have elapsed. +- **An idle watchdog is not a total deadline** — it rearms per outstanding iterator demand and deliberately excludes consumer think time. diff --git a/packages/util/timeout/src/index.ts b/packages/util/timeout/src/index.ts index 47c5d87c84..a9bd47eb08 100644 --- a/packages/util/timeout/src/index.ts +++ b/packages/util/timeout/src/index.ts @@ -21,6 +21,15 @@ export class TimeoutReason extends Error { } } +/** Largest delay Node schedules without clamping it to one millisecond. */ +export const MAX_TIMER_DELAY_MS = 2_147_483_647 + +function assertTimerDelay(timeoutMs: number, name: string): void { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMER_DELAY_MS) { + throw new Error(`${name} must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } +} + /** * Validate a caller's optional timeout hint, use the backend default, then cap * it. Supplied values must be positive and finite; zero is not a public @@ -53,6 +62,20 @@ export interface Deadline { [Symbol.dispose](): void } +/** Rearmable timeout around one outstanding async-iterator demand. */ +export interface IdleWatchdog { + /** Stable signal aborted by upstream cancellation or this watchdog's timeout. */ + readonly signal: AbortSignal + /** + * Await one iterator demand while the idle timer is armed. + * @param iterator - iterator whose next value represents provider progress. + * @returns the iterator's next result. + */ + next(iterator: AsyncIterator): Promise> + /** Clear an armed timer; safe to call once at the owning stream's exit. */ + [Symbol.dispose](): void +} + /** * Fuse upstream cancellation with an identifiable timeout. `timeoutMs <= 0` is * the internal no-timer sentinel; the returned disposer clears an armed timer. @@ -74,6 +97,8 @@ export function deadline( return { signal: upstream ?? new AbortController().signal, [Symbol.dispose]() {} } } + assertTimerDelay(timeoutMs, 'deadline timeoutMs') + const timer = new AbortController() const id = setTimeout(() => { timer.abort(new TimeoutReason(code, timeoutMs)) }, timeoutMs) return { @@ -85,6 +110,57 @@ export function deadline( } } +/** + * Create a rearmable idle watchdog for an async iterator. The timer exists only + * while {@link IdleWatchdog.next} is outstanding, so consumer think time does + * not count as provider idle time. The returned signal is stable for the whole + * call and only notifies; the iterator must observe it to terminate its work. + * + * @param upstream - caller cancellation fused into the stable signal. + * @param timeoutMs - positive finite idle interval in milliseconds. + * @param code - capability-owned code carried by the timeout reason. + * @returns a stable signal, guarded next operation, and timer disposer. + */ +export function idleWatchdog( + upstream: AbortSignal | undefined, + timeoutMs: number, + code: string, +): IdleWatchdog { + assertTimerDelay(timeoutMs, 'idleWatchdog timeoutMs') + const timeout = new AbortController() + const signal = upstream === undefined + ? timeout.signal + : AbortSignal.any([upstream, timeout.signal]) + let timer: ReturnType | undefined + let outstanding = false + let disposed = false + + return { + signal, + async next(iterator: AsyncIterator): Promise> { + if (disposed) throw new Error('idleWatchdog is disposed') + if (outstanding) throw new Error('idleWatchdog next is already outstanding') + outstanding = true + timer = setTimeout(() => { + timeout.abort(new TimeoutReason(code, timeoutMs)) + }, timeoutMs) + try { + return await iterator.next() + } finally { + clearTimeout(timer) + timer = undefined + outstanding = false + } + }, + [Symbol.dispose](): void { + if (disposed) return + disposed = true + if (timer !== undefined) clearTimeout(timer) + timer = undefined + }, + } +} + /** * Recover a timeout reason from a reason-bearing object. Supplying `code` * distinguishes this deadline from a nested upstream deadline; a foreign code diff --git a/packages/util/timeout/tests/timeout.spec.ts b/packages/util/timeout/tests/timeout.spec.ts index dd4da3adde..11779c915f 100644 --- a/packages/util/timeout/tests/timeout.spec.ts +++ b/packages/util/timeout/tests/timeout.spec.ts @@ -1,5 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { clampTimeout, deadline, timeoutOf, TimeoutReason } from '@deepseek-ai/dsh-timeout' +import { + clampTimeout, + deadline, + idleWatchdog, + MAX_TIMER_DELAY_MS, + timeoutOf, + TimeoutReason, +} from '@deepseek-ai/dsh-timeout' describe('TimeoutReason', () => { it('is an Error carrying the code and elapsed ms', () => { @@ -67,6 +74,13 @@ describe('deadline — timeout arm', () => { expect(d.signal.aborted).toBe(false) expect(timeoutOf(d.signal)).toBeUndefined() }) + + it('rejects delays that Node would clamp to one millisecond', () => { + expect(() => deadline(undefined, MAX_TIMER_DELAY_MS + 1, 'BASH_TIMEOUT')) + .toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`) + expect(() => deadline(undefined, Number.POSITIVE_INFINITY, 'BASH_TIMEOUT')) + .toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`) + }) }) describe('deadline — fuse with upstream', () => { @@ -182,3 +196,74 @@ describe('deadline — nested deadlines', () => { expect(timeoutOf(inner.signal)?.code).toBe('OUTER_TIMEOUT') // but IS a timeout, unscoped }) }) + +describe('idleWatchdog', () => { + afterEach(() => { vi.useRealTimers() }) + + it('arms only while next is outstanding and rearms the same signal for later demand', async () => { + vi.useFakeTimers() + const first = Promise.withResolvers>() + const second = Promise.withResolvers>() + const iterator: AsyncIterator = { + next: vi.fn() + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise), + } + using watchdog = idleWatchdog(undefined, 100, 'LLM_STREAM_IDLE_TIMEOUT') + const stableSignal = watchdog.signal + + const firstNext = watchdog.next(iterator) + await vi.advanceTimersByTimeAsync(99) + expect(stableSignal.aborted).toBe(false) + first.resolve({ done: false, value: 1 }) + await expect(firstNext).resolves.toEqual({ done: false, value: 1 }) + + await vi.advanceTimersByTimeAsync(10_000) + expect(stableSignal.aborted).toBe(false) + expect(watchdog.signal).toBe(stableSignal) + + const secondNext = watchdog.next(iterator) + await vi.advanceTimersByTimeAsync(100) + expect(timeoutOf(stableSignal, 'LLM_STREAM_IDLE_TIMEOUT')).toMatchObject({ timeoutMs: 100 }) + second.reject(stableSignal.reason) + await expect(secondNext).rejects.toBe(stableSignal.reason) + }) + + it('keeps an earlier upstream abort distinct from its own timeout', async () => { + vi.useFakeTimers() + const upstream = new AbortController() + using watchdog = idleWatchdog(upstream.signal, 100, 'LLM_STREAM_IDLE_TIMEOUT') + upstream.abort('caller cancelled') + expect(watchdog.signal.aborted).toBe(true) + expect(timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT')).toBeUndefined() + await vi.advanceTimersByTimeAsync(1_000) + expect(watchdog.signal.reason).toBe('caller cancelled') + }) + + it('clears an outstanding arm on disposal', async () => { + vi.useFakeTimers() + const pending = Promise.withResolvers>() + const watchdog = idleWatchdog(undefined, 100, 'LLM_STREAM_IDLE_TIMEOUT') + void watchdog.next({ next: () => pending.promise }) + watchdog[Symbol.dispose]() + await vi.advanceTimersByTimeAsync(1_000) + expect(watchdog.signal.aborted).toBe(false) + pending.resolve({ done: true, value: undefined }) + await expect(watchdog.next({ next: () => Promise.resolve({ done: true, value: undefined }) })) + .rejects.toThrow(/disposed/) + watchdog[Symbol.dispose]() + }) + + it('rejects invalid bounds and concurrent iterator demand', async () => { + expect(() => idleWatchdog(undefined, 0, 'IDLE')).toThrow(/positive finite/) + expect(() => idleWatchdog(undefined, Number.NaN, 'IDLE')).toThrow(/positive finite/) + expect(() => idleWatchdog(undefined, MAX_TIMER_DELAY_MS + 1, 'IDLE')) + .toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`) + const pending = Promise.withResolvers>() + using watchdog = idleWatchdog(undefined, 100, 'IDLE') + const iterator = { next: () => pending.promise } + void watchdog.next(iterator) + await expect(watchdog.next(iterator)).rejects.toThrow(/already outstanding/) + pending.resolve({ done: true, value: undefined }) + }) +}) diff --git a/packages/web/tool-web/tests/integration.spec.ts b/packages/web/tool-web/tests/integration.spec.ts index e99f524b3c..abc5256b9f 100644 --- a/packages/web/tool-web/tests/integration.spec.ts +++ b/packages/web/tool-web/tests/integration.spec.ts @@ -19,6 +19,8 @@ import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa' import * as ToolWeb from '@deepseek-ai/dsh-tool-web' import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy' +const testToolSignal = new AbortController().signal + type Handler = (req: IncomingMessage, res: ServerResponse) => void let server: Server @@ -56,7 +58,7 @@ afterEach(async () => { let counter = 0 type ToolResult = { isError: boolean; content: { type: string; text?: string }[]; error?: { code: string } } function call(name: string, args: unknown): Promise { - return ctx.tools.execute({ callId: CallId(`call-${++counter}`), name, arguments: args }) + return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++counter}`), name, arguments: args }) } describe('web_fetch integration over the real backend', () => { @@ -147,7 +149,7 @@ describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetc }) it('returns a structured TOOL_TIMEOUT (not the provider WEB_FETCH_TIMEOUT) when the tool-call budget wins', async () => { - const out = await tctx.tools.execute({ callId: CallId('slow-1'), name: 'web_fetch', arguments: { url: slowBase } }) + const out = await tctx.tools.execute({ signal: testToolSignal, callId: CallId('slow-1'), name: 'web_fetch', arguments: { url: slowBase } }) expect(out.isError).toBe(true) // The outer tool-call deadline won: TOOL_TIMEOUT, owned by dsh-timeout-policy, // NOT the provider's own WEB_FETCH_TIMEOUT (its 30s backstop never fired). diff --git a/packages/web/tool-web/tests/spill.spec.ts b/packages/web/tool-web/tests/spill.spec.ts index a2b1f9889d..33279fc9e9 100644 --- a/packages/web/tool-web/tests/spill.spec.ts +++ b/packages/web/tool-web/tests/spill.spec.ts @@ -19,6 +19,8 @@ import { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import type { ToolExecution } from '@deepseek-ai/dsh-tools' + +const testToolSignal = new AbortController().signal import WebService from '@deepseek-ai/dsh-web' import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local' import LocalSpillStore from '@deepseek-ai/dsh-spill-local' @@ -63,7 +65,7 @@ afterEach(async () => { /** A web_fetch call carrying a session owner (so the policy can scope the spill). */ function fetchCall(): Promise<{ isError: boolean; content: { type: string; text?: string }[] }> { const agent = { session: { header: { id: SessionId('web-sess') } } } - const exec = { callId: CallId('call-1'), name: 'web_fetch', arguments: { url: base }, agent } as unknown as ToolExecution + const exec = { callId: CallId('call-1'), name: 'web_fetch', arguments: { url: base }, agent, signal: testToolSignal } as unknown as ToolExecution return ctx.tools.execute(exec) } diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index 088ace395e..c7d8dd572a 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -18,6 +18,8 @@ import { WEB_SEARCH_MAX_RESULTS, } from '@deepseek-ai/dsh-tool-web' +const testToolSignal = new AbortController().signal + const available = true function searchProvider(result: WebSearchResult, isAvailable = available): WebSearchProvider { @@ -39,7 +41,7 @@ async function mountTools(opts: { if (opts.fetchProvider) ctx.web.registerFetchProvider(opts.fetchProvider) const fiber = await ctx.plugin(ToolWeb, opts.config ?? {}) let counter = 0 - const call = (name: string, args: unknown) => ctx.tools.execute({ callId: CallId(`call-${++counter}`), name, arguments: args }) as never + const call = (name: string, args: unknown) => ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++counter}`), name, arguments: args }) as never return { ctx, fiber, call } } @@ -166,9 +168,9 @@ describe('tool-web registration', () => { const names = ctx.tools.schemas().map(s => s.name) expect(names).toContain('web_search') expect(names).toContain('web_fetch') - expect(ctx.tools.executionMode({ callId: CallId('search-safe'), name: 'web_search', arguments: { query: 'q' } })) + expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('search-safe'), name: 'web_search', arguments: { query: 'q' } })) .toEqual({ kind: 'parallel' }) - expect(ctx.tools.executionMode({ callId: CallId('fetch-safe'), name: 'web_fetch', arguments: { url: 'https://a.test' } })) + expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('fetch-safe'), name: 'web_fetch', arguments: { url: 'https://a.test' } })) .toEqual({ kind: 'parallel' }) await fiber.dispose() expect(ctx.tools.schemas().map(s => s.name)).not.toContain('web_search') @@ -274,7 +276,7 @@ describe('tool-web execution through the real registry', () => { await fiber.dispose() }) - it('executes web_fetch with no caller signal (forwards undefined to the seam)', async () => { + it('forwards the required caller signal to web_fetch', async () => { const seen: { signal?: AbortSignal | undefined; passedSignal?: boolean } = {} const fetchProvider = { id: 'stub-fetch', @@ -286,11 +288,10 @@ describe('tool-web execution through the real registry', () => { }, } const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider }) - // No signal on the execution: the tool passes `undefined`. - const out = await ctx.tools.execute({ callId: CallId('fetch-2'), name: 'web_fetch', arguments: { url: 'https://a.test' } }) + const out = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('fetch-2'), name: 'web_fetch', arguments: { url: 'https://a.test' } }) expect(out.isError).toBe(false) - expect(seen.passedSignal).toBe(false) - expect(seen.signal).toBeUndefined() + expect(seen.passedSignal).toBe(true) + expect(seen.signal).toBe(testToolSignal) await fiber.dispose() }) diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 2a8ef96682..6aa8ea1c96 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -174,17 +174,14 @@ export function apply(ctx: Context, config: Config): void { meta: args.meta, ...args.args !== undefined ? { args: args.args } : {}, parent, - ...exec.signal ? { signal: exec.signal } : {}, + signal: exec.signal, }) // Bridge the tool's abort signal to the run: if the parent step is aborted while the // script is in flight, cancel the whole run. The signal also enters the engine directly, but // this local bridge preserves the tool contract even if an implementation ignores it. const onAbort = (): void => { run.cancel('parent step aborted') } - exec.signal?.addEventListener('abort', onAbort, { once: true }) - // `addEventListener` does NOT fire for a signal already aborted before - // this line — cancel explicitly in that case. - if (exec.signal?.aborted) run.cancel('parent step aborted') + exec.signal.addEventListener('abort', onAbort, { once: true }) try { const result = await run.result @@ -196,7 +193,7 @@ export function apply(ctx: Context, config: Config): void { } return [{ type: 'text', text: renderResult(run, result, maxResultChars) }] } finally { - exec.signal?.removeEventListener('abort', onAbort) + exec.signal.removeEventListener('abort', onAbort) // Always reach run quiescence — never leak a live script or children. await run.dispose() } diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 08bc8171c3..75a1e89ed0 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow' @@ -13,6 +13,8 @@ import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' import * as toolWorkflow from '../src/index.ts' import { SessionId } from '@deepseek-ai/dsh-session' +const testToolSignal = new AbortController().signal + /** A controllable engine standing in behind ctx.workflows (the tool's only seam). */ class StubEngine extends WorkflowService { requests: WorkflowStartRequest[] = [] @@ -60,6 +62,7 @@ const META = { name: 'audit', description: 'd' } function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?: AbortSignal }): Promise { return ctx.tools.execute({ + signal: testToolSignal, callId: CallId('call-1'), name: 'workflow', arguments: args, @@ -154,14 +157,16 @@ describe('dsh-tool-workflow', () => { expect(result.error?.code).toBe('INVALID_ARGS') }) - it('cancels the run when exec.signal is ALREADY aborted at call time', async () => { + it('skips workflow startup when exec.signal is already aborted', async () => { const { ctx, engine, parent } = await setup() const controller = new AbortController() controller.abort() const result = await execute(ctx, { script: SCRIPT, meta: META }, { agent: parent, signal: controller.signal }) expect(result.isError).toBe(true) - expect(engine.cancels).toContain('parent step aborted') - expect(engine.disposed).toBe(1) + expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) + expect(engine.requests).toHaveLength(0) + expect(engine.cancels).toHaveLength(0) + expect(engine.disposed).toBe(0) }) it('truncates an oversized rendered value with a notice (maxResultChars)', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1c3c84521..0381eeda0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -227,6 +227,10 @@ importers: '@deepseek-ai/dsh-workflow-workerthread': specifier: workspace:* version: link:../packages/workflow/workflow-workerthread + devDependencies: + node-pty: + specifier: 1.1.0 + version: 1.1.0 packages/bash/bash: devDependencies: @@ -402,6 +406,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../llm/llm-retry '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -755,6 +762,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../llm/llm-retry '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -1195,6 +1205,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -1214,10 +1227,56 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../llm-deepseek + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/llm/llm-retry: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-persistence-sqlite': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-sqlite + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/llm/token-meter: dependencies: schemastery: @@ -2027,6 +2086,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../llm/llm-retry '@deepseek-ai/dsh-permission': specifier: workspace:^ version: link:../permission @@ -2186,6 +2248,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../llm/llm-retry '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2585,6 +2650,9 @@ importers: '@deepseek-ai/dsh-llm-pi-ai': specifier: workspace:^ version: link:../../packages/llm/llm-pi-ai + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../packages/llm/llm-retry '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths @@ -6179,6 +6247,9 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-addon-landlock-run-linux-arm64@0.0.0-test.0: resolution: {integrity: sha512-oJsXcC33qKl9mWYx0n9YPJ2pUAoY39PoIX0Gx4lDrSCTEvENFrEaODAsQYNY+eEGpn9YMN7E+FOftvea3/1FqQ==} engines: {node: '>=20'} @@ -6256,6 +6327,9 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-pty@1.1.0: + resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} + non-layered-tidy-tree-layout@2.0.2: resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==} @@ -10605,6 +10679,8 @@ snapshots: neo-async@2.6.2: {} + node-addon-api@7.1.1: {} + node-addon-landlock-run-linux-arm64@0.0.0-test.0: optional: true @@ -10673,6 +10749,10 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-pty@1.1.0: + dependencies: + node-addon-api: 7.1.1 + non-layered-tidy-tree-layout@2.0.2: optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 26bfaeeb0b..55947e1131 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -25,6 +25,8 @@ peerDependencyRules: allowBuilds: esbuild: true lefthook: true + # Cross-platform PTY boundary for the TUI process smoke, including ConPTY on Windows. + node-pty: true # Pulled in by @earendil-works/pi-ai (optional LLM API backend). pnpm lists # them only because they ship lifecycle scripts, but those are no-ops we don't # need, so we deny them — install still succeeds. diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 6f34f2b15b..a9405ddd80 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 97556dd019..d605b1ee01 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -35,6 +35,7 @@ export const LINK_MAP: Record = { ContinuationStop: 'core.md', GenerateOptions: 'core.md', LlmCallConfig: 'core.md', + LlmFailure: 'llm-streaming.md', LlmModelInfo: 'core.md', LlmProviderInfo: 'core.md', Message: 'core.md', @@ -116,6 +117,7 @@ export const LINK_MAP: Record = { PreToolDecision: 'tools.md', ToolDefinition: 'tools.md', ToolExecution: 'tools.md', + ToolDispatchExecution: 'tools.md', ToolExecutionInput: 'tools.md', ToolExecutionMode: 'tools.md', ToolExecutionResult: 'tools.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 66b5658272..9c1df58e91 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -33,6 +33,7 @@ { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "ToolProviderResult", "source": "packages/core/system-prompt/src/index.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "LlmFailure", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "AppIdentity", "source": "packages/llm/llm/src/attribution.ts" }, @@ -78,6 +79,7 @@ { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionToken", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionInput", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDispatchExecution", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionMode", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolRunContext", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolGuard", "source": "packages/core/tools/src/index.ts" }, diff --git a/tsconfig.build.json b/tsconfig.build.json index 189f23fdc4..0504806166 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -36,6 +36,7 @@ { "path": "./packages/ui/tool-ask-user" }, { "path": "./packages/context/workspace-context" }, { "path": "./packages/core/agent-loop" }, + { "path": "./packages/llm/llm-retry" }, { "path": "./packages/examples/agent-spine-demo" }, { "path": "./packages/examples/cli-demo" }, { "path": "./packages/bash/bash" }, diff --git a/tsconfig.json b/tsconfig.json index 29474f33eb..1bf732aff2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -49,6 +49,7 @@ { "path": "./packages/ui/tool-ask-user" }, { "path": "./packages/context/workspace-context" }, { "path": "./packages/core/agent-loop" }, + { "path": "./packages/llm/llm-retry" }, { "path": "./packages/examples/agent-spine-demo" }, { "path": "./packages/examples/cli-demo" }, { "path": "./packages/bash/bash" },