Merge remote-tracking branch 'origin/master' into simpl-d-fs-write-only
# Conflicts: # docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md # packages/fs/fs-local/src/fsio.ts
This commit is contained in:
@@ -1,18 +1,16 @@
|
||||
# AGENTS.md — Harness Packages
|
||||
|
||||
This directory contains all `@deepseek-ai/dsh-*` harness packages. When editing code here, follow these conventions:
|
||||
This directory contains all `@deepseek-ai/dsh-*` harness packages. Repo-wide conventions (effects, declaration merging, waterfall semantics, ESM, testing policy) are in the root [AGENTS.md](../AGENTS.md) § Conventions; the points below are packages-specific.
|
||||
|
||||
- **Effect-based registrations**: every contribution (tool, section, adapter, agent, event listener) goes through `ctx.effect()` / `ctx.on()`, and `register()` methods return disposers. Never use bare arrays or manual cleanup.
|
||||
- **Declaration merging**: services declare their ctx key in `declare module 'cordis' { interface Context { } }` and their events in `interface Events`. Merge-extensible maps (`ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap`, `TurnTriggerMap`, `TurnEndReasonMap`, `SessionEventMap`) are how plugins add new variants.
|
||||
- **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)`; call `next()` to delegate, or return without it to short-circuit (veto). Never call `next()` after returning.
|
||||
- **Plugin export shape — namespace OR default, never both.** A *service* package exports the service class as `export default` (the Loader instantiates it). A *function/namespace* plugin exports `name` / `inject` / `Config` / `apply` as separate named exports and **must NOT add `export default`** — the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default export collapses the module to the bare `apply` function and silently discards the `inject`/`name`/`Config` namespace, leaving the plugin with no injected services (it then throws `cannot get property … without inject` at load). See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md).
|
||||
- **Read an optional (non-injected) service via `ctx.get(name)`, not `ctx.<name>`.** For a service a plugin reads opportunistically but deliberately leaves out of `static inject` (e.g. `AgentLoop` reading `sessionPersistence`), the `ctx.<name>` property proxy resolves by an ancestor-only fiber walk that throws when the call arrives through a foreign traceable shadow (the service lives on a sibling fiber). `ctx.get(name)` is the topology-independent global-store lookup, strict by default (an inactive/absent backend reads as `undefined` — prefer it over the `ctx.get(name, false)` overload, which also skips the active-state check). Services that ARE in `static inject` resolve fine via `ctx.<name>`. See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md).
|
||||
- **Tests**: vitest in `packages/<group>/<pkg>/tests/*.spec.ts`. Every registry needs an HMR-safety test (register a plugin, dispose its fiber, assert cleanup). Err on the side of more tests — edge cases, error paths, event ordering, races. A plugin shipped via `cordis.yml` also needs at least one test that drives it through the REAL Loader/export path (hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape) — see AGENTS.md § Defensive patterns "Line coverage is not behavior coverage". Real-API (with-key) e2e tests are cheap here (we are DeepSeek) and welcome — write many, especially smoke tests; see AGENTS.md § Secrets / .env.
|
||||
- **A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader/export path** — hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape. Full testing policy (tiers, with-key generosity, real-entry-path guards): [docs/testing.md](../docs/testing.md).
|
||||
|
||||
Naming notes:
|
||||
- A *service* `src/index.ts` exports the service class as `export default` + all public types; a *function/namespace plugin* `src/index.ts` exports `name`/`inject`/`Config`/`apply` as named exports and NO default (see the plugin-export-shape rule above)
|
||||
- `src/types.ts` contain only types — no runtime code
|
||||
- Tests live at package level under `tests/`, not `src/__tests__/`
|
||||
- A package's README and module/JSDoc comments are part of the change: when you alter behavior (config keys, defaults, error codes, wire fields), update them in the same commit. CI runs `pnpm run doc-sync`, which typechecks fenced `ts` blocks in `packages/*/*.md` and `packages/*/*/*.md`, regenerates the cordis events/services catalog from the `interface Events` / `interface Context` declarations (failing if the committed copy is stale), and checks markdown wrapping across this file too — but it does NOT catch prose drift (config keys, defaults, error codes), so those stay on the author. A new event needs an `@mode` tag on its JSDoc (the catalog generator hard-errors without it — see the root AGENTS.md).
|
||||
|
||||
- A *service* `src/index.ts` exports the service class as `export default` + all public types; a *function/namespace plugin* `src/index.ts` exports `name`/`inject`/`Config`/`apply` as named exports and NO default (the export-shape rule above).
|
||||
- `src/types.ts` contains only types — no runtime code.
|
||||
- Tests live at package level under `tests/`, not `src/__tests__/`.
|
||||
- A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; prose accuracy stays on the author ([the documentation standard](../docs/AGENTS.md)).
|
||||
|
||||
Read the per-package README.md for package-specific details: service API, events, extension points, TODOs.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Packages
|
||||
|
||||
Harness packages, all under the `@deepseek-ai/dsh-*` scope. Each package is a Cordis plugin (microkernel-style): it exports either a default `Service` subclass or a functional plugin that gets registered via `ctx.plugin()`, declares its ctx key/events where applicable through declaration merging, and exposes extension points through `ctx.effect()`, `ctx.on()`, and `ctx.waterfall()`.
|
||||
Harness packages, all under the `@deepseek-ai/dsh-*` scope. Each package is a Cordis plugin (microkernel-style): it exports either a default `Service` subclass or a functional plugin, declares its ctx key/events through declaration merging, and exposes extension points through `ctx.effect()`, `ctx.on()`, and `ctx.waterfall()`. Authoring conventions: [AGENTS.md](AGENTS.md) (subtree) and the root [AGENTS.md](../AGENTS.md) § Conventions.
|
||||
|
||||
## Hierarchy
|
||||
|
||||
Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group directory is a pure container (no `package.json` of its own); the package name stays `@deepseek-ai/dsh-<pkg>` regardless of group. Each group has a `README.md` describing its role and whether it is product or support infrastructure.
|
||||
Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group directory is a pure container (no `package.json` of its own); the package name stays `@deepseek-ai/dsh-<pkg>` regardless of group. **Each group README is the canonical per-package map** — package roles, ctx keys, and the product-vs-support split live there, next to the code.
|
||||
|
||||
| Group | Role | Release expectation |
|
||||
|---|---|---|
|
||||
@@ -18,115 +18,16 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
|
||||
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface |
|
||||
| [`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) | 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 |
|
||||
| [`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 have to 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 the hierarchy docs).
|
||||
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).
|
||||
|
||||
## Dependency graph
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
dsh-brand (no harness deps — type-only Branded<B> primitive)
|
||||
dsh-llm ← dsh-brand (vocabulary; brands CallId)
|
||||
dsh-bash ← dsh-brand (abstract executor seam; brands BashTaskId/OwnerToken)
|
||||
dsh-session ← dsh-llm, dsh-brand
|
||||
dsh-system-prompt ← dsh-llm
|
||||
dsh-agent ← dsh-llm, dsh-session, dsh-brand
|
||||
dsh-compact ← dsh-session, dsh-llm (abstract compaction seam; tool deferred)
|
||||
dsh-compact-basic ← dsh-compact, dsh-session, dsh-llm, dsh-agent (char/4 + token-budget retention backend)
|
||||
dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent
|
||||
dsh-bash-local ← dsh-bash (BashExecutor impl)
|
||||
dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas)
|
||||
dsh-fs ← dsh-llm, dsh-brand (filesystem provider seam + fs/* events)
|
||||
dsh-fs-local ← dsh-fs (FileSystem impl)
|
||||
dsh-fs-policy ← dsh-fs (observed-state + freshness policy gate, no service)
|
||||
dsh-tool-fs ← dsh-fs, dsh-tools (file tools + executor)
|
||||
dsh-web ← dsh-llm (abstract web seam; search/fetch registries, WebError)
|
||||
dsh-web-search-exa ← dsh-web (Exa WebSearchProvider)
|
||||
dsh-web-search-perplexity ← dsh-web (Perplexity WebSearchProvider)
|
||||
dsh-web-search-deepseek ← dsh-web (DeepSeek native-web-search WebSearchProvider)
|
||||
dsh-web-fetch-local ← dsh-web (anonymous public HTTP(S) WebFetchProvider)
|
||||
dsh-tool-web ← dsh-web, dsh-tools, dsh-system-prompt (web tool schemas)
|
||||
dsh-llm-deepseek ← dsh-llm (DeepSeek adapter)
|
||||
dsh-llm-pi-ai ← dsh-llm (pi-ai-backed adapter)
|
||||
dsh-agent-loop ← dsh-llm, dsh-session, dsh-session-persistence, dsh-system-prompt, dsh-tools, dsh-agent
|
||||
dsh-invariants ← dsh-llm, dsh-session, dsh-agent (dev-mode contract checks)
|
||||
dsh-acp ← dsh-agent, dsh-llm, dsh-session, dsh-session-persistence, dsh-tools (ACP JSON-RPC bridge)
|
||||
dsh-ui-stdio ← dsh-agent, dsh-llm, dsh-session (stdio readline UI plugin)
|
||||
dsh-llm-replay ← dsh-llm, dsh-session (record/replay adapter for keyless snapshot tests)
|
||||
dsh-subagent ← dsh-agent, dsh-llm, dsh-tools (abstract subagent provider-registry seam)
|
||||
dsh-subagent-inprocess ← dsh-subagent, dsh-agent, dsh-session, dsh-llm (shared in-process run driver)
|
||||
dsh-subagent-mock ← dsh-subagent, dsh-agent, dsh-llm (scripted provider for tests)
|
||||
dsh-subagent-spawn ← dsh-subagent, dsh-subagent-inprocess (in-process fresh child backend)
|
||||
dsh-subagent-fork ← dsh-subagent, dsh-subagent-inprocess, dsh-agent, dsh-session (in-process child seeded from parent log)
|
||||
dsh-subagent-acp ← dsh-subagent, dsh-agent, dsh-llm, @agentclientprotocol/sdk (out-of-process child over ACP)
|
||||
dsh-tool-subagent ← dsh-subagent, dsh-tools, dsh-agent, dsh-llm (model-facing delegation tool)
|
||||
dsh-tool-todo ← dsh-tools, dsh-agent, dsh-session (model-facing todo_write tool; whole list on the session log)
|
||||
dsh-agent-core ← timer, dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent, dsh-invariants, dsh-tool-bash, dsh-agent-loop (the providerless spine, as one bundle plugin)
|
||||
dsh-stdio-agent ← dsh-agent-core, dsh-ui-stdio, dsh-session-persistence-jsonl, dsh-agent, dsh-session (stdio chat APP + bin)
|
||||
dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl (ACP server APP + bin)
|
||||
```
|
||||
The inter-package dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI).
|
||||
|
||||
The rule: **extension** plugins depend on interfaces, never on the concrete loop. `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it — swapping the loop means shipping a different bundle, not rewiring every extension. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
|
||||
|
||||
## What goes where
|
||||
|
||||
| Package | Group | Role | ctx key |
|
||||
|---|---|---|---|
|
||||
| `llm/` | `llm` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` |
|
||||
| `session/` | `core` | Event-sourced session log + in-memory store | `ctx.sessions` |
|
||||
| `system-prompt/` | `core` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
|
||||
| `tools/` | `core` | Tool registry + `tools/pre-execute`/`tools/post-execute` pipeline | `ctx.tools` |
|
||||
| `agent/` | `core` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
|
||||
| `agent-loop/` | `core` | THE concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
|
||||
| `agent-core/` | `core` | Bundle plugin: the providerless/executor-less/UI-less spine as code (forwards `agent-loop`'s `agents`) | (loads the spine) |
|
||||
| `bash/` | `bash` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` |
|
||||
| `bash-local/` | `bash` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
|
||||
| `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |
|
||||
| `fs/` | `fs` | Filesystem provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` events | `ctx.fs` |
|
||||
| `fs-local/` | `fs` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) |
|
||||
| `fs-policy/` | `fs` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit via the `fs/*` event gate | (no service — `fs/*` listeners) |
|
||||
| `tool-fs/` | `fs` | Model-facing `read`/`write`/`edit` tools + executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) |
|
||||
| `compact/` | `compact` | Abstract compaction seam + `compact/*` events + `CompactionResult` | `ctx.compact` |
|
||||
| `compact-basic/` | `compact` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
|
||||
| `web/` | `web` | Abstract web seam (search/fetch provider registries + selection + vocabulary + `WebError`) | `ctx.web` |
|
||||
| `web-search-exa/` | `web` | Exa-backed `WebSearchProvider` | (registers on `ctx.web`) |
|
||||
| `web-search-perplexity/` | `web` | Perplexity-backed `WebSearchProvider` | (registers on `ctx.web`) |
|
||||
| `web-search-deepseek/` | `web` | DeepSeek-backed `WebSearchProvider` using native `web_search` through the Anthropic-compatible API | (registers on `ctx.web`) |
|
||||
| `web-fetch-local/` | `web` | Anonymous public HTTP(S) `WebFetchProvider` | (registers on `ctx.web`) |
|
||||
| `tool-web/` | `web` | Model-facing `web_search`/`web_fetch` tool schemas | (registers on `ctx.tools`) |
|
||||
| `llm-deepseek/` | `llm` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) |
|
||||
| `llm-pi-ai/` | `llm` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) |
|
||||
| `session-persistence/` | `session-persistence` | Persistence seam + write coordinator | `ctx.sessionPersistence` |
|
||||
| `session-persistence-jsonl/` | `session-persistence` | JSONL-sidecar persistence backend | (registers `ctx.sessionPersistence`) |
|
||||
| `session-persistence-sqlite/` | `session-persistence` | SQLite persistence backend | (registers `ctx.sessionPersistence`) |
|
||||
| `invariants/` | `support` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) |
|
||||
| `acp/` | `ui` | Agent Client Protocol bridge: serves the agent to an ACP editor over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
|
||||
| `stdio-agent/` | `ui` | Terminal stdio chat APP: agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) |
|
||||
| `acp-agent/` | `ui` | ACP server APP: agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) |
|
||||
| `ui-stdio/` | `support` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent | (drives `ctx.agents`) |
|
||||
| `llm-replay/` | `support` | Record/replay adapter: short-circuits `llm/stream` with chunks from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) |
|
||||
| `subagent/` | `subagent` | Abstract subagent seam: named-provider registry for delegating to child agents | `ctx.subagents` |
|
||||
| `subagent-inprocess/` | `subagent` | Shared in-process subagent run driver used by spawn/fork; pure library, registers nothing | (none) |
|
||||
| `subagent-spawn/` | `subagent` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) |
|
||||
| `subagent-fork/` | `subagent` | In-process backend: a child agent seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) |
|
||||
| `subagent-acp/` | `subagent` | Out-of-process backend: a child agent in a spawned subprocess, driven over the Agent Client Protocol | (registers on `ctx.subagents`) |
|
||||
| `subagent-mock/` | `support` | Scripted `SubagentProvider` for testing the seam through the real load path | (registers on `ctx.subagents`) |
|
||||
| `tool-subagent/` | `subagent` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
|
||||
| `tool-todo/` | `todo` | Model-facing `todo_write` tool; writes the whole task list to the session log (`todo/write`) | (registers on `ctx.tools`) |
|
||||
| `hook-protocol/` | `hooks` | Shared Claude Code / Codex hook wire-protocol library: matcher, codec, `runHook`, merge, `hook/*` events | (none — library, no service) |
|
||||
| `hooks-claude/` | `hooks` | Bridge: runs a Claude Code `hooks.json` / settings on the interception seams | (registers event listeners) |
|
||||
| `hooks-codex/` | `hooks` | Bridge: runs a Codex `hooks.json` (a subset of the CC protocol) on the seams | (registers event listeners) |
|
||||
| `brand/` | `util` | Type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) | (none — type-only) |
|
||||
The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it — swapping the loop means shipping a different bundle, not rewiring every extension. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
|
||||
|
||||
Each package has its own `README.md` with purpose, service API, events, extension points, and deliberate non-goals (TODOs).
|
||||
|
||||
## Conventions (applied across all harness packages)
|
||||
|
||||
- **Registrations are effects**: every contribution (adapter, tool, section, agent, event listener) goes through `ctx.effect()` / `ctx.on()`, so disposal and HMR clean up automatically. Every `register()` returns the disposer.
|
||||
- **Declaration merging for events and ctx**: services declare their events in `declare module 'cordis' { interface Events { ... } }` and their ctx key in `interface Context`.
|
||||
- **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)` and MUST call `next()` to delegate; returning without it short-circuits (the veto mechanism).
|
||||
- **Extensible unions**: `ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap`, `TurnTriggerMap`, `TurnEndReasonMap`, and `SessionEventMap` use the merge-extensible-map pattern so plugins can add variants via declaration merging.
|
||||
- **ESM everywhere**; imports use package names across package boundaries and explicit `.ts` relative specifiers within a package.
|
||||
- **Tests**: vitest, colocated under `packages/<group>/<pkg>/tests/*.spec.ts`. Every registry needs an HMR-safety test. Err on the side of more tests.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -77,16 +77,32 @@ export abstract class BashExecutor extends Service {
|
||||
* call this, then pass the result to {@link run}/{@link start} — keeping
|
||||
* defaulting in the implementation that owns the config while the seam type
|
||||
* stays explicit (no hidden `?? default` inside run/start).
|
||||
* @param request - the caller's request; omitted fields get this
|
||||
* implementation's defaults, capped fields are clamped.
|
||||
* @returns the fully-specified spec to hand to {@link run}/{@link start}.
|
||||
*/
|
||||
abstract resolve(request: BashExecRequest): BashExecSpec
|
||||
|
||||
/** Run a command in the foreground; resolves when it finishes. */
|
||||
/**
|
||||
* Run a command in the foreground; resolves when it finishes.
|
||||
* @param spec - a resolved spec from {@link resolve}, never a raw request.
|
||||
* @returns the outcome; nonzero exits, timeout kills, and abort kills
|
||||
* resolve with a descriptive result rather than reject.
|
||||
*/
|
||||
abstract run(spec: BashExecSpec): Promise<BashRunResult>
|
||||
|
||||
/** Start a background task and return its handle immediately. */
|
||||
/**
|
||||
* Start a background task and return its handle immediately.
|
||||
* @param spec - a resolved spec from {@link resolve}, never a raw request.
|
||||
* @returns the live task handle; completion fires {@link onTaskDone}.
|
||||
*/
|
||||
abstract start(spec: BashExecSpec): BashTask
|
||||
|
||||
/** Look up a background task by id. */
|
||||
/**
|
||||
* Look up a background task by id.
|
||||
* @param id - the task id to look up.
|
||||
* @returns the tracked task, or undefined for an id this executor never issued.
|
||||
*/
|
||||
abstract get(id: BashTaskId): BashTask | undefined
|
||||
|
||||
/**
|
||||
@@ -101,24 +117,38 @@ export abstract class BashExecutor extends Service {
|
||||
* loudly at the subsequent {@link readOutput}/{@link kill} ("unknown task").
|
||||
* Storing ownership in the executor (disposed with ITS fiber) — not in the
|
||||
* tool plugin — is what makes ownership survive a `tool-bash` HMR reload.
|
||||
* @param id - the background task id to look up ownership for.
|
||||
* @returns the token recorded at start, verbatim; undefined for an unknown
|
||||
* id or a known-but-ownerless task.
|
||||
*/
|
||||
abstract ownerOf(id: BashTaskId): OwnerToken | undefined
|
||||
|
||||
/** All tracked background tasks (insertion order). */
|
||||
/**
|
||||
* All tracked background tasks (insertion order).
|
||||
* @returns every task this executor started, running or finished.
|
||||
*/
|
||||
abstract list(): BashTask[]
|
||||
|
||||
/** Read output produced since the previous read. Throws for unknown ids. */
|
||||
/**
|
||||
* Read output produced since the previous read. Throws for unknown ids.
|
||||
* @param id - the task to read from.
|
||||
* @returns the incremental read; consecutive reads never re-deliver output.
|
||||
*/
|
||||
abstract readOutput(id: BashTaskId): BashTaskRead
|
||||
|
||||
/**
|
||||
* Kill a running background task. Returns false when it had already
|
||||
* finished (no-op). Throws for unknown ids.
|
||||
* @param id - the task to kill.
|
||||
* @returns true when this call killed it, false when it had already finished.
|
||||
*/
|
||||
abstract kill(id: BashTaskId): boolean
|
||||
|
||||
/**
|
||||
* Register a background-task completion listener (disposed with the
|
||||
* calling fiber). Listeners never fire after this service is disposed.
|
||||
* @param listener - called exactly once per task completion.
|
||||
* @returns the disposer that unregisters the listener.
|
||||
*/
|
||||
onTaskDone(listener: BashTaskListener): () => void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,7 +8,7 @@ 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, `image` = 85, 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.
|
||||
@@ -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
|
||||
@@ -148,9 +149,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,24 +210,27 @@ 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':
|
||||
@@ -236,7 +242,7 @@ export class BasicCompactService extends CompactService {
|
||||
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 +272,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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].`)
|
||||
|
||||
@@ -820,6 +820,19 @@ describe('BasicCompactService token estimation (char/4 heuristic)', () => {
|
||||
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 +875,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', () => {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -141,6 +141,7 @@ export abstract class CompactService extends Service {
|
||||
* prior replace can leave the surface non-monotonic in seq order), or if
|
||||
* either boundary is not a balanced tool-pairing cut (would split a step's
|
||||
* tool-call/result pair).
|
||||
* @returns what the compaction did (the replaced range and its summary node).
|
||||
*/
|
||||
abstract compactRegion(
|
||||
session: Session,
|
||||
|
||||
@@ -79,7 +79,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// Release quiescence waiters on a transition OUT of running BEFORE emitting
|
||||
// (the disposer handles the disposed transition separately). Settling first
|
||||
// means a throwing `agent/status` subscriber cannot starve a `whenIdle()`
|
||||
// waiter (AGENTS.md "contain callback exceptions" — a lifecycle await must
|
||||
// waiter (docs/defensive-patterns.md "contain callback exceptions" — a lifecycle await must
|
||||
// not hang on one bad listener).
|
||||
if (status !== 'running') this.settleIdleWaiters()
|
||||
try {
|
||||
|
||||
@@ -121,6 +121,9 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* deliberate resume-or-create policy (resume the prior session if one exists,
|
||||
* else start fresh) or an explicit caller-chosen session id — revisit when the
|
||||
* UI/ACP path owns session selection.
|
||||
* @param id - the agent id; also seeds the generated session id.
|
||||
* @param options - loop options (model, limits, …); defaults applied per option.
|
||||
* @returns the running agent, owned by the calling fiber (no handle).
|
||||
*/
|
||||
create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent {
|
||||
this.assertAgentIdFree(id)
|
||||
@@ -142,6 +145,9 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* `seed` (a balanced completed-turn prefix of the parent's log) so the child
|
||||
* starts with the parent's context. Returns an {@link AgentHandle} the owner
|
||||
* disposes to tear down exactly this agent.
|
||||
* @param options - agent id, caller-supplied session id, optional seed/meta,
|
||||
* and agent options.
|
||||
* @returns the handle whose dispose tears down exactly this agent.
|
||||
*/
|
||||
createAgent(options: CreateAgentOptions): AgentHandle {
|
||||
// Check the agent id BEFORE preparing the session: register() would reject a
|
||||
@@ -168,6 +174,8 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* configured. NOT hard-injected (that would make non-persistent demos pend
|
||||
* forever) — callers that need resume (ACP) inject `sessionPersistence`, so
|
||||
* by the time this runs the service exists.
|
||||
* @param options - the persisted session id to reload, plus agent id/options.
|
||||
* @returns the handle for the agent resumed on the reconstructed session.
|
||||
*/
|
||||
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
// Read the service through `ctx.get('sessionPersistence')` — a direct
|
||||
|
||||
@@ -126,6 +126,8 @@ export class AgentRegistry extends Service {
|
||||
* Register the agent-creation factory (the loop calls this on construction,
|
||||
* effect-scoped). Throws if a factory is already registered. Returns the
|
||||
* disposer; on dispose the factory slot is cleared.
|
||||
* @param factory - the loop-owned factory {@link create}/{@link resume} delegate to.
|
||||
* @returns the disposer that clears the factory slot.
|
||||
*/
|
||||
setFactory(factory: AgentFactory): () => void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
@@ -142,6 +144,8 @@ export class AgentRegistry extends Service {
|
||||
* agent): this constructs the agent and its session. Throws if no factory is
|
||||
* registered. Returns an {@link AgentHandle} — the owner disposes it to tear
|
||||
* down exactly this agent.
|
||||
* @param options - agent id, session id/seed/metadata, and agent options.
|
||||
* @returns the handle whose dispose tears down exactly this agent.
|
||||
*/
|
||||
create(options: CreateAgentOptions): AgentHandle {
|
||||
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
|
||||
@@ -152,6 +156,8 @@ export class AgentRegistry extends Service {
|
||||
* Load a persisted session and resume an agent on it through the registered
|
||||
* factory. Rejects if no factory is registered; the factory rejects if
|
||||
* session persistence is not configured. Returns an {@link AgentHandle}.
|
||||
* @param options - the persisted session id plus agent id and options.
|
||||
* @returns the handle for the resumed agent.
|
||||
*/
|
||||
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
|
||||
@@ -162,6 +168,8 @@ export class AgentRegistry extends Service {
|
||||
* Register a live agent. Throws if an agent with the same id is already
|
||||
* registered. Emits `agent/created` on registration and `agent/disposed`
|
||||
* when the calling fiber is disposed. Returns the disposer.
|
||||
* @param agent - the already-constructed agent to record in the store.
|
||||
* @returns the disposer that removes the agent and emits `agent/disposed`.
|
||||
*/
|
||||
register(agent: Agent): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: AgentRegistry) {
|
||||
@@ -200,10 +208,19 @@ export class AgentRegistry extends Service {
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a live agent.
|
||||
* @param id - the agent id to look up.
|
||||
* @returns the agent, or undefined when no live agent has that id.
|
||||
*/
|
||||
get(id: AgentId): Agent | undefined {
|
||||
return this.store.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* All live agents, in registration order.
|
||||
* @returns a fresh array; mutating it does not affect the registry.
|
||||
*/
|
||||
list(): Agent[] {
|
||||
return [...this.store.values()]
|
||||
}
|
||||
|
||||
@@ -228,12 +228,14 @@ declare module 'cordis' {
|
||||
/**
|
||||
* An agent was registered in the {@link AgentRegistry} and is ready to
|
||||
* receive messages.
|
||||
* @param agent - the newly registered agent, already resolvable in the registry.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/created'(agent: Agent): void
|
||||
/**
|
||||
* An agent was disposed and removed from the registry; its fiber and any
|
||||
* in-flight turn have been torn down.
|
||||
* @param agent - the agent that was torn down; its handle is now inert.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/disposed'(agent: Agent): void
|
||||
@@ -241,12 +243,17 @@ declare module 'cordis' {
|
||||
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive
|
||||
* lifecycle off this transition, never off a status you just requested —
|
||||
* `send()` does not flip status to `running` before it returns.
|
||||
* @param agent - the agent whose status flipped.
|
||||
* @param status - the status just entered (the transition's destination).
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/status'(agent: Agent, status: AgentStatus): void
|
||||
/**
|
||||
* A message entered the agent's inbox (queued or steering). `source` is
|
||||
* the resolved source (defaults applied), not the caller's raw options.
|
||||
* @param agent - the agent whose inbox received the message.
|
||||
* @param content - the enqueued content blocks, verbatim.
|
||||
* @param info - the resolved source plus whether it entered as steering.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
|
||||
@@ -260,6 +267,8 @@ declare module 'cordis' {
|
||||
* so via `agent.inject()` (a `context/message` the first request sees), not
|
||||
* by returning a decision. Cannot block the session from starting; that gap
|
||||
* is deliberate (a bridge logs/injects, it does not gate startup).
|
||||
* @param agent - the agent whose session lifecycle began.
|
||||
* @param source - why the session started (fresh startup, resume, …).
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/session-start'(agent: Agent, source: SessionStartSource): void
|
||||
@@ -295,6 +304,11 @@ declare module 'cordis' {
|
||||
* listener needs to measure pressure (the system prompt counts toward the
|
||||
* budget). `signal` cancels any in-flight work a listener starts (e.g. a
|
||||
* summarization model call).
|
||||
* @param agent - the agent about to open the step.
|
||||
* @param turn - the already-open turn this step belongs to.
|
||||
* @param step - the number of the step about to start.
|
||||
* @param fullSystemPrompt - the assembled prompt, for measuring token pressure.
|
||||
* @param signal - aborts in-flight listener work when the turn is torn down.
|
||||
* @mode serial
|
||||
*/
|
||||
// TODO: `fullSystemPrompt` is a smell on a generic per-step seam — compaction
|
||||
@@ -310,6 +324,9 @@ declare module 'cordis' {
|
||||
* turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook.
|
||||
* Call `next()` to delegate to the default (allow unchanged), or return a
|
||||
* {@link PromptDecision} without calling `next()` to short-circuit.
|
||||
* @param agent - the agent draining its inbox.
|
||||
* @param content - the drained message's blocks, as queued.
|
||||
* @param source - the message's resolved source.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/prompt-submit'(agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
|
||||
@@ -319,12 +336,20 @@ declare module 'cordis' {
|
||||
* delegate, or return without it to short-circuit. For surface mutation that
|
||||
* must precede history derivation (compaction), use {@link agent/pre-step}
|
||||
* instead — by the time this fires, `options.messages` is already derived.
|
||||
* @param agent - the agent making the model call.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the step whose request this is.
|
||||
* @param options - the assembled request; listeners return a transformed copy.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise<GenerateOptions>): Promise<GenerateOptions>
|
||||
/**
|
||||
* Waterfall: post-process the assembled assistant {@link Message} before
|
||||
* tool dispatch (validation, content rewriting, …).
|
||||
* @param agent - the agent that received the step's response.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the step that produced the message.
|
||||
* @param message - the assistant message as assembled from the stream.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/step-result'(agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>
|
||||
@@ -335,6 +360,9 @@ declare module 'cordis' {
|
||||
* Listeners force-continue (`/goal`, `/loop` — optionally attaching a
|
||||
* `reason` recorded as next-step steering) or force-stop (budget guards).
|
||||
* Call `next()` to delegate to the default, or return a decision to override.
|
||||
* @param agent - the agent deciding whether to run another step.
|
||||
* @param turn - the turn being continued or stopped.
|
||||
* @param defaultDecision - what the loop would do absent an override.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
|
||||
@@ -342,12 +370,20 @@ declare module 'cordis' {
|
||||
// ---- streaming + tool notifications (emit) ----
|
||||
/**
|
||||
* Steering content was injected into a running turn.
|
||||
* @param agent - the agent that absorbed the steering.
|
||||
* @param turn - the running turn that received it.
|
||||
* @param content - the injected blocks.
|
||||
* @param source - the steering message's resolved source.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/steering'(agent: Agent, turn: number, content: ContentBlock[], source: MessageSource): void
|
||||
/**
|
||||
* A step or turn errored. The loop reports a failure here (plus the logger)
|
||||
* even when the error has no in-turn position for a session `error` event.
|
||||
* @param agent - the agent whose turn errored.
|
||||
* @param turn - the turn in which the failure surfaced.
|
||||
* @param step - the step at which the failure surfaced.
|
||||
* @param error - the failure, verbatim.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/error'(agent: Agent, turn: number, step: number, error: Error): void
|
||||
|
||||
@@ -4,17 +4,20 @@
|
||||
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
|
||||
* the freshness half is exercised by `pnpm run verify-cordis-catalog` in CI.
|
||||
* What a freshness diff CANNOT prove is that the generator REJECTS malformed
|
||||
* source the way it promises to — a missing `@mode` tag, or a tag that
|
||||
* contradicts the signature shape. These tests drive `collectEvents()` against
|
||||
* synthetic fixture packages to prove each guard fires (and that a well-formed
|
||||
* event passes), mirroring the drift-guard negative tests for verify-type-equiv.
|
||||
* source the way it promises to — a missing `@mode` tag, a tag that
|
||||
* contradicts the signature shape, or a JSDoc-completeness violation (missing
|
||||
* prose, an undocumented parameter, a stale `@param`, a missing `@returns`, an
|
||||
* unannotated return type). These tests drive `collectEvents()` /
|
||||
* `collectServices()` against synthetic fixture packages to prove each guard
|
||||
* fires (and that well-formed declarations pass), mirroring the drift-guard
|
||||
* negative tests for verify-type-equiv.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { collectEvents } from '../../../../scripts/gen-cordis-catalog.ts'
|
||||
import { collectEvents, collectServices } from '../../../../scripts/gen-cordis-catalog.ts'
|
||||
|
||||
/** Write a fixture package exposing one `interface Events` block and return the
|
||||
* scan root to hand `collectEvents`. */
|
||||
@@ -29,12 +32,31 @@ function fixtureRoot(eventsBlock: string): string {
|
||||
return root
|
||||
}
|
||||
|
||||
/** Write a fixture package exposing one `interface Context` entry (`ctx.fix` →
|
||||
* `FixService`) plus the class source, and return the scan root to hand
|
||||
* `collectServices`. */
|
||||
function serviceFixtureRoot(classSource: string): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cordis-catalog-'))
|
||||
const dir = join(root, 'packages', 'group', 'fix', 'src')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(dir, 'index.ts'),
|
||||
`declare module 'cordis' {\n interface Context {\n fix: FixService\n }\n}\n\n${classSource}\n`,
|
||||
)
|
||||
return root
|
||||
}
|
||||
|
||||
const roots: string[] = []
|
||||
const make = (block: string): string => {
|
||||
const r = fixtureRoot(block)
|
||||
roots.push(r)
|
||||
return r
|
||||
}
|
||||
const makeService = (classSource: string): string => {
|
||||
const r = serviceFixtureRoot(classSource)
|
||||
roots.push(r)
|
||||
return r
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
||||
@@ -43,7 +65,7 @@ afterEach(() => {
|
||||
describe('gen-cordis-catalog collectEvents', () => {
|
||||
it('extracts a well-formed event with its @mode and JSDoc', () => {
|
||||
const events = collectEvents(make(
|
||||
' /**\n * A thing happened.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
|
||||
' /**\n * A thing happened.\n * @param id - which thing.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
|
||||
))
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({ name: 'fix/happened', scope: 'fix', mode: 'emit', doc: 'A thing happened.' })
|
||||
@@ -51,7 +73,7 @@ describe('gen-cordis-catalog collectEvents', () => {
|
||||
|
||||
it('classifies a trailing-next signature as a waterfall', () => {
|
||||
const events = collectEvents(make(
|
||||
' /**\n * Intercept it.\n * @mode waterfall\n */\n \'fix/intercept\'(x: number, next: () => Promise<number>): Promise<number>',
|
||||
' /**\n * Intercept it.\n * @param x - the value under interception.\n * @mode waterfall\n */\n \'fix/intercept\'(x: number, next: () => Promise<number>): Promise<number>',
|
||||
))
|
||||
expect(events[0]?.mode).toBe('waterfall')
|
||||
})
|
||||
@@ -65,19 +87,154 @@ describe('gen-cordis-catalog collectEvents', () => {
|
||||
|
||||
it('hard-errors when an event is missing its @mode tag', () => {
|
||||
expect(() => collectEvents(make(
|
||||
' /** No mode here. */\n \'fix/untagged\'(id: string): void',
|
||||
' /** No mode here. */\n \'fix/untagged\'(): void',
|
||||
))).toThrow(/missing an @mode tag/)
|
||||
})
|
||||
|
||||
it('hard-errors when @mode contradicts a trailing-next (waterfall) shape', () => {
|
||||
expect(() => collectEvents(make(
|
||||
' /**\n * Mislabeled.\n * @mode emit\n */\n \'fix/wrong\'(x: number, next: () => Promise<number>): Promise<number>',
|
||||
' /**\n * Mislabeled.\n * @param x - the value.\n * @mode emit\n */\n \'fix/wrong\'(x: number, next: () => Promise<number>): Promise<number>',
|
||||
))).toThrow(/trailing 'next' parameter .* tagged '@mode emit'/)
|
||||
})
|
||||
|
||||
it('hard-errors when @mode waterfall has no trailing next to delegate to', () => {
|
||||
expect(() => collectEvents(make(
|
||||
' /**\n * Not actually a waterfall.\n * @mode waterfall\n */\n \'fix/nonext\'(id: string): void',
|
||||
' /**\n * Not actually a waterfall.\n * @param id - which thing.\n * @mode waterfall\n */\n \'fix/nonext\'(id: string): void',
|
||||
))).toThrow(/tagged '@mode waterfall' but has no trailing 'next'/)
|
||||
})
|
||||
|
||||
it('hard-errors on an undocumented payload parameter', () => {
|
||||
expect(() => collectEvents(make(
|
||||
' /**\n * A thing happened.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
|
||||
))).toThrow(/is missing @param id/)
|
||||
})
|
||||
|
||||
it('hard-errors on a stale @param naming no real parameter', () => {
|
||||
expect(() => collectEvents(make(
|
||||
' /**\n * A thing happened.\n * @param id - which thing.\n * @param ghost - not a parameter.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
|
||||
))).toThrow(/@param ghost does not match any parameter/)
|
||||
})
|
||||
|
||||
it('hard-errors on an @param with an empty description', () => {
|
||||
expect(() => collectEvents(make(
|
||||
' /**\n * A thing happened.\n * @param id\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
|
||||
))).toThrow(/@param id has an empty description/)
|
||||
})
|
||||
|
||||
it('hard-errors on an event whose JSDoc has no description prose', () => {
|
||||
expect(() => collectEvents(make(
|
||||
' /**\n * @param id - which thing.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
|
||||
))).toThrow(/no description prose/)
|
||||
})
|
||||
|
||||
it('exempts the `this` receiver and the trailing waterfall `next` from @param', () => {
|
||||
const events = collectEvents(make(
|
||||
' /**\n * Scoped interception.\n * @param x - the value under interception.\n * @mode waterfall\n */\n \'fix/scoped\'(this: object, x: number, next: () => Promise<number>): Promise<number>',
|
||||
))
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hard-errors on a binding-pattern parameter @param cannot name', () => {
|
||||
expect(() => collectEvents(make(
|
||||
' /**\n * A thing happened.\n * @mode emit\n */\n \'fix/destructured\'({ id }: { id: string }): void',
|
||||
))).toThrow(/is a binding pattern/)
|
||||
})
|
||||
|
||||
it('aggregates every violation into one error instead of failing fast', () => {
|
||||
expect(() => collectEvents(make(
|
||||
' /** First. */\n \'fix/one\'(): void\n /** Second. */\n \'fix/two\'(): void',
|
||||
))).toThrow(/2 JSDoc completeness violation\(s\)[\s\S]*fix\/one[\s\S]*fix\/two/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gen-cordis-catalog collectServices', () => {
|
||||
const WELL_FORMED = `/** Fixture service. */
|
||||
export class FixService {
|
||||
/**
|
||||
* Do the thing.
|
||||
* @param id - which thing to do.
|
||||
* @returns the outcome of doing it.
|
||||
*/
|
||||
run(id: string): string { return id }
|
||||
|
||||
/** Fire and forget (void needs no @returns). */
|
||||
poke(): void {}
|
||||
|
||||
/** Flush (Promise<void> needs no @returns either). */
|
||||
flush(): Promise<void> { return Promise.resolve() }
|
||||
}`
|
||||
|
||||
it('extracts a well-formed service with its methods and class JSDoc', () => {
|
||||
const services = collectServices(makeService(WELL_FORMED))
|
||||
expect(services).toHaveLength(1)
|
||||
expect(services[0]).toMatchObject({ key: 'fix', type: 'FixService', abstract: false, doc: 'Fixture service.' })
|
||||
expect(services[0]?.methods).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('hard-errors on a public method with no JSDoc at all', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n run(id: string): string { return id }\n}',
|
||||
))).toThrow(/ctx\.fix\.run .* has no JSDoc/)
|
||||
})
|
||||
|
||||
it('hard-errors on an undocumented method parameter', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @returns the outcome.\n */\n run(id: string): string { return id }\n}',
|
||||
))).toThrow(/ctx\.fix\.run .* is missing @param id/)
|
||||
})
|
||||
|
||||
it('hard-errors on a missing @returns for a non-void return type', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @param id - which thing.\n */\n run(id: string): string { return id }\n}',
|
||||
))).toThrow(/is missing @returns \(return type: string\)/)
|
||||
})
|
||||
|
||||
it('hard-errors on an unannotated (inferred) return type', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @param id - which thing.\n */\n run(id: string) { return id }\n}',
|
||||
))).toThrow(/no return type annotation/)
|
||||
})
|
||||
|
||||
it('hard-errors on a service class with no JSDoc', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'export class FixService {\n /** Fire and forget. */\n poke(): void {}\n}',
|
||||
))).toThrow(/class FixService has no JSDoc/)
|
||||
})
|
||||
|
||||
it('hard-errors on a stale method @param', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n /**\n * Fire and forget.\n * @param ghost - not a parameter.\n */\n poke(): void {}\n}',
|
||||
))).toThrow(/@param ghost does not match any parameter/)
|
||||
})
|
||||
|
||||
it('hard-errors on a method whose JSDoc is tags with no description prose', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n /**\n * @param id - which thing.\n * @returns the outcome.\n */\n run(id: string): string { return id }\n}',
|
||||
))).toThrow(/no description prose above its block tags/)
|
||||
})
|
||||
|
||||
it('hard-errors on a method @param with an empty description', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n /**\n * Fire and forget.\n * @param id\n */\n poke(id: string): void {}\n}',
|
||||
))).toThrow(/@param id has an empty description/)
|
||||
})
|
||||
|
||||
it('hard-errors on an @returns with an empty description', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @param id - which thing.\n * @returns\n */\n run(id: string): string { return id }\n}',
|
||||
))).toThrow(/@returns has an empty description/)
|
||||
})
|
||||
|
||||
it('hard-errors on a binding-pattern method parameter @param cannot name', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n */\n run({ id }: { id: string }): void {}\n}',
|
||||
))).toThrow(/is a binding pattern/)
|
||||
})
|
||||
|
||||
it('ignores private/protected/static members (not the ctx.<key> surface)', () => {
|
||||
const services = collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n private hidden(id: string): string { return id }\n protected hook(): void {}\n static helper(): void {}\n}',
|
||||
))
|
||||
expect(services[0]?.methods).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,12 +30,15 @@ declare module 'cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* A session was created in the store.
|
||||
* @param session - the session just entered and announced.
|
||||
* @mode emit
|
||||
*/
|
||||
'session/created'(session: Session): void
|
||||
/**
|
||||
* An event was appended to a session log (sync, fire-and-forget). This is
|
||||
* the per-append feed a UI or invariant plugin tails.
|
||||
* @param session - the session whose log grew.
|
||||
* @param event - the appended event, exactly as recorded.
|
||||
* @mode emit
|
||||
*/
|
||||
'session/event'(session: Session, event: SessionEvent): void
|
||||
@@ -45,6 +48,7 @@ declare module 'cordis' {
|
||||
* plugins (JSONL, SQLite) drain their write-behind buffers here and on
|
||||
* fiber dispose. Awaited (parallel), not a waterfall: every listener runs
|
||||
* and the loop waits for all of them, but none can veto.
|
||||
* @param session - the session whose buffered events must reach durable storage.
|
||||
* @mode parallel
|
||||
*/
|
||||
'session/flush'(session: Session): Promise<void> | void
|
||||
@@ -342,6 +346,9 @@ export class SessionStore extends Service {
|
||||
* {@link prepare} + {@link enter} + {@link announce} (see `dsh-agent-loop`'s
|
||||
* `startOwned`).
|
||||
*
|
||||
* @param id - the session id; omitted, the store mints `session-<n>`.
|
||||
* @param options - seed events and/or creation metadata for the header.
|
||||
* @returns the live session, already entered and announced.
|
||||
* @throws if a session with `id` already exists, or if `meta.cwd` is a
|
||||
* non-absolute path (storage backends key directories off it).
|
||||
*/
|
||||
@@ -367,6 +374,9 @@ export class SessionStore extends Service {
|
||||
* chain rather than as racing sibling effects — which would detach `onAppend`
|
||||
* before the loop's closing `session/flush`, dropping the closing events.
|
||||
*
|
||||
* @param id - the session id; omitted, the store mints `session-<n>`.
|
||||
* @param options - seed events and/or creation metadata for the header.
|
||||
* @returns the constructed session, NOT yet in the store.
|
||||
* @throws if a session with `id` already exists, or if `meta.cwd` is a
|
||||
* non-absolute path.
|
||||
*/
|
||||
@@ -404,6 +414,8 @@ export class SessionStore extends Service {
|
||||
* the two back-to-back so they never trip this, but the public seam cannot
|
||||
* assume that.
|
||||
*
|
||||
* @param session - a {@link prepare}d session not yet in the store.
|
||||
* @returns the detach disposer (`onAppend = undefined` + store removal).
|
||||
* @throws if a session with this id is already in the store.
|
||||
*/
|
||||
enter(session: Session): () => void {
|
||||
@@ -418,15 +430,25 @@ export class SessionStore extends Service {
|
||||
|
||||
/** Emit `session/created` for an {@link enter}ed session. Separate from
|
||||
* {@link enter} so the caller can yield the detach disposer first (rollback
|
||||
* safety — see {@link enter}). */
|
||||
* safety — see {@link enter}).
|
||||
* @param session - the entered session to announce to listeners. */
|
||||
announce(session: Session): void {
|
||||
this.ctx.emit('session/created', session)
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a live session.
|
||||
* @param id - the session id to look up.
|
||||
* @returns the session, or undefined when no live session has that id.
|
||||
*/
|
||||
get(id: SessionId): Session | undefined {
|
||||
return this.store.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* All live sessions, in creation order.
|
||||
* @returns a fresh array; mutating it does not affect the store.
|
||||
*/
|
||||
list(): Session[] {
|
||||
return [...this.store.values()]
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ declare module 'cordis' {
|
||||
* Waterfall around prompt assembly — mutate or extend the
|
||||
* {@link PromptAssembly} (sections + tool schemas) before it is rendered.
|
||||
* Bound to the {@link SystemPrompt} service; call `next()` to delegate.
|
||||
* @param assembly - the assembly built from the registered sections and
|
||||
* tool providers; listeners may mutate it or return a replacement.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
|
||||
@@ -80,6 +82,8 @@ export class SystemPrompt extends Service {
|
||||
* Contribute a text section to the system prompt. Order is determined by
|
||||
* `section.order` (ascending). The section is removed when the calling
|
||||
* fiber is disposed. Emits `system-prompt/change` on register/unregister.
|
||||
* @param section - the section to contribute (name, order, text or provider).
|
||||
* @returns the disposer that removes the section.
|
||||
*/
|
||||
section(section: PromptSection): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
@@ -105,6 +109,8 @@ export class SystemPrompt extends Service {
|
||||
* Contribute a tool-schema provider that is evaluated at each assembly
|
||||
* call (so it can reflect the live registry state). The provider is
|
||||
* removed when the calling fiber is disposed. Emits `system-prompt/change`.
|
||||
* @param provider - evaluated at every {@link assemble} for fresh schemas.
|
||||
* @returns the disposer that removes the provider.
|
||||
*/
|
||||
tools(provider: () => ToolSchema[]): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
@@ -132,6 +138,7 @@ export class SystemPrompt extends Service {
|
||||
* listeners the opportunity to mutate or replace the assembly before it
|
||||
* reaches the model. Await the result before reading the assembly values —
|
||||
* waterfall listeners may be async.
|
||||
* @returns the assembly after the waterfall has run.
|
||||
*/
|
||||
assemble(): Promise<PromptAssembly> {
|
||||
const assembly: PromptAssembly = {
|
||||
|
||||
@@ -60,6 +60,7 @@ declare module 'cordis' {
|
||||
* tool body never runs. Input rewrite is deliberately NOT offered here (see
|
||||
* {@link PreToolDecision}); `ask` degrades to deny until the permission
|
||||
* system lands (`FIXME(permissions)`).
|
||||
* @param exec - the pending call (name, parsed arguments, caller agent).
|
||||
* @mode waterfall
|
||||
*/
|
||||
'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
|
||||
@@ -74,6 +75,8 @@ declare module 'cordis' {
|
||||
* `execute`'s outer try/catch (and the tool body keeps its own inner
|
||||
* try/catch, so a thrown tool still reaches `post-execute` as an `isError`
|
||||
* result).
|
||||
* @param exec - the call that just ran (name, parsed arguments, caller agent).
|
||||
* @param result - the dispatch outcome a listener may accept, replace, or block.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'tools/post-execute'(this: ToolRegistry, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
|
||||
@@ -277,6 +280,9 @@ export class ToolRegistry extends Service {
|
||||
* registered. The tool's schema (minus the `execute` function) is
|
||||
* automatically contributed to the system-prompt assembly. Disposed
|
||||
* with the calling fiber. Emits `tools/change` on register/unregister.
|
||||
* @param definition - the tool's schema plus its execute (and optional
|
||||
* presentation) functions.
|
||||
* @returns the disposer that unregisters the tool.
|
||||
*/
|
||||
register(definition: ToolDefinition): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
|
||||
@@ -300,6 +306,11 @@ export class ToolRegistry extends Service {
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a registered tool.
|
||||
* @param name - the tool name as registered.
|
||||
* @returns the definition, or undefined when no tool has that name.
|
||||
*/
|
||||
get(name: string): ToolDefinition | undefined {
|
||||
return this.store.get(name)
|
||||
}
|
||||
@@ -313,6 +324,7 @@ export class ToolRegistry extends Service {
|
||||
* those (especially the functions) must never leak into a model request. An
|
||||
* allowlist can't drift when a new non-schema member is added to the
|
||||
* definition; a denylist (rest-destructure) would silently leak it.
|
||||
* @returns one deep-cloned schema per registered tool, in registration order.
|
||||
*/
|
||||
schemas(): ToolSchema[] {
|
||||
return [...this.store.values()].map(({ name, description, parameters, strict }): ToolSchema => ({
|
||||
@@ -334,6 +346,9 @@ export class ToolRegistry extends Service {
|
||||
* still inspect. If the tool is not registered, the result is an `isError`
|
||||
* carrying a `UNKNOWN_TOOL` structured error. A thrown {@link HarnessError}
|
||||
* surfaces its `{ name, code }` on the result.
|
||||
* @param exec - the call to run (name, parsed arguments, caller agent, signal).
|
||||
* @returns the final result after both waterfalls; failures resolve as
|
||||
* `isError` results, never rejections.
|
||||
*/
|
||||
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
|
||||
try {
|
||||
|
||||
@@ -102,6 +102,8 @@ declare module 'cordis' {
|
||||
* chain. The slot is first-wins: the first non-`next()` decider (registration
|
||||
* order, or `prepend`) occupies it; a second decider is a misconfiguration,
|
||||
* not layering. `actor` is the opaque tool-execution context, never read here.
|
||||
* @param target - the resolved target about to be written.
|
||||
* @param actor - the opaque tool-execution context the decider keys off.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
|
||||
@@ -114,6 +116,8 @@ declare module 'cordis' {
|
||||
* `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset
|
||||
* or has not observed the target. Does NOT call `next()`: one decision,
|
||||
* first-wins (see {@link Events.'fs/write-intent'}).
|
||||
* @param target - the resolved target about to be edited.
|
||||
* @param actor - the opaque tool-execution context the decider keys off.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
|
||||
@@ -126,6 +130,9 @@ declare module 'cordis' {
|
||||
* await listener promises — async or fallible audit/telemetry does not
|
||||
* belong here. No listener ⇒ nothing recorded. `actor` is the opaque
|
||||
* tool-execution context.
|
||||
* @param target - the target that was read/written/edited.
|
||||
* @param version - the version the actor now holds as its observation.
|
||||
* @param actor - the observing tool-execution context; undefined records nothing useful.
|
||||
* @mode emit
|
||||
*/
|
||||
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
|
||||
@@ -180,13 +187,26 @@ export abstract class FileSystem extends Service {
|
||||
* caller's per-session workspace (`exec.agent.session.header.cwd`) without the
|
||||
* provider depending on `dsh-agent`/`dsh-session`. Mirrors how `dsh-tool-bash`
|
||||
* defaults a bash `workdir` to the session cwd.
|
||||
* @param path - the path to resolve; relative paths resolve against `opts.cwd`.
|
||||
* @param opts - `cwd` overrides the backend's default base for relative paths.
|
||||
* @returns the stable target; the same file yields the same `targetKey`.
|
||||
*/
|
||||
abstract resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>
|
||||
|
||||
/** Return target metadata, or `undefined` when the target does not exist. */
|
||||
/**
|
||||
* Return target metadata, or `undefined` when the target does not exist.
|
||||
* @param target - the resolved target to stat.
|
||||
* @param signal - aborts the metadata round-trip.
|
||||
* @returns metadata only, never content; undefined for an absent target.
|
||||
*/
|
||||
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
|
||||
|
||||
/** Read the whole regular text file as a single decoded string. */
|
||||
/**
|
||||
* Read the whole regular text file as a single decoded string.
|
||||
* @param target - the resolved target to read.
|
||||
* @param signal - aborts the read.
|
||||
* @returns the full decoded UTF-8 content.
|
||||
*/
|
||||
abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
|
||||
|
||||
/**
|
||||
@@ -194,12 +214,18 @@ export abstract class FileSystem extends Service {
|
||||
* semantics as {@link readText}, for large files). The backend owns
|
||||
* cross-chunk UTF-8 decoding and binary rejection so the policy layer never
|
||||
* touches raw bytes.
|
||||
* @param target - the resolved target to read.
|
||||
* @param signal - aborts the stream, including between chunks.
|
||||
* @returns the chunk iterable, decoded and validated like {@link readText}.
|
||||
*/
|
||||
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
|
||||
|
||||
/**
|
||||
* List direct children of a directory in stable name order. Returns resolved
|
||||
* child targets plus cheap metadata only; never reads file contents.
|
||||
* @param target - the resolved directory target.
|
||||
* @param signal - aborts the listing.
|
||||
* @returns one entry per direct child, in stable name order.
|
||||
*/
|
||||
abstract listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>
|
||||
|
||||
@@ -208,6 +234,11 @@ export abstract class FileSystem extends Service {
|
||||
* create-vs-replace decision and stale guard when supplied; OMITTING it is an
|
||||
* unconditional create-or-overwrite (the bare provider — no version guard, no
|
||||
* read-first requirement). Atomic either way.
|
||||
* @param target - the resolved target to write.
|
||||
* @param content - the full new file content.
|
||||
* @param expected - the write intent guarding the write; omit for unconditional.
|
||||
* @param signal - aborts before the atomic rename takes effect.
|
||||
* @returns the outcome, including the version the write produced.
|
||||
*/
|
||||
abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
|
||||
|
||||
@@ -217,6 +248,11 @@ export abstract class FileSystem extends Service {
|
||||
* matching; OMITTING it edits the current content unconditionally (no version
|
||||
* guard). Either way applies the replacement and writes atomically — one
|
||||
* mutation critical section — and a missing target reports `FS_STALE_VERSION`.
|
||||
* @param target - the resolved target to edit.
|
||||
* @param edit - the literal search/replace request.
|
||||
* @param expected - the version guard; omit for an unconditional edit.
|
||||
* @param signal - aborts before the atomic rename takes effect.
|
||||
* @returns the outcome, including the version the edit produced.
|
||||
*/
|
||||
abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -18,21 +18,22 @@
|
||||
|
||||
import { FsError } 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. */
|
||||
@@ -77,8 +78,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 {
|
||||
@@ -89,9 +90,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
|
||||
@@ -116,7 +117,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(
|
||||
@@ -125,12 +126,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,
|
||||
@@ -104,7 +123,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 () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ declare module 'cordis' {
|
||||
* Waterfall around every streaming model call (retry, caching, routing).
|
||||
* Bound to the {@link LlmService}; call `next()` to reach the resolved
|
||||
* adapter's stream, or yield your own chunks to short-circuit.
|
||||
* @param options - the full request; listeners may rewrite it before delegating.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
|
||||
@@ -77,6 +78,9 @@ export class LlmService extends Service {
|
||||
* Register an adapter for the given model names. Throws `LlmError` with code
|
||||
* `DUPLICATE_ADAPTER` if any model already has an adapter (all-or-nothing).
|
||||
* Disposed with the fiber.
|
||||
* @param models - every model name this adapter should serve.
|
||||
* @param adapter - the adapter that streams calls for those models.
|
||||
* @returns the disposer that unregisters all of them.
|
||||
*/
|
||||
registerAdapter(models: string[], adapter: LlmAdapter): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: LlmService) {
|
||||
@@ -95,7 +99,10 @@ export class LlmService extends Service {
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/** Model names with a registered adapter. */
|
||||
/**
|
||||
* Model names with a registered adapter.
|
||||
* @returns the registered names, in registration order.
|
||||
*/
|
||||
models(): string[] {
|
||||
return [...this.adapters.keys()]
|
||||
}
|
||||
@@ -110,6 +117,8 @@ export class LlmService extends Service {
|
||||
* Stream one model call as raw chunks (token-level deltas). Throws
|
||||
* `LlmError` with code `NO_ADAPTER` if no adapter is registered for
|
||||
* `options.model`. Dispatches through the `llm/stream` waterfall.
|
||||
* @param options - the full request; `options.model` selects the adapter.
|
||||
* @returns the chunk stream, possibly wrapped by `llm/stream` listeners.
|
||||
*/
|
||||
stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
return this.ctx.waterfall(this, 'llm/stream', options, () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -105,6 +105,7 @@ export abstract class SessionPersistence extends Service {
|
||||
* until the first {@link append} (lazy materialization), in which case a
|
||||
* created-but-never-appended session is absent from {@link list}
|
||||
* — abandoned sessions leave nothing behind.
|
||||
* @param meta - the immutable header (id, version, cwd, lineage) to record.
|
||||
*/
|
||||
abstract create(meta: SessionHeader): Promise<void>
|
||||
|
||||
@@ -114,6 +115,8 @@ export abstract class SessionPersistence extends Service {
|
||||
* contracts: the first event's `seq` MUST equal the stored next-seq (after
|
||||
* `load` has durably closed any interrupted turn). Rejects non-JSON-
|
||||
* serializable `event.data` with an error naming the offending event type.
|
||||
* @param id - the session the batch belongs to.
|
||||
* @param events - the contiguous batch to persist, in seq order.
|
||||
*/
|
||||
abstract append(id: SessionId, events: readonly SessionEvent[]): Promise<void>
|
||||
|
||||
@@ -138,10 +141,16 @@ export abstract class SessionPersistence extends Service {
|
||||
* COMMITTED region (at or before the last real `turn/end`) makes the session
|
||||
* unloadable (reject). Rejects an unknown format `version`. See the session-persistence RFC for
|
||||
* the crash-recovery contract.
|
||||
* @param id - the persisted session to reload.
|
||||
* @returns the header plus the event log, ending on a balanced `turn/end` —
|
||||
* immediately usable as a session seed.
|
||||
*/
|
||||
abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>
|
||||
|
||||
/** Lightweight listing from metadata, without a full-log parse. */
|
||||
/**
|
||||
* Lightweight listing from metadata, without a full-log parse.
|
||||
* @returns one header per materialized session.
|
||||
*/
|
||||
abstract list(): Promise<SessionHeader[]>
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) },
|
||||
},
|
||||
)
|
||||
|
||||
@@ -64,12 +64,14 @@ declare module 'cordis' {
|
||||
* A subagent run started — emitted after the provider is resolved and its
|
||||
* capabilities validated, as the child run begins. Paired with
|
||||
* {@link Events['subagent/end']}.
|
||||
* @param info - which provider started which child agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'subagent/start'(info: SubagentRunInfo): void
|
||||
/**
|
||||
* A subagent run settled — emitted when {@link SubagentRun.result}
|
||||
* resolves (any stop reason). Paired with {@link Events['subagent/start']}.
|
||||
* @param info - the run identity plus stop reason and final output.
|
||||
* @mode emit
|
||||
*/
|
||||
'subagent/end'(info: SubagentRunEndInfo): void
|
||||
@@ -129,6 +131,8 @@ export class SubagentService extends Service {
|
||||
* Register a provider under its `provider.name`. Throws {@link SubagentError}
|
||||
* (`DUPLICATE_PROVIDER`) if the name is already taken. Effect-scoped: disposed
|
||||
* with the calling fiber (HMR-safe).
|
||||
* @param provider - the provider; its `name` is the registry key.
|
||||
* @returns the disposer that unregisters the provider.
|
||||
*/
|
||||
registerProvider(provider: SubagentProvider): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: SubagentService) {
|
||||
@@ -145,12 +149,19 @@ export class SubagentService extends Service {
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/** Look up a registered provider by name (`undefined` if absent). */
|
||||
/**
|
||||
* Look up a registered provider by name (`undefined` if absent).
|
||||
* @param name - the provider name as registered.
|
||||
* @returns the provider, or undefined when the name is unknown.
|
||||
*/
|
||||
getProvider(name: string): SubagentProvider | undefined {
|
||||
return this.providers.get(name)
|
||||
}
|
||||
|
||||
/** The names of all registered providers (insertion order). */
|
||||
/**
|
||||
* The names of all registered providers (insertion order).
|
||||
* @returns the registered provider names.
|
||||
*/
|
||||
list(): string[] {
|
||||
return [...this.providers.keys()]
|
||||
}
|
||||
@@ -162,6 +173,9 @@ export class SubagentService extends Service {
|
||||
* for the first unmet one — fail loud, before any child is created), then
|
||||
* delegates to {@link SubagentProvider.start} and emits `subagent/start` /
|
||||
* `subagent/end` around the run.
|
||||
* @param name - the provider to run on.
|
||||
* @param request - the child's prompt, capabilities, and options.
|
||||
* @returns the live run (its `result` resolves when the child settles).
|
||||
*/
|
||||
start(name: string, request: SubagentStartRequest): SubagentRun {
|
||||
const provider = this.providers.get(name)
|
||||
|
||||
@@ -51,8 +51,8 @@ const DESCRIPTION =
|
||||
* `InferArgs` maps an `enum` string prop to plain `string`, so the compiler sees
|
||||
* `args.todos` as `{ content: string; status: string }[]`; the
|
||||
* `status as TodoItem['status']` narrowing records that registry guarantee
|
||||
* rather than re-checking it (an unreachable re-check would be dead code — see
|
||||
* AGENTS.md "don't validate scenarios that can't happen"). What remains is the
|
||||
* rather than re-checking it (an unreachable re-check would be dead code the
|
||||
* coverage gate would flag). What remains is the
|
||||
* value rules the DSL has no vocabulary for: non-empty unique content (stored
|
||||
* trimmed, so the persisted value matches the dedupe/length key), and at most
|
||||
* one `in_progress` task.
|
||||
|
||||
@@ -202,8 +202,8 @@ interface SessionRecord {
|
||||
* Drive the in-flight prompt's settle from the harness event stream. The bridge
|
||||
* settles off the durable log: the `turn/end` session event on the
|
||||
* `session/event` feed for the prompt's own turn, with the agent
|
||||
* erroring/settling to idle as a fallback (AGENTS.md "honor cross-seam contracts
|
||||
* on BOTH sides") for the case where a throwing peer `session/event` listener
|
||||
* erroring/settling to idle as a fallback (docs/defensive-patterns.md "honor
|
||||
* cross-seam contracts on BOTH sides") for the case where a throwing peer `session/event` listener
|
||||
* starved the bridge's listener before it saw the boundary. The first of these
|
||||
* to fire settles the prompt; `settle` is then cleared so the others are no-ops
|
||||
* (settle-exactly-once).
|
||||
@@ -284,7 +284,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// sessionUpdate returns a promise; a closed connection rejects it. The
|
||||
// update is best-effort UI feed, never load-bearing for correctness, so a
|
||||
// throwing/rejecting send must not break the turn (the chunk is emitted
|
||||
// inside the model step — see AGENTS.md "contain callback exceptions").
|
||||
// inside the model step — see docs/defensive-patterns.md "contain callback exceptions").
|
||||
/* v8 ignore next 3 -- the rejection only fires on a stdout/connection write
|
||||
failure (closed pipe), which the in-memory test transport never induces;
|
||||
the swallow is a defensive best-effort guard like the loop's emit traps */
|
||||
@@ -639,7 +639,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
conn = new AgentSideConnection(makeAgent, stream)
|
||||
|
||||
/**
|
||||
* Tear ALL live sessions down to quiescence (AGENTS.md "dispose must reach
|
||||
* Tear ALL live sessions down to quiescence (docs/defensive-patterns.md "dispose must reach
|
||||
* quiescence"): for each session settle any pending prompt `cancelled`, then
|
||||
* run that session's {@link AgentHandle} `dispose()` — which stops the loop
|
||||
* (sets `disposed`, aborts the in-flight step), AWAITS the loop's exit (the
|
||||
@@ -892,7 +892,7 @@ export class ToolPresenter {
|
||||
* @param onError invoked when a tool's `presentCall`/`presentResult` THROWS;
|
||||
* the presenter swallows the error and falls back to the generic
|
||||
* presentation so a buggy display callback can never fail a live turn or a
|
||||
* `session/load` replay (AGENTS.md "contain callback exceptions at the
|
||||
* `session/load` replay (docs/defensive-patterns.md "contain callback exceptions at the
|
||||
* boundary"). Defaults to a no-op for callers that don't supply a logger.
|
||||
*/
|
||||
constructor(
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('acp bridge', () => {
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// e2e/integration tests own their resources (AGENTS.md): dispose even on
|
||||
// e2e/integration tests own their resources (docs/testing.md): dispose even on
|
||||
// failure so a flaky run never leaks a context or persistence dir.
|
||||
if (harness) await harness.dispose()
|
||||
harness = undefined
|
||||
|
||||
@@ -158,7 +158,7 @@ export async function makeBridgeHarness(options: {
|
||||
* Plug the REAL `dsh-bash-local` executor + `dsh-tool-bash` tools (instead of
|
||||
* a test's own inline tool). Lets a test drive the actual `bash` tool — its
|
||||
* real `presentCall`/`presentResult` — through the bridge, so tool-call UI
|
||||
* tests verify the SHIPPING tool, not a stand-in (AGENTS.md "prefer the real
|
||||
* tests verify the SHIPPING tool, not a stand-in (docs/testing.md "prefer the real
|
||||
* implementation over a mock in tests").
|
||||
*/
|
||||
withBash?: boolean
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('acp bridge — session/load replay', () => {
|
||||
// bridge. The replayed tool_call/tool_call_update must carry the tool's OWN
|
||||
// presentation — identical to how it streamed live — via a throwaway
|
||||
// presenter that pairs call→result as the log replays in order. Uses the
|
||||
// shipping tool (withBash), not a stand-in (AGENTS.md "prefer the real
|
||||
// shipping tool (withBash), not a stand-in (docs/testing.md "prefer the real
|
||||
// implementation over a mock in tests").
|
||||
live = await makeBridgeHarness({
|
||||
storageDir,
|
||||
|
||||
@@ -287,7 +287,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
|
||||
|
||||
it('a THROWING presentCall/presentResult is contained: generic fallback + onError, never propagates', () => {
|
||||
// A buggy tool whose display callbacks throw must NOT fail a live turn or a
|
||||
// session/load replay (AGENTS.md "contain callback exceptions at the
|
||||
// session/load replay (docs/defensive-patterns.md "contain callback exceptions at the
|
||||
// boundary"). The presenter swallows the throw, reports via onError, and
|
||||
// falls back to the generic presentation.
|
||||
const boom: ToolDefinition = {
|
||||
@@ -379,7 +379,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
|
||||
it('forwards fs-tool render intents onto the wire (REAL read → generic locations, edit → diff content)', async () => {
|
||||
// Use the SHIPPING fs tools (not a stand-in), booted through their real
|
||||
// plugins, so the wire tool_call carries the actual presentCall output —
|
||||
// read's follow-along `locations` and edit's `diff` content block. (AGENTS.md
|
||||
// read's follow-along `locations` and edit's `diff` content block. (docs/testing.md
|
||||
// "prefer the real implementation over a mock".)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('acp bridge — turn outcomes', () => {
|
||||
it('the REAL bash tool drives the tool-call UI end-to-end: command title + description block + console output', async () => {
|
||||
// Use the SHIPPING tool (dsh-tool-bash + dsh-bash-local), not an inline
|
||||
// stand-in, so this verifies the actual presentCall/presentResult the editor
|
||||
// sees (AGENTS.md "prefer the real implementation over a mock in tests").
|
||||
// sees (docs/testing.md "prefer the real implementation over a mock").
|
||||
// The mock MODEL still scripts the tool call (no real LLM needed), but the
|
||||
// tool and executor are real: a real `echo` runs and its real output flows
|
||||
// back through the bridge.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
@@ -279,3 +280,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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
|
||||
/**
|
||||
* Real-API smoke for the DeepSeek search provider. Self-skips without
|
||||
* `$DEEPSEEK_API_KEY`, per the with-key e2e policy in AGENTS.md § Secrets. This
|
||||
* `$DEEPSEEK_API_KEY`, per the with-key e2e policy in docs/testing.md. This
|
||||
* is the only test that proves DeepSeek's Anthropic-compatible endpoint actually
|
||||
* triggers native `web_search` and returns the structured result blocks the
|
||||
* provider parses — a mock cannot confirm the wire shape is real.
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ExaSearchProvider, EXA_DEFAULT_BASE_URL, EXA_DEFAULT_HIGHLIGHTS_PER_RES
|
||||
|
||||
/**
|
||||
* Real-API smoke for the Exa search provider. Self-skips without `$EXA_API_KEY`
|
||||
* (CI has no secrets), per the with-key e2e policy in AGENTS.md § Secrets.
|
||||
* (CI has no secrets), per the with-key e2e policy in docs/testing.md.
|
||||
*/
|
||||
const apiKey = process.env.EXA_API_KEY
|
||||
const maybe = apiKey !== undefined && apiKey.length > 0 ? describe : describe.skip
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAU
|
||||
|
||||
/**
|
||||
* Real-API smoke for the Perplexity search provider. Self-skips without
|
||||
* `$PERPLEXITY_API_KEY`, per the with-key e2e policy in AGENTS.md § Secrets.
|
||||
* `$PERPLEXITY_API_KEY`, per the with-key e2e policy in docs/testing.md.
|
||||
*/
|
||||
const apiKey = process.env.PERPLEXITY_API_KEY
|
||||
const maybe = apiKey !== undefined && apiKey.length > 0 ? describe : describe.skip
|
||||
|
||||
@@ -129,6 +129,8 @@ export class WebService extends Service {
|
||||
* if its id is already registered for search. Returns a disposer; emits
|
||||
* `web/providers-change` after a successful register and again on dispose.
|
||||
* Disposed with the calling fiber.
|
||||
* @param provider - the provider; its `id` is the registry key.
|
||||
* @returns the disposer that unregisters the provider.
|
||||
*/
|
||||
registerSearchProvider(provider: WebSearchProvider): () => void {
|
||||
return this.registerProvider(this.searchProviders, provider)
|
||||
@@ -139,6 +141,8 @@ export class WebService extends Service {
|
||||
* if its id is already registered for fetch. Returns a disposer; emits
|
||||
* `web/providers-change` after a successful register and again on dispose.
|
||||
* Disposed with the calling fiber.
|
||||
* @param provider - the provider; its `id` is the registry key.
|
||||
* @returns the disposer that unregisters the provider.
|
||||
*/
|
||||
registerFetchProvider(provider: WebFetchProvider): () => void {
|
||||
return this.registerProvider(this.fetchProviders, provider)
|
||||
@@ -165,7 +169,10 @@ export class WebService extends Service {
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/** Search-capability selection status, derived live (never stored). */
|
||||
/**
|
||||
* Search-capability selection status, derived live (never stored).
|
||||
* @returns which provider would serve a search right now, or why none would.
|
||||
*/
|
||||
searchStatus(): WebCapabilityStatus {
|
||||
return resolveStatus({
|
||||
providers: this.searchProviders,
|
||||
@@ -173,7 +180,10 @@ export class WebService extends Service {
|
||||
})
|
||||
}
|
||||
|
||||
/** Fetch-capability selection status, derived live (never stored). */
|
||||
/**
|
||||
* Fetch-capability selection status, derived live (never stored).
|
||||
* @returns which provider would serve a fetch right now, or why none would.
|
||||
*/
|
||||
fetchStatus(): WebCapabilityStatus {
|
||||
return resolveStatus({
|
||||
providers: this.fetchProviders,
|
||||
@@ -186,6 +196,9 @@ export class WebService extends Service {
|
||||
* time with the selection rules above; throws {@link WebError} when the
|
||||
* capability cannot run. The seam enforces `request.maxResults` on the result:
|
||||
* if the provider over-returns, `sources[]` is truncated and `truncated` set.
|
||||
* @param request - the query plus result-shaping options.
|
||||
* @param exec - the tool-execution context, forwarded to the provider.
|
||||
* @returns the provider's results, capped to `request.maxResults`.
|
||||
*/
|
||||
async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult> {
|
||||
const provider = resolveProvider({
|
||||
@@ -200,6 +213,9 @@ export class WebService extends Service {
|
||||
* Retrieve one URL through the selected provider. Resolves the provider at
|
||||
* call time with the selection rules above; throws {@link WebError} when the
|
||||
* capability cannot run. A non-2xx response is a result, not a throw.
|
||||
* @param request - the URL plus retrieval options.
|
||||
* @param exec - the tool-execution context, forwarded to the provider.
|
||||
* @returns the retrieval outcome; non-2xx responses resolve descriptively.
|
||||
*/
|
||||
async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult> {
|
||||
const provider = resolveProvider({
|
||||
|
||||
Reference in New Issue
Block a user