Merge remote-tracking branch 'origin/master' into simpl-f-steering-mirror
# Conflicts: # docs/rfc/README.md
This commit is contained in:
107 files changed
+995
-798
No files matched your search
@@ -21,7 +21,7 @@ Independent judgment governs *what to look at* and *how to apply a rule to this
|
||||
|
||||
These define the conventions and gates this repo is checked against, and they are authoritative. Read them at the source so this skill never drifts out of sync — and apply judgment in *interpreting* them for the case at hand, not in deciding whether they apply.
|
||||
|
||||
- **[AGENTS.md](../../../AGENTS.md) § Conventions** — effect-based registrations, declaration-merging for events/ctx keys, waterfall `next()` discipline, discriminated-union match-don't-chain, explicit-over-implicit at seams, the empty-`catch` rule, symmetry.
|
||||
- **[AGENTS.md](../../../AGENTS.md) § Conventions** — effect-based registrations, declaration-merging for events/ctx keys, waterfall `next()` discipline, discriminated-union match-don't-chain, explicit-over-implicit at seams, no hardcoded tunables in plugins, the empty-`catch` rule, symmetry.
|
||||
- **[docs/defensive-patterns.md](../../../docs/defensive-patterns.md)** — each section is a bug class that bit us. Reviewing anything touching process lifecycle, async/await, disposal, or adapter error paths? Re-read this first — then look for the *adjacent* mistake it doesn't name.
|
||||
- **AGENTS.md § Type safety and documentation + [docs/AGENTS.md](../../../docs/AGENTS.md)** — the doc-sync rule (code change ⇒ update README + JSDoc in the SAME commit) and the writing rules (current-state-never-history, one line per paragraph, one home per fact, the word-budget gate).
|
||||
- **[packages/AGENTS.md](../../../packages/AGENTS.md)** — per-package conventions (file layout, the HMR-safety test requirement).
|
||||
@@ -44,6 +44,7 @@ Where your independent reasoning earns its keep. Start here, then keep going acr
|
||||
- **e2e verifies the world, not the agent's self-report.** For real-API tests, confirm the assertion re-runs the command/checks the file externally — a keyword probe lets a cheating agent pass. For a behavior change to the agent's real flows, a no-key/mock test alone is usually insufficient: a with-key e2e (especially a smoke test that boots the real example and checks the world) is cheap here and catches "green units, broken product" — encourage it rather than treating real-API tests as expensive (see [docs/testing.md](../../../docs/testing.md)).
|
||||
- **Plugin export shape + real-loader coverage.** A new/changed `cordis.yml`-loaded plugin: is it a function/namespace plugin (`name`/`inject`/`Config`/`apply` named exports) with NO `export default`? A stray default export makes the Loader's `unwrapExports` drop `inject` and the plugin crashes at load with `cannot get property … without inject` — invisible to hand-built `ctx.plugin({...})` tests and to line coverage. Confirm there's a test driving it through the REAL loader path (the no-key subprocess e2e for ACP is the model). And any opportunistic read of a service NOT in `static inject` should use `ctx.get(name)`, not `ctx.<name>` (the property proxy throws through a foreign shadow). See [packages/AGENTS.md](../../../packages/AGENTS.md) and [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md).
|
||||
- **Seam discipline.** New swappable capability? Check it's split per the capability-seams RFC (interface / impl / consumer), and that the consumer injects the interface key, never an implementation type.
|
||||
- **Hardcoded tunables that should be plugin config.** A literal timeout, grace period, output/truncation cap, result-count limit, retry count, buffer size, model name, API base URL, user agent, or filesystem path introduced inside a plugin belongs on the plugin's schemastery `Config` with the shipped value as its default (AGENTS.md § Conventions "No hardcoded tunables in plugins"). A named `DEFAULT_*` constant or a test-only injection seam is not configurability — the question to ask is whether a `cordis.yml` deployment can change the value without a code edit. Protocol/wire constants, semantic constants, values pinned by an external spec, and security invariants are exempt; a new `Config` field also needs its README row and range validation. No gate detects a hardcoded tunable — this check is entirely on the reviewer.
|
||||
- **Test quality — sufficiency, not just coverage.** 100% per-file coverage and a green suite are necessary, not sufficient: they prove the lines *ran*, not that the feature *works the way it ships*. Judge whether the tests are sufficient on two axes. (1) **Would they fail if the behavior regressed?** A test that passes but asserts the wrong thing — or restates the implementation instead of the contract (events fired, disposal reached, the world changed) — is worse than none. (2) **Do they exercise the REAL thing, the way it's actually used?** Prefer the genuine collaborator over a fake, drive the change through its real entry path (the cordis Loader, the ACP bridge, a booted subprocess — not a hand-built `ctx.plugin({...})` that bypasses `unwrapExports`), and verify the WORLD (re-read the file/log/registry externally), not the agent's self-report. A test that fakes the inputs just enough to cover every line will agree with whatever the author assumed; the real thing won't. When a test sets up a *clean/happy* path to reach a line, ask whether the line's PURPOSE is exercised — e.g. a durability/teardown path "tested" by a fully-completed turn never proves the mid-flight teardown it exists for; a torn-tail recovery branch covered by a well-formed log never proves recovery. Flag tests that hit the line but not the scenario. See [docs/testing.md](../../../docs/testing.md) § "Test the real entry path" and § "Prefer the real implementation over a mock".
|
||||
- **Snapshot coverage for transcript/UX changes.** If the PR changes the editor-facing transcript or end-to-end agent UX — the ACP bridge's event→update translation, the agent loop's observable output, tool presentation, or anything an editor renders — it must add or update a snapshot scenario (`examples/*/tests/**/*.snapshot.ts`, goldens under `examples/acp-agent/tests/snapshots/`) or note explicitly why none applies (AGENTS.md § Conventions). Review the golden diff itself: a changed `stdout.golden.txt` / `session.golden.txt` is a behavior change in disguise — confirm it's intended, not an accidental regression someone re-recorded away. A pure internal refactor with no observable-output change is exempt, but the PR should say so. See [docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md).
|
||||
- **Bilingual docs: review translation quality, not just pairing.** If the PR adds or edits a doc pair, read the changed English and Chinese sides and compare the meaning, not only the mechanical diff. Verify terms against [terminology.md](../../../docs/i18n/terminology.md), including first-occurrence annotations and "do not translate as" prohibitions; if a new term has no established precedent, the PR should keep it in English, list it under `待定术语`, and update the terminology table once the rendering is decided. A green `verify-translation-pairing` only proves hashes, switchers, and structure were recorded — it does not prove the translation is faithful, natural, or correctly termed. Treat [translation-rules.md](../../../docs/i18n/translation-rules.md) MUST/MUST NOT violations as blocking.
|
||||
|
||||
@@ -22,7 +22,7 @@ packages/ Harness packages at packages/<group>/<pkg>/, all named @deepseek-ai
|
||||
hooks/ Claude Code / Codex hook bridges + shared wire-protocol library
|
||||
session-persistence/ persistence seam + JSONL/SQLite backends
|
||||
ui/ ACP bridge + the stdio/ACP app packages (each with a bin)
|
||||
support/ dev/test infrastructure: invariants, ui-stdio, llm-replay, subagent-mock
|
||||
support/ dev/test infrastructure: invariants, llm-replay, subagent-mock
|
||||
util/ zero-dependency utilities (Branded<B>)
|
||||
examples/ Runnable demos: thin cordis.yml leaves over the app packages (see examples/AGENTS.md)
|
||||
docs/ architecture, generated catalogs, RFCs, postmortems, cookbook (see docs/AGENTS.md)
|
||||
@@ -89,6 +89,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY` (and optional `DEEPSEEK_BASE_UR
|
||||
- **Plugins, not loop changes**: new behavior goes on the documented extension seams; changing `agent-loop` requires updating docs/architecture.md.
|
||||
- **Capability seams are three packages** — interface / implementation / consumer ([capability seams](docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)); don't split preemptively.
|
||||
- **Explicit > implicit at package seams**: no optional field silently filled by a hidden `?? default` inside `run()`; defaulting is an explicit `resolve(request): Spec` step in the owning implementation (the `dsh-bash` request/spec split is the template).
|
||||
- **No hardcoded tunables in plugins**: anything two deployments could want different — timeouts, caps, grace periods, model names, base URLs — is a defaulted, validated `Config` field, not a literal; a `DEFAULT_*` constant or test-only seam is not configurability. The test: changeable from `cordis.yml`, no code edit. Protocol/wire constants, external-spec values, security invariants stay hardcoded.
|
||||
- **Opaque cross-boundary ids are branded** (`Branded<B>` from `dsh-brand`), never bare `string` ([branded IDs](docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)).
|
||||
- **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement.
|
||||
- **Symmetry is usually more correct**: parallel values get parallel form; asymmetry is a smell for a missed extraction.
|
||||
|
||||
@@ -59,7 +59,7 @@ Two seams bend the template deliberately:
|
||||
|
||||
## The vocabulary (dsh-llm)
|
||||
|
||||
Messages are arrays of typed **content blocks** (`text`, `reasoning`, `tool-call`, `tool-result`, `image`); the union derives from the merge-extensible `ContentBlockMap`; the same pattern types `MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`. Streaming is a raw chunk protocol (`block-start` … `finish`) with `BlockAssembler` as the single shared chunk→block assembler; the loop logs raw chunks (replay fidelity) while assembling them. `LlmAdapter` is the provider seam: subclass, implement `stream()`, register via `ctx.llm.registerAdapter(models, adapter)`; `dsh-llm-deepseek` and `dsh-llm-pi-ai` implement the one contract as deliberate design twins ([twin RFC](rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md)). The StreamChunk conventions (usage/finish ordering, raw-string tool arguments, the two sanctioned error paths) are pinned in `dsh-llm/src/types.ts` and [llm-streaming.md](core-data-structures/llm-streaming.md).
|
||||
Messages are arrays of typed **content blocks** (`text`, `reasoning`, `tool-call`, `tool-result`); the union derives from the merge-extensible `ContentBlockMap`; the same pattern types `MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`. The core set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map in the same coordinated change that maps it in the adapters, surfaces it in the UI bridges, and prices it in compaction ([the drop-image RFC](rfc/implemented/simplification/2026-07-04-drop-image-content-block.md)). Streaming is a raw chunk protocol (`block-start` … `finish`) with `BlockAssembler` as the single shared chunk→block assembler; the loop logs raw chunks (replay fidelity) while assembling them. `LlmAdapter` is the provider seam: subclass, implement `stream()`, register via `ctx.llm.registerAdapter(models, adapter)`; `dsh-llm-deepseek` and `dsh-llm-pi-ai` implement the one contract as deliberate design twins ([twin RFC](rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md)). The StreamChunk conventions (usage/finish ordering, raw-string tool arguments, the two sanctioned error paths) are pinned in `dsh-llm/src/types.ts` and [llm-streaming.md](core-data-structures/llm-streaming.md).
|
||||
|
||||
## Event-sourced sessions (dsh-session)
|
||||
|
||||
|
||||
@@ -315,18 +315,6 @@ Types: [ToolExecution](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:66`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
### `web/*`
|
||||
|
||||
#### `web/providers-change` — emit
|
||||
|
||||
Fired after the provider registry changes — a search or fetch provider was registered or disposed. Carries no payload and no capability graph: it means only "the provider registry changed; observers may recompute status from `ctx.web`". `searchStatus()` / `fetchStatus()` stay derived, not stored.
|
||||
|
||||
```ts cordis-catalog
|
||||
'web/providers-change'(this: WebService): void
|
||||
```
|
||||
|
||||
Source: [`packages/web/web/src/index.ts:65`](../../packages/web/web/src/index.ts)
|
||||
|
||||
## Services
|
||||
|
||||
The `ctx.<key>` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.
|
||||
@@ -531,25 +519,23 @@ Source: [`packages/core/tools/src/index.ts:268`](../../packages/core/tools/src/i
|
||||
|
||||
The web access service. Registered as `ctx.web` (one instance per context).
|
||||
|
||||
Selection semantics (identical for status and execution, never order- dependent):
|
||||
Selection semantics (resolved at execution time, never order-dependent):
|
||||
|
||||
- A configured id that is registered and `status().available` → that provider.
|
||||
- A configured id not registered → `configured-missing` / `WEB_PROVIDER_CONFIGURED_MISSING`.
|
||||
- A configured id registered but unavailable → `configured-unavailable` / `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.
|
||||
- A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`.
|
||||
- A configured id registered but unavailable → `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.
|
||||
- No id configured, exactly one registered usable provider → that provider.
|
||||
- No id configured, multiple usable providers → `ambiguous` / `WEB_PROVIDER_AMBIGUOUS`.
|
||||
- No id configured, no usable provider → `none` / `WEB_PROVIDER_UNAVAILABLE`.
|
||||
- No id configured, multiple usable providers → `WEB_PROVIDER_AMBIGUOUS`.
|
||||
- No id configured, no usable provider → `WEB_PROVIDER_UNAVAILABLE`.
|
||||
|
||||
```ts cordis-catalog
|
||||
registerSearchProvider(provider: WebSearchProvider): () => void
|
||||
registerFetchProvider(provider: WebFetchProvider): () => void
|
||||
searchStatus(): WebCapabilityStatus
|
||||
fetchStatus(): WebCapabilityStatus
|
||||
async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>
|
||||
async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>
|
||||
```
|
||||
|
||||
Source: [`packages/web/web/src/index.ts:105`](../../packages/web/web/src/index.ts)
|
||||
Source: [`packages/web/web/src/index.ts:87`](../../packages/web/web/src/index.ts)
|
||||
|
||||
## Inherited tier (cordis core + loader/hmr/timer)
|
||||
|
||||
|
||||
@@ -87,11 +87,10 @@ interface ContentBlockMap {
|
||||
'reasoning': ReasoningBlock
|
||||
'tool-call': ToolCallBlock
|
||||
'tool-result': ToolResultBlock
|
||||
'image': ImageBlock
|
||||
}
|
||||
```
|
||||
|
||||
The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ToolCallBlock` (`id: CallId`, `name`, raw-JSON `arguments`), `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`), `ImageBlock` (`url`, `mimeType?`). `ContentBlock = ContentBlockMap[ContentBlockType]`.
|
||||
The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ToolCallBlock` (`id: CallId`, `name`, raw-JSON `arguments`), `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. The core set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the merge-extensible map together with the adapter/UI/compaction support that honors it.
|
||||
|
||||
A `Message` is a role plus blocks:
|
||||
|
||||
|
||||
@@ -59,7 +59,6 @@ interface ContentBlockMap {
|
||||
'reasoning': ReasoningBlock
|
||||
'tool-call': ToolCallBlock
|
||||
'tool-result': ToolResultBlock
|
||||
'image': ImageBlock
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Search and fetch share no request schema and no business logic, but they are del
|
||||
|
||||
## Search request and result
|
||||
|
||||
The model-facing tool argument is just a `query`; `maxResults` is a consumer-owned bound (`dsh-tool-web`'s `WEB_SEARCH_MAX_RESULTS`, default `8`) passed through the seam and enforced on the way back — if a provider over-returns, the seam truncates `sources[]` and sets `truncated`.
|
||||
The model-facing tool argument is just a `query`; `maxResults` is a consumer-owned bound (`dsh-tool-web`'s `searchMaxResults` config, default `8`) passed through the seam and enforced on the way back — if a provider over-returns, the seam truncates `sources[]` and sets `truncated`.
|
||||
|
||||
```ts type-equiv
|
||||
interface WebSearchRequest {
|
||||
@@ -73,9 +73,9 @@ type WebFetchBody =
|
||||
| { readonly kind: 'text'; readonly content: string }
|
||||
```
|
||||
|
||||
## Provider and capability status
|
||||
## Provider status
|
||||
|
||||
A provider's `status()` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to selection, not a health system.
|
||||
A provider's `status()` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to execution-time selection, not a health system: `search()`/`fetch()` read it to pick a usable provider, and a selection failure surfaces as the structured `WebError` the caller routes on — which carries the branchable detail (the missing id, the ambiguous candidate set) in its code and message.
|
||||
|
||||
```ts type-equiv
|
||||
type WebProviderStatus =
|
||||
@@ -83,15 +83,7 @@ type WebProviderStatus =
|
||||
| { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' }
|
||||
```
|
||||
|
||||
The service aggregates provider status into a `WebCapabilityStatus`: whether the capability has a selected usable provider, or the broad category in which selection fails. It carries the winning `providerId` on the available branch but NOT the per-reason payload (the missing id, the ambiguous set) — that branchable detail lives in the thrown `WebError`, the surface callers route on, so the same fact never gets two homes that can disagree.
|
||||
|
||||
```ts type-equiv
|
||||
type WebCapabilityStatus =
|
||||
| { readonly available: true; readonly providerId: string }
|
||||
| { readonly available: false; readonly reason: 'none' | 'configured-missing' | 'configured-unavailable' | 'ambiguous' }
|
||||
```
|
||||
|
||||
Selection never depends on registration, config, or HMR order: a capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or the matching env var feeding the same field), or auto-selects when exactly one usable provider is registered; multiple usable providers with no configured id is `ambiguous`, not first-wins.
|
||||
Selection never depends on registration, config, or HMR order: a capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or the matching env var feeding the same field), or auto-selects when exactly one usable provider is registered; multiple usable providers with no configured id is `WEB_PROVIDER_AMBIGUOUS`, not first-wins.
|
||||
|
||||
## Errors
|
||||
|
||||
@@ -99,4 +91,4 @@ Selection never depends on registration, config, or HMR order: a capability has
|
||||
|
||||
## The service
|
||||
|
||||
`WebService` (`ctx.web`, defined in [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts)) is a provider registry plus a provider-selecting execution surface, close to `LlmService`'s shape: `registerSearchProvider`/`registerFetchProvider` (duplicate ids throw `WEB_DUPLICATE_PROVIDER`, return disposers, emit `web/providers-change`), `searchStatus`/`fetchStatus` (derived, never stored), and `search`/`fetch` (resolve the provider at call time, throw a structured `WebError` when the capability cannot run). Providers issue requests with the platform-native `fetch` (Node 24), mirroring `dsh-llm-deepseek`; the `dsh-web-fetch-local` provider owns safe retrieval (http/https-only, credential rejection, byte/char/timeout/redirect caps, same-origin-only redirects with per-hop re-validation, charset decoding) while `dsh-tool-web` owns presentation (HTML→markdown). SSRF / private-network blocking is deferred (see the RFC) — until it lands, `web_fetch` must not be enabled where it can reach sensitive internal targets.
|
||||
`WebService` (`ctx.web`, defined in [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts)) is a provider registry plus a provider-selecting execution surface, close to `LlmService`'s shape: `registerSearchProvider`/`registerFetchProvider` (duplicate ids throw `WEB_DUPLICATE_PROVIDER`, return disposers) and `search`/`fetch` (resolve the provider at call time, throw a structured `WebError` when the capability cannot run). Providers issue requests with the platform-native `fetch` (Node 24), mirroring `dsh-llm-deepseek`; the `dsh-web-fetch-local` provider owns safe retrieval (http/https-only, credential rejection, byte/char/timeout/redirect caps, same-origin-only redirects with per-hop re-validation, charset decoding) while `dsh-tool-web` owns presentation (HTML→markdown). SSRF / private-network blocking is deferred (see the RFC) — until it lands, `web_fetch` must not be enabled where it can reach sensitive internal targets.
|
||||
@@ -48,9 +48,6 @@ graph TD
|
||||
tools --> agent
|
||||
tools --> llm
|
||||
tools --> system-prompt
|
||||
ui-stdio --> agent
|
||||
ui-stdio --> llm
|
||||
ui-stdio --> session
|
||||
acp --> agent
|
||||
acp --> llm
|
||||
acp --> session
|
||||
@@ -119,9 +116,9 @@ graph TD
|
||||
acp-agent --> session-persistence-jsonl
|
||||
stdio-agent --> agent
|
||||
stdio-agent --> agent-core
|
||||
stdio-agent --> llm
|
||||
stdio-agent --> session
|
||||
stdio-agent --> session-persistence-jsonl
|
||||
stdio-agent --> ui-stdio
|
||||
subagent-fork --> agent
|
||||
subagent-fork --> session
|
||||
subagent-fork --> subagent
|
||||
@@ -158,7 +155,6 @@ graph TD
|
||||
| `session-persistence-jsonl` | `session`, `session-persistence` |
|
||||
| `session-persistence-sqlite` | `session`, `session-persistence` |
|
||||
| `tools` | `agent`, `llm`, `system-prompt` |
|
||||
| `ui-stdio` | `agent`, `llm`, `session` |
|
||||
| `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` |
|
||||
| `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` |
|
||||
| `hooks-codex` | `agent`, `hook-protocol`, `llm`, `session`, `tools` |
|
||||
@@ -174,6 +170,6 @@ graph TD
|
||||
| `subagent-mock` | `agent`, `llm`, `subagent` |
|
||||
| `tool-subagent` | `agent`, `llm`, `subagent`, `tools` |
|
||||
| `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` |
|
||||
| `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `ui-stdio` |
|
||||
| `stdio-agent` | `agent`, `agent-core`, `llm`, `session`, `session-persistence-jsonl` |
|
||||
| `subagent-fork` | `agent`, `session`, `subagent`, `subagent-inprocess` |
|
||||
| `subagent-spawn` | `subagent`, `subagent-inprocess` |
|
||||
+3
-3
@@ -52,10 +52,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
|
||||
| Title | First proposed |
|
||||
|---|---|
|
||||
| [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 |
|
||||
| [Drop the `image` content block until a path can honor it](proposed/simplification/2026-07-04-drop-image-content-block.md) | 2026-07-04 |
|
||||
| [Drop `GenerateOptions.prefill` and `ToolSchema.strict` — request knobs with no working end-to-end path](proposed/simplification/2026-07-04-drop-inert-request-knobs.md) | 2026-07-04 |
|
||||
| [Drop the unconsumed web observation surface — the `providers-change` event and the status methods](proposed/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) | 2026-07-04 |
|
||||
| [Fold the stdio UI helper into the stdio app](proposed/simplification/2026-07-04-fold-stdio-ui-helper.md) | 2026-07-04 |
|
||||
| [Prune dead core-spine surface — `SurfaceManager.invalidate()`, the loop-internal exports, `ToolExecutionResult.callId`](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 |
|
||||
| [Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger)](proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) | 2026-07-04 |
|
||||
| [Prune write-only fields and a dead routing knob from the fs seam](proposed/simplification/2026-07-04-prune-write-only-fs-surface.md) | 2026-07-04 |
|
||||
@@ -119,6 +116,9 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
|
||||
| [Stop mirroring durable boundaries as agent events](implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 |
|
||||
| [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 |
|
||||
| [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 |
|
||||
| [Drop the `image` content block until a path can honor it](implemented/simplification/2026-07-04-drop-image-content-block.md) | 2026-07-04 |
|
||||
| [Drop the unconsumed web observation surface — the `providers-change` event and the status methods](implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) | 2026-07-04 |
|
||||
| [Fold the stdio UI helper into the stdio app](implemented/simplification/2026-07-04-fold-stdio-ui-helper.md) | 2026-07-04 |
|
||||
| [Remove the `agent/steering` mirror emit](implemented/simplification/2026-07-04-remove-agent-steering-mirror.md) | 2026-07-04 |
|
||||
|
||||
### Architecture
|
||||
|
||||
@@ -10,12 +10,12 @@ The harness needs one internal language for messages that the loop, session log,
|
||||
|
||||
## Decision
|
||||
|
||||
Own it: messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`, `image`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs.
|
||||
Own it: messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs.
|
||||
|
||||
In-session context injection (`context/message`, `steering/message`) renders as tagged user-role envelopes (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Live-adapter review has since validated the tagged-envelope rendering against current DeepSeek behavior; a future provider-specific mismatch should be handled in that adapter rather than by adding a new role to the canonical content vocabulary.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Reasoning, prefill, cache hints, and multimodal content all have a home without provider contortions.
|
||||
- Reasoning, prefill, and cache hints have a home without provider contortions. Multimodal content deliberately has NO core block type: the core set is limited to blocks every shipping path honors, and a multimodal feature adds its block type through the merge-extensible map in the same coordinated change that maps it in the adapters, surfaces it in the UI bridges, and prices it in compaction — see [the drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md).
|
||||
- Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests.
|
||||
- IDs that cross package boundaries are branded (`CallId`, `SessionId`, `AgentId`) — nominal typing at zero runtime cost.
|
||||
@@ -22,7 +22,7 @@ Introduce web access as a first-class capability seam following [the capability-
|
||||
|
||||
Providers do not register tools. Providers register capabilities. `dsh-tool-web` is the only owner of model-facing names, descriptions, prompt guidance, JSON schemas, and presentation.
|
||||
|
||||
Search and fetch are separate capabilities and separate model-facing tools, but they are deliberately one seam. `ctx.web` is a single web-access middle layer between provider packages on one side and the tool consumer on the other: one service to inject, one provider-selection policy owner, one abort/error vocabulary, one place a product configures "how this harness reaches the web." The two halves do not share a request schema and have no shared business logic — search normalizes provider-backed discovery into a portable result with optional answer text and citeable sources, while fetch retrieves a concrete public HTTP(S) URL and returns a status code plus bounded decoded content — but they are parallel registries on one capability surface, not two surfaces. The cost is a `WebService` whose registry/status/exec methods come in `Search`/`Fetch` pairs; that parallelism is intentional, not a missed extraction. Splitting into `dsh-search` and `dsh-fetch` is the rejected alternative below.
|
||||
Search and fetch are separate capabilities and separate model-facing tools, but they are deliberately one seam. `ctx.web` is a single web-access middle layer between provider packages on one side and the tool consumer on the other: one service to inject, one provider-selection policy owner, one abort/error vocabulary, one place a product configures "how this harness reaches the web." The two halves do not share a request schema and have no shared business logic — search normalizes provider-backed discovery into a portable result with optional answer text and citeable sources, while fetch retrieves a concrete public HTTP(S) URL and returns a status code plus bounded decoded content — but they are parallel registries on one capability surface, not two surfaces. The cost is a `WebService` whose registry/exec methods come in `Search`/`Fetch` pairs; that parallelism is intentional, not a missed extraction. Splitting into `dsh-search` and `dsh-fetch` is the rejected alternative below.
|
||||
|
||||
`dsh-tool-web` should register model-facing web tools when the product has enabled those tools and the `ctx.web` seam is present. Backend availability is an execution-time concern, not a schema-registration concern:
|
||||
|
||||
@@ -33,11 +33,11 @@ Search and fetch are separate capabilities and separate model-facing tools, but
|
||||
|
||||
This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`.
|
||||
|
||||
The first version's provider-change signal is intentionally small. `web/providers-change` has no payload, carries no capability graph, and does not expose provider metadata. It means only "the provider registry changed; observers may recompute status from `ctx.web`." `searchStatus()` and `fetchStatus()` remain derived, not stored, and they are diagnostics plus execution-resolution inputs rather than tool-schema visibility switches.
|
||||
The seam deliberately exposes no observation surface — no registry-change event and no aggregated capability-status query. Unavailability is a fact a caller observes by executing: `search()`/`fetch()` resolve the provider at call time and throw the structured `WebError` that names what failed. [The observation-surface RFC](../simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) records that judgment: derived-on-call selection and enablement-based registration leave no consumer that needs a change signal or an availability probe distinct from executing and routing the error, and a future provider-status panel reintroduces the smallest signal or query it actually consumes.
|
||||
|
||||
## Package topology
|
||||
|
||||
The three-package interface/implementation/consumer split follows bash and filesystem, but the *interface* package is closer to the LLM seam. `LlmService` (`packages/llm/llm/src/index.ts`) is a name-keyed provider registry: `registerAdapter(models, adapter)` stores adapters in a `Map`, returns a disposer, throws `DUPLICATE_ADAPTER` on duplicate keys, and throws `NO_ADAPTER` at resolution time. `ctx.web` follows that registry shape, but has two capability kinds and one small selection-status layer so diagnostics and execution can explain why a search or fetch capability can or cannot run.
|
||||
The three-package interface/implementation/consumer split follows bash and filesystem, but the *interface* package is closer to the LLM seam. `LlmService` (`packages/llm/llm/src/index.ts`) is a name-keyed provider registry: `registerAdapter(models, adapter)` stores adapters in a `Map`, returns a disposer, throws `DUPLICATE_ADAPTER` on duplicate keys, and throws `NO_ADAPTER` at resolution time. `ctx.web` follows that registry shape, but has two capability kinds and a richer selection policy (a configured provider id, or auto-select when exactly one usable provider is registered), so the `WebError` an execution throws can explain why a search or fetch capability cannot run.
|
||||
|
||||
The dependency direction mirrors bash and filesystem:
|
||||
|
||||
@@ -52,7 +52,7 @@ The dependency direction mirrors bash and filesystem:
|
||||
implementation
|
||||
```
|
||||
|
||||
At runtime, provider packages register capabilities with `ctx.web`; `tool-web` reads capability status and registers stable tools with `ctx.tools`:
|
||||
At runtime, provider packages register capabilities with `ctx.web`; `tool-web` registers stable tools with `ctx.tools` and executes through the seam:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
@@ -60,12 +60,12 @@ flowchart LR
|
||||
perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web
|
||||
deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web
|
||||
fetchLocal["@deepseek-ai/dsh-web-fetch-local"] -->|registerFetchProvider| web
|
||||
toolWeb["@deepseek-ai/dsh-tool-web"] -->|searchStatus/fetchStatus| web
|
||||
toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web
|
||||
toolWeb -->|ctx.tools.register| webSearch["tool: web_search"]
|
||||
toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"]
|
||||
```
|
||||
|
||||
`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, status types, and error codes. It does not import tool, agent, session, LLM, or provider packages.
|
||||
`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider status type, and error codes. It does not import tool, agent, session, LLM, or provider packages.
|
||||
|
||||
Provider packages depend on `@deepseek-ai/dsh-web` and Cordis. They own credentials, endpoint config, provider-specific request mapping, provider-specific response parsing, and provider-specific error translation into `WebError`. They issue network requests with the platform-native `fetch` (Node 24), mirroring `@deepseek-ai/dsh-llm-deepseek`'s adapter, NOT a cordis HTTP-client service (`ctx.http`/`@cordisjs/plugin-http`) — even where a Perplexity provider's request is shaped like an OpenAI-compatible chat completion, that wire shape is a provider-private detail and does not make the provider depend on `ctx.llm`. A provider does NOT own the `ctx.web` key (two search providers cannot both own it): like `dsh-llm-deepseek`, each provider package is a function/namespace plugin (`inject: ['web']`) whose `apply` constructs the backend and calls `ctx.web.registerSearchProvider` / `registerFetchProvider`. `@deepseek-ai/dsh-web` is the `export default` service that owns the key.
|
||||
|
||||
@@ -92,9 +92,6 @@ interface WebService {
|
||||
registerSearchProvider(provider: WebSearchProvider): () => void
|
||||
registerFetchProvider(provider: WebFetchProvider): () => void
|
||||
|
||||
searchStatus(): WebCapabilityStatus
|
||||
fetchStatus(): WebCapabilityStatus
|
||||
|
||||
search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>
|
||||
fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>
|
||||
}
|
||||
@@ -106,39 +103,33 @@ interface WebExecContext {
|
||||
|
||||
`WebExecContext` is execution control, not business input. The first version should carry only `signal` so `tool-web` can propagate turn cancellation, tool timeout, and agent disposal into provider network requests, SSE readers, and expensive decoding. It should not pass `ToolExecution` through the seam, because that would make `dsh-web` depend on `dsh-tools`.
|
||||
|
||||
`@deepseek-ai/dsh-web` should also declare a Cordis event named `web/providers-change`. Provider ids are stable strings and unique within their capability kind. Registering a duplicate search provider id or duplicate fetch provider id should fail rather than silently replace the old provider. Provider registration returns a disposer, emits `web/providers-change` after successful registration, and emits it again when the provider is disposed. The registry should follow the existing `ctx.tools.register()` / `ctx.systemPrompt.section()` pattern: wrap the mutation in `ctx.effect()`, install the rollback disposer before emitting `web/providers-change`, and let a throwing registration-time change listener roll back the just-added provider instead of leaking it into the registry.
|
||||
Provider ids are stable strings and unique within their capability kind. Registering a duplicate search provider id or duplicate fetch provider id should fail rather than silently replace the old provider. Provider registration returns a disposer and follows the existing `ctx.tools.register()` / `ctx.systemPrompt.section()` pattern: wrap the mutation in `ctx.effect()` so the registration is torn down with the contributing fiber.
|
||||
|
||||
## Provider status and selection
|
||||
|
||||
Provider status and capability selection are separate concepts, but both stay minimal. A provider reports only whether that concrete implementation is usable by cheap local checks such as credential presence or parseable endpoint config. A provider `status()` must not make network calls. The service reports whether the capability has a selected usable provider, or why execution would fail.
|
||||
Provider status and capability selection are separate concepts, but both stay minimal. A provider reports only whether that concrete implementation is usable by cheap local checks such as credential presence or parseable endpoint config. A provider `status()` must not make network calls.
|
||||
|
||||
`LlmService` has no status type at all: availability is expressed as registry membership plus a resolution-time throw. `ctx.web` needs a small status answer because product apps, diagnostics, tests, and execution can report precise provider-selection failures without probing individual providers from the tool layer. Status must be derived from the configured provider id, registered providers, and each provider's cheap local `status()` on each call; it must not be stored as mutable service state.
|
||||
`LlmService` has no status type at all: availability is expressed as registry membership plus a resolution-time throw. `ctx.web` follows the same discipline. The seam exposes no aggregated capability-status query — `search()` / `fetch()` derive the selection on each call from the configured provider id, the registered providers, and each provider's cheap local `status()`, and a selection failure is the structured `WebError` thrown at execution time, whose code answers "in which broad category does this capability fail" and whose message answers "exactly which provider/ids/reason." A caller that needs to know whether a capability can run executes and routes that error; nothing is stored as mutable service state.
|
||||
|
||||
`WebCapabilityStatus` stays intentionally small: `available` plus a `reason` discriminant, and the selected `providerId` on the available branch so diagnostics can report which provider won. It does NOT carry the per-reason payload (the unavailable provider id, the ambiguous candidate set, the underlying provider-unavailable reason). That branchable detail lives in the structured `WebError` thrown at execution time, which is the surface callers route on; duplicating it into the status union would give the same fact two homes that can disagree. `searchStatus()` / `fetchStatus()` answer "can this capability run, and if not, in which broad category does it fail" — enough for startup diagnostics and the execution-resolution decision — and the thrown error answers "exactly which provider/ids/reason."
|
||||
|
||||
`WebProviderStatus` is an input to selection, not a health system. `tool-web` reads only the aggregated `searchStatus()` / `fetchStatus()`, never each provider's `status()` directly, so selection policy has one owner.
|
||||
`WebProviderStatus` is an input to selection, not a health system. `tool-web` never calls a provider's `status()` directly — its only path into the seam is `search()` / `fetch()` — so selection policy has one owner.
|
||||
|
||||
```ts
|
||||
type WebProviderStatus =
|
||||
| { readonly available: true }
|
||||
| { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' }
|
||||
|
||||
type WebCapabilityStatus =
|
||||
| { readonly available: true; readonly providerId: string }
|
||||
| { readonly available: false; readonly reason: 'none' | 'configured-missing' | 'configured-unavailable' | 'ambiguous' }
|
||||
```
|
||||
|
||||
Selection must not depend on registration order. Cordis load order, config ordering, and HMR timing are not product semantics.
|
||||
|
||||
| Situation | Status / behavior |
|
||||
| Situation | Execution behavior |
|
||||
|---|---|
|
||||
| A configured provider id is registered and `status().available === true` | `available: true` for that provider |
|
||||
| A configured provider id is not registered | `configured-missing`; execution fails with `WEB_PROVIDER_CONFIGURED_MISSING` |
|
||||
| A configured provider id is registered but unavailable | `configured-unavailable`; execution fails with `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` |
|
||||
| No provider id is configured and exactly one provider for that kind is registered and available | `available: true` for that single provider |
|
||||
| No provider id is configured and no provider for that kind is registered | `none`; execution fails with `WEB_PROVIDER_UNAVAILABLE` |
|
||||
| No provider id is configured and multiple usable providers for that kind are registered | `ambiguous`; execution fails with `WEB_PROVIDER_AMBIGUOUS` rather than choosing by registration order |
|
||||
| No provider id is configured and providers exist but none are usable | `none`; execution fails with `WEB_PROVIDER_UNAVAILABLE` |
|
||||
| A configured provider id is registered and `status().available === true` | runs that provider |
|
||||
| A configured provider id is not registered | fails with `WEB_PROVIDER_CONFIGURED_MISSING` |
|
||||
| A configured provider id is registered but unavailable | fails with `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` |
|
||||
| No provider id is configured and exactly one provider for that kind is registered and available | runs that single provider |
|
||||
| No provider id is configured and no provider for that kind is registered | fails with `WEB_PROVIDER_UNAVAILABLE` |
|
||||
| No provider id is configured and multiple usable providers for that kind are registered | fails with `WEB_PROVIDER_AMBIGUOUS` rather than choosing by registration order |
|
||||
| No provider id is configured and providers exist but none are usable | fails with `WEB_PROVIDER_UNAVAILABLE` |
|
||||
|
||||
The "single provider auto-selects" rule is for tests, demos, and simple deployments. Product configs should set explicit provider ids:
|
||||
|
||||
@@ -167,7 +158,7 @@ The "single provider auto-selects" rule is for tests, demos, and simple deployme
|
||||
|
||||
Operational overrides such as environment variables may exist, but they must feed the same explicit selection path. For example, `DSH_WEB_SEARCH_PROVIDER=perplexity` is equivalent to config `searchProvider: perplexity`; it is not a hidden priority chain inside `dsh-tool-web`.
|
||||
|
||||
`ctx.web.search()` and `ctx.web.fetch()` resolve the provider at execution time using the same rules as the status query. If the selected capability is unavailable, they throw `WebError` with a structured code such as `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, or `WEB_PROVIDER_AMBIGUOUS`. If no provider is explicitly configured and no usable provider exists, the status and execution error are both the generic `none` / `WEB_PROVIDER_UNAVAILABLE` case; the first version should not add a diagnostic summary of every unavailable provider.
|
||||
`ctx.web.search()` and `ctx.web.fetch()` resolve the provider at execution time using the selection rules above. If the selected capability is unavailable, they throw `WebError` with a structured code such as `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, or `WEB_PROVIDER_AMBIGUOUS`. If no provider is explicitly configured and no usable provider exists, the execution error is the generic `WEB_PROVIDER_UNAVAILABLE` case; the first version should not add a diagnostic summary of every unavailable provider.
|
||||
|
||||
## Search request and result schema
|
||||
|
||||
@@ -175,7 +166,7 @@ The first `web_search` model-facing tool should be small. The only model-facing
|
||||
|
||||
- `query`: required string.
|
||||
|
||||
`max_results` is NOT exposed to the model in the first version. It is a `dsh-tool-web`-layer decision: the tool sets the result bound — a default of `8` (aligning with OpenCode's Exa default), as an exported constant mirroring `dsh-tool-fs`'s `READ_LIMIT` / `GREP_LIMIT` — and passes it to the seam as `maxResults` on the `WebSearchRequest`. Keeping it off the model schema means the model just asks a question and the product controls how much context comes back; the field can be promoted to a model-facing argument later without breaking the seam.
|
||||
`max_results` is NOT exposed to the model in the first version. It is a `dsh-tool-web`-layer decision: the tool sets the result bound — the `searchMaxResults` plugin config, default `8` (aligning with OpenCode's Exa default), mirroring `dsh-tool-fs`'s `readLimit` — and passes it to the seam as `maxResults` on the `WebSearchRequest`. Keeping it off the model schema means the model just asks a question and the product controls how much context comes back; the field can be promoted to a model-facing argument later without breaking the seam.
|
||||
|
||||
`maxResults` flows tool → seam → provider, and the bound is enforced on the way back:
|
||||
|
||||
@@ -269,14 +260,14 @@ SSRF / private-network protection (blocking private, loopback, link-local, multi
|
||||
|
||||
`dsh-tool-web` owns two `ToolDefinition`s: `web_search` and `web_fetch`. It owns model-facing JSON schemas, snake_case argument names, prompt sections, result rendering to `ContentBlock[]`, `presentCall`, and `presentResult`.
|
||||
|
||||
`dsh-tool-web` must not enumerate providers or call provider `status()` directly. Its execution path is `ctx.web.search()` / `ctx.web.fetch()`, and any optional startup diagnostics should read only `ctx.web.searchStatus()` / `ctx.web.fetchStatus()`. That keeps provider selection in one layer; otherwise the tool package could decide one provider is usable while execution resolves a different state.
|
||||
`dsh-tool-web` must not enumerate providers or call provider `status()` directly. Its only path into the seam is `ctx.web.search()` / `ctx.web.fetch()`. That keeps provider selection in one layer; otherwise the tool package could decide one provider is usable while execution resolves a different state.
|
||||
|
||||
Tool registration in the first version is a minimal stable sync:
|
||||
|
||||
1. On plugin startup, read the `dsh-tool-web` `Config` (`search?: boolean`, `fetch?: boolean`, both default `true`) that enables or disables each web tool.
|
||||
2. If web search is enabled, register `web_search` (its disposer is fiber-scoped via the effect-based registry).
|
||||
3. If web fetch is enabled, register `web_fetch` (likewise fiber-scoped).
|
||||
4. Do not dispose either tool merely because `ctx.web.searchStatus()` or `ctx.web.fetchStatus()` is unavailable.
|
||||
4. Do not dispose either tool merely because its selected provider is missing, unusable, or ambiguous.
|
||||
5. Disposing the `tool-web` fiber tears down its registrations automatically.
|
||||
|
||||
Provider status changes affect execution results and diagnostics, not whether the model-facing schema exists. If a product wants no web tools at all, it disables `dsh-tool-web` or the individual web tool in config; if it wants web tools but the backend is misconfigured, the model sees a structured tool error at execution time.
|
||||
@@ -311,7 +302,7 @@ Tool execution should let these errors flow through `ToolRegistry.execute()`, wh
|
||||
|
||||
Tests should prove the seam contract without turning this RFC into an implementation checklist.
|
||||
|
||||
`dsh-web` tests cover provider registration and disposal, duplicate provider ids, `web/providers-change` emission, rollback when a registration-time `web/providers-change` listener throws, `searchStatus()` and `fetchStatus()` for the selection table above, execution-time provider resolution, `maxResults` truncation of `sources[]` with `truncated` set when a provider over-returns, abort propagation through `WebExecContext.signal`, and structured `WebError` codes.
|
||||
`dsh-web` tests cover provider registration and disposal (proved through execution behavior — a registered provider serves `search()`/`fetch()`, a disposed one no longer resolves), duplicate provider ids, the selection table above exercised through execution-time provider resolution, `maxResults` truncation of `sources[]` with `truncated` set when a provider over-returns, abort propagation through `WebExecContext.signal`, and structured `WebError` codes.
|
||||
|
||||
Search provider tests cover request mapping, response parsing into `content` plus `sources[]`, missing credentials, provider errors, timeout/abort, truncation, and a self-skipping with-key smoke test for each real provider. Perplexity fixtures must include URL-only citations so the optional source fields stay honest.
|
||||
|
||||
@@ -329,7 +320,7 @@ This is new capability work, so no compatibility migration is required while the
|
||||
|
||||
Land the work in seam order:
|
||||
|
||||
1. Add `packages/web/web` with `ctx.web`, provider registration, provider status, capability status, selection, request/result/error types, and contract tests.
|
||||
1. Add `packages/web/web` with `ctx.web`, provider registration, provider status, selection, request/result/error types, and contract tests.
|
||||
2. Add `packages/web/web-search-exa` with parser/unit tests and a self-skipping real-provider smoke test.
|
||||
3. Add `packages/web/web-search-perplexity` with parser/unit tests and a self-skipping real-provider smoke test.
|
||||
4. Add `packages/web/web-search-deepseek` with parser/unit tests and a self-skipping real-provider smoke test.
|
||||
@@ -370,7 +361,7 @@ Rejected for the seam. `prompt` turns fetch into LLM summarization and couples p
|
||||
|
||||
**Perplexity citations may be sparse.** A citation may be only a URL. Making `title` and `snippet` optional keeps the seam truthful but means `tool-web` must render useful fallback labels.
|
||||
|
||||
**Stable tool registration can defer misconfiguration to execution.** Keeping the tool visible is correct when the product enabled web access, but product apps that expect web search should surface `configured-missing`, `configured-unavailable`, and `ambiguous` loudly during startup diagnostics so users do not discover setup problems only after the model calls the tool.
|
||||
**Stable tool registration can defer misconfiguration to execution.** Keeping the tool visible is correct when the product enabled web access, but product apps that expect web search should surface the structured `WEB_PROVIDER_CONFIGURED_MISSING` / `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` / `WEB_PROVIDER_AMBIGUOUS` failures loudly so users do not discover setup problems only after the model calls the tool.
|
||||
|
||||
**Provider state can change after startup.** A tool can be visible in the request assembled at step start and lose its provider before execution. The execution path must resolve again and fail with a structured error.
|
||||
|
||||
@@ -388,5 +379,5 @@ Rejected for the seam. `prompt` turns fetch into LLM summarization and couples p
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should product app packages treat `configured-missing`, `configured-unavailable`, and `ambiguous` as fatal startup errors when web is explicitly configured, or should `dsh-web` only report status and let apps decide?
|
||||
- Should product app packages probe web configuration at startup (treating `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, and `WEB_PROVIDER_AMBIGUOUS` as fatal when web is explicitly configured), or leave misconfiguration to surface at the first execution?
|
||||
- Where should permission policy for public web access live once the deferred permission system lands: a dedicated web permission plugin on `tools/execute`, provider config, or both?
|
||||
@@ -17,7 +17,7 @@ Two forces shape the design. First, compaction is **swappable**: token counting
|
||||
Per the [capability-seams RFC](../../implemented/architecture/2026-06-13-capability-seams.md), compaction ships as separate packages so the contract, the algorithm, and (later) the consumer surface evolve independently:
|
||||
|
||||
1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, and the `compact/*` session events. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*.
|
||||
2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that owns the entire algorithm — token estimation (char/4 + per-block overhead), the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, and the `agent/pre-step` auto-compaction listener. A tokenizer-based or template-based backend is a sibling package (or a subclass overriding the two protected estimation/summarization hooks).
|
||||
2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that owns the entire algorithm — token estimation (chars per token — the `charsPerToken` config, default 4 — + per-block overhead), the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, and the `agent/pre-step` auto-compaction listener. A tokenizer-based or template-based backend is a sibling package (or a subclass overriding the two protected estimation/summarization hooks).
|
||||
3. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first.
|
||||
|
||||
### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# RFC: Drop the `image` content block until a path can honor it
|
||||
|
||||
Status: implemented (proposed and accepted 2026-07-04)
|
||||
|
||||
## Problem
|
||||
|
||||
`ImageBlock` (`packages/llm/llm/src/types.ts`) had no production producer, and every consumer on every path DROPPED it: the deepseek adapter's serializer skipped image blocks (a documented MVP limitation), the pi-ai converter skipped them as unrepresentable, the ACP codec neither advertises image prompt capability nor forwarded image blocks outbound and REJECTS image prompt content inbound, and the compaction estimator charged a flat token constant and rendered `[image]`. An `ImageBlock` constructed then would silently vanish from the wire — the vocabulary advertised a capability no path honored, which is the silent-data-loss shape AGENTS.md's defensive patterns warn against. The only constructors anywhere were tests pinning the skip/drop/estimate branches.
|
||||
|
||||
## Decision
|
||||
|
||||
Remove `ImageBlock`, its `ContentBlockMap` entry (and its `cache?: CacheHint` field with it), the explicit `image` estimate/placeholder arms in compact-basic, and the image-naming comments in the deepseek serializer's, pi-ai converter's, and ACP codec's default arms — those default arms absorb the case the way they absorb any unknown block type. Updated in the same change: the vocabulary line in [architecture.md](../../../architecture.md), the block list in `packages/llm/llm/README.md`, the deepseek README's image-skip row, the pi-ai README's images-not-representable row, the compact-basic README's image-estimation and `[image]`-placeholder rows, the pastes in [core.md](../../../core-data-structures/core.md) and [llm-streaming.md](../../../core-data-structures/llm-streaming.md), and the [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md)'s block list and multimodal-home consequence per [implemented/AGENTS.md](../AGENTS.md); the tests that constructed image blocks to exercise the removed branches were dropped (the estimate pin) or retargeted onto the merge-extensible default arms (plugin-added block types). The ACP codec's inbound rejection of image PROMPT content is unaffected — that guard is about protocol content a client can send regardless of our vocabulary, and it stays.
|
||||
|
||||
## Why not keep it?
|
||||
|
||||
This was the most contested cut in the batch. Multimodal input (screenshots) is a plausible near-term coding-agent feature, and the [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md) reserved the slot deliberately. Two responses. First, `ContentBlockMap` is merge-extensible by design: a real multimodal feature reintroduces `image` in core in the same coordinated change that maps it in the adapters, advertises and renders it in ACP, and prices it in compaction — the producer and its consumers arrive together, which is how the map is meant to grow. Second, the middle option — keep the type but make adapters throw UNSUPPORTED instead of silently dropping — converts this into exactly the shape the sibling request-knobs proposal (`2026-07-04-drop-inert-request-knobs`) argues against: surface whose only implementation is rejection. Absence (a compile error at the would-be producer) is strictly clearer than either silent loss or universal throw.
|
||||
|
||||
The recorded fallback, had review landed on keeping the slot: keep `ImageBlock` but replace every silent skip with a loud rejection, and document that policy in the vocabulary — the silent drop was the one state with no defender. Review landed on removal; the fallback stands as the documented alternative should the slot ever return ahead of a full feature.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- No `ImageBlock` / harness `type: 'image'` block construction outside this RFC; the codec's inbound ACP-image rejection still passes its tests.
|
||||
- Adapter/codec/compaction switches handle the case through their unknown-block default arms (pinned by the plugin-added-block tests).
|
||||
- Doc pastes, the manifest, and the architecture vocabulary list updated; `pnpm run doc-sync` green.
|
||||
|
||||
## Risks
|
||||
|
||||
Re-adding a core vocabulary type later touches several packages at once — but that coordinated change is the shape a real multimodal feature needs anyway (adapter mapping, ACP advertisement, compaction pricing), and none of it existed to preserve.
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
# RFC: Drop the unconsumed web observation surface — the `providers-change` event and the status methods
|
||||
|
||||
Status: proposed
|
||||
Status: implemented (proposed and accepted 2026-07-04)
|
||||
|
||||
## Problem
|
||||
|
||||
`WebService` exposes an observation surface no production code observes:
|
||||
|
||||
- **`web/providers-change`** (`packages/web/web/src/index.ts`) is declared and emitted on every provider registration and disposal, and each registration effect's rollback yield is ordered BEFORE the emit solely so a throwing change listener unwinds the registration. No listener exists outside the package's own two unit tests (one of which exists to pin that rollback ordering).
|
||||
- **`searchStatus()` / `fetchStatus()` and the `WebCapabilityStatus` union** (same package) have zero production callers: `dsh-tool-web` executes directly through `ctx.web.search()`/`fetch()` and surfaces unavailability as the structured `WebError` codes the seam throws at execution time (`packages/web/tool-web/src/search.ts`, `packages/web/tool-web/src/fetch.ts`); the only status callers are the web packages' own tests. The prose in `packages/web/tool-web/README.md` and [architecture.md](../../../architecture.md) still claims the tool "reads only the aggregated `searchStatus()`/`fetchStatus()`" — drift that survives only because nothing checks prose against call sites.
|
||||
- **`searchStatus()` / `fetchStatus()` and the `WebCapabilityStatus` union** (same package) have zero production callers: `dsh-tool-web` executes directly through `ctx.web.search()`/`fetch()` and surfaces unavailability as the structured `WebError` codes the seam throws at execution time (`packages/web/tool-web/src/search.ts`, `packages/web/tool-web/src/fetch.ts`); the only status callers are the web packages' own tests. The prose in `packages/web/tool-web/README.md` and [architecture.md](../../../architecture.md) claims the tool "reads only the aggregated `searchStatus()`/`fetchStatus()`" — drift that survives only because nothing checks prose against call sites.
|
||||
|
||||
The seam's own design starves both surfaces of consumers: tool registration follows product ENABLEMENT, not provider availability (`packages/web/tool-web/src/index.ts`), and provider selection resolves at execution time, never cached — so there is no cache to invalidate, no registration set to recompute, and no caller that needs an availability probe distinct from executing and routing the structured error. HMR cleanup is carried by the effect disposers themselves.
|
||||
|
||||
@@ -15,7 +15,7 @@ This mirrors [drop the unconsumed `llm/adapter-change` event](../../implemented/
|
||||
|
||||
## Proposal
|
||||
|
||||
Delete the event declaration, both emits, and the rollback-before-emit ordering (the plain `ctx.effect` disposer keeps HMR cleanup). Delete `searchStatus()`/`fetchStatus()`/`WebCapabilityStatus` — the provider-private `status()` stays, since it feeds execution-time selection. Delete the two event tests and rewrite the status-based test assertions onto the behavior a real caller observes (a successful `search()`/`fetch()`, or the structured `WebError` codes for unavailable/ambiguous/misconfigured provider sets). Run `pnpm run gen-cordis-catalog`; update `packages/web/web/README.md`, `packages/web/tool-web/README.md` (the drifted reads-status sentence), [web.md](../../../core-data-structures/web.md), and the web paragraph in [architecture.md](../../../architecture.md). The implementing PR amends the [web capability seam RFC](../../implemented/architecture/2026-06-24-web-capability-seam.md)'s facts (it specifies the event and the status aggregation) per [implemented/AGENTS.md](../../implemented/AGENTS.md).
|
||||
Delete the event declaration, both emits, and the rollback-before-emit ordering (the plain `ctx.effect` disposer keeps HMR cleanup). Delete `searchStatus()`/`fetchStatus()`/`WebCapabilityStatus` — the provider-private `status()` stays, since it feeds execution-time selection. Delete the listener-throw rollback test that exists solely for the removed event, and rewrite the emission assertions and every status-based assertion onto the behavior a real caller observes (a successful `search()`/`fetch()`, or the structured `WebError` codes for unavailable/ambiguous/misconfigured provider sets). Run `pnpm run gen-cordis-catalog`; update `packages/web/web/README.md`, `packages/web/tool-web/README.md` (the drifted reads-status sentence), [web.md](../../../core-data-structures/web.md), and the web paragraph in [architecture.md](../../../architecture.md). Amend the [web capability seam RFC](../../implemented/architecture/2026-06-24-web-capability-seam.md)'s facts (it specified the event and the status aggregation) per [implemented/AGENTS.md](../AGENTS.md).
|
||||
|
||||
## Why not keep it?
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# RFC: Fold the stdio UI helper into the stdio app
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The readline UI was a whole package (`@deepseek-ai/dsh-ui-stdio` under `packages/support/`) whose only runtime importer was the app package `@deepseek-ai/dsh-stdio-agent`. The examples reach the readline UI by loading the app, never by composing the helper themselves; every other repo reference was mechanical or descriptive surface that existed BECAUSE the package boundary existed — manifest and tsconfig entries, generated module-graph rows, dependency-graph and README rows, and doc comments naming the package. The ui group README recorded the support placement rationale ("exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product"), which left a standing tension: a shipped product app depending on a support package documented as NOT product surface.
|
||||
|
||||
The boundary bought package metadata, workspace and tsconfig references, module-graph rows, README entries, and publint surface for a helper that is not independently swappable: the stdio app's front-door cluster always includes the readline UI, and nothing else can meaningfully consume it.
|
||||
|
||||
## Decision
|
||||
|
||||
The helper lives inside `@deepseek-ai/dsh-stdio-agent` as the in-package `stdio-chat` module (`packages/ui/stdio-agent/src/stdio-chat.ts`): `createStdioChat`, its `StdioRuntime` test seam, and its unit tests (`packages/ui/stdio-agent/tests/stdio-chat.spec.ts`, `readline.spec.ts`) moved with it, so EOF handling, rendering, disposal, and piped-vs-TTY behavior stay unit-covered under the per-file coverage gate without hijacking process globals. The module keeps the named `name`/`inject`/`Config`/`apply` export shape — the contract the app's `ctx.plugin(uiStdio, …)` mount consumes — and the keyless Loader-path smokes in `examples/echo-agent` and `examples/coding-agent` keep proving the composed tree boots through the real Loader (the app's export SHAPE is pinned by the stdio-agent unit suite's explicit `unwrapExports` assertion, since a bundle without `inject` would boot past a stray default rather than crash).
|
||||
|
||||
The `packages/support/ui-stdio` package is gone: manifest, tsconfig references, module-graph rows, and README rows deleted; the doc comments that named the package (the example e2e module docs, `packages/README.md`, the support and todo READMEs, [the ui group README](../../../../packages/ui/README.md)) describe the in-package module.
|
||||
|
||||
## Why not promote it to `ui/` instead?
|
||||
|
||||
Promotion would have resolved the support-vs-product mismatch while keeping the boundary — the right call only if the readline UI were an independently swappable integration or had a second composer, and the consumer census said neither. The structured ACP bridge stays its own package because it is the product protocol surface with its own contract and snapshot tiers; the readline helper is scaffolding for one app's front door. Re-extraction stays cheap pre-release: if a second product app wants the readline UI, split it back out then, with that consumer shaping the package contract.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The stdio app owns its whole front door; a leaf `cordis.yml` still loads one app package and nothing changed shape for the demos.
|
||||
- A future standalone terminal UI that wants the helper as a package reintroduces it with that second consumer, rather than the repo keeping a boundary for hypothetical reuse.
|
||||
@@ -1,27 +0,0 @@
|
||||
# RFC: Drop the `image` content block until a path can honor it
|
||||
|
||||
Status: proposed
|
||||
|
||||
## Problem
|
||||
|
||||
`ImageBlock` (`packages/llm/llm/src/types.ts`) has no production producer, and every consumer on every path DROPS it: the deepseek adapter's serializer skips image blocks (a documented MVP limitation), the pi-ai converter skips them as unrepresentable, the ACP codec neither advertises image prompt capability nor forwards image blocks outbound and REJECTS image prompt content inbound, and the compaction estimator charges a flat token constant and renders `[image]`. An `ImageBlock` constructed today would silently vanish from the wire — the vocabulary advertises a capability no path honors, which is the silent-data-loss shape AGENTS.md's defensive patterns warn against. The only constructors anywhere are tests pinning the skip/drop/estimate branches.
|
||||
|
||||
## Proposal
|
||||
|
||||
Remove `ImageBlock`, its `ContentBlockMap` entry, the explicit `image` estimate/placeholder arms in compact-basic, and the image-naming comments in the deepseek serializer's, pi-ai converter's, and ACP codec's default arms — those default arms already absorb the case the way they absorb any unknown block type. Update the vocabulary line in [architecture.md](../../../architecture.md), the block list in `packages/llm/llm/README.md`, the deepseek README's image-skip row, the pi-ai README's images-not-representable row, the compact-basic README's image-estimation and `[image]`-placeholder rows, the pastes in [core.md](../../../core-data-structures/core.md) and [llm-streaming.md](../../../core-data-structures/llm-streaming.md), and the type-equiv manifest; amend the [content-block vocabulary RFC](../../implemented/architecture/2026-06-11-content-block-vocabulary.md)'s block list and multimodal-home consequence per [implemented/AGENTS.md](../../implemented/AGENTS.md); drop or retarget the tests that construct image blocks to exercise the removed branches. The ACP codec's inbound rejection of image PROMPT content is unaffected — that guard is about protocol content a client can send regardless of our vocabulary, and it stays.
|
||||
|
||||
## Why not keep it?
|
||||
|
||||
This is the most contested cut in the batch. Multimodal input (screenshots) is a plausible near-term coding-agent feature, and the [content-block vocabulary RFC](../../implemented/architecture/2026-06-11-content-block-vocabulary.md) reserved the slot deliberately. Two responses. First, `ContentBlockMap` is merge-extensible by design: a real multimodal feature reintroduces `image` in core in the same coordinated change that maps it in the adapters, advertises and renders it in ACP, and prices it in compaction — the producer and its consumers arrive together, which is how the map is meant to grow. Second, the middle option — keep the type but make adapters throw UNSUPPORTED instead of silently dropping — converts this into exactly the shape the [request-knobs RFC](2026-07-04-drop-inert-request-knobs.md) argues against: surface whose only implementation is rejection. Absence (a compile error at the would-be producer) is strictly clearer than either silent loss or universal throw.
|
||||
|
||||
If review lands on keeping the slot, the fallback this RFC records is: keep `ImageBlock` but replace every silent skip with a loud rejection, and document that policy in the vocabulary — the current silent drop is the one state with no defender.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- No `ImageBlock` / harness `type: 'image'` block construction outside this RFC; the codec's inbound ACP-image rejection still passes its tests.
|
||||
- Adapter/codec/compaction switches handle the case through their unknown-block default arms (pinned by the existing plugin-added-block tests where present).
|
||||
- Doc pastes, the manifest, and the architecture vocabulary list updated; `pnpm run doc-sync` green.
|
||||
|
||||
## Risks
|
||||
|
||||
Re-adding a core vocabulary type later touches several packages at once — but that coordinated change is the shape a real multimodal feature needs anyway (adapter mapping, ACP advertisement, compaction pricing), and none of it exists today to preserve.
|
||||
@@ -1,27 +0,0 @@
|
||||
# RFC: Fold the stdio UI helper into the stdio app
|
||||
|
||||
Status: proposed
|
||||
|
||||
## Problem
|
||||
|
||||
`@deepseek-ai/dsh-ui-stdio` is a whole package whose only runtime importer is the app package `@deepseek-ai/dsh-stdio-agent` (`packages/ui/stdio-agent/src/index.ts`). The examples reach the readline UI by loading the app, never by composing the helper themselves; every other repo reference is mechanical or descriptive surface that exists BECAUSE the package boundary exists — manifest and tsconfig entries, generated module-graph rows, dependency-graph and README rows, and doc comments naming the package. [The ui group README](../../../../packages/ui/README.md) records the placement rationale — the helper "exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product" — which leaves a standing tension: a shipped product app depends on a support package documented as NOT product surface.
|
||||
|
||||
The boundary buys package metadata, workspace and tsconfig references, module-graph rows, README entries, and publint surface for a helper that is not independently swappable: the stdio app's front-door cluster always includes the readline UI, and nothing else can meaningfully consume it.
|
||||
|
||||
## Proposal
|
||||
|
||||
Fold the helper into `@deepseek-ai/dsh-stdio-agent`: move `createStdioChat`, its `StdioRuntime` test seam, and its unit tests into `packages/ui/stdio-agent`; delete the `packages/support/ui-stdio` package with its manifest, references, module-graph rows, and README rows; update every reference that names the package (the example e2e module docs, `packages/README.md`, the support and todo README rows, the stdio-agent README, the ui group README, tsconfig references, the generated module graph). Keep the runtime seam so EOF handling, rendering, disposal, and piped-vs-TTY behavior stay unit-covered without hijacking process globals; the keyless Loader-path smokes keep guarding the export shape end-to-end.
|
||||
|
||||
## Why not promote it to `ui/` instead?
|
||||
|
||||
Promotion would resolve the support-vs-product mismatch while keeping the boundary — the right call only if the readline UI were an independently swappable integration or had a second composer, and the consumer census says neither. The structured ACP bridge stays its own package because it is the product protocol surface with its own contract and snapshot tiers; the readline helper is scaffolding for one app's front door. Re-extraction stays cheap pre-release: if a second product app wants the readline UI, split it back out then, with that consumer shaping the package contract.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `packages/support/ui-stdio` no longer exists; the helper and its tests live in `packages/ui/stdio-agent`; no reference to the deleted package remains outside RFC history.
|
||||
- The stdio app still renders transcript events, handles stdin lines and EOF, renders todo checklists, and disposes readline listeners under HMR; the echo/coding keyless smokes still boot through the real Loader path and guard the export shape.
|
||||
- Manifests, tsconfig references, the generated module graph, and docs are updated; `pnpm run test:coverage`, `pnpm run test:snapshot`, `pnpm run doc-sync`, `pnpm run build`, and `pnpm run hygiene` pass.
|
||||
|
||||
## Risks
|
||||
|
||||
A future standalone terminal UI may want the helper as a package again — reintroduce it with that second consumer rather than keeping the boundary for hypothetical reuse. Moving tests risks blurring app-composition tests with UI-rendering tests; keeping the runtime seam and the colocated unit tests avoids that.
|
||||
+1
-1
@@ -28,4 +28,4 @@ The [content-block vocabulary RFC](../../implemented/architecture/2026-06-11-con
|
||||
|
||||
## Risks
|
||||
|
||||
None operational — nothing can construct these values today. The mirror-event removals (recorded in [the boundary-mirror RFC](../../implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) and [the stream-chunk RFC](../../implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md)) touch only transient `agent/*` events, never the durable vocabulary, so there is no collision. Elsewhere in the vocabulary the admission policy already holds: `rejected`, `prompt/blocked`, and `hook/invoked`/`hook/result` each have live producers — this RFC extends the same bar to the three variants that lack one. If the [image-block RFC](2026-07-04-drop-image-content-block.md) ships first, one of the three `cache?` fields leaves with it; the two proposals are independent and compose in either order.
|
||||
None operational — nothing can construct these values today. The mirror-event removals (recorded in [the boundary-mirror RFC](../../implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) and [the stream-chunk RFC](../../implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md)) touch only transient `agent/*` events, never the durable vocabulary, so there is no collision. Elsewhere in the vocabulary the admission policy already holds: `rejected`, `prompt/blocked`, and `hook/invoked`/`hook/result` each have live producers — this RFC extends the same bar to the three variants that lack one. If the [image-block RFC](../../implemented/simplification/2026-07-04-drop-image-content-block.md) ships first, one of the three `cache?` fields leaves with it; the two proposals are independent and compose in either order.
|
||||
@@ -6,7 +6,7 @@ Status: proposed
|
||||
|
||||
The [fs seam split](../../implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) moved read routing and policy out of the backend into `dsh-tool-fs` and `dsh-fs-policy`. Four pieces of surface kept the pre-split shape — populated on every call, read by nobody:
|
||||
|
||||
1. **`STREAM_MIN_SIZE` + `FsIoInternals.streamMinSize` in `dsh-fs-local`** (`packages/fs/fs-local/src/fsio.ts`, re-exported from `packages/fs/fs-local/src/index.ts`): zero readers anywhere, including fs-local's own source and tests. The backend has no read routing — `readWholeText`/`streamWholeText` are separate primitives the caller chooses between — and the real routing constant lives in the consumer (`packages/fs/tool-fs/src/read.ts`, compared against `info.size`). Two mirrors of the 10 MiB fact; the backend's is dead, and the knob's JSDoc claims a "read routing" override that does not exist.
|
||||
1. **`STREAM_MIN_SIZE` + `FsIoInternals.streamMinSize` in `dsh-fs-local`** — *already removed by the no-hardcoded-tunables audit (the routing bound became `dsh-tool-fs`'s `readStreamMinSize` config); listed here for the record of the full prune, no work remains.* Originally (`packages/fs/fs-local/src/fsio.ts`, re-exported from `packages/fs/fs-local/src/index.ts`): zero readers anywhere, including fs-local's own source and tests. The backend has no read routing — `readWholeText`/`streamWholeText` are separate primitives the caller chooses between — and the real routing constant lives in the consumer (`packages/fs/tool-fs/src/read.ts`, compared against `info.size`). Two mirrors of the 10 MiB fact; the backend's is dead, and the knob's JSDoc claims a "read routing" override that does not exist.
|
||||
2. **`FsTarget.inputPath`** (`packages/fs/fs/src/types.ts`): every backend and every test fake must fabricate a "diagnostics only" value with zero production readers — the policy plugin and every error message use `targetKey`/`displayPath`. The `listDir` producer exposes the semantic wobble: directory children get the bare entry name, which was nobody's "input".
|
||||
3. **`FsEditOutcome.replacements` + `.replaceAll`** (`packages/fs/fs/src/types.ts`): `replacements` has zero production readers (the single-match policy itself stays — it is enforced by the `FS_AMBIGUOUS_EDIT`/`FS_EDIT_NOT_FOUND` throws inside the backend, whose error message keeps the internal count); `replaceAll` is read only by `formatEditOutput` in `packages/fs/tool-fs/src/edit.ts` — as an echo of the `replace_all` argument the tool already holds. Shrunk, `FsEditOutcome` becomes `{ version, before, after }`, parallel to `FsWriteOutcome`'s genuinely backend-discovered fields.
|
||||
4. **`FileReadOutcome.limit` + `.version`** (`packages/fs/tool-fs/src/read-render.ts`): populated by the read tool, but `formatReadOutput` renders `offset`/`lines`/`totalLines`/`truncatedByBytes` only, and the `fs/observed` emit uses `info.version` directly rather than the outcome copy.
|
||||
|
||||
@@ -9,8 +9,8 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
* Keyless Loader-path smoke for examples/coding-agent: boot the REAL example
|
||||
* through the `@deepseek-ai/dsh-stdio-agent` bin against its `cordis.yml` (the
|
||||
* cordis Loader, `unwrapExports`, the full plugin tree incl. the
|
||||
* `@deepseek-ai/dsh-agent-core` bundle and the extracted
|
||||
* `@deepseek-ai/dsh-ui-stdio`), then close stdin with no prompt and assert the
|
||||
* `@deepseek-ai/dsh-agent-core` bundle and the app's in-package readline UI
|
||||
* module), then close stdin with no prompt and assert the
|
||||
* ready banner + a clean exit.
|
||||
*
|
||||
* No prompt is ever sent, so the model is NEVER called — this is why it runs
|
||||
@@ -18,9 +18,10 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
* `apply()` only requires a key to be PRESENT (it does not validate it and only
|
||||
* uses it when a stream actually starts), so a dummy key lets the tree boot
|
||||
* while the absence of any prompt guarantees no network call. The value is the
|
||||
* real-Loader-path guard for the app + bundle + UI plugin export shapes (a broken
|
||||
* `export default` that drops `inject`/`Config` would crash here — see postmortem
|
||||
* 0001), complementing coding-agent's with-key e2e suites which prove the real
|
||||
* real-Loader-path guard that the composed tree boots (see postmortem 0001;
|
||||
* the app carries no `inject`, so its export SHAPE is pinned by the stdio-agent
|
||||
* unit suite's unwrap assertion, not by a crash here),
|
||||
* complementing coding-agent's with-key e2e suites which prove the real
|
||||
* product.
|
||||
*/
|
||||
|
||||
|
||||
@@ -13,11 +13,12 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
*
|
||||
* This is the guard the per-file unit suite structurally cannot be: it drives
|
||||
* the `@deepseek-ai/dsh-stdio-agent` app plugin, the `@deepseek-ai/dsh-agent-core`
|
||||
* bundle it loads, the extracted `@deepseek-ai/dsh-ui-stdio` plugin, AND the
|
||||
* example-local `mock-llm.ts` / `echo-tool.ts` through their REAL load path, so
|
||||
* a broken plugin export shape (a stray `export default` that `unwrapExports`
|
||||
* would collapse, dropping `inject`/`Config`) fails here even though hand-mounted
|
||||
* unit tests stay green (see docs/postmortem/0001). It needs no API key — the
|
||||
* bundle it loads, the app's in-package readline UI module, AND the
|
||||
* example-local `mock-llm.ts` / `echo-tool.ts` through their REAL load path
|
||||
* (see docs/postmortem/0001). The app itself carries no `inject`, so a stray
|
||||
* `export default` would boot rather than crash here — the export SHAPE is
|
||||
* pinned by the explicit unwrap assertion in the stdio-agent unit suite; this
|
||||
* smoke proves the composed tree actually runs. It needs no API key — the
|
||||
* `mock-echo` adapter never touches the network — so it runs in the default e2e
|
||||
* gate.
|
||||
*
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) + the app packages | Product — stable surface |
|
||||
| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, stdio UI, replay adapter) | Support — lower compatibility expectations |
|
||||
| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations |
|
||||
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free |
|
||||
|
||||
The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not treat every package as an equal public contract. New packages join an existing group; adding a new top-level group is a deliberate act (extend the group READMEs and this table).
|
||||
|
||||
@@ -12,6 +12,7 @@ Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `L
|
||||
timeoutMs: 120000 # default foreground timeout
|
||||
maxTimeoutMs: 600000 # cap for per-call overrides
|
||||
maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk
|
||||
graceMs: 3000 # SIGTERM→SIGKILL escalation grace on kills
|
||||
```
|
||||
|
||||
## Behavior (and where it came from)
|
||||
@@ -19,7 +20,7 @@ Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `L
|
||||
Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices:
|
||||
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
|
||||
- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after a 3s grace (OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
|
||||
- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
|
||||
- **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file.
|
||||
- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin`, when supplied, is written to the child and closed; with none supplied, fd 0 is `/dev/null` — the exact pre-seam default, so a command that probes stdin's file type is unaffected. Both `env`/`stdin` are set by in-process plugins (the hooks bridges); the model-facing tool doesn't expose them. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
- **Background tasks** — `start()` returns immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), `readOutput()` is incremental with whole-stream byte offsets, and disposal kills everything. The spec's opaque `owner` token is stored on the tracked task and returned by `ownerOf(id)` — the executor never interprets it (the consumer's access policy does), and because it lives with the task here it survives a `tool-bash` HMR reload.
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import { runBash } from './run.ts'
|
||||
import { DEFAULT_GRACE_MS, runBash } from './run.ts'
|
||||
import type { RunInternals, RunningBash } from './run.ts'
|
||||
|
||||
export { DEFAULT_GRACE_MS, ENV_OVERRIDES, killGroup, OutputCollector, runBash } from './run.ts'
|
||||
@@ -33,6 +33,8 @@ export interface Config {
|
||||
maxTimeoutMs?: number
|
||||
/** Per-stream in-memory output cap; overflow spills to a temp file. */
|
||||
maxOutputBytes?: number
|
||||
/** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
|
||||
graceMs?: number
|
||||
}
|
||||
|
||||
/** The shape after schemastery applied the defaults (cwd has none). */
|
||||
@@ -57,7 +59,7 @@ interface TrackedTask extends BashTask {
|
||||
* Local-subprocess bash executor. Defaults follow the agent-tool survey
|
||||
* consensus: 120s default / 600s max timeout (Claude Code, OpenCode), 64KB
|
||||
* in-memory output with full-stream spill files (pi, OpenCode),
|
||||
* process-group SIGTERM→SIGKILL kills (OpenCode).
|
||||
* process-group SIGTERM→SIGKILL kills with a 3s grace (OpenCode).
|
||||
*/
|
||||
export class LocalBashExecutor extends BashExecutor {
|
||||
static Config: z<Config> = z.object({
|
||||
@@ -65,11 +67,12 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
timeoutMs: z.number().default(120_000),
|
||||
maxTimeoutMs: z.number().default(600_000),
|
||||
maxOutputBytes: z.number().default(64_000),
|
||||
graceMs: z.number().default(DEFAULT_GRACE_MS),
|
||||
})
|
||||
|
||||
private tasks = new Map<BashTaskId, TrackedTask>()
|
||||
private nextTaskId = 1
|
||||
/** Test seam: timer/spill knobs forwarded to runBash. */
|
||||
/** Test seam: spill knobs forwarded to runBash. */
|
||||
internals: RunInternals = {}
|
||||
|
||||
/** Validated config (schemastery applied the defaults before construction). */
|
||||
@@ -83,6 +86,7 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
assertPositiveFinite('timeoutMs', this.config.timeoutMs)
|
||||
assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
|
||||
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
|
||||
assertPositiveFinite('graceMs', this.config.graceMs)
|
||||
ctx.effect(() => async () => {
|
||||
// Kill every live process group and WAIT for the processes to close so
|
||||
// nothing outlives the fiber (HMR safety) — a TERM-trapping child is
|
||||
@@ -132,6 +136,7 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
cwd: spec.workdir,
|
||||
timeoutMs: spec.timeoutMs,
|
||||
maxOutputBytes: this.config.maxOutputBytes,
|
||||
graceMs: this.config.graceMs,
|
||||
signal: spec.signal,
|
||||
stdin: spec.stdin,
|
||||
env: spec.env,
|
||||
@@ -150,6 +155,7 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
cwd: spec.workdir,
|
||||
timeoutMs: 0,
|
||||
maxOutputBytes: this.config.maxOutputBytes,
|
||||
graceMs: this.config.graceMs,
|
||||
signal: spec.signal,
|
||||
stdin: spec.stdin,
|
||||
env: spec.env,
|
||||
|
||||
@@ -73,6 +73,8 @@ export interface SpawnSpec {
|
||||
timeoutMs: number
|
||||
/** Per-stream in-memory cap; overflow spills to disk (tail kept in memory). */
|
||||
maxOutputBytes: number
|
||||
/** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
|
||||
graceMs: number
|
||||
/** Abort signal — kills the process group when fired. */
|
||||
signal?: AbortSignal | undefined
|
||||
/**
|
||||
@@ -100,15 +102,13 @@ export interface SpawnOutcome {
|
||||
stderr: CollectedOutput
|
||||
}
|
||||
|
||||
/** Injectable knobs so tests can exercise escalation/spill without long waits. */
|
||||
/** Injectable knobs so tests can exercise spill behavior without the OS tmpdir. */
|
||||
export interface RunInternals {
|
||||
/** Grace period between SIGTERM and SIGKILL on the process group. */
|
||||
graceMs?: number
|
||||
/** Directory for spill files (defaults to the OS temp dir). */
|
||||
spillDir?: string
|
||||
}
|
||||
|
||||
/** Default SIGTERM→SIGKILL grace period (matches OpenCode's 3s). */
|
||||
/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */
|
||||
export const DEFAULT_GRACE_MS = 3_000
|
||||
|
||||
let spillCounter = 0
|
||||
@@ -292,7 +292,6 @@ export interface RunningBash {
|
||||
* no inherited shell state); revisit when real workflows demand it.
|
||||
*/
|
||||
export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash {
|
||||
const graceMs = internals.graceMs ?? DEFAULT_GRACE_MS
|
||||
const spillDir = internals.spillDir ?? privateSpillDir()
|
||||
|
||||
if (spec.signal?.aborted) {
|
||||
@@ -331,7 +330,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
|
||||
const kill = (): void => {
|
||||
if (graceTimer !== undefined) return // escalation already in flight
|
||||
killGroup(pid, 'SIGTERM')
|
||||
graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, graceMs)
|
||||
graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs)
|
||||
}
|
||||
|
||||
if (spec.timeoutMs > 0) {
|
||||
|
||||
@@ -11,9 +11,10 @@ const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
|
||||
|
||||
async function setup(config: ConstructorParameters<typeof LocalBashExecutor>[1] = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalBashExecutor, config)
|
||||
// A short kill grace via the REAL config path, so escalation tests stay fast.
|
||||
await ctx.plugin(LocalBashExecutor, { graceMs: 200, ...config })
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
bash.internals = { spillDir, graceMs: 200 }
|
||||
bash.internals = { spillDir }
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
@@ -80,12 +81,22 @@ describe('LocalBashExecutor.run', () => {
|
||||
await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/)
|
||||
await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/)
|
||||
await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/)
|
||||
await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/)
|
||||
|
||||
const { bash } = await setup()
|
||||
expect(() => bash.resolve({ command: 'true', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/)
|
||||
expect(() => bash.resolve({ command: 'true', timeoutMs: -1 })).toThrow(/request\.timeoutMs/)
|
||||
})
|
||||
|
||||
it('kill escalation uses the configured graceMs (a TERM-trapping task dies by SIGKILL)', async () => {
|
||||
const { bash } = await setup() // setup pins graceMs: 200 via config
|
||||
const task = bash.start(bash.resolve({ command: 'trap \'\' TERM; sleep 60' }))
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
bash.kill(task.id)
|
||||
await task.done
|
||||
expect(task.signal).toBe('SIGKILL')
|
||||
})
|
||||
|
||||
it('per-call timeout takes precedence under the cap and kills on expiry', async () => {
|
||||
const { bash } = await setup({ timeoutMs: 60_000 })
|
||||
const result = await bash.run(bash.resolve({ command: 'sleep 60', timeoutMs: 100 }))
|
||||
@@ -271,9 +282,9 @@ describe('LocalBashExecutor background tasks', () => {
|
||||
|
||||
it('disposing with already-finished tasks only kills the running ones', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalBashExecutor, {})
|
||||
const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
bash.internals = { spillDir, graceMs: 200 }
|
||||
bash.internals = { spillDir }
|
||||
|
||||
const finished = bash.start(bash.resolve({ command: 'true' }))
|
||||
await finished.done
|
||||
@@ -288,9 +299,9 @@ describe('LocalBashExecutor background tasks', () => {
|
||||
|
||||
it('disposing the executor fiber kills running tasks (no orphans)', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalBashExecutor, {})
|
||||
const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
bash.internals = { spillDir, graceMs: 200 }
|
||||
bash.internals = { spillDir }
|
||||
const listener = vi.fn()
|
||||
bash.onTaskDone(listener)
|
||||
|
||||
@@ -337,9 +348,9 @@ describe('review fixes: lifecycle hardening', () => {
|
||||
|
||||
it('dispose AWAITS a TERM-trapping process (SIGKILL escalation included)', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalBashExecutor, {})
|
||||
const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
bash.internals = { spillDir, graceMs: 200 }
|
||||
bash.internals = { spillDir }
|
||||
|
||||
const task = bash.start(bash.resolve({ command: 'trap \'\' TERM; sleep 60' }))
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
@@ -28,6 +28,7 @@ function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]>
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 0,
|
||||
maxOutputBytes: 64_000,
|
||||
graceMs: 3_000,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -106,7 +107,7 @@ describe('runBash', () => {
|
||||
})
|
||||
|
||||
it('escalates to SIGKILL when SIGTERM is trapped', async () => {
|
||||
const running = runBash(spec('trap \'\' TERM; echo ready; sleep 60'), { graceMs: 200 })
|
||||
const running = runBash(spec('trap \'\' TERM; echo ready; sleep 60', { graceMs: 200 }))
|
||||
await waitForStdout(running, 'ready\n')
|
||||
running.kill()
|
||||
const result = await running.done
|
||||
|
||||
@@ -21,8 +21,8 @@ async function setup() {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 }
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
|
||||
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
|
||||
await ctx.plugin(ToolBash)
|
||||
return ctx
|
||||
}
|
||||
@@ -184,8 +184,8 @@ describe('bash tool', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100 })
|
||||
;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 }
|
||||
await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 })
|
||||
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
|
||||
await ctx.plugin(ToolBash)
|
||||
const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' })
|
||||
expect(text(result)).toContain('[output truncated; full output: ')
|
||||
@@ -316,8 +316,8 @@ describe('background tools', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100 })
|
||||
;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 }
|
||||
await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 })
|
||||
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
|
||||
await ctx.plugin(ToolBash)
|
||||
|
||||
const started = await call(ctx, 'bash', { command: 'for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', description: 'test command', run_in_background: true })
|
||||
@@ -568,8 +568,8 @@ describe('background task ownership (cross-session isolation)', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 }
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
|
||||
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
|
||||
const fiber = await ctx.plugin(ToolBash)
|
||||
|
||||
const a = fakeAgent('sess-a')
|
||||
@@ -824,7 +824,7 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
|
||||
const ctx = await setup()
|
||||
const present = ctx.tools.get('bash')!.presentResult!(
|
||||
{ command: 'x', description: 'x' },
|
||||
{ content: [{ type: 'image', url: 'https://x/y.png' }], isError: false },
|
||||
{ content: [{ type: 'reasoning', text: 'unexpected' }], isError: false },
|
||||
)
|
||||
expect(present).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ A three-package capability seam (see [capability seams](../../docs/rfc/implement
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` |
|
||||
| `compact-basic/` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
|
||||
| `compact-basic/` | A backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
|
||||
| `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). A tokenizer- or template-based backend would replace `compact-basic` without touching the interface or the tool.
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-compact-basic
|
||||
|
||||
The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a char/4 token heuristic, token-budget retention, and summarization routed through the agent request pipeline.
|
||||
The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a chars-per-token heuristic (the `charsPerToken` config, default 4), token-budget retention, and summarization routed through the agent request pipeline.
|
||||
|
||||
This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design.
|
||||
|
||||
@@ -8,10 +8,10 @@ This is the implementation tier of the compaction capability — see the [interf
|
||||
|
||||
The abstract contract states only WHAT compaction does; this backend owns every HOW decision:
|
||||
|
||||
- **Token estimation** — `estimateContentTokens()`: char/4 with per-block structural overhead (`text`/`reasoning` = `ceil(len/4) + 4`, `tool-call` from name + arguments, `tool-result` recursive, `image` = 85, unknown blocks via JSON length).
|
||||
- **Token estimation** — `estimateContentTokens()`: chars divided by the `charsPerToken` config (default 4) with per-block structural overhead (`text`/`reasoning` = `ceil(len/charsPerToken) + 4`, `tool-call` from name + arguments, `tool-result` recursive, unknown blocks via JSON length).
|
||||
- **Retention policy** — `compactIfNeeded()` walks the surface nodes tail→head summing per-node token estimates, and retains the smallest tail-run of WHOLE units (a closed step, or a single no-step node such as a pre-step `user/message` or inter-step `steering/message`) whose total reaches `retainTokens`; everything older is compacted. Retention is **turn-agnostic** — turn boundaries play no role, so a single runaway turn that alone exceeds the window compacts its OWN early closed steps rather than being retained verbatim (the failure mode that motivated dropping turn-protection: a tool-heavy turn must stay compactable or the harness dies exactly when compaction is needed). The only structural guard is **tool-pairing balance**: the compacted region's edges are balanced cuts on the surface (no unanswered tool-call crosses either edge), so it never splits a step's `assistant/message` tool-calls from their `tool/result`s. When the only compactable content left is an un-splittable open tail step, it declines (returns `null`) and retries once an older step closes. **Single-unit overflow is out of scope, by design**: if one retained unit (a single closed step, or a large pasted `user/message`) ALONE exceeds the budget, compaction cannot help and the call may go out over-budget — bounding an individual unit's size is a separate concern. `compactRegion()` enforces tool-pairing balance strictly, throwing on a boundary that would split a step. `dsh-session` exports `isToolPairingBalanced` for the check.
|
||||
- **Dynamic convergence** — no static summary-length config pretends to bound what the model will write. If framing/estimator/system overhead leaves the compacted surface above threshold, `compactIfNeeded()` re-compacts the head checkpoint up to `compactionRetries` extra times; if it still cannot get below threshold, it throws. A summary whose estimated stored size is not smaller than the shadowed content fails closed before it mutates the surface.
|
||||
- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) so the summarizer is told what existed rather than silently dropping it.
|
||||
- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[tool-call: name(args)]`, `[tool-result: …]`, …) so the summarizer is told what existed rather than silently dropping it.
|
||||
- **Checkpoint framing** — the raw summary is not landed directly. `compactRegion()` wraps it in a checkpoint preamble (so a resuming model reads it as a checkpoint, not a fresh user request, and builds on the captured context rather than restating it) plus `<compacted-summary>…</compacted-summary>` tags. Because region compaction can be invoked manually, a surface may hold several checkpoints, so the framing does not claim everything after it is recent or verbatim. The tags make a prior checkpoint detectable in the transcript on the next compaction cycle: the summarization prompt then instructs the model to merge it in place (preserve still-true facts, drop stale ones) rather than re-summarize it verbatim — a cheap incremental merge that needs no extra log/event machinery. The unframed summary stays on the `compact/summary` provenance event.
|
||||
- **Surface mutation** — `compactRegion()` appends the `compact/start` → `compact/summary` → `compact/end` log records and the single `user/message` replace node carrying the framed summary (see the interface README).
|
||||
- **Auto-compaction** — an `agent/pre-step` listener delegates to `compactIfNeeded()` before every step (not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts, and per-step firing is the only moment to rescue it before overflow). `agent/pre-step` is a serial (awaited, in-order) surface-mutation checkpoint that fires after `turn/start` and BEFORE the step opens (`step/start`) and its request history is derived, so compaction mutates the surface — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives once from the result: no double-derive, and the listener cannot see (or need to rewrite) an already-assembled `messages` array. The listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`); because Cordis `serial` bails early on non-void return values, the listener returns `void` and does not use the dispatcher's bail channel as a veto surface.
|
||||
@@ -32,6 +32,7 @@ Every knob is **required** except `auto` — there is no concrete data yet to ju
|
||||
| `maxTokens` | yes | Provider generation cap for the summarization call; may include reasoning tokens. |
|
||||
| `compactionRetries` | yes | Extra compaction attempts after the first if the compacted surface remains over threshold. |
|
||||
| `auto` | no (default `true`) | Register the `agent/pre-step` auto-compaction listener. Set `false` for manual-only. |
|
||||
| `charsPerToken` | no (default `4`) | Token-estimator text density (estimated tokens = chars / `charsPerToken`; may be fractional). The default suits English text; CJK-heavy deployments should set ~1-2 or the estimate undershoots several-fold and compaction fires too late. |
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-compact-basic",
|
||||
"description": "Basic compaction backend (char/4 token estimation + token-budget retention + llm.generate() summarization) for the DeepSeek Harness",
|
||||
"description": "Basic compaction backend (chars-per-token estimation + token-budget retention + llm.generate() summarization) for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* `BasicCompactService`: the first implementation of the
|
||||
* `@deepseek-ai/dsh-compact` seam. It owns the entire compaction strategy:
|
||||
*
|
||||
* - **Token estimation** — char/4 heuristic with per-block structural overhead.
|
||||
* - **Token estimation** — chars/`charsPerToken` heuristic (config, default 4)
|
||||
* with per-block structural overhead.
|
||||
* - **Retention policy** — walk surface nodes tail→head, keep recent nodes up
|
||||
* to a token budget, compact everything older. The cutoff is snapped forward
|
||||
* to the next balanced tool-pairing boundary so a compacted region never
|
||||
@@ -44,9 +45,6 @@ export { resolveConfig } from './types.ts'
|
||||
/** Per-block structural overhead for JSON framing / type tag. */
|
||||
const BLOCK_OVERHEAD = 4
|
||||
|
||||
/** Heuristic token count for an image block (~85 tokens for low-res URL). */
|
||||
const IMAGE_TOKEN_COST = 85
|
||||
|
||||
/** Role-field framing overhead added per message in {@link BasicCompactService.estimateTokens}. */
|
||||
const ROLE_OVERHEAD = 4
|
||||
|
||||
@@ -148,9 +146,11 @@ function finishError(finish: FinishReason): Error | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic, dependency-light compaction backend. Defaults target a 128K context
|
||||
* window, compacting at 80% utilization and retaining ~20K tokens of recent
|
||||
* context.
|
||||
* Basic, dependency-light compaction backend: estimates the surface's token
|
||||
* footprint, summarizes the stale prefix through the model, and shadows it
|
||||
* behind a durable checkpoint. Every threshold/budget knob is required config
|
||||
* ({@link BasicCompactConfig}); the estimator's text density is the
|
||||
* `charsPerToken` knob.
|
||||
*/
|
||||
export class BasicCompactService extends CompactService {
|
||||
static inject = ['llm']
|
||||
@@ -207,36 +207,36 @@ export class BasicCompactService extends CompactService {
|
||||
|
||||
// ---- Token estimation (overridable hooks) ----
|
||||
|
||||
// TODO: char/4 is a coarse heuristic. Replace with an exact count — a real
|
||||
// tokenizer, or the provider's post-response `usage` (input tokens) fed back
|
||||
// as a correction — so threshold decisions match the model's actual budget.
|
||||
// TODO: chars/charsPerToken is a coarse heuristic. Replace with an exact
|
||||
// count — a real tokenizer, or the provider's post-response `usage` (input
|
||||
// tokens) fed back as a correction — so threshold decisions match the
|
||||
// model's actual budget.
|
||||
/**
|
||||
* Estimate the token count of content blocks — char/4 with per-block
|
||||
* overhead. Override in a subclass to plug in a real tokenizer.
|
||||
* Estimate the token count of content blocks — chars divided by the
|
||||
* `charsPerToken` config, with per-block overhead. Override in a subclass to
|
||||
* plug in a real tokenizer.
|
||||
*/
|
||||
estimateContentTokens(blocks: readonly ContentBlock[]): number {
|
||||
const { charsPerToken } = this.config
|
||||
let tokens = 0
|
||||
for (const block of blocks) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
case 'reasoning':
|
||||
tokens += Math.ceil(block.text.length / 4) + BLOCK_OVERHEAD
|
||||
tokens += Math.ceil(block.text.length / charsPerToken) + BLOCK_OVERHEAD
|
||||
break
|
||||
case 'tool-call':
|
||||
tokens += Math.ceil(block.name.length / 4)
|
||||
+ Math.ceil(block.arguments.length / 4)
|
||||
tokens += Math.ceil(block.name.length / charsPerToken)
|
||||
+ Math.ceil(block.arguments.length / charsPerToken)
|
||||
+ BLOCK_OVERHEAD
|
||||
break
|
||||
case 'tool-result':
|
||||
tokens += this.estimateContentTokens(block.content) + BLOCK_OVERHEAD
|
||||
break
|
||||
case 'image':
|
||||
tokens += IMAGE_TOKEN_COST
|
||||
break
|
||||
default:
|
||||
// Unknown block types (merge-extensible ContentBlockMap):
|
||||
// estimate conservatively via JSON stringify.
|
||||
tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / 4)
|
||||
tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / charsPerToken)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
@@ -266,7 +266,7 @@ export class BasicCompactService extends CompactService {
|
||||
total += this.estimateContentTokens(msg.content)
|
||||
total += ROLE_OVERHEAD
|
||||
}
|
||||
if (systemPrompt) total += Math.ceil(systemPrompt.length / 4)
|
||||
if (systemPrompt) total += Math.ceil(systemPrompt.length / this.config.charsPerToken)
|
||||
return total
|
||||
}
|
||||
|
||||
@@ -706,10 +706,10 @@ export class BasicCompactService extends CompactService {
|
||||
/**
|
||||
* Render content blocks to a single plain-text string for the summarization
|
||||
* prompt. Text and reasoning contribute their text; every other block type
|
||||
* contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`,
|
||||
* …) so the summarizer is told what non-text content existed in the region
|
||||
* rather than silently losing it. Blocks join with newlines; empty-text
|
||||
* blocks contribute nothing.
|
||||
* contributes a type-tagged placeholder (`[tool-call: name(args)]`,
|
||||
* `[tool-result: …]`, …) so the summarizer is told what non-text content
|
||||
* existed in the region rather than silently losing it. Blocks join with
|
||||
* newlines; empty-text blocks contribute nothing.
|
||||
*/
|
||||
private _blocksToText(blocks: readonly ContentBlock[]): string {
|
||||
const parts: string[] = []
|
||||
@@ -729,9 +729,6 @@ export class BasicCompactService extends CompactService {
|
||||
parts.push(inner ? `[tool-result: ${inner}]` : '[tool-result]')
|
||||
break
|
||||
}
|
||||
case 'image':
|
||||
parts.push('[image]')
|
||||
break
|
||||
// ContentBlockMap is merge-extensible — render an unknown block as a
|
||||
// bare type-tagged placeholder so a plugin-added block type is still
|
||||
// signalled to the summarizer rather than dropped.
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Backend configuration. Every knob is REQUIRED except `auto`: there is no
|
||||
* concrete data yet to justify default thresholds/budgets, so a consumer must
|
||||
* state each value explicitly rather than inherit a guessed default. `auto`
|
||||
* alone defaults to `true` (auto-compaction is the intended posture).
|
||||
* Backend configuration. Every knob is REQUIRED except `auto` and
|
||||
* `charsPerToken`: there is no concrete data yet to justify default
|
||||
* thresholds/budgets, so a consumer must state each value explicitly rather
|
||||
* than inherit a guessed default. `auto` alone defaults to `true`
|
||||
* (auto-compaction is the intended posture), and `charsPerToken` defaults to
|
||||
* the English-text heuristic its estimator was calibrated on.
|
||||
*/
|
||||
export interface BasicCompactConfig {
|
||||
/** Context window size in tokens. */
|
||||
@@ -30,13 +32,21 @@ export interface BasicCompactConfig {
|
||||
compactionRetries: number
|
||||
/** Enable automatic compaction on the `agent/pre-step` seam (default true). */
|
||||
auto?: boolean
|
||||
/**
|
||||
* Text density for the token estimator: estimated tokens = chars /
|
||||
* `charsPerToken`. Defaults to 4 (typical English text). A CJK-heavy
|
||||
* deployment should set ~1-2 — CJK runs at roughly 1-2 chars per token, so
|
||||
* the default UNDERestimates several-fold and compaction fires far too late.
|
||||
* May be fractional.
|
||||
*/
|
||||
charsPerToken?: number
|
||||
}
|
||||
|
||||
/** Resolved config with `auto` defaulted. */
|
||||
/** Resolved config with `auto` and `charsPerToken` defaulted. */
|
||||
export type ResolvedConfig = Required<BasicCompactConfig>
|
||||
|
||||
/**
|
||||
* Default `auto` when unset and reject nonsensical numeric knobs.
|
||||
* Default `auto`/`charsPerToken` when unset and reject nonsensical numeric knobs.
|
||||
*
|
||||
* Convergence is not a static config invariant: provider generation caps can be
|
||||
* spent on hidden or surfaced reasoning tokens, and the model may emit a summary
|
||||
@@ -46,13 +56,14 @@ export type ResolvedConfig = Required<BasicCompactConfig>
|
||||
* throwing if the surface still exceeds the threshold.
|
||||
*/
|
||||
export function resolveConfig(config: BasicCompactConfig): ResolvedConfig {
|
||||
const resolved: ResolvedConfig = { auto: true, ...config }
|
||||
const resolved: ResolvedConfig = { auto: true, charsPerToken: 4, ...config }
|
||||
|
||||
assertPositiveInteger('contextWindow', resolved.contextWindow)
|
||||
assertRatio('thresholdRatio', resolved.thresholdRatio)
|
||||
assertNonNegativeInteger('retainTokens', resolved.retainTokens)
|
||||
assertPositiveInteger('maxTokens', resolved.maxTokens)
|
||||
assertNonNegativeInteger('compactionRetries', resolved.compactionRetries)
|
||||
assertPositiveFinite('charsPerToken', resolved.charsPerToken)
|
||||
if (typeof resolved.summarizationModel !== 'string') {
|
||||
throw new Error('BasicCompactConfig: summarizationModel must be a string.')
|
||||
}
|
||||
@@ -74,6 +85,12 @@ function assertNonNegativeInteger(name: string, value: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
function assertPositiveFinite(name: string, value: number): void {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive finite number.`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRatio(name: string, value: number): void {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a number in (0, 1].`)
|
||||
|
||||
@@ -811,15 +811,23 @@ describe('BasicCompactService token estimation (char/4 heuristic)', () => {
|
||||
])).toBe(10)
|
||||
})
|
||||
|
||||
it('estimates image blocks at fixed 85 tokens', () => {
|
||||
const svc = new BasicCompactService(new Context(), cfg({ auto: false }))
|
||||
expect(svc.estimateContentTokens([{ type: 'image', url: 'https://example.com/img.png' }])).toBe(85)
|
||||
})
|
||||
|
||||
it('returns 0 for empty content blocks', () => {
|
||||
const svc = new BasicCompactService(new Context(), cfg({ auto: false }))
|
||||
expect(svc.estimateContentTokens([])).toBe(0)
|
||||
})
|
||||
|
||||
it('honors a configured charsPerToken (fractional densities included)', () => {
|
||||
// 'this is a somewhat longer text block' = 36 chars.
|
||||
const blocks: ContentBlock[] = [{ type: 'text', text: 'this is a somewhat longer text block' }]
|
||||
// charsPerToken 2: ceil(36/2)+4 = 22 — a CJK-density config doubles the estimate.
|
||||
const dense = new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 2 }))
|
||||
expect(dense.estimateContentTokens(blocks)).toBe(22)
|
||||
// Fractional density is legal: ceil(36/1.5)+4 = 28.
|
||||
const fractional = new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 1.5 }))
|
||||
expect(fractional.estimateContentTokens(blocks)).toBe(28)
|
||||
// The system-prompt term scales with the same knob: 36-char prompt at density 2 → ceil(36/2) = 18.
|
||||
expect(dense.estimateTokens([], 'this is a somewhat longer text block')).toBe(18)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BasicCompactService HMR safety', () => {
|
||||
@@ -862,6 +870,10 @@ describe('BasicCompactService config validation', () => {
|
||||
)).toThrow(/summarizationModel must be a string/)
|
||||
expect(() => new BasicCompactService(new Context(), cfg({ auto: 'no' } as unknown as Partial<BasicCompactConfig>)))
|
||||
.toThrow(/auto must be a boolean/)
|
||||
expect(() => new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 0 })))
|
||||
.toThrow(/charsPerToken .* positive finite number/)
|
||||
expect(() => new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: Number.NaN })))
|
||||
.toThrow(/charsPerToken .* positive finite number/)
|
||||
})
|
||||
|
||||
it('accepts a large retain budget because convergence is enforced dynamically', () => {
|
||||
@@ -1324,7 +1336,7 @@ describe('BasicCompactService edge cases', () => {
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [
|
||||
{ type: 'tool-result', toolCallId: CallId('n1'), content: [{ type: 'image', url: 'https://x/n.png' }] },
|
||||
{ type: 'tool-result', toolCallId: CallId('n1'), content: [{ type: 'chart', data: 'x' } as unknown as ContentBlock] },
|
||||
{ type: 'custom-widget', payload: 'x' } as unknown as ContentBlock,
|
||||
{ type: 'tool-call', id: CallId('b1'), name: 'bash', arguments: '{}' },
|
||||
],
|
||||
@@ -1343,7 +1355,7 @@ describe('BasicCompactService edge cases', () => {
|
||||
const nodes = s.surface.nodes
|
||||
await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
|
||||
const { text } = svc.summarizeCalls[0]!
|
||||
expect(text).toContain('[tool-result: [image]]') // nested tool-result with content
|
||||
expect(text).toContain('[tool-result: [chart]]') // nested tool-result with content
|
||||
expect(text).toContain('[custom-widget]') // unknown block placeholder
|
||||
expect(text).toContain('Tool result (call b1): [tool-result]') // empty nested → bare placeholder
|
||||
})
|
||||
@@ -1510,25 +1522,28 @@ describe('BasicCompactService edge cases', () => {
|
||||
it('renders non-text blocks as type-tagged placeholders across all message kinds', async () => {
|
||||
const svc = createTestService()
|
||||
const s = new Session(SessionId('placeholders'))
|
||||
// A plugin-added block type (merge-extensible ContentBlockMap) — the
|
||||
// placeholder path must cover every message kind, not just assistant.
|
||||
const chart = (id: string): ContentBlock => ({ type: 'chart', data: id } as unknown as ContentBlock)
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
// user/message with only an image block → '[image]' placeholder.
|
||||
s.append('user/message', { content: [{ type: 'image', url: 'https://x/y.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
// assistant/message with an image block AND the tool-call its tool/result
|
||||
// answers (so the surface is tool-pairing balanced) → '[image]' placeholder.
|
||||
// user/message with only a plugin-added block → '[chart]' placeholder.
|
||||
s.append('user/message', { content: [chart('y')], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
// assistant/message with a plugin-added block AND the tool-call its
|
||||
// tool/result answers (so the surface is tool-pairing balanced).
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [
|
||||
{ type: 'image', url: 'https://x/z.png' },
|
||||
chart('z'),
|
||||
{ type: 'tool-call', id: CallId('e1'), name: 'bash', arguments: '{}' },
|
||||
],
|
||||
}, { surfaceOp: 'append' })
|
||||
// tool/result with an image block → '[image]' placeholder.
|
||||
// tool/result with a plugin-added block → '[chart]' placeholder.
|
||||
s.append('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'bash', arguments: '{}' })
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'image', url: 'https://x/r.png' }], isError: false }, { surfaceOp: 'append' })
|
||||
// context/message and steering/message with image content.
|
||||
s.append('context/message', { content: [{ type: 'image', url: 'https://x/c.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('steering/message', { turn: 1, content: [{ type: 'image', url: 'https://x/s.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [chart('r')], isError: false }, { surfaceOp: 'append' })
|
||||
// context/message and steering/message with plugin-added content.
|
||||
s.append('context/message', { content: [chart('c')], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('steering/message', { turn: 1, content: [chart('s')], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
@@ -1537,11 +1552,11 @@ describe('BasicCompactService edge cases', () => {
|
||||
await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
|
||||
const { text } = svc.summarizeCalls[0]!
|
||||
// Every non-text block surfaces as a placeholder rather than being dropped.
|
||||
expect(text).toContain('User: [image]')
|
||||
expect(text).toContain('Assistant: [image]')
|
||||
expect(text).toContain('Tool result (call e1): [image]')
|
||||
expect(text).toContain('[Context: [image]]')
|
||||
expect(text).toContain('[Steering: [image]]')
|
||||
expect(text).toContain('User: [chart]')
|
||||
expect(text).toContain('Assistant: [chart]')
|
||||
expect(text).toContain('Tool result (call e1): [chart]')
|
||||
expect(text).toContain('[Context: [chart]]')
|
||||
expect(text).toContain('[Steering: [chart]]')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ This package is the interface tier of the compaction capability, split so each c
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` |
|
||||
| `@deepseek-ai/dsh-compact-basic` (deferred) | a backend: char/4 estimation + token-budget retention + `llm.stream()` summarization |
|
||||
| `@deepseek-ai/dsh-compact-basic` (deferred) | a backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization |
|
||||
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
|
||||
|
||||
Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md).
|
||||
|
||||
@@ -26,9 +26,6 @@ import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { TextDecoder } from 'node:util'
|
||||
import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** Files at or above this size stream their text; smaller files read whole. */
|
||||
export const STREAM_MIN_SIZE = 10 * 1024 * 1024
|
||||
|
||||
const BINARY_SAMPLE_BYTES = 8192
|
||||
|
||||
function isENOENT(error: unknown): boolean {
|
||||
@@ -85,13 +82,10 @@ function versionOf(info: Stats): FsVersion {
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seam: lets specs force the streaming read path (via a small
|
||||
* `streamMinSize`) and pin the temp-file name (to prove exclusive-open
|
||||
* behavior) without a 10 MB fixture or a name race.
|
||||
* Test seam: lets specs pin the temp-file name (to prove exclusive-open
|
||||
* behavior) without a name race.
|
||||
*/
|
||||
export interface FsIoInternals {
|
||||
/** Override {@link STREAM_MIN_SIZE} for read routing. */
|
||||
streamMinSize?: number
|
||||
/** Override the generated private staging-dir name (relative to the target dir). */
|
||||
tempDirName?: (writePath: string) => string
|
||||
/** Override the generated temp-file name (relative to the private staging dir). */
|
||||
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
import type { FsIoInternals } from './fsio.ts'
|
||||
|
||||
export {
|
||||
STREAM_MIN_SIZE,
|
||||
applyLiteralEdit,
|
||||
listDirectory,
|
||||
probe,
|
||||
|
||||
@@ -11,11 +11,22 @@ await ctx.plugin(ToolFs) // this package — re
|
||||
|
||||
`@deepseek-ai/dsh-fs-policy` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit.
|
||||
|
||||
## Config
|
||||
|
||||
All keys are optional; the defaults are the shipped read caps.
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `readLimit` | `2000` | Default and maximum lines returned by one `read` call (the tool schema advertises it as the `limit` default). |
|
||||
| `readMaxLineLength` | `2000` | Characters kept per line before truncation (the suffix names the cap). |
|
||||
| `readMaxBytes` | `51200` | Byte cap on one `read` call's selected lines; overflow ends the window with a "capped" footer. |
|
||||
| `readStreamMinSize` | `10485760` | Files at or above this size (or with unknown size) stream instead of loading whole into memory. |
|
||||
|
||||
## Tools (schemas per [the filesystem tool schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md))
|
||||
|
||||
| Tool | Arguments | Behavior |
|
||||
|---|---|---|
|
||||
| `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at 2000 lines. |
|
||||
| `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at the configured `readLimit` (2000). |
|
||||
| `write` | `file_path`, `content` | Create or fully replace a file. With the policy plugin: overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. Without it: unconditional. |
|
||||
| `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. With the policy plugin: requires a prior `read` (any window) and the file unchanged since. Without it: unconditional. |
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"diff": "^9.0.0"
|
||||
"diff": "^9.0.0",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
|
||||
@@ -23,11 +23,14 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { applyReadTool } from './read.ts'
|
||||
import z from 'schemastery'
|
||||
import { applyReadTool, READ_LIMIT, STREAM_MIN_SIZE } from './read.ts'
|
||||
import { applyWriteTool } from './write.ts'
|
||||
import { applyEditTool } from './edit.ts'
|
||||
import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts'
|
||||
|
||||
export { READ_LIMIT, STREAM_MIN_SIZE, applyReadTool, parseReadArgs } from './read.ts'
|
||||
export type { ReadToolCaps } from './read.ts'
|
||||
export { applyWriteTool, formatWriteOutput, parseWriteArgs } from './write.ts'
|
||||
export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.ts'
|
||||
export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow, formatReadOutput } from './read-render.ts'
|
||||
@@ -41,9 +44,49 @@ export const name = 'tool-fs'
|
||||
/** Services required by the filesystem tool suite. */
|
||||
export const inject = ['tools', 'fs', 'systemPrompt']
|
||||
|
||||
/** Plugin config (all optional — `Config` supplies the defaults). */
|
||||
export interface Config {
|
||||
/** Default and maximum number of lines returned by one `read` call. */
|
||||
readLimit?: number
|
||||
/** Maximum characters returned for a single line before truncation. */
|
||||
readMaxLineLength?: number
|
||||
/** Maximum bytes returned for the selected lines of one `read` call. */
|
||||
readMaxBytes?: number
|
||||
/** Files at or above this size stream instead of loading whole into memory. */
|
||||
readStreamMinSize?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
readLimit: z.number().default(READ_LIMIT),
|
||||
readMaxLineLength: z.number().default(READ_MAX_LINE_LENGTH),
|
||||
readMaxBytes: z.number().default(READ_MAX_BYTES),
|
||||
readStreamMinSize: z.number().default(STREAM_MIN_SIZE),
|
||||
})
|
||||
|
||||
/** The shape after schemastery applied the defaults. */
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/** Every read cap counts lines/chars/bytes — a positive integer, or windowing arithmetic misbehaves silently. */
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`tool-fs: ${name} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the full `read`/`write`/`edit` filesystem tool suite. */
|
||||
export function apply(ctx: Context): void {
|
||||
applyReadTool(ctx)
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// schemastery (Config) has already filled every defaulted field.
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveInteger('readLimit', resolved.readLimit)
|
||||
assertPositiveInteger('readMaxLineLength', resolved.readMaxLineLength)
|
||||
assertPositiveInteger('readMaxBytes', resolved.readMaxBytes)
|
||||
assertPositiveInteger('readStreamMinSize', resolved.readStreamMinSize)
|
||||
applyReadTool(ctx, {
|
||||
limit: resolved.readLimit,
|
||||
maxLineLength: resolved.readMaxLineLength,
|
||||
maxBytes: resolved.readMaxBytes,
|
||||
streamMinSize: resolved.readStreamMinSize,
|
||||
})
|
||||
applyWriteTool(ctx)
|
||||
applyEditTool(ctx)
|
||||
}
|
||||
@@ -19,21 +19,22 @@
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** Maximum characters returned for a single line. */
|
||||
/** Default maximum characters returned for a single line (the `readMaxLineLength` config). */
|
||||
export const READ_MAX_LINE_LENGTH = 2000
|
||||
|
||||
/** Maximum bytes returned for selected file lines. */
|
||||
/** Default maximum bytes returned for selected file lines (the `readMaxBytes` config). */
|
||||
export const READ_MAX_BYTES = 50 * 1024
|
||||
|
||||
const READ_MAX_LINE_SUFFIX = `... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`
|
||||
const LINE_BUFFER_CAP = READ_MAX_LINE_LENGTH + 1
|
||||
|
||||
/** Resolved read window. The consumer applies its defaults/caps before calling. */
|
||||
export interface ReadWindow {
|
||||
/** 1-based first line to return. */
|
||||
offset: number
|
||||
/** Maximum number of lines to return. */
|
||||
limit: number
|
||||
/** Maximum characters returned for a single line; overflow is truncated with a suffix. */
|
||||
maxLineLength: number
|
||||
/** Maximum bytes of selected output; overflow stops the scan and marks `truncatedByBytes`. */
|
||||
maxBytes: number
|
||||
}
|
||||
|
||||
/** One line returned from a text file. */
|
||||
@@ -82,8 +83,8 @@ function newAccumulator(): WindowAccumulator {
|
||||
return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false }
|
||||
}
|
||||
|
||||
function truncateLine(line: string): string {
|
||||
return line.length > READ_MAX_LINE_LENGTH ? `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}` : line
|
||||
function truncateLine(line: string, maxLineLength: number): string {
|
||||
return line.length > maxLineLength ? `${line.substring(0, maxLineLength)}... (line truncated to ${maxLineLength} chars)` : line
|
||||
}
|
||||
|
||||
function lineByteSize(line: string, currentLineCount: number): number {
|
||||
@@ -94,9 +95,9 @@ function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindo
|
||||
acc.totalLines += 1
|
||||
if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return
|
||||
|
||||
const text = truncateLine(rawLine)
|
||||
const text = truncateLine(rawLine, request.maxLineLength)
|
||||
const bytes = lineByteSize(text, acc.lines.length)
|
||||
if (acc.outputBytes + bytes > READ_MAX_BYTES) {
|
||||
if (acc.outputBytes + bytes > request.maxBytes) {
|
||||
acc.truncatedByBytes = true
|
||||
acc.done = true
|
||||
return
|
||||
@@ -121,7 +122,7 @@ function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string
|
||||
* Accepts an `AsyncIterable<string>` (a chunked `streamText`) or an
|
||||
* `Iterable<string>` (a whole-file `readText` wrapped as `[text]`), so one code
|
||||
* path serves both. Scans for newlines with a capped line buffer (a newline-free
|
||||
* giant line is truncated, never buffered past {@link READ_MAX_LINE_LENGTH}),
|
||||
* giant line is truncated, never buffered past `request.maxLineLength`),
|
||||
* enforces the byte cap, and throws `FS_NOT_FOUND` for an offset past EOF.
|
||||
*/
|
||||
export async function buildWindow(
|
||||
@@ -130,12 +131,14 @@ export async function buildWindow(
|
||||
displayPath: string,
|
||||
): Promise<WindowResult> {
|
||||
const acc = newAccumulator()
|
||||
// One char past the truncation point is enough to prove a line overflows.
|
||||
const lineBufferCap = request.maxLineLength + 1
|
||||
let lineBuffer = ''
|
||||
|
||||
function appendToLineBuffer(segment: string): void {
|
||||
if (lineBuffer.length >= LINE_BUFFER_CAP) return
|
||||
if (lineBuffer.length >= lineBufferCap) return
|
||||
lineBuffer += segment
|
||||
if (lineBuffer.length > LINE_BUFFER_CAP) lineBuffer = lineBuffer.slice(0, LINE_BUFFER_CAP)
|
||||
if (lineBuffer.length > lineBufferCap) lineBuffer = lineBuffer.slice(0, lineBufferCap)
|
||||
}
|
||||
|
||||
function flushLine(): void {
|
||||
|
||||
@@ -23,12 +23,27 @@ import { buildWindow, formatReadOutput } from './read-render.ts'
|
||||
import type { FileReadOutcome } from './read-render.ts'
|
||||
import { sessionCwd } from './session-cwd.ts'
|
||||
|
||||
/** Default and maximum number of lines returned by one `read` call. */
|
||||
/** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */
|
||||
export const READ_LIMIT = 2000
|
||||
|
||||
/** Files at or above this size stream; smaller files read whole into memory. */
|
||||
/**
|
||||
* Default streaming threshold (the `readStreamMinSize` config): files at or
|
||||
* above this size stream; smaller files read whole into memory.
|
||||
*/
|
||||
export const STREAM_MIN_SIZE = 10 * 1024 * 1024
|
||||
|
||||
/** Resolved read-tool caps — plugin config after defaulting (see `Config` in index.ts). */
|
||||
export interface ReadToolCaps {
|
||||
/** Default and maximum number of lines returned by one call. */
|
||||
limit: number
|
||||
/** Maximum characters returned for a single line. */
|
||||
maxLineLength: number
|
||||
/** Maximum bytes returned for selected file lines. */
|
||||
maxBytes: number
|
||||
/** Files at or above this size stream; smaller files read whole into memory. */
|
||||
streamMinSize: number
|
||||
}
|
||||
|
||||
/** Validated `read` arguments after defaulting. */
|
||||
interface ReadInput {
|
||||
filePath: string
|
||||
@@ -43,17 +58,17 @@ function parsePositiveInteger(value: number, name: string): number {
|
||||
return value
|
||||
}
|
||||
|
||||
/** Validate value constraints the schema DSL can't express. */
|
||||
export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }): ReadInput {
|
||||
/** Validate value constraints the schema DSL can't express. `maxLimit` is the deployment's line cap. */
|
||||
export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }, maxLimit: number): ReadInput {
|
||||
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
|
||||
const offset = args.offset === undefined ? 1 : parsePositiveInteger(args.offset, 'offset')
|
||||
const limit = args.limit === undefined ? READ_LIMIT : parsePositiveInteger(args.limit, 'limit')
|
||||
if (limit > READ_LIMIT) throw new Error(`limit must be less than or equal to ${READ_LIMIT}`)
|
||||
const limit = args.limit === undefined ? maxLimit : parsePositiveInteger(args.limit, 'limit')
|
||||
if (limit > maxLimit) throw new Error(`limit must be less than or equal to ${maxLimit}`)
|
||||
return { filePath: args.file_path, offset, limit }
|
||||
}
|
||||
|
||||
/** Register the `read` tool and its system-prompt guidance. */
|
||||
export function applyReadTool(ctx: Context): void {
|
||||
export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:read',
|
||||
order: 100,
|
||||
@@ -66,10 +81,10 @@ export function applyReadTool(ctx: Context): void {
|
||||
parameters: {
|
||||
file_path: { type: 'string', required: true, description: 'Path to read, resolved by the filesystem backend.' },
|
||||
offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' },
|
||||
limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${READ_LIMIT}.` },
|
||||
limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${caps.limit}.` },
|
||||
},
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseReadArgs(args)
|
||||
const input = parseReadArgs(args, caps.limit)
|
||||
const cwd = sessionCwd(exec)
|
||||
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
|
||||
|
||||
@@ -83,10 +98,14 @@ export function applyReadTool(ctx: Context): void {
|
||||
|
||||
// Stream when the file is large OR size is unknown, so a size-less backend
|
||||
// never buffers an arbitrarily large file.
|
||||
const chunks = info.size === undefined || info.size >= STREAM_MIN_SIZE
|
||||
const chunks = info.size === undefined || info.size >= caps.streamMinSize
|
||||
? await ctx.fs.streamText(target, exec.signal)
|
||||
: [await ctx.fs.readText(target, exec.signal)]
|
||||
const window = await buildWindow(chunks, { offset: input.offset, limit: input.limit }, target.displayPath)
|
||||
const window = await buildWindow(
|
||||
chunks,
|
||||
{ offset: input.offset, limit: input.limit, maxLineLength: caps.maxLineLength, maxBytes: caps.maxBytes },
|
||||
target.displayPath,
|
||||
)
|
||||
|
||||
const outcome: FileReadOutcome = {
|
||||
offset: input.offset,
|
||||
@@ -106,7 +125,8 @@ export function applyReadTool(ctx: Context): void {
|
||||
// appended (`Read foo.txt (5 - 8)`), `read` kind (icon), and a follow-along
|
||||
// location whose line is the read's offset (defaulting to 1). The window is
|
||||
// derived from the RAW args (offset/limit as the model passed them), NOT the
|
||||
// tool's defaulted 1/READ_LIMIT, so an unbounded read shows a bare title.
|
||||
// tool's defaulted 1/configured limit, so an unbounded read shows a bare
|
||||
// title (and the presenter stays a pure function of args, config-free).
|
||||
presentCall(args): GenericCallView {
|
||||
const { offset, limit } = args
|
||||
const window = limit !== undefined && limit > 0
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWindow, READ_MAX_LINE_LENGTH } from '@deepseek-ai/dsh-tool-fs'
|
||||
import { buildWindow, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '@deepseek-ai/dsh-tool-fs'
|
||||
import type { ReadWindow } from '@deepseek-ai/dsh-tool-fs'
|
||||
|
||||
const READ_ALL: ReadWindow = { offset: 1, limit: 2000 }
|
||||
const DEFAULT_CAPS = { maxLineLength: READ_MAX_LINE_LENGTH, maxBytes: READ_MAX_BYTES }
|
||||
const READ_ALL: ReadWindow = { offset: 1, limit: 2000, ...DEFAULT_CAPS }
|
||||
|
||||
/** Yield `text` as one chunk (whole-file read shape). */
|
||||
async function* whole(text: string): AsyncIterable<string> {
|
||||
@@ -34,7 +35,7 @@ describe('buildWindow', () => {
|
||||
})
|
||||
|
||||
it('applies offset/limit', async () => {
|
||||
const result = await buildWindow(whole('one\ntwo\nthree\nfour'), { offset: 2, limit: 2 }, 'f')
|
||||
const result = await buildWindow(whole('one\ntwo\nthree\nfour'), { offset: 2, limit: 2, ...DEFAULT_CAPS }, 'f')
|
||||
expect(result.lines.map(l => l.number)).toEqual([2, 3])
|
||||
expect(result.totalLines).toBe(4)
|
||||
})
|
||||
@@ -62,7 +63,7 @@ describe('buildWindow', () => {
|
||||
})
|
||||
|
||||
it('rejects an offset past EOF', async () => {
|
||||
await expect(buildWindow(whole('one\ntwo'), { offset: 9, limit: 1 }, 'f')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
await expect(buildWindow(whole('one\ntwo'), { offset: 9, limit: 1, ...DEFAULT_CAPS }, 'f')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('flushes a final line with no trailing newline', async () => {
|
||||
@@ -76,9 +77,22 @@ describe('buildWindow', () => {
|
||||
expect(result.totalLines).toBe(2)
|
||||
})
|
||||
|
||||
describe('caps are per-request (the plugin config reaches the window)', () => {
|
||||
it('truncates lines at a custom maxLineLength and names it in the suffix', async () => {
|
||||
const result = await buildWindow(whole('abcdefghij'), { offset: 1, limit: 10, maxLineLength: 5, maxBytes: READ_MAX_BYTES }, 'f')
|
||||
expect(result.lines[0]?.text).toBe('abcde... (line truncated to 5 chars)')
|
||||
})
|
||||
|
||||
it('caps output at a custom maxBytes', async () => {
|
||||
const result = await buildWindow(whole('aaaa\nbbbb\ncccc'), { offset: 1, limit: 10, maxLineLength: 2000, maxBytes: 9 }, 'f')
|
||||
expect(result.lines.map(l => l.text)).toEqual(['aaaa', 'bbbb'])
|
||||
expect(result.truncatedByBytes).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('chunked input (streamed read shape)', () => {
|
||||
it('windows identically when text arrives in small chunks', async () => {
|
||||
const result = await buildWindow(chunked('one\ntwo\nthree', 2), { offset: 2, limit: 1 }, 'f')
|
||||
const result = await buildWindow(chunked('one\ntwo\nthree', 2), { offset: 2, limit: 1, ...DEFAULT_CAPS }, 'f')
|
||||
expect(result.lines).toEqual([{ number: 2, text: 'two' }])
|
||||
expect(result.totalLines).toBe(3)
|
||||
})
|
||||
|
||||
@@ -495,3 +495,71 @@ describe('result-time contextual diff (meta + presentResult)', () => {
|
||||
expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: null, newText: 'y' }] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('read caps are plugin config', () => {
|
||||
async function setupWith(config: ToolFs.Config) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeFs)
|
||||
await ctx.plugin(FsPolicy)
|
||||
await ctx.plugin(ToolFs, config)
|
||||
return { ctx, fs: ctx.fs as FakeFs }
|
||||
}
|
||||
|
||||
it('a configured readLimit is both the default and the cap, and the schema names it', async () => {
|
||||
const { ctx, fs } = await setupWith({ readLimit: 2 })
|
||||
fs.files.set('key:a.txt', 'one\ntwo\nthree\nfour')
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt' })
|
||||
expect(text(result)).toContain('(Showing lines 1-2 of 4. Use offset=3 to continue.)')
|
||||
const overCap = await call(ctx, 'read', { file_path: 'a.txt', limit: 3 })
|
||||
expect(overCap.isError).toBe(true)
|
||||
expect(text(overCap)).toContain('less than or equal to 2')
|
||||
const readSchema = ctx.tools.schemas().find(s => s.name === 'read')
|
||||
expect(JSON.stringify(readSchema)).toContain('Defaults to 2.')
|
||||
})
|
||||
|
||||
it('a configured readMaxLineLength truncates lines at the configured length', async () => {
|
||||
const { ctx, fs } = await setupWith({ readMaxLineLength: 4 })
|
||||
fs.files.set('key:a.txt', 'abcdefgh')
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt' })
|
||||
expect(text(result)).toContain('1: abcd... (line truncated to 4 chars)')
|
||||
})
|
||||
|
||||
it('a configured readMaxBytes caps the window at the configured bytes', async () => {
|
||||
const { ctx, fs } = await setupWith({ readMaxBytes: 9 })
|
||||
fs.files.set('key:a.txt', 'aaaa\nbbbb\ncccc')
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt' })
|
||||
expect(text(result)).toContain('Output capped.')
|
||||
expect(text(result)).not.toContain('cccc')
|
||||
})
|
||||
|
||||
it('a configured readStreamMinSize routes smaller files to the streaming path', async () => {
|
||||
const { ctx, fs } = await setupWith({ readStreamMinSize: 5 })
|
||||
fs.files.set('key:a.txt', 'alpha\nbeta')
|
||||
const readSpy = vi.spyOn(fs, 'readText')
|
||||
const streamSpy = vi.spyOn(fs, 'streamText')
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(streamSpy).toHaveBeenCalled()
|
||||
expect(readSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['readLimit', { readLimit: 0 }],
|
||||
['readLimit', { readLimit: 2.5 }],
|
||||
['readMaxLineLength', { readMaxLineLength: -1 }],
|
||||
['readMaxBytes', { readMaxBytes: Number.NaN }],
|
||||
['readStreamMinSize', { readStreamMinSize: 0 }],
|
||||
] as const)('rejects a non-positive or fractional %s at load', async (name, config) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeFs)
|
||||
await expect(ctx.plugin(ToolFs, config)).rejects.toThrow(new RegExp(`tool-fs: ${name} must be a positive integer`))
|
||||
})
|
||||
|
||||
it('has no default export (namespace plugin export shape)', () => {
|
||||
expect('default' in ToolFs).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/tools" },
|
||||
{ "path": "../../core/system-prompt" },
|
||||
|
||||
@@ -58,6 +58,18 @@ export function appendHookInvoked(session: Session, invocation: HookInvocation):
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a hook's stderr for {@link HookResultRecord.stderrSummary}: trimmed,
|
||||
* `undefined` when empty, cut at `maxChars` with an ellipsis when over. The
|
||||
* bound is a parameter — like `runHook`'s `defaultTimeoutMs`, each bridge owns
|
||||
* the config default and passes it in.
|
||||
*/
|
||||
export function summarizeStderr(stderr: string, maxChars: number): string | undefined {
|
||||
const t = stderr.trim()
|
||||
if (t.length === 0) return undefined
|
||||
return t.length > maxChars ? t.slice(0, maxChars) + '…' : t
|
||||
}
|
||||
|
||||
/** Append a `hook/result` outcome event to `session` (pairs with a prior `hook/invoked`). */
|
||||
export function appendHookResult(session: Session, record: HookResultRecord): void {
|
||||
session.append('hook/result', {
|
||||
|
||||
@@ -34,5 +34,5 @@ export { runHook } from './runner.ts'
|
||||
export type { RunHookOptions, RunHookResult } from './runner.ts'
|
||||
export { mergeHookOutputs } from './merge.ts'
|
||||
export type { MergedDecision, MergedHookOutcome } from './merge.ts'
|
||||
export { appendHookInvoked, appendHookResult } from './events.ts'
|
||||
export { appendHookInvoked, appendHookResult, summarizeStderr } from './events.ts'
|
||||
export type { HookInvocation, HookResultRecord } from './events.ts'
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { appendHookInvoked, appendHookResult } from '@deepseek-ai/dsh-hook-protocol'
|
||||
import { appendHookInvoked, appendHookResult, summarizeStderr } from '@deepseek-ai/dsh-hook-protocol'
|
||||
|
||||
describe('hook/* session events', () => {
|
||||
it('appendHookInvoked records a log-only hook/invoked (with matcher when present)', () => {
|
||||
@@ -59,3 +59,20 @@ describe('hook/* session events', () => {
|
||||
expect(result?.type === 'hook/result' && result.data.handlerId).toBe('pair-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeStderr', () => {
|
||||
it('returns undefined for empty/whitespace stderr', () => {
|
||||
expect(summarizeStderr('', 500)).toBeUndefined()
|
||||
expect(summarizeStderr(' \n\t ', 500)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('passes through a summary at or under the cap, trimmed', () => {
|
||||
expect(summarizeStderr(' blocked: bad tool ', 500)).toBe('blocked: bad tool')
|
||||
expect(summarizeStderr('abc', 3)).toBe('abc')
|
||||
})
|
||||
|
||||
it('truncates past the cap with an ellipsis', () => {
|
||||
expect(summarizeStderr('abcdef', 4)).toBe('abcd…')
|
||||
expect(summarizeStderr('x'.repeat(600), 500)).toBe('x'.repeat(500) + '…')
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ const config: Config = {
|
||||
pluginRoot: '/path/to/plugin', // optional: replaces ${CLAUDE_PLUGIN_ROOT} in command strings
|
||||
projectDir: '/path/to/project', // optional: replaces ${CLAUDE_PROJECT_DIR} AND sets the hook env var; defaults to the session cwd when omitted
|
||||
defaultTimeoutMs: 600_000, // optional: per-hook timeout when a hook sets none (CC default)
|
||||
stderrSummaryMaxChars: 500, // optional: char cap on the hook/result event's persisted stderr summary
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
matchesMatcher,
|
||||
mergeHookOutputs,
|
||||
runHook,
|
||||
summarizeStderr,
|
||||
type HookOutput,
|
||||
type MatcherGroup,
|
||||
type MergedHookOutcome,
|
||||
@@ -73,6 +74,8 @@ export interface Config {
|
||||
projectDir?: string
|
||||
/** Default per-hook timeout in ms when a hook sets none (CC default: 600000). */
|
||||
defaultTimeoutMs?: number
|
||||
/** Character cap for the `hook/result` event's persisted stderr summary. */
|
||||
stderrSummaryMaxChars?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
@@ -80,6 +83,7 @@ export const Config: z<Config> = z.object({
|
||||
pluginRoot: z.string(),
|
||||
projectDir: z.string(),
|
||||
defaultTimeoutMs: z.number().default(600_000),
|
||||
stderrSummaryMaxChars: z.number().default(500),
|
||||
})
|
||||
|
||||
/** A stable per-handler id so an invoked/result pair correlates in the log. */
|
||||
@@ -91,14 +95,18 @@ function nextHandlerId(point: string): string {
|
||||
/** The `{kind:'plugin'}` source stamped on every context this bridge injects. */
|
||||
const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'hooks-claude' }
|
||||
|
||||
/** Truncate a stderr blob for the `hook/result` summary field. */
|
||||
function summarize(stderr: string): string | undefined {
|
||||
const t = stderr.trim()
|
||||
if (t.length === 0) return undefined
|
||||
return t.length > 500 ? t.slice(0, 500) + '…' : t
|
||||
/** The summary cap bounds a persisted event field — a positive integer or the slice misbehaves silently. */
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`hooks-claude: ${name} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// Validate the cap BEFORE the config-file parse: a bad value must fail the
|
||||
// load loudly, not be skipped by the parse-failure early return.
|
||||
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? 500
|
||||
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
|
||||
// --- Parse the config ONCE at load. A read/parse failure is contained: the
|
||||
// bridge logs and registers nothing rather than crashing boot (a typo'd path
|
||||
// must not take the agent down). ---
|
||||
@@ -182,7 +190,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.logger.warn(`hooks-claude: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
|
||||
}
|
||||
if (session && opts.turn !== undefined) {
|
||||
const stderrSummary = summarize(output.stderr)
|
||||
const stderrSummary = summarizeStderr(output.stderr, stderrSummaryMaxChars)
|
||||
appendHookResult(session, {
|
||||
turn: opts.turn, point, handlerId,
|
||||
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
|
||||
|
||||
@@ -27,7 +27,7 @@ function hooks(d: string, h: unknown): string {
|
||||
writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json')
|
||||
}
|
||||
|
||||
type HarnessOpts = { pluginRoot?: string; projectDir?: string }
|
||||
type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxChars?: number }
|
||||
async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -139,6 +139,31 @@ describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', ()
|
||||
await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true)
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis
|
||||
})
|
||||
|
||||
it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => {
|
||||
const d = dir()
|
||||
const path = hooks(d, {})
|
||||
for (const bad of [0, -5, 1.5, Number.NaN]) {
|
||||
const adapter = new MockAdapter([])
|
||||
await expect(harness(path, adapter, { stderrSummaryMaxChars: bad }))
|
||||
.rejects.toThrow(/hooks-claude: stderrSummaryMaxChars must be a positive integer/)
|
||||
}
|
||||
})
|
||||
|
||||
it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 })
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ const config: Config = {
|
||||
configPath: '/path/to/.codex/hooks.json', // required
|
||||
model: 'deepseek-v4', // optional: stamped on every payload (Codex includes `model`)
|
||||
defaultTimeoutMs: 600_000, // optional: per-hook timeout when a hook sets none
|
||||
stderrSummaryMaxChars: 500, // optional: char cap on the hook/result event's persisted stderr summary
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
matchesMatcher,
|
||||
mergeHookOutputs,
|
||||
runHook,
|
||||
summarizeStderr,
|
||||
type HookOutput,
|
||||
type MatcherGroup,
|
||||
type MergedHookOutcome,
|
||||
@@ -49,12 +50,15 @@ export interface Config {
|
||||
model?: string
|
||||
/** Default per-hook timeout in ms when a hook sets none (Codex default: 600000). */
|
||||
defaultTimeoutMs?: number
|
||||
/** Character cap for the `hook/result` event's persisted stderr summary. */
|
||||
stderrSummaryMaxChars?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
configPath: z.string().required(),
|
||||
model: z.string().default(''),
|
||||
defaultTimeoutMs: z.number().default(600_000),
|
||||
stderrSummaryMaxChars: z.number().default(500),
|
||||
})
|
||||
|
||||
let handlerCounter = 0
|
||||
@@ -64,13 +68,18 @@ function nextHandlerId(point: string): string {
|
||||
|
||||
const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'hooks-codex' }
|
||||
|
||||
function summarize(stderr: string): string | undefined {
|
||||
const t = stderr.trim()
|
||||
if (t.length === 0) return undefined
|
||||
return t.length > 500 ? t.slice(0, 500) + '…' : t
|
||||
/** The summary cap bounds a persisted event field — a positive integer or the slice misbehaves silently. */
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`hooks-codex: ${name} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// Validate the cap BEFORE the config-file parse: a bad value must fail the
|
||||
// load loudly, not be skipped by the parse-failure early return.
|
||||
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? 500
|
||||
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
|
||||
let parsed: CodexHookConfig = {}
|
||||
try {
|
||||
const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8'))
|
||||
@@ -140,7 +149,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.logger.warn(`hooks-codex: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
|
||||
}
|
||||
if (session && opts.turn !== undefined) {
|
||||
const stderrSummary = summarize(output.stderr)
|
||||
const stderrSummary = summarizeStderr(output.stderr, stderrSummaryMaxChars)
|
||||
appendHookResult(session, {
|
||||
turn: opts.turn, point, handlerId,
|
||||
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
|
||||
|
||||
@@ -23,12 +23,12 @@ function hooks(d: string, h: unknown): string {
|
||||
writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json')
|
||||
}
|
||||
|
||||
async function harness(configPath: string, adapter: MockAdapter): Promise<Context> {
|
||||
async function harness(configPath: string, adapter: MockAdapter, opts: { stderrSummaryMaxChars?: number } = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(HooksCodex, { configPath, model: 'm' })
|
||||
await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
@@ -204,6 +204,29 @@ describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true)
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis
|
||||
})
|
||||
|
||||
it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => {
|
||||
const d = dir()
|
||||
hooks(d, {})
|
||||
for (const bad of [0, -5, 1.5, Number.NaN]) {
|
||||
const adapter = new MockAdapter([])
|
||||
await expect(harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: bad }))
|
||||
.rejects.toThrow(/hooks-codex: stderrSummaryMaxChars must be a positive integer/)
|
||||
}
|
||||
})
|
||||
|
||||
it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 })
|
||||
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…')
|
||||
})
|
||||
|
||||
it('warns on a skipped async hook and a direct apply() defaults the timeout', async () => {
|
||||
|
||||
@@ -34,7 +34,6 @@ A second, independent implementation of the same seam exists in `@deepseek-ai/ds
|
||||
## Limitations (MVP, documented deliberately)
|
||||
|
||||
- `prefill` throws `LlmError('UNSUPPORTED')` — DeepSeek's chat-prefix completion is a Beta feature on the `/beta` base URL; future work.
|
||||
- `image` blocks are skipped (no vision support on these models).
|
||||
- `tool_choice` is not mapped (not part of the core vocabulary).
|
||||
|
||||
## Errors
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
* rule for thinking mode — required there, ignored elsewhere, so we save
|
||||
* the tokens elsewhere); `tool-call` → `tool_calls[]`
|
||||
* - `tool-result` → its own `{role: 'tool'}` message (text flattened)
|
||||
* - `image` → skipped (MVP limitation, documented in the README)
|
||||
*
|
||||
* @module dsh-llm-deepseek/serialize
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { serializeMessages, serializeRequest } from '@deepseek-ai/dsh-llm-deepseek'
|
||||
|
||||
function request(overrides: Partial<GenerateOptions> = {}): GenerateOptions {
|
||||
@@ -110,11 +110,17 @@ describe('serializeMessages', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('skips image blocks (documented MVP limitation)', () => {
|
||||
it('skips plugin-added block types (merge-extensible ContentBlockMap)', () => {
|
||||
const wire = serializeMessages([
|
||||
{ role: 'user', content: [{ type: 'image', url: 'data:image/png;base64,x' }, { type: 'text', text: 'see image' }] },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'chart', data: 'x' } as unknown as ContentBlock,
|
||||
{ type: 'text', text: 'see chart' },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(wire).toEqual([{ role: 'user', content: 'see image' }])
|
||||
expect(wire).toEqual([{ role: 'user', content: 'see chart' }])
|
||||
})
|
||||
|
||||
it('emits an empty user message rather than dropping block-less messages', () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ pi-ai declares the openai/anthropic/google/mistral/AWS SDKs as install-time depe
|
||||
|
||||
## Limitations
|
||||
|
||||
Same MVP contract as llm-deepseek: `prefill` throws `UNSUPPORTED`, images are not representable, `tool_choice` is not mapped.
|
||||
Same MVP contract as llm-deepseek: `prefill` throws `UNSUPPORTED`, `tool_choice` is not mapped.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ export function toPiContext(options: GenerateOptions): PiContext {
|
||||
})
|
||||
break
|
||||
default:
|
||||
// image / plugin-added block types: not representable here.
|
||||
// plugin-added block types: not representable here.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai'
|
||||
import { mapStopReason, mapUsage, toPiContext, toStreamChunks } from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
|
||||
@@ -171,13 +171,13 @@ describe('toPiContext', () => {
|
||||
expect(context.messages.map(message => message.role)).toEqual(['user', 'user', 'toolResult'])
|
||||
})
|
||||
|
||||
it('skips image and unknown blocks in assistant content', () => {
|
||||
it('skips plugin-added (unknown) blocks in assistant content', () => {
|
||||
const context = toPiContext({
|
||||
model: 'm',
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'image', url: 'data:,x' },
|
||||
{ type: 'chart', data: 'x' } as unknown as ContentBlock,
|
||||
{ type: 'text', text: 'visible' },
|
||||
],
|
||||
}],
|
||||
|
||||
@@ -25,7 +25,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
|
||||
|
||||
### Content-block vocabulary (`types.ts`)
|
||||
|
||||
Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`, `tool-result`, `image`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging.
|
||||
Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`, `tool-result`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. The core set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map together with the adapter/UI/compaction support that honors it.
|
||||
|
||||
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages.
|
||||
|
||||
|
||||
@@ -57,24 +57,22 @@ export interface ToolResultBlock {
|
||||
cache?: CacheHint
|
||||
}
|
||||
|
||||
/** An image, by URL or data URL. */
|
||||
export interface ImageBlock {
|
||||
type: 'image'
|
||||
url: string
|
||||
mimeType?: string
|
||||
cache?: CacheHint
|
||||
}
|
||||
|
||||
/**
|
||||
* All known content block shapes, keyed by their `type` tag.
|
||||
* Merge-extensible: plugins add new block types via declaration merging.
|
||||
*
|
||||
* The core set is deliberately limited to blocks every shipping path honors.
|
||||
* Multimodal content (images, audio, …) has no core block type: a feature
|
||||
* that needs one adds it via declaration merging in the same coordinated
|
||||
* change that maps it in the adapters, surfaces it in the UI bridges, and
|
||||
* prices it in compaction — a producer never lands without its consumers
|
||||
* (see docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md).
|
||||
*/
|
||||
export interface ContentBlockMap {
|
||||
'text': TextBlock
|
||||
'reasoning': ReasoningBlock
|
||||
'tool-call': ToolCallBlock
|
||||
'tool-result': ToolResultBlock
|
||||
'image': ImageBlock
|
||||
}
|
||||
|
||||
export type ContentBlockType = keyof ContentBlockMap
|
||||
|
||||
@@ -63,12 +63,12 @@ describe('BlockAssembler', () => {
|
||||
|
||||
it('throws from assemble() when a partial has an unhandled blockType', () => {
|
||||
const assembler = new BlockAssembler()
|
||||
// Directly push a block-end for an image block whose block-start never
|
||||
// called ensure — but the image block-type flows through normally.
|
||||
// What we really need is a partial whose blockType is not text/reasoning/tool-call.
|
||||
// We can achieve this via a block-start for 'image' followed by blocks().
|
||||
assembler.push({ type: 'block-start', index: 0, blockType: 'image' } as unknown as StreamChunk)
|
||||
expect(() => assembler.blocks()).toThrow('cannot assemble incomplete block of type "image"')
|
||||
// A partial whose blockType is not text/reasoning/tool-call cannot be
|
||||
// assembled without its block-end. A plugin-added block type (here
|
||||
// 'video', via the merge-extensible ContentBlockMap) opened by a
|
||||
// block-start with no closing block-end exercises that throw.
|
||||
assembler.push({ type: 'block-start', index: 0, blockType: 'video' } as unknown as StreamChunk)
|
||||
expect(() => assembler.blocks()).toThrow('cannot assemble incomplete block of type "video"')
|
||||
})
|
||||
|
||||
it('mustGet throws when an index is missing from the partials map (invariant violation)', () => {
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('BlockAssembler properties', () => {
|
||||
fc.assert(fc.property(streamArb, (chunks) => {
|
||||
const blocks = feed(chunks).blocks()
|
||||
for (const block of blocks) {
|
||||
expect(['text', 'reasoning', 'tool-call', 'tool-result', 'image']).toContain(block.type)
|
||||
expect(['text', 'reasoning', 'tool-call', 'tool-result']).toContain(block.type)
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i
|
||||
|
||||
Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) lives in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row), so no separate column is needed.
|
||||
|
||||
The repo targets Node ≥ 24 (the root `engines` field), which includes the stable `node:sqlite` module. The database opens with `foreign_keys = ON` (so `ON DELETE CASCADE` drops a session's events with its row) and `journal_mode = WAL`. The table-layout version is stored in `PRAGMA user_version` and checked on open: a fresh database is stamped with the current `SCHEMA_VERSION`; a database written by any other, incompatible build (a non-current `user_version`, older or newer) is rejected rather than opened against an unknown layout — there is no migration (unreleased software).
|
||||
The repo targets Node ≥ 24 (the root `engines` field), which includes the stable `node:sqlite` module. The database opens with `foreign_keys = ON` (so `ON DELETE CASCADE` drops a session's events with its row) and the configured `journal_mode` (default `wal`; pick a rollback-journal mode like `delete` on filesystems where WAL's shared-memory files do not work, e.g. network mounts). The table-layout version is stored in `PRAGMA user_version` and checked on open: a fresh database is stamped with the current `SCHEMA_VERSION`; a database written by any other, incompatible build (a non-current `user_version`, older or newer) is rejected rather than opened against an unknown layout — there is no migration (unreleased software).
|
||||
|
||||
## Contract semantics over rows
|
||||
|
||||
@@ -21,6 +21,7 @@ The repo targets Node ≥ 24 (the root `engines` field), which includes the stab
|
||||
```ts
|
||||
interface Config {
|
||||
path: string // SQLite database file path, or ':memory:' for an in-process DB
|
||||
journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' // journal_mode pragma; default 'wal'
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { Session, SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow,
|
||||
type JournalMode, openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow,
|
||||
} from './schema.ts'
|
||||
|
||||
export { SCHEMA_VERSION } from './schema.ts'
|
||||
@@ -54,6 +54,13 @@ export interface Config {
|
||||
* dirs) on construction.
|
||||
*/
|
||||
path: string
|
||||
/**
|
||||
* SQLite `journal_mode` pragma. `wal` (the default) is the recorded
|
||||
* durability model; pick a rollback-journal mode (`delete`/`truncate`/
|
||||
* `persist`) on filesystems where WAL's shared-memory files do not work
|
||||
* (network mounts). See {@link JournalMode}.
|
||||
*/
|
||||
journalMode?: JournalMode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,6 +73,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
path: z.string().required(),
|
||||
journalMode: z.union(['wal', 'delete', 'truncate', 'persist'] as const).default('wal'),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -83,18 +91,19 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
super(ctx)
|
||||
// Open the database asynchronously (the parent directory may need creating);
|
||||
// every hook awaits `ready` first. Opening synchronously would force a sync
|
||||
// mkdir and block plugin apply.
|
||||
this.ready = this.openDb(config.path)
|
||||
// mkdir and block plugin apply. schemastery (static Config) has already
|
||||
// filled `journalMode`; the cast records that runtime fact.
|
||||
this.ready = this.openDb(config.path, (config as Required<Config>).journalMode)
|
||||
this.coordinator = new PersistenceCoordinator<number>(this.ctx, this)
|
||||
}
|
||||
|
||||
private async openDb(path: string): Promise<void> {
|
||||
private async openDb(path: string, journalMode: JournalMode): Promise<void> {
|
||||
if (path !== ':memory:') {
|
||||
const abs = resolve(path)
|
||||
await mkdir(dirname(abs), { recursive: true, mode: 0o700 })
|
||||
this.db = openDatabase(abs)
|
||||
this.db = openDatabase(abs, journalMode)
|
||||
} else {
|
||||
this.db = openDatabase(path)
|
||||
this.db = openDatabase(path, journalMode)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,10 +45,21 @@ export interface EventRow {
|
||||
surface_op: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Journal modes the backend will run under. `wal` is the default and the
|
||||
* durability model the persistence ADR records; the rollback-journal modes
|
||||
* (`delete`/`truncate`/`persist`) exist for filesystems where WAL's
|
||||
* shared-memory files do not work (network mounts). `memory`/`off` are
|
||||
* excluded: dropping journal durability silently contradicts what this
|
||||
* backend promises.
|
||||
*/
|
||||
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
|
||||
|
||||
/**
|
||||
* Open the database at `path` and apply the schema + pragmas. `foreign_keys`
|
||||
* makes `ON DELETE CASCADE` drop a session's events with its row; `journal_mode
|
||||
* = WAL` matches the durability model the ADR records (the row shape maps 1:1
|
||||
* makes `ON DELETE CASCADE` drop a session's events with its row; the
|
||||
* `journal_mode` pragma is set from the plugin's `journalMode` config (`wal`
|
||||
* default — the durability model the ADR records; the row shape maps 1:1
|
||||
* onto `SessionEvent`; opencode runs this exact shape on SQLite/WAL).
|
||||
*
|
||||
* The table-layout version is persisted in SQLite's `PRAGMA user_version` and
|
||||
@@ -66,10 +77,12 @@ export interface EventRow {
|
||||
* makes the version check reject both sibling v3 databases instead of opening
|
||||
* one against columns it does not have.
|
||||
*/
|
||||
export function openDatabase(path: string): DatabaseSync {
|
||||
export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync {
|
||||
const db = new DatabaseSync(path)
|
||||
db.exec('PRAGMA foreign_keys = ON')
|
||||
db.exec('PRAGMA journal_mode = WAL')
|
||||
// journalMode is a closed in-code union (validated by the plugin Config), not
|
||||
// user-controlled SQL — safe to interpolate (PRAGMA takes no bound params).
|
||||
db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`)
|
||||
// `PRAGMA user_version` always returns exactly one row { user_version }.
|
||||
const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number }
|
||||
if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -53,7 +54,7 @@ runCoordinatorContract('sqlite', async (): Promise<CoordinatorFixture> => {
|
||||
// A row past the committed region whose `data` does not parse: scanRows
|
||||
// bounds the preserved prefix at it and returns its seq as tornFrom, which
|
||||
// the backend surfaces to the coordinator as the tornMarker to delete from.
|
||||
const db = openDatabase(path)
|
||||
const db = openDatabase(path, 'wal')
|
||||
const next = (db.prepare('SELECT COALESCE(MAX(seq), -1) + 1 AS n FROM events WHERE session_id = ?')
|
||||
.get(id) as { n: number }).n
|
||||
db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
|
||||
@@ -192,7 +193,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5
|
||||
await b1.dispose()
|
||||
// Hand-write an interrupted turn (turn/start seq 6, no turn/end).
|
||||
const db = openDatabase(path)
|
||||
const db = openDatabase(path, 'wal')
|
||||
db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)')
|
||||
.run(m.id, 'turn/start', JSON.stringify({ turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
db.close()
|
||||
@@ -204,7 +205,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
expect(loaded.events.at(-1)!.type).toBe('turn/end')
|
||||
// load() is mutating: the synthetic turn/end MUST be on disk so the stored log
|
||||
// is balanced and the cursor is truthful (contract: load closes, not defers).
|
||||
const probe = openDatabase(path)
|
||||
const probe = openDatabase(path, 'wal')
|
||||
const stored = probe.prepare('SELECT seq, type FROM events WHERE session_id = ? ORDER BY seq').all(m.id) as { seq: number; type: string }[]
|
||||
probe.close()
|
||||
expect(stored.map(r => r.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
||||
@@ -237,21 +238,21 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
|
||||
it('rejects opening a database whose schema version is not the current build (newer OR older)', async () => {
|
||||
const path = await freshDbPath()
|
||||
openDatabase(path).close() // stamp user_version = SCHEMA_VERSION
|
||||
openDatabase(path, 'wal').close() // stamp user_version = SCHEMA_VERSION
|
||||
// Bump user_version past what this build supports.
|
||||
const dbNewer = openDatabase(path)
|
||||
const dbNewer = openDatabase(path, 'wal')
|
||||
dbNewer.exec(`PRAGMA user_version = ${SCHEMA_VERSION + 1}`)
|
||||
dbNewer.close()
|
||||
expect(() => openDatabase(path)).toThrow(/incompatible with this build/)
|
||||
expect(() => openDatabase(path, 'wal')).toThrow(/incompatible with this build/)
|
||||
|
||||
// A stale OLDER version (e.g. a pre-summary-drop v1 DB) is also rejected —
|
||||
// we do not migrate (unreleased software, no backward-compat).
|
||||
const olderPath = await freshDbPath()
|
||||
openDatabase(olderPath).close()
|
||||
const dbOlder = openDatabase(olderPath)
|
||||
openDatabase(olderPath, 'wal').close()
|
||||
const dbOlder = openDatabase(olderPath, 'wal')
|
||||
dbOlder.exec('PRAGMA user_version = 1')
|
||||
dbOlder.close()
|
||||
expect(() => openDatabase(olderPath)).toThrow(/incompatible with this build/)
|
||||
expect(() => openDatabase(olderPath, 'wal')).toThrow(/incompatible with this build/)
|
||||
})
|
||||
|
||||
it('rejects a sibling v3 database (the merge-collided version) rather than opening it against missing columns', async () => {
|
||||
@@ -261,11 +262,11 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
// of this build's columns, so it MUST be rejected, not opened. Stamp a v3
|
||||
// database and confirm the version check refuses it.
|
||||
const path = await freshDbPath()
|
||||
openDatabase(path).close() // creates + stamps user_version = SCHEMA_VERSION (4)
|
||||
const db = openDatabase(path)
|
||||
openDatabase(path, 'wal').close() // creates + stamps user_version = SCHEMA_VERSION (4)
|
||||
const db = openDatabase(path, 'wal')
|
||||
db.exec('PRAGMA user_version = 3')
|
||||
db.close()
|
||||
expect(() => openDatabase(path)).toThrow(/schema version 3, incompatible with this build/)
|
||||
expect(() => openDatabase(path, 'wal')).toThrow(/schema version 3, incompatible with this build/)
|
||||
})
|
||||
|
||||
it('a corrupt-JSON row in the uncommitted tail is discarded on load, not unloadable', async () => {
|
||||
@@ -281,7 +282,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
// unloadable; a torn tail must be discarded. scanRows finds the last
|
||||
// turn/end on the seq+type columns (never parsing tail `data`), so the
|
||||
// unparsable row after it bounds the preserved prefix and is deleted by load.
|
||||
const db = openDatabase(path)
|
||||
const db = openDatabase(path, 'wal')
|
||||
db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)')
|
||||
.run(m.id, 'turn/start', '{not valid json')
|
||||
db.close()
|
||||
@@ -370,6 +371,29 @@ describe('SessionPersistenceSqlite: edge cases', () => {
|
||||
await b2.dispose()
|
||||
})
|
||||
|
||||
it('journalMode config reaches the database (default wal, rollback modes selectable)', async () => {
|
||||
// :memory: databases always report journal_mode=memory, so probe file DBs.
|
||||
const walPath = await freshDbPath()
|
||||
const bWal = await backend(walPath)
|
||||
await bWal.ctx.sessionPersistence.create(meta('jm-wal'))
|
||||
expect((openDatabase(walPath, 'wal').prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('wal')
|
||||
await bWal.dispose()
|
||||
|
||||
const deletePath = await freshDbPath()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: deletePath, journalMode: 'delete' })
|
||||
await ctx.sessionPersistence.create(meta('jm-delete'))
|
||||
// Probe through a second connection: journal_mode=delete is a per-database
|
||||
// property only insofar as no WAL files exist — assert the world, not the
|
||||
// backend's self-report (no -wal sidecar after writes in delete mode).
|
||||
const db = openDatabase(deletePath, 'delete')
|
||||
expect((db.prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('delete')
|
||||
db.close()
|
||||
expect(existsSync(`${deletePath}-wal`)).toBe(false)
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('HMR: a DIFFERENT session colliding with a materialized on-disk id is rejected', async () => {
|
||||
const path = await freshDbPath()
|
||||
// Instance 1 materializes a session and disposes.
|
||||
|
||||
@@ -25,6 +25,8 @@ Unlike the in-process backends, the child does NOT share this cordis context —
|
||||
| `cwd` | string | parent cwd | Working directory for the child process and its ACP session. |
|
||||
| `permission` | `'allow' \| 'reject'` | `reject` | How to auto-answer the child's `session/request_permission` prompts. `reject` declines every prompt (answer `cancelled`); `allow` approves via the first allow-shaped option. The first cut surfaces no prompt to a human. |
|
||||
| `env` | Record<string,string> | `{}` | Extra env vars for the child (e.g. its own `DEEPSEEK_API_KEY`). Forwarded on top of a credential-scrubbed copy of the parent env, so an explicit key reaches the child while ambient secrets do not leak implicitly. |
|
||||
| `disposeEofGraceMs` | number | `6000` | Dispose ladder tier 1: how long the child gets to quiesce on its own after stdin EOF (flush persistence, tear down its nested subprocesses) before SIGTERM. |
|
||||
| `disposeGraceMs` | number | `3000` | Dispose ladder tier 2: grace between SIGTERM and the SIGKILL escalation. |
|
||||
|
||||
```yaml
|
||||
- id: subagent-acp
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import { type AcpRunSpec, type PermissionPolicy, startAcpRun } from './run.ts'
|
||||
import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts'
|
||||
|
||||
export const name = 'subagent-acp'
|
||||
export const inject = ['subagents']
|
||||
@@ -52,6 +52,14 @@ export interface Config {
|
||||
* ambient secrets do not leak implicitly.
|
||||
*/
|
||||
env: Record<string, string>
|
||||
/**
|
||||
* Grace period (ms) for the child's EOF-driven quiesce on dispose — its
|
||||
* window to flush persistence and tear down its own nested subprocesses
|
||||
* before the parent escalates to a signal.
|
||||
*/
|
||||
disposeEofGraceMs?: number
|
||||
/** Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation on dispose. */
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
@@ -61,8 +69,20 @@ export const Config: z<Config> = z.object({
|
||||
cwd: z.string(),
|
||||
permission: z.union(['allow', 'reject'] as const).default('reject'),
|
||||
env: z.dict(z.string()).default({}),
|
||||
disposeEofGraceMs: z.number().default(DEFAULT_DISPOSE_EOF_GRACE_MS),
|
||||
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
|
||||
})
|
||||
|
||||
/** A dispose grace must be a positive finite number (it bounds the teardown wait). */
|
||||
function assertPositiveFinite(name: string, value: number): void {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`subagent-acp: ${name} must be a positive finite number`)
|
||||
}
|
||||
}
|
||||
|
||||
/** The shape after schemastery applied the defaults (cwd has none). */
|
||||
type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
|
||||
|
||||
/**
|
||||
* The ACP provider. Advertises NO start-time capabilities: an out-of-process
|
||||
* child cannot honor `outputSchema`/`maxDepth`/`toolFilter` (the service rejects
|
||||
@@ -71,7 +91,7 @@ export const Config: z<Config> = z.object({
|
||||
class AcpProvider implements SubagentProvider {
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false }
|
||||
|
||||
constructor(readonly name: string, private readonly ctx: Context, private readonly config: Config) {}
|
||||
constructor(readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig) {}
|
||||
|
||||
start(request: SubagentStartRequest) {
|
||||
const spec: AcpRunSpec = {
|
||||
@@ -80,6 +100,8 @@ class AcpProvider implements SubagentProvider {
|
||||
cwd: this.config.cwd ?? process.cwd(),
|
||||
permission: this.config.permission,
|
||||
env: this.config.env,
|
||||
disposeEofGraceMs: this.config.disposeEofGraceMs,
|
||||
disposeGraceMs: this.config.disposeGraceMs,
|
||||
onError: (error, stopReason) => {
|
||||
// The seam forbids `result` rejecting, so a child-level failure is
|
||||
// flattened to a stop reason — preserve it here rather than losing it.
|
||||
@@ -91,5 +113,9 @@ class AcpProvider implements SubagentProvider {
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.subagents.registerProvider(new AcpProvider(config.providerName, ctx, config))
|
||||
// schemastery (Config) has already filled every defaulted field.
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveFinite('disposeEofGraceMs', resolved.disposeEofGraceMs)
|
||||
assertPositiveFinite('disposeGraceMs', resolved.disposeGraceMs)
|
||||
ctx.subagents.registerProvider(new AcpProvider(resolved.providerName, ctx, resolved))
|
||||
}
|
||||
@@ -73,16 +73,16 @@ export interface AcpRunSpec {
|
||||
/**
|
||||
* Grace period (ms) for the child's EOF-driven quiesce in
|
||||
* {@link SubagentRun.dispose} — the window to flush persistence and tear down
|
||||
* its OWN nested subprocesses before the parent escalates to a signal. Defaults
|
||||
* to {@link DEFAULT_DISPOSE_EOF_GRACE_MS}; a test injects a small value.
|
||||
* its OWN nested subprocesses before the parent escalates to a signal. The
|
||||
* plugin fills this from its `disposeEofGraceMs` config.
|
||||
*/
|
||||
disposeEofGraceMs?: number
|
||||
disposeEofGraceMs: number
|
||||
/**
|
||||
* Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation in
|
||||
* {@link SubagentRun.dispose}. Defaults to {@link DEFAULT_DISPOSE_GRACE_MS};
|
||||
* a test injects a small value to exercise the escalation without a long wait.
|
||||
* {@link SubagentRun.dispose}. The plugin fills this from its
|
||||
* `disposeGraceMs` config.
|
||||
*/
|
||||
disposeGraceMs?: number
|
||||
disposeGraceMs: number
|
||||
/**
|
||||
* Sink for a child-level failure that the run flattened into a stop reason
|
||||
* (the seam contract forbids `result` rejecting). The driver calls this with
|
||||
@@ -94,19 +94,20 @@ export interface AcpRunSpec {
|
||||
}
|
||||
|
||||
/**
|
||||
* Default grace for the child's EOF-driven quiesce on dispose — the window for it
|
||||
* to flush persistence and tear down its OWN nested subprocesses (which may run
|
||||
* their own `SIGTERM`→`SIGKILL` escalation) before the parent escalates to a
|
||||
* signal. Deliberately LARGER than {@link DEFAULT_DISPOSE_GRACE_MS}: a cooperative
|
||||
* child whose teardown is itself waiting on a signal-trapping grandchild (e.g. a
|
||||
* bash subprocess in its own ~3s SIGTERM→SIGKILL grace) plus a final flush needs
|
||||
* MORE than a single signal-grace of headroom, or the parent's SIGTERM cuts it off
|
||||
* exactly as it reaches its own SIGKILL+flush. The child is an arbitrary ACP agent,
|
||||
* so this is a standalone generous default, NOT derived from any child's internals.
|
||||
* Default grace for the child's EOF-driven quiesce on dispose (the
|
||||
* `disposeEofGraceMs` config) — the window for it to flush persistence and tear
|
||||
* down its OWN nested subprocesses (which may run their own `SIGTERM`→`SIGKILL`
|
||||
* escalation) before the parent escalates to a signal. Deliberately LARGER than
|
||||
* {@link DEFAULT_DISPOSE_GRACE_MS}: a cooperative child whose teardown is itself
|
||||
* waiting on a signal-trapping grandchild (e.g. a bash subprocess in its own ~3s
|
||||
* SIGTERM→SIGKILL grace) plus a final flush needs MORE than a single
|
||||
* signal-grace of headroom, or the parent's SIGTERM cuts it off exactly as it
|
||||
* reaches its own SIGKILL+flush. The child is an arbitrary ACP agent, so this is
|
||||
* a standalone generous default, NOT derived from any child's internals.
|
||||
*/
|
||||
export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000
|
||||
|
||||
/** Default grace between SIGTERM and SIGKILL on dispose (mirrors the bash executor). */
|
||||
/** Default grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config; mirrors the bash executor). */
|
||||
export const DEFAULT_DISPOSE_GRACE_MS = 3_000
|
||||
|
||||
/**
|
||||
@@ -372,8 +373,8 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
|
||||
// Reach quiescence, not merely request it (dispose must AWAIT the child
|
||||
// actually stopping). If the child is already gone, nothing to do.
|
||||
if (child.exitCode !== null || child.signalCode !== null) return
|
||||
const eofGraceMs = spec.disposeEofGraceMs ?? DEFAULT_DISPOSE_EOF_GRACE_MS
|
||||
const graceMs = spec.disposeGraceMs ?? DEFAULT_DISPOSE_GRACE_MS
|
||||
const eofGraceMs = spec.disposeEofGraceMs
|
||||
const graceMs = spec.disposeGraceMs
|
||||
// 1. Graceful: end the ACP request stream (stdin EOF) and let the child
|
||||
// quiesce ON ITS OWN. Our acp-agent has NO SIGTERM handler in a normal
|
||||
// session — it tears down via the server bridge's connection-close path
|
||||
|
||||
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import * as acp from '../src/index.ts'
|
||||
import { acpStopReason, acpContentText, buildChildEnv, SENSITIVE_ENV_PATTERN, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts'
|
||||
import { acpStopReason, acpContentText, buildChildEnv, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, SENSITIVE_ENV_PATTERN, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts'
|
||||
|
||||
/**
|
||||
* Keyless integration tests for the ACP subagent backend. Each spawns a REAL
|
||||
@@ -171,7 +171,7 @@ describe('dsh-subagent-acp', () => {
|
||||
const run = startAcpRun(
|
||||
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent, signal: controller.signal },
|
||||
// `touch <sentinel>` — runs only if the process is actually spawned.
|
||||
{ command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {} },
|
||||
{ command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS },
|
||||
)
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
@@ -390,7 +390,7 @@ describe('dsh-subagent-acp', () => {
|
||||
// absent-sink branch).
|
||||
const run = startAcpRun(
|
||||
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent },
|
||||
{ command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {} },
|
||||
{ command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS },
|
||||
)
|
||||
const result = await run.result
|
||||
// The seam contract: a child-level failure resolves error, never rejects.
|
||||
@@ -398,6 +398,47 @@ describe('dsh-subagent-acp', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('plugin-config dispose graces reach the run (SIGKILL escalation through the provider)', async () => {
|
||||
// Same trap scenario as the direct startAcpRun escalation test, but the
|
||||
// graces arrive via the PLUGIN CONFIG through the registered provider — so a
|
||||
// regression that stops threading config into AcpRunSpec (falling back to
|
||||
// the 6s/3s defaults) blows past the 4000ms bound and fails loud.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-cfg-trap-'))
|
||||
const ready = join(tmp, 'trap-armed')
|
||||
try {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, mockServer],
|
||||
permission: 'reject',
|
||||
env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, TSX_TSCONFIG_PATH: repoTsconfig },
|
||||
disposeEofGraceMs: 150,
|
||||
disposeGraceMs: 150,
|
||||
})
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
await waitForFile(ready)
|
||||
await expect(Promise.race([
|
||||
run.dispose(),
|
||||
new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return — config graces not threaded to the run')) }, 4000) }),
|
||||
])).resolves.toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a non-positive dispose grace at load', async () => {
|
||||
for (const bad of [{ disposeEofGraceMs: 0 }, { disposeGraceMs: -1 }, { disposeEofGraceMs: Number.NaN }]) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', args: [], permission: 'reject', env: {}, ...bad }))
|
||||
.rejects.toThrow(/subagent-acp: dispose(?:Eof)?GraceMs must be a positive finite number/)
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves error via the provider (real load path) when the command does not exist', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
@@ -428,6 +469,8 @@ describe('dsh-subagent-acp', () => {
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: {},
|
||||
disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
|
||||
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
|
||||
onError: (error, stopReason) => { errors.push({ message: error.message, stopReason }) },
|
||||
},
|
||||
)
|
||||
|
||||
@@ -5,8 +5,7 @@ Packages that exist to serve development, testing, and the examples rather than
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `invariants/` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) |
|
||||
| `ui-stdio/` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent | (drives `ctx.agents`) |
|
||||
| `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) |
|
||||
| `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) |
|
||||
|
||||
`invariants` runs only in dev mode (contract checks, not runtime behavior). `ui-stdio` and `llm-replay` were extracted from the examples for reuse and to bring them under the per-file coverage gate; they back the demos and the snapshot test tier. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers.
|
||||
`invariants` runs only in dev mode (contract checks, not runtime behavior). `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers.
|
||||
@@ -1,44 +0,0 @@
|
||||
# @deepseek-ai/dsh-ui-stdio
|
||||
|
||||
A minimal stdio (readline) UI, as a plugin. It reads lines from stdin and feeds them to an agent (`send` when idle, `steer` while a turn is running), and renders that agent's streamed output and tool activity to stdout. A UI is "just a plugin" here — it consumes the `session/event` transcript feed plus a few `agent/*` control events (`agent/status`, `agent/created`/`agent/disposed`) and the `agents` service (`inject: ['agents']`), so the same plugin drives any example or product surface.
|
||||
|
||||
This is a **convenience REPL for local testing and the demos, not a product surface** — its observable behavior is free to change. It is deliberately NOT treated as a load-bearing consumer when weighing whether a live event/API must exist: the boundary mirror events were removed precisely because "ui-stdio renders from them" is not a product constraint (it was migrated to `session/event`). The real product surfaces are the ACP bridge (`dsh-acp`) and the app packages.
|
||||
|
||||
This package consolidates what were two near-identical copies under `examples/echo-agent` and `examples/coding-agent`. The coding copy was a superset; this package IS that superset — dimmed chain-of-thought rendering plus robust piped-stdin EOF handling — with the per-consumer differences moved into `Config`.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `welcome` | string | `'ready.'` | Banner printed once on start, before the first `> ` prompt. |
|
||||
| `agent` | string | `'main'` | Id of the agent that stdin **drives** (`send`/`steer`) and whose `agent/status` gates the EOF exit. Rendering is **not** scoped by it — see below. |
|
||||
|
||||
```yaml
|
||||
- id: ui-stdio
|
||||
name: '@deepseek-ai/dsh-ui-stdio'
|
||||
config:
|
||||
welcome: 'agent REPL ready. Give it a coding task.'
|
||||
```
|
||||
|
||||
## Rendering
|
||||
|
||||
Rendering is **global** — every agent's events are written to stdout, not just `config.agent`'s. `config.agent` scopes only *input* (which agent stdin drives) and the EOF-exit gate; the single-agent demos this serves have just one agent, so the distinction is moot for them. (A multi-agent UI that needs per-agent panes would filter these handlers by the agent argument — deliberately out of scope here.)
|
||||
|
||||
- `session/event` — the durable transcript feed drives ALL rendering, from a single listener so `inReasoning` transitions stay deterministic in append order: `assistant/chunk` writes the model's `text-delta` verbatim and wraps `reasoning-delta` in the dim SGR (`\x1B[2m … \x1B[0m`) so the chain-of-thought is visually subordinate to the answer (inert when no `reasoning-delta` chunks arrive, e.g. a mock model); `turn/start` prints a `[<agent> turn N]` header (the short agent label comes from a session-id→agent-id map seeded from `ctx.agents.list()` at install and kept live via `agent/created`/`agent/disposed`, since the turn event carries only the turn number); `turn/end` prints the trailing `> ` prompt; `tool/call` renders `[tool call] name(args)`; `tool/result` renders the joined text blocks as `[tool result] …`; and `todo/write` renders a glyphed checklist.
|
||||
|
||||
## The I/O seam
|
||||
|
||||
The production entry point `apply(ctx, config)` binds the real `process` streams. The testable core is `createStdioChat(ctx, config, runtime)`, where `runtime: StdioRuntime` supplies `input` / `output` / `exit`. This seam is deliberately **not** part of the serializable `Config` (streams and functions do not belong in YAML config); it exists so the render, EOF, and disposal branches can be exercised with fakes instead of hijacking globals.
|
||||
|
||||
## Piped-stdin exit
|
||||
|
||||
On stdin EOF the plugin exits the process, but carefully:
|
||||
|
||||
- **No work submitted** (empty stdin, blank-only lines): exit immediately — no turn will ever start, so there is nothing to wait for. Gating on an observed `running` here would hang forever.
|
||||
- **Work submitted**: exit the next time the agent settles to `idle` *after* having been observed `running`. `agent.send()` does not synchronously flip status to `running`, so requiring an observed `running` first (`sawRunning`) avoids exiting in the gap before the turn starts and dropping work; and the loop batches several queued messages into one turn, so the exit keys off the idle transition rather than counting sends.
|
||||
|
||||
Disposal (HMR or fiber teardown) closes the readline interface, which also fires `close` — a `disposed` guard ensures teardown never calls `process.exit`.
|
||||
|
||||
## Plugin export shape
|
||||
|
||||
Named `name` / `inject` / `Config` / `apply`, with **no default export**: the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default would collapse the module to the bare function and drop the `inject` namespace (see [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)). The keyless Loader-path e2e smokes in `examples/{echo,coding}-agent` guard this end-to-end.
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-ui-stdio",
|
||||
"description": "Minimal stdio (readline) UI plugin: renders agent/* events to stdout and feeds stdin lines to the agent",
|
||||
"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"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./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",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,4 +6,4 @@ The model-facing todo tool. A single **product** package — there is no interfa
|
||||
|---|---|---|
|
||||
| `tool-todo/` | Model-facing `todo_write` tool; writes the whole list to the session log (`todo/write`) | (registers on `ctx.tools`) |
|
||||
|
||||
The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [stdio UI](../support/ui-stdio) prints the list, the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate.
|
||||
The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [stdio app's readline UI](../ui/stdio-agent) prints the list, the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate.
|
||||
@@ -18,7 +18,7 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup
|
||||
|
||||
## Rendering
|
||||
|
||||
The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [stdio UI](../../support/ui-stdio) prints a glyphed checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires).
|
||||
The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [stdio app's readline UI](../../ui/stdio-agent) prints a glyphed checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires).
|
||||
|
||||
## Export shape
|
||||
|
||||
|
||||
@@ -8,6 +8,6 @@ Integrations that expose the agent to an external editor or client. These are **
|
||||
| `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) |
|
||||
| `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) |
|
||||
|
||||
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline `ui-stdio` plugin is the unstructured analogue but lives in `support/` because it exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product.
|
||||
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline UI is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own.
|
||||
|
||||
`stdio-agent` and `acp-agent` are the two **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is the swappable backends plus one app entry plus any optional product tools. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention.
|
||||
@@ -69,8 +69,8 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
|
||||
* client as message content. Today only `text` maps; `resource_link` is an
|
||||
* ACP prompt-only input rendered into text by {@link acpPromptToText};
|
||||
* `reasoning` is surfaced via `agent_thought_chunk`
|
||||
* streaming rather than as a message block, and `tool-call`/`tool-result`/
|
||||
* `image` are handled by the tool-call update path or not advertised.
|
||||
* streaming rather than as a message block, and `tool-call`/`tool-result`
|
||||
* are handled by the tool-call update path.
|
||||
*/
|
||||
export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock | undefined {
|
||||
switch (block.type) {
|
||||
@@ -78,7 +78,7 @@ export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock |
|
||||
return { type: 'text', text: block.text }
|
||||
// reasoning → streamed as agent_thought_chunk, not a message block
|
||||
// tool-call / tool-result → the tool_call / tool_call_update path
|
||||
// image → not advertised
|
||||
// plugin-added block types → not surfaced
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk'
|
||||
import {
|
||||
@@ -33,9 +34,9 @@ describe('harnessBlockToAcpContent', () => {
|
||||
expect(harnessBlockToAcpContent({ type: 'text', text: 'hi' })).toEqual({ type: 'text', text: 'hi' })
|
||||
})
|
||||
|
||||
it('returns undefined for non-text blocks (reasoning/tool/image)', () => {
|
||||
it('returns undefined for non-text blocks (reasoning / plugin-added)', () => {
|
||||
expect(harnessBlockToAcpContent({ type: 'reasoning', text: 'think' })).toBeUndefined()
|
||||
expect(harnessBlockToAcpContent({ type: 'image', url: 'https://x/y.png', mimeType: 'image/png' })).toBeUndefined()
|
||||
expect(harnessBlockToAcpContent({ type: 'chart', data: 'x' } as unknown as ContentBlock)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('streamSessionEventUpdate', () => {
|
||||
it('drops non-text tool-result content (text-only)', () => {
|
||||
const update = updatesFor(evt('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'),
|
||||
content: [{ type: 'image', url: 'https://x/y.png' }],
|
||||
content: [{ type: 'reasoning', text: 'private' }],
|
||||
isError: false,
|
||||
}))[0]
|
||||
expect((update as { content: unknown[] }).content).toEqual([])
|
||||
|
||||
@@ -13,7 +13,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha
|
||||
| `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) |
|
||||
| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model`/`systemPrompt` |
|
||||
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
|
||||
| `@deepseek-ai/dsh-ui-stdio` | the readline UI, bound to the `main` agent |
|
||||
| `stdio-chat` (in-package module) | the readline UI, bound to the `main` agent |
|
||||
|
||||
`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`.
|
||||
|
||||
|
||||
@@ -34,10 +34,10 @@
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
|
||||
"@cordisjs/plugin-logger-console": "^1.0.0",
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-core": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
|
||||
"@deepseek-ai/dsh-ui-stdio": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6",
|
||||
"schemastery": "^3.17.0"
|
||||
},
|
||||
@@ -46,10 +46,10 @@
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@cordisjs/plugin-logger-console": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-core": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-ui-stdio": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6",
|
||||
"schemastery": "^3.17.0"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* The stdio chat app: the providerless agent spine ({@link
|
||||
* @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster a terminal
|
||||
* chat needs — a console logger, the readline `ui-stdio` UI, JSONL session
|
||||
* chat needs — a console logger, the readline UI (the in-package `stdio-chat`
|
||||
* module), JSONL session
|
||||
* persistence, and a pre-created `main` agent the UI drives.
|
||||
*
|
||||
* The cluster is BAKED IN, not left to the leaf: a stdio app always logs to the
|
||||
* console (stdout is just the terminal) and always pre-creates the `main` agent
|
||||
* `ui-stdio` sends to. The leaf supplies the swappable backends (the LLM
|
||||
* the readline UI sends to. The leaf supplies the swappable backends (the LLM
|
||||
* adapter, the bash executor), optional product tools, the optional `hmr`
|
||||
* dev-reload plugin, and this app's {@link Config} (model, prompt, persistence
|
||||
* root, welcome banner).
|
||||
@@ -29,8 +30,10 @@
|
||||
* Plugin export shape: named `name`/`Config`/`apply`, NO default export — the
|
||||
* cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray
|
||||
* default would collapse the module to the bare `apply` and drop the `Config`
|
||||
* namespace (see docs/postmortem/0001). The keyless Loader-path smoke in the
|
||||
* echo example guards this end-to-end.
|
||||
* namespace (see docs/postmortem/0001). This app carries no `inject`, so a
|
||||
* collapsed shape would BOOT rather than crash a smoke — the shape is pinned by
|
||||
* the explicit `unwrapExports` assertion in this package's unit suite, and the
|
||||
* keyless echo smoke proves the composed tree runs through the real Loader.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-stdio-agent
|
||||
*/
|
||||
@@ -42,7 +45,7 @@ import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-core'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import * as uiStdio from '@deepseek-ai/dsh-ui-stdio'
|
||||
import * as uiStdio from './stdio-chat.ts'
|
||||
|
||||
export const name = 'stdio-agent'
|
||||
|
||||
@@ -81,7 +84,7 @@ export const Config: z<Config> = z.object({
|
||||
* Compose the spine with the stdio front door. The console logger comes first
|
||||
* (infra), then the agent-core bundle pre-creating the `main` agent from this
|
||||
* app's `model`/`systemPrompt`/`resumeSessionId`, then the JSONL backend, then
|
||||
* the `ui-stdio` UI bound to `main`. The `hmr` dev-reload plugin is a leaf
|
||||
* the readline UI bound to `main`. The `hmr` dev-reload plugin is a leaf
|
||||
* concern (see the module doc), so it is not mounted here.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
|
||||
+12
-17
@@ -1,23 +1,18 @@
|
||||
/**
|
||||
* Minimal stdio UI plugin: reads lines from stdin → `agent.send()`/`steer()`,
|
||||
* and renders the durable transcript to stdout. A UI is "just a plugin" — it
|
||||
* consumes the `session/event` feed (the assistant token stream, turn/step
|
||||
* boundaries, tool activity, todos) plus a few `agent/*` control events
|
||||
* (`agent/status`, `agent/created`/`agent/disposed`) and the `agents` service,
|
||||
* so the same plugin drives any example or product surface.
|
||||
* The stdio app's readline UI: reads lines from stdin → `agent.send()`/
|
||||
* `steer()`, and renders the durable transcript to stdout. A UI is "just a
|
||||
* plugin" — it consumes the `session/event` feed (the assistant token stream,
|
||||
* turn/step boundaries, tool activity, todos) plus a few `agent/*` control
|
||||
* events (`agent/status`, `agent/created`/`agent/disposed`) and the `agents`
|
||||
* service. Dimmed chain-of-thought rendering plus robust piped-stdin EOF→idle
|
||||
* exit handling, configured via {@link Config}.
|
||||
*
|
||||
* Consolidates what were two near-identical copies under `examples/echo-agent`
|
||||
* and `examples/coding-agent` (the latter a superset). This package IS that
|
||||
* superset: dimmed chain-of-thought rendering plus the robust piped-stdin
|
||||
* EOF→idle exit handling, configured per consumer via {@link Config}.
|
||||
* An internal module of the stdio app, not a package of its own: the app's
|
||||
* front-door cluster always includes this UI, and nothing else composes it.
|
||||
* The export shape stays named `name`/`inject`/`Config`/`apply` — the plugin
|
||||
* contract the app's `ctx.plugin(uiStdio, …)` mount consumes.
|
||||
*
|
||||
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default
|
||||
* export — the cordis Loader's `unwrapExports` does `exports.default ?? exports`,
|
||||
* so a stray default would collapse the module to the bare function and drop
|
||||
* the `inject` namespace (see docs/postmortem/0001). The keyless Loader-path
|
||||
* e2e smokes in `examples/{echo,coding}-agent` guard this end-to-end.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-ui-stdio
|
||||
* @module @deepseek-ai/dsh-stdio-agent/stdio-chat
|
||||
*/
|
||||
|
||||
import { createInterface } from 'node:readline'
|
||||
@@ -35,7 +35,7 @@ const stdioBin = join(repoRoot, 'packages/ui/stdio-agent/lib/bin.js')
|
||||
const dshPackages = [
|
||||
'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
|
||||
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local',
|
||||
'bash/tool-bash', 'support/invariants', 'support/ui-stdio',
|
||||
'bash/tool-bash', 'support/invariants',
|
||||
'session-persistence/session-persistence',
|
||||
'session-persistence/session-persistence-jsonl', 'ui/stdio-agent',
|
||||
]
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'
|
||||
import type { Readable, Writable } from 'node:stream'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import type { StdioRuntime } from '../src/index.ts'
|
||||
import type { StdioRuntime } from '../src/stdio-chat.ts'
|
||||
|
||||
const createInterface = vi.hoisted(() => vi.fn(() => {
|
||||
const reader = new EventEmitter() as EventEmitter & { close(): void }
|
||||
@@ -32,7 +32,7 @@ function fakeRuntime(inputIsTTY: boolean, outputIsTTY: boolean): StdioRuntime {
|
||||
|
||||
describe('createStdioChat readline mode', () => {
|
||||
it('enables terminal editing only when both stdio streams are TTYs', async () => {
|
||||
const { createStdioChat } = await import('../src/index.ts')
|
||||
const { createStdioChat } = await import('../src/stdio-chat.ts')
|
||||
|
||||
const tty = fakeRuntime(true, true)
|
||||
createStdioChat(fakeContext(), {}, tty)
|
||||
@@ -12,9 +12,11 @@ import * as stdioAgent from '../src/index.ts'
|
||||
* agent; `persistenceRoot`/`welcome`/`resumeSessionId` route to their backends.
|
||||
*
|
||||
* `hmr` is NOT part of this plugin (it is a leaf entry — a Loader-only dev
|
||||
* plugin the in-process tier cannot import); the REAL Loader-path guard (export
|
||||
* shape, `unwrapExports`, the whole subprocess tree incl. `hmr`) is the keyless
|
||||
* echo smoke in `examples/echo-agent`. Here we assert the composition + config
|
||||
* plugin the in-process tier cannot import); the keyless echo smoke in
|
||||
* `examples/echo-agent` proves the whole subprocess tree (incl. `hmr`) boots
|
||||
* through the real Loader, while the export SHAPE is pinned by this suite's
|
||||
* explicit `unwrapExports` assertion (an inject-less app would boot past a
|
||||
* stray default rather than crash). Here we assert the composition + config
|
||||
* forwarding the unit tier can reach.
|
||||
*/
|
||||
async function mount(config: stdioAgent.Config): Promise<Context> {
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { createStdioChat, type Config, type StdioRuntime } from '../src/index.ts'
|
||||
import { createStdioChat, type Config, type StdioRuntime } from '../src/stdio-chat.ts'
|
||||
|
||||
/**
|
||||
* Unit tests for the stdio UI plugin. They drive the REAL plugin body
|
||||
@@ -31,9 +31,6 @@
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-jsonl"
|
||||
},
|
||||
{
|
||||
"path": "../../support/ui-stdio"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,7 +8,7 @@ Each tool is registered independently; a product that wants only one disables th
|
||||
|
||||
| Tool | Args | Behavior |
|
||||
|---|---|---|
|
||||
| `web_search` | `query` (string) | Discovery. Returns an optional answer plus source URLs. `max_results` is **not** model-facing — the tool sets the bound (`WEB_SEARCH_MAX_RESULTS = 8`) and passes it to the seam. |
|
||||
| `web_search` | `query` (string) | Discovery. Returns an optional answer plus source URLs. `max_results` is **not** model-facing — the tool sets the bound (the `searchMaxResults` config, default 8) and passes it to the seam. |
|
||||
| `web_fetch` | `url` (string), `timeout_ms` (number, optional) | Retrieves a specific URL. HTML bodies are rendered to markdown-ish text; text bodies pass through. A non-2xx status is reported, not an error. |
|
||||
|
||||
## Config
|
||||
@@ -17,6 +17,7 @@ Each tool is registered independently; a product that wants only one disables th
|
||||
|---|---|---|
|
||||
| `search` | `true` | Register `web_search`. |
|
||||
| `fetch` | `true` | Register `web_fetch`. |
|
||||
| `searchMaxResults` | `8` | Upper bound on sources returned by one `web_search` call (the seam truncates a longer provider list and flags it). |
|
||||
|
||||
```yaml
|
||||
- id: tool-web
|
||||
@@ -27,4 +28,4 @@ Each tool is registered independently; a product that wants only one disables th
|
||||
|
||||
Tool registration follows product **enablement**, not backend availability. A tool stays visible even when its selected provider is missing, misconfigured, ambiguous, or temporarily unavailable; the seam resolves the provider at execution time and execution fails with a structured `WebError` (e.g. `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`), which `ToolRegistry.execute()` turns into an error tool result the model can read and hooks/UI can route on. This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. To remove a web tool entirely, disable it here in config.
|
||||
|
||||
The tool reads only the aggregated `ctx.web.searchStatus()` / `fetchStatus()` for diagnostics — never each provider's `status()` directly — so provider selection has one owner.
|
||||
The tool never calls a provider's `status()` and never enumerates providers — its only execution path is `ctx.web.search()` / `ctx.web.fetch()`, and provider unavailability reaches it as the structured `WebError` codes selection throws at execution time. Provider selection stays entirely inside the seam, with one owner.
|
||||
@@ -20,7 +20,7 @@
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-web'
|
||||
import { applyWebSearchTool } from './search.ts'
|
||||
import { applyWebSearchTool, WEB_SEARCH_MAX_RESULTS } from './search.ts'
|
||||
import { applyWebFetchTool } from './fetch.ts'
|
||||
|
||||
export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall } from './search.ts'
|
||||
@@ -38,13 +38,26 @@ export interface Config {
|
||||
search?: boolean
|
||||
/** Register `web_fetch`. Defaults to true. */
|
||||
fetch?: boolean
|
||||
/** Upper bound on sources returned by one `web_search` call. */
|
||||
searchMaxResults?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
search: z.boolean().default(true),
|
||||
fetch: z.boolean().default(true),
|
||||
searchMaxResults: z.number().default(WEB_SEARCH_MAX_RESULTS),
|
||||
})
|
||||
|
||||
/** The shape after schemastery applies its defaults to every field. */
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/** The result cap must be a positive integer (it bounds a provider's source list). */
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`tool-web: ${name} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the enabled web tools. `search`/`fetch` default to true; a product
|
||||
* that wants only one disables the other in config. The tools' disposers are
|
||||
@@ -52,6 +65,9 @@ export const Config: z<Config> = z.object({
|
||||
* teardown is needed.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
if (config.search !== false) applyWebSearchTool(ctx)
|
||||
if (config.fetch !== false) applyWebFetchTool(ctx)
|
||||
// schemastery (Config) has already filled every defaulted field.
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveInteger('searchMaxResults', resolved.searchMaxResults)
|
||||
if (resolved.search) applyWebSearchTool(ctx, resolved.searchMaxResults)
|
||||
if (resolved.fetch) applyWebFetchTool(ctx)
|
||||
}
|
||||
@@ -13,10 +13,10 @@ import type { WebSearchResult } from '@deepseek-ai/dsh-web'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
/**
|
||||
* Default upper bound on returned sources. Owned by the consumer (not the
|
||||
* provider or model), mirroring `dsh-tool-fs`'s `READ_LIMIT`/`GREP_LIMIT`. The
|
||||
* model just asks a question; the product controls how much context returns.
|
||||
* The default `8` aligns with OpenCode's Exa default.
|
||||
* Default upper bound on returned sources (the `searchMaxResults` config).
|
||||
* Owned by the consumer (not the provider or model), mirroring `dsh-tool-fs`'s
|
||||
* `READ_LIMIT`. The model just asks a question; the product controls how much
|
||||
* context returns. The default `8` aligns with OpenCode's Exa default.
|
||||
*/
|
||||
export const WEB_SEARCH_MAX_RESULTS = 8
|
||||
|
||||
@@ -67,8 +67,8 @@ export function presentSearchCall(args: { query: string }): GenericCallView {
|
||||
return { card: 'generic', title: args.query, kind: 'search', rawInput: args.query }
|
||||
}
|
||||
|
||||
/** Register the `web_search` tool and its system-prompt guidance. */
|
||||
export function applyWebSearchTool(ctx: Context): void {
|
||||
/** Register the `web_search` tool and its system-prompt guidance. `maxResults` is the deployment's source cap. */
|
||||
export function applyWebSearchTool(ctx: Context, maxResults: number): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:web_search',
|
||||
order: 110,
|
||||
@@ -84,7 +84,7 @@ export function applyWebSearchTool(ctx: Context): void {
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseSearchArgs(args)
|
||||
const result = await ctx.web.search(
|
||||
{ query: input.query, maxResults: WEB_SEARCH_MAX_RESULTS },
|
||||
{ query: input.query, maxResults },
|
||||
exec.signal ? { signal: exec.signal } : undefined,
|
||||
)
|
||||
return [{ type: 'text', text: formatSearchOutput(result) }]
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
presentFetchCall,
|
||||
renderBody,
|
||||
htmlToMarkdown,
|
||||
WEB_SEARCH_MAX_RESULTS,
|
||||
} from '@deepseek-ai/dsh-tool-web'
|
||||
|
||||
const available: WebProviderStatus = { available: true }
|
||||
@@ -187,9 +188,12 @@ describe('tool-web registration', () => {
|
||||
})
|
||||
|
||||
it('registers web_search even when no provider is available (schema follows enablement, not availability)', async () => {
|
||||
const { fiber, ctx } = await mountTools()
|
||||
const { fiber, ctx, call } = await mountTools()
|
||||
expect(ctx.tools.schemas().map(s => s.name)).toContain('web_search')
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'none' })
|
||||
// No provider is registered: the schema stays visible and execution reports
|
||||
// the structured unavailability instead.
|
||||
const out = await call('web_search', { query: 'q' })
|
||||
expect(out.error?.code).toBe('WEB_PROVIDER_UNAVAILABLE')
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -279,3 +283,48 @@ describe('tool-web execution through the real registry', () => {
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchMaxResults is plugin config', () => {
|
||||
it('forwards the default cap to the seam when unconfigured', async () => {
|
||||
const seen: { maxResults?: number | undefined } = {}
|
||||
const provider: WebSearchProvider = {
|
||||
id: 'stub-search',
|
||||
status: () => available,
|
||||
search: (request) => { seen.maxResults = request.maxResults; return Promise.resolve({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) },
|
||||
}
|
||||
const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
|
||||
await call('web_search', { query: 'q' })
|
||||
expect(seen.maxResults).toBe(WEB_SEARCH_MAX_RESULTS)
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards a configured cap to the seam, which enforces it', async () => {
|
||||
const sources = Array.from({ length: 5 }, (_, i) => ({ url: `https://s${i}.test` }))
|
||||
const provider: WebSearchProvider = {
|
||||
id: 'stub-search',
|
||||
status: () => available,
|
||||
search: request => Promise.resolve({ providerId: 'stub-search', query: request.query, sources, truncated: false }),
|
||||
}
|
||||
const { fiber, call } = await mountTools({ config: { searchMaxResults: 2 }, webConfig: { searchProvider: 'stub-search' }, search: provider })
|
||||
const out = await call('web_search', { query: 'q' })
|
||||
expect(out.isError).toBe(false)
|
||||
const body = out.content.map(b => b.text).join('')
|
||||
expect(body).toContain('https://s1.test')
|
||||
expect(body).not.toContain('https://s2.test')
|
||||
expect(body).toContain('Showing the first 2 sources.')
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['zero', 0],
|
||||
['negative', -3],
|
||||
['fractional', 1.5],
|
||||
])('rejects a %s searchMaxResults at load', async (_label, value) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(WebService, {})
|
||||
await expect(ctx.plugin(ToolWeb, { searchMaxResults: value }))
|
||||
.rejects.toThrow(/tool-web: searchMaxResults must be a positive integer/)
|
||||
})
|
||||
})
|
||||
@@ -377,9 +377,11 @@ describe('web-fetch-local plugin registration', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(fetchPlugin, {})
|
||||
expect(ctx.web.fetchStatus()).toEqual({ available: true, providerId: LOCAL_FETCH_PROVIDER_ID })
|
||||
await expect(ctx.web.fetch({ url: `${base}/` }))
|
||||
.resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 })
|
||||
await fiber.dispose()
|
||||
expect(ctx.web.fetchStatus()).toEqual({ available: false, reason: 'configured-missing' })
|
||||
await expect(ctx.web.fetch({ url: `${base}/` }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
|
||||
})
|
||||
|
||||
it('has no default export (namespace plugin export shape)', () => {
|
||||
@@ -418,7 +420,8 @@ describe('web-fetch-local plugin registration', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(fetchPlugin, { maxRedirects: 0 })
|
||||
expect(ctx.web.fetchStatus()).toEqual({ available: true, providerId: LOCAL_FETCH_PROVIDER_ID })
|
||||
await expect(ctx.web.fetch({ url: `${base}/` }))
|
||||
.resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 })
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -264,12 +264,14 @@ describe('DeepSeekSearchProvider error handling', () => {
|
||||
|
||||
describe('web-search-deepseek plugin registration', () => {
|
||||
it('registers the provider into ctx.web (HMR-safe)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse(searchResponse())))
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(deepseekPlugin, { apiKey: 'ds-key' })
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: DEEPSEEK_PROVIDER_ID })
|
||||
await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: DEEPSEEK_PROVIDER_ID })
|
||||
await fiber.dispose()
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' })
|
||||
await expect(ctx.web.search({ query: 'q' }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
|
||||
})
|
||||
|
||||
it('rejects maxTokens: 0 at plugin construction', async () => {
|
||||
@@ -315,13 +317,14 @@ describe('web-search-deepseek plugin registration', () => {
|
||||
})
|
||||
|
||||
it('boots over ctx.web through the unwrapped module without an inject error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse(searchResponse())))
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(deepseekPlugin) as Parameters<Context['plugin']>[0]
|
||||
// A collapsed export shape (dropped inject) would throw "without inject" here.
|
||||
const fiber = await ctx.plugin(unwrapped, { apiKey: 'ds-key' })
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: DEEPSEEK_PROVIDER_ID })
|
||||
await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: DEEPSEEK_PROVIDER_ID })
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -334,7 +337,6 @@ describe('web-search-deepseek plugin registration', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(deepseekPlugin, {})
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: DEEPSEEK_PROVIDER_ID })
|
||||
await ctx.web.search({ query: 'q' })
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(url).toBe('https://api.deepseek.com/anthropic/v1/messages')
|
||||
@@ -354,7 +356,8 @@ describe('web-search-deepseek plugin registration', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
|
||||
await ctx.plugin(deepseekPlugin, {})
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-unavailable' })
|
||||
await expect(ctx.web.search({ query: 'q' }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' }))
|
||||
} finally {
|
||||
if (prev !== undefined) process.env.DEEPSEEK_API_KEY = prev
|
||||
}
|
||||
|
||||
@@ -205,12 +205,14 @@ describe('ExaSearchProvider error handling', () => {
|
||||
|
||||
describe('web-search-exa plugin registration', () => {
|
||||
it('registers the provider into ctx.web (HMR-safe)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ results: [] })))
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(exaPlugin, { apiKey: 'exa-key' })
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: EXA_PROVIDER_ID })
|
||||
await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: EXA_PROVIDER_ID })
|
||||
await fiber.dispose()
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' })
|
||||
await expect(ctx.web.search({ query: 'q' }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
|
||||
})
|
||||
|
||||
it('has no default export (namespace plugin export shape)', () => {
|
||||
@@ -238,7 +240,6 @@ describe('web-search-exa plugin registration', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(exaPlugin, {})
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: EXA_PROVIDER_ID })
|
||||
await ctx.web.search({ query: 'q' })
|
||||
const [url] = fetchMock.mock.calls[0] as unknown as [string]
|
||||
expect(url).toBe('https://api.exa.ai/search')
|
||||
@@ -256,7 +257,8 @@ describe('web-search-exa plugin registration', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID })
|
||||
await ctx.plugin(exaPlugin, {})
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-unavailable' })
|
||||
await expect(ctx.web.search({ query: 'q' }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' }))
|
||||
} finally {
|
||||
if (prev !== undefined) process.env.EXA_API_KEY = prev
|
||||
}
|
||||
|
||||
@@ -186,12 +186,14 @@ describe('PerplexitySearchProvider error handling', () => {
|
||||
|
||||
describe('web-search-perplexity plugin registration', () => {
|
||||
it('registers the provider into ctx.web (HMR-safe)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ choices: [{ message: { content: 'a' } }], citations: [] })))
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(perplexityPlugin, { apiKey: 'pplx-key' })
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: PERPLEXITY_PROVIDER_ID })
|
||||
await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: PERPLEXITY_PROVIDER_ID })
|
||||
await fiber.dispose()
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' })
|
||||
await expect(ctx.web.search({ query: 'q' }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
|
||||
})
|
||||
|
||||
it('has no default export (namespace plugin export shape)', () => {
|
||||
@@ -219,7 +221,6 @@ describe('web-search-perplexity plugin registration', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(perplexityPlugin, {})
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: PERPLEXITY_PROVIDER_ID })
|
||||
await ctx.web.search({ query: 'q' })
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(url).toBe('https://api.perplexity.ai/chat/completions')
|
||||
@@ -238,7 +239,8 @@ describe('web-search-perplexity plugin registration', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID })
|
||||
await ctx.plugin(perplexityPlugin, {})
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-unavailable' })
|
||||
await expect(ctx.web.search({ query: 'q' }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' }))
|
||||
} finally {
|
||||
if (prev !== undefined) process.env.PERPLEXITY_API_KEY = prev
|
||||
}
|
||||
|
||||
+11
-12
@@ -18,8 +18,7 @@ Search and fetch share no request schema and no business logic, but they are del
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `registerSearchProvider(provider)` / `registerFetchProvider(provider)` | Register a backend. Throws `WebError` `WEB_DUPLICATE_PROVIDER` on a duplicate id within that capability kind. Returns a disposer; emits `web/providers-change` on register and on dispose. Disposed with the calling fiber. |
|
||||
| `searchStatus()` / `fetchStatus()` | Derived (never stored) `WebCapabilityStatus`: whether the capability has a selected usable provider, or the broad category it fails in. Diagnostics + execution-resolution input. |
|
||||
| `registerSearchProvider(provider)` / `registerFetchProvider(provider)` | Register a backend. Throws `WebError` `WEB_DUPLICATE_PROVIDER` on a duplicate id within that capability kind. Returns a disposer. Disposed with the calling fiber. |
|
||||
| `search(request, exec?)` | Resolve the search provider and run one search. Enforces `request.maxResults` on the result (truncates `sources[]`, sets `truncated`). Throws `WebError` when the capability cannot run. |
|
||||
| `fetch(request, exec?)` | Resolve the fetch provider and retrieve one URL. A non-2xx response is a result, not a throw. Throws `WebError` for failures to safely retrieve or represent the resource. |
|
||||
|
||||
@@ -27,18 +26,18 @@ Providers register **capabilities**, not tools. `dsh-tool-web` is the only owner
|
||||
|
||||
## Selection
|
||||
|
||||
Selection never depends on registration, config, or HMR order. A capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or env `$DSH_WEB_SEARCH_PROVIDER`/`$DSH_WEB_FETCH_PROVIDER` feeding the same fields), or auto-selects when exactly one usable provider is registered:
|
||||
Selection never depends on registration, config, or HMR order. A capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or env `$DSH_WEB_SEARCH_PROVIDER`/`$DSH_WEB_FETCH_PROVIDER` feeding the same fields), or auto-selects when exactly one usable provider is registered. `search()`/`fetch()` resolve the provider at execution time:
|
||||
|
||||
| Situation | `WebCapabilityStatus` | Execution |
|
||||
|---|---|---|
|
||||
| configured id registered and `status().available` | `available` for it | runs |
|
||||
| configured id not registered | `configured-missing` | `WEB_PROVIDER_CONFIGURED_MISSING` |
|
||||
| configured id registered but unavailable | `configured-unavailable` | `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` |
|
||||
| no id, exactly one registered usable provider | `available` for it | runs |
|
||||
| no id, no usable provider | `none` | `WEB_PROVIDER_UNAVAILABLE` |
|
||||
| no id, multiple usable providers | `ambiguous` | `WEB_PROVIDER_AMBIGUOUS` |
|
||||
| Situation | Execution |
|
||||
|---|---|
|
||||
| configured id registered and `status().available` | runs that provider |
|
||||
| configured id not registered | `WEB_PROVIDER_CONFIGURED_MISSING` |
|
||||
| configured id registered but unavailable | `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` |
|
||||
| no id, exactly one registered usable provider | runs it |
|
||||
| no id, no usable provider | `WEB_PROVIDER_UNAVAILABLE` |
|
||||
| no id, multiple usable providers | `WEB_PROVIDER_AMBIGUOUS` |
|
||||
|
||||
`WebCapabilityStatus` carries only `available` + a `reason` discriminant (plus the winning `providerId` on the available branch). The branchable per-reason detail lives in the thrown `WebError`, which is the surface callers route on — so the same fact never gets two homes that can disagree. A provider's own `status()` is a cheap local check (credential presence, parseable config) and **must not make network calls**; `dsh-tool-web` reads only the aggregated `searchStatus()`/`fetchStatus()`, never each provider's `status()` directly.
|
||||
The failure branches throw `WebError`, whose structured code (plus message detail — the missing id, the ambiguous candidate set) is the surface callers route on. A provider's own `status()` is a cheap local check (credential presence, parseable config) that feeds this execution-time selection and **must not make network calls**; `dsh-tool-web` never calls a provider's `status()` — it executes through `ctx.web.search()`/`fetch()` and routes on the thrown codes, so provider selection has one owner.
|
||||
|
||||
## Vocabulary
|
||||
|
||||
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in New Issue
Block a user