Merge remote-tracking branch 'origin/master' into session-fork
# Conflicts: # docs/architecture.md # docs/cordis-catalog/events-and-services.md # docs/core-data-structures/core.md # docs/module-graph.md # docs/rfc/README.md # packages/README.md # tsconfig.base.json
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,109 +1,34 @@
|
||||
# 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 |
|
||||
|---|---|---|
|
||||
| [`core/`](core/README.md) | Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
|
||||
| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
|
||||
| [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
|
||||
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface |
|
||||
| [`session-fork/`](session-fork/README.md) | Session fork capability family: live-session fork snapshots and child session creation | 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 |
|
||||
| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, stdio UI, replay adapter) | Support — lower compatibility expectations |
|
||||
| [`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, replay adapter, subagent mock) | Support — lower compatibility expectations |
|
||||
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free |
|
||||
|
||||
The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not 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-session-fork ← dsh-session (live-session fork snapshots + child session creation)
|
||||
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-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/execute` waterfall | `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`) |
|
||||
| `compact/` | `compact` | Abstract compaction seam + `compact/*` events + `CompactionResult` | `ctx.compact` |
|
||||
| `session-fork/` | `session-fork` | Session fork service over live session seeds | `ctx.sessionFork` |
|
||||
| `compact-basic/` | `compact` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
|
||||
| `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`) |
|
||||
| `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,11 +20,11 @@ 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** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results.
|
||||
- **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.
|
||||
|
||||
## Sandboxing
|
||||
|
||||
`TODO(permissions/sandbox)`: execution policy does NOT belong in this package. Wrap the `tools/execute` waterfall (veto/ask) or implement a sandboxing `BashExecutor` — see docs/architecture.md § plugin checklist. Reference points: Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies seatbelt/landlock plus an execpolicy prefix-rule engine.
|
||||
`TODO(permissions/sandbox)`: execution policy does NOT belong in this package. Use the `tools/pre-execute` deny/ask gate or implement a sandboxing `BashExecutor` — see docs/architecture.md § Extending The Harness. Reference points: Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies seatbelt/landlock plus an execpolicy prefix-rule engine.
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
* own process group (see `./run.ts` for the plumbing and the agent-tool
|
||||
* survey notes), tracks background tasks, and kills everything on dispose.
|
||||
*
|
||||
* TODO(permissions/sandbox): execution policy does NOT belong here — wrap
|
||||
* the `tools/execute` waterfall (see docs/architecture.md § plugin
|
||||
* checklist) or implement a sandboxing `BashExecutor`. Reference points:
|
||||
* TODO(permissions/sandbox): execution policy does NOT belong here — use
|
||||
* the `tools/pre-execute` deny/ask gate (see docs/architecture.md
|
||||
* § Extending The Harness) or implement a sandboxing `BashExecutor`.
|
||||
* Reference points:
|
||||
* Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies
|
||||
* seatbelt/landlock plus an execpolicy prefix-rule engine.
|
||||
*
|
||||
@@ -17,7 +18,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 +34,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 +60,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 +68,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 +87,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
|
||||
@@ -116,6 +121,10 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
|
||||
timeoutMs,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
// Carry stdin/env through verbatim — optional, no config default (absent
|
||||
// means none). env merges AFTER the scrub in run.ts.
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
// Carry the owner through verbatim (required-but-nullable on the spec):
|
||||
// the executor never interprets it — the consumer's access policy does.
|
||||
owner: request.owner,
|
||||
@@ -128,7 +137,10 @@ 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,
|
||||
}, this.internals).done
|
||||
return { ...outcome, timeoutMs: spec.timeoutMs }
|
||||
}
|
||||
@@ -144,7 +156,10 @@ 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,
|
||||
}, this.internals)
|
||||
|
||||
const id = BashTaskId(`bash-${this.nextTaskId++}`)
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
* @module dsh-bash-local/run
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { type ChildProcessByStdio, spawn } from 'node:child_process'
|
||||
import type { Readable, Writable } from 'node:stream'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { closeSync, mkdtempSync, openSync, writeSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -42,13 +43,26 @@ export const ENV_OVERRIDES = {
|
||||
*/
|
||||
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
|
||||
|
||||
/** process.env minus credential-shaped vars, plus the model-friendly overrides. */
|
||||
export function childEnv(): NodeJS.ProcessEnv {
|
||||
/**
|
||||
* `process.env` minus credential-shaped vars, plus the model-friendly
|
||||
* overrides, plus any caller-supplied `extra` entries.
|
||||
*
|
||||
* Layering matters: the scrub drops `process.env` credentials, then
|
||||
* `ENV_OVERRIDES` forces the model-friendly terminal vars, then `extra` is
|
||||
* merged LAST so an explicit caller entry wins even when its name matches the
|
||||
* scrub pattern (the scrub is the control that stops the HARNESS's ambient
|
||||
* credentials leaking into a spawned command; a caller that explicitly sets a
|
||||
* var named a value it already holds, not that ambient secret). `extra` is set
|
||||
* by in-process plugins (the hooks bridges), not the model — `dsh-tool-bash`
|
||||
* builds its request from named fields only and does not forward model input
|
||||
* here (see its README, § "The tool builds its request from named args only").
|
||||
*/
|
||||
export function childEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value
|
||||
}
|
||||
return { ...env, ...ENV_OVERRIDES }
|
||||
return { ...env, ...ENV_OVERRIDES, ...extra }
|
||||
}
|
||||
|
||||
/** What to run and under which limits (resolved — no defaults in here). */
|
||||
@@ -59,8 +73,23 @@ 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
|
||||
/**
|
||||
* Bytes to write to the child's stdin, then close it. Absent (or empty)
|
||||
* leaves stdin closed/empty. Set by in-process plugins (the hooks bridges);
|
||||
* the model-facing `dsh-tool-bash` tool does not thread model input here.
|
||||
*/
|
||||
stdin?: string | undefined
|
||||
/**
|
||||
* Extra environment entries, merged onto the scrubbed env AFTER the
|
||||
* credential scrub and the model-friendly overrides (so an explicit entry
|
||||
* wins). Set by in-process plugins; the model-facing tool does not forward
|
||||
* model input here.
|
||||
*/
|
||||
env?: Record<string, string> | undefined
|
||||
}
|
||||
|
||||
/** Raw outcome of one closed process (before result shaping). */
|
||||
@@ -73,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
|
||||
@@ -265,19 +292,26 @@ 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) {
|
||||
throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
|
||||
}
|
||||
|
||||
const child = spawn('bash', ['-c', spec.command], {
|
||||
cwd: spec.cwd,
|
||||
env: childEnv(),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
})
|
||||
// stdin is a pipe ONLY when the caller supplied bytes; with none it is `ignore`
|
||||
// (fd 0 → /dev/null) — the exact pre-seam default. This matters: a spawn pipe
|
||||
// and /dev/null are NOT observationally identical (node's pipe is an AF_UNIX
|
||||
// socket, so a command that probes stdin's type — `test -c /dev/stdin`, `stat
|
||||
// /proc/self/fd/0` — sees a char device vs a socket), so the no-stdin path
|
||||
// (every model-driven call) must keep /dev/null rather than regress to a socket.
|
||||
// Two LITERAL `stdio` tuples (not one variable tuple): only a literal lets the
|
||||
// typed `spawn` overload infer non-null stdout/stderr, which the
|
||||
// `ChildProcessByStdio` annotation captures (stdin `Writable | null`; stdout/
|
||||
// stderr the non-null `Readable` the collectors attach to without a cast).
|
||||
const env = childEnv(spec.env)
|
||||
const child: ChildProcessByStdio<Writable | null, Readable, Readable> = spec.stdin !== undefined
|
||||
? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
|
||||
: spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
|
||||
|
||||
const stdout = new OutputCollector(spec.maxOutputBytes, 'stdout', spillDir)
|
||||
const stderr = new OutputCollector(spec.maxOutputBytes, 'stderr', spillDir)
|
||||
@@ -296,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) {
|
||||
@@ -312,6 +346,24 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
|
||||
}
|
||||
spec.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
// Write stdin and close it, but ONLY when the caller supplied bytes — with no
|
||||
// stdin, fd 0 is `ignore` (/dev/null) and `child.stdin` is null. The error
|
||||
// handler must exist whenever we write: an unhandled 'error' on the stream
|
||||
// would throw and crash the host. We swallow the error rather than reject
|
||||
// `done`, and that is correct for ANY stdin-write error, not just the common
|
||||
// one — the stdin write is BEST-EFFORT, while the command's authoritative
|
||||
// outcome is its exit code + captured output, which the `close` handler reports
|
||||
// regardless of whether the write landed. The expected case is EPIPE (the child
|
||||
// exited without reading, so closing our end of a still-full pipe fails); a rare
|
||||
// non-EPIPE pipe fault means the command ran with incomplete stdin, and it
|
||||
// surfaces that itself through its own exit/output (e.g. a hook that gets
|
||||
// truncated JSON errors out) — rejecting here would instead discard that real
|
||||
// output and turn it into an opaque infrastructure error, which is worse.
|
||||
if (child.stdin !== null) {
|
||||
child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ })
|
||||
child.stdin.end(spec.stdin)
|
||||
}
|
||||
|
||||
const done = new Promise<SpawnOutcome>((resolve, reject) => {
|
||||
child.on('error', (error) => {
|
||||
// Spawn-level failure (ENOENT cwd, EACCES, …): no close event with
|
||||
|
||||
@@ -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 }))
|
||||
@@ -106,6 +117,23 @@ describe('LocalBashExecutor.run', () => {
|
||||
const { bash } = await setup()
|
||||
await expect(bash.run(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))).rejects.toThrow(/ENOENT/)
|
||||
})
|
||||
|
||||
it('resolve() carries stdin/env onto the spec, and run() threads them to the command', async () => {
|
||||
const { bash } = await setup()
|
||||
const spec = bash.resolve({ command: 'cat; echo "[$DSH_SEAM_VAR]"', stdin: 'piped\n', env: { DSH_SEAM_VAR: 'env-ok' } })
|
||||
// resolve() keeps the stdin/env fields verbatim (optional, no default).
|
||||
expect(spec.stdin).toBe('piped\n')
|
||||
expect(spec.env).toEqual({ DSH_SEAM_VAR: 'env-ok' })
|
||||
const result = await bash.run(spec)
|
||||
expect(result.stdout.text).toBe('piped\n[env-ok]\n')
|
||||
})
|
||||
|
||||
it('resolve() omits stdin/env when the request supplies neither', async () => {
|
||||
const { bash } = await setup()
|
||||
const spec = bash.resolve({ command: 'true' })
|
||||
expect('stdin' in spec).toBe(false)
|
||||
expect('env' in spec).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalBashExecutor background tasks', () => {
|
||||
@@ -131,6 +159,19 @@ describe('LocalBashExecutor background tasks', () => {
|
||||
await Promise.all([first.done, second.done])
|
||||
})
|
||||
|
||||
it('threads stdin and extra env into a background task', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({
|
||||
command: 'cat; echo "[$DSH_BG_VAR]"',
|
||||
stdin: 'bg-stdin\n',
|
||||
env: { DSH_BG_VAR: 'bg-env' },
|
||||
}))
|
||||
const read = await readUntil(bash, task.id, '[bg-env]')
|
||||
expect(read.delta).toContain('bg-stdin')
|
||||
await task.done
|
||||
expect(task.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('readOutput returns increments without re-delivery', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'echo first; sleep 1; echo second' }))
|
||||
@@ -241,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
|
||||
@@ -258,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)
|
||||
|
||||
@@ -307,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
|
||||
@@ -158,6 +159,62 @@ describe('runBash', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('stdin and extra env (set by in-process plugins)', () => {
|
||||
it('writes stdin to the command and closes it', async () => {
|
||||
const result = await runBash(spec('cat', { stdin: 'hello from stdin\n' })).done
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.text).toBe('hello from stdin\n')
|
||||
})
|
||||
|
||||
it('a command that reads stdin sees EOF when none is supplied', async () => {
|
||||
// No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no
|
||||
// output (it does NOT block).
|
||||
const result = await runBash(spec('cat')).done
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.text).toBe('')
|
||||
})
|
||||
|
||||
it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => {
|
||||
// The no-stdin path must stay observationally identical to the pre-seam
|
||||
// `ignore` default: a command that probes stdin's file type sees a char
|
||||
// device (/dev/null). Regressing to an always-open pipe would make fd 0 a
|
||||
// socket (node's spawn pipe is an AF_UNIX socket, not a FIFO), flipping
|
||||
// `test -c /dev/stdin` for every model-driven call. When bytes ARE supplied,
|
||||
// fd 0 is that pipe (a socket), as it must be to carry them.
|
||||
const none = await runBash(spec('test -c /dev/stdin && echo char || echo other')).done
|
||||
expect(none.stdout.text).toBe('char\n')
|
||||
const piped = await runBash(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done
|
||||
expect(piped.stdout.text).toBe('socket\n')
|
||||
})
|
||||
|
||||
it('merges extra env entries onto the scrubbed environment', async () => {
|
||||
const result = await runBash(spec('echo "$DSH_EXTRA_ONE/$DSH_EXTRA_TWO"', {
|
||||
env: { DSH_EXTRA_ONE: 'alpha', DSH_EXTRA_TWO: 'beta' },
|
||||
})).done
|
||||
expect(result.stdout.text).toBe('alpha/beta\n')
|
||||
})
|
||||
|
||||
it('an explicit extra env entry overrides the model-friendly override and the scrub', async () => {
|
||||
// TERM is a model-friendly OVERRIDE (dumb); an explicit extra entry wins.
|
||||
// DSH_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit
|
||||
// entry is still honored — the scrub only drops AMBIENT process.env creds.
|
||||
const result = await runBash(spec('echo "$TERM/$DSH_OVERRIDE_KEY"', {
|
||||
env: { TERM: 'xterm-256color', DSH_OVERRIDE_KEY: 'explicit-wins' },
|
||||
})).done
|
||||
expect(result.stdout.text).toBe('xterm-256color/explicit-wins\n')
|
||||
})
|
||||
|
||||
it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => {
|
||||
// The child exits immediately without reading; closing our end of a stdin
|
||||
// pipe still holding ~1MiB triggers EPIPE on the write. The handler must
|
||||
// swallow it: `done` resolves normally with the child's real exit.
|
||||
const big = 'x'.repeat(1024 * 1024)
|
||||
const result = await runBash(spec('exit 7', { stdin: big })).done
|
||||
expect(result.exitCode).toBe(7)
|
||||
expect(result.aborted).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('output truncation and spill', () => {
|
||||
it('keeps the tail and spills the full stream to disk', async () => {
|
||||
// 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail.
|
||||
|
||||
@@ -28,4 +28,6 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal
|
||||
|
||||
## Vocabulary
|
||||
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`OwnerToken | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts.
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`OwnerToken | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts.
|
||||
|
||||
`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so they would be redundant tool params. This is not a security boundary: the implementation's credential scrub (not these fields) is what keeps the harness's ambient secrets out of a spawned command. They are plain optionals on the resolved spec (unlike `owner`'s required-but-nullable): a missing one means "none", the safe default. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -45,6 +45,24 @@ export interface BashExecRequest {
|
||||
timeoutMs?: number | undefined
|
||||
/** Abort signal — implementations kill the command when it fires. */
|
||||
signal?: AbortSignal | undefined
|
||||
/**
|
||||
* Bytes to write to the command's stdin, then close it. Absent leaves stdin
|
||||
* closed/empty (the default for model-driven tool calls). Set by in-process
|
||||
* plugins (e.g. the hooks bridges, which write a hook command's JSON payload
|
||||
* to its stdin); the model-facing bash tool does not expose it as a parameter
|
||||
* (a model that needs stdin uses shell syntax like a heredoc or a pipe).
|
||||
*/
|
||||
stdin?: string | undefined
|
||||
/**
|
||||
* Extra environment entries for the command, merged AFTER the
|
||||
* implementation's credential scrub (so an explicit entry here is honored even
|
||||
* when its name matches the scrub pattern — the caller named a value it holds,
|
||||
* not the harness's ambient secret). Set by in-process plugins (the hooks
|
||||
* bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing
|
||||
* bash tool does not expose it as a parameter (a model that needs an env var
|
||||
* uses shell syntax like `FOO=bar cmd`).
|
||||
*/
|
||||
env?: Record<string, string> | undefined
|
||||
/**
|
||||
* Opaque OWNER token for a background task — the consumer's isolation key
|
||||
* (the tool layer passes the owning agent's `session.header.id`). The
|
||||
@@ -70,6 +88,22 @@ export interface BashExecSpec {
|
||||
timeoutMs: number
|
||||
/** Abort signal — implementations kill the command when it fires. */
|
||||
signal?: AbortSignal | undefined
|
||||
/**
|
||||
* Bytes to write to the command's stdin (then close it), carried through
|
||||
* verbatim from {@link BashExecRequest.stdin}. OPTIONAL on the resolved spec
|
||||
* (unlike `owner`): it has no config default, so a missing one means "no
|
||||
* stdin" — the safe, ordinary case — not a silent footgun, so it stays a
|
||||
* plain optional rather than required-but-nullable (see the request field).
|
||||
*/
|
||||
stdin?: string | undefined
|
||||
/**
|
||||
* Extra environment entries, carried through verbatim from
|
||||
* {@link BashExecRequest.env} and merged by the implementation AFTER its
|
||||
* credential scrub (an explicit entry wins even when its name matches the
|
||||
* scrub pattern). OPTIONAL on the spec for the same reason as `stdin` — no
|
||||
* config default, absent means "no extra env".
|
||||
*/
|
||||
env?: Record<string, string> | undefined
|
||||
/**
|
||||
* Opaque owner token, REQUIRED-but-nullable (mirrors `workdir`/`timeoutMs`
|
||||
* being required on the resolved spec): {@link BashExecutor.resolve} carries
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registered over the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`). Pure schema + text shaping; every process concern lives behind the seam, so sandboxed or remote executor implementations swap in without changing what the model sees.
|
||||
|
||||
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash']`).
|
||||
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`).
|
||||
|
||||
The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on.
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -34,12 +36,16 @@ The owning agent's session token (`session.header.id`) is stamped onto the task
|
||||
|
||||
## UI presentation
|
||||
|
||||
These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam — a UI never special-cases tool names. For `bash`: the **title** is the exact `command` ("ls -la src") and `kind` is `execute` (terminal/run treatment), matching the reference ACP adapters (claude-agent-acp, codex-acp), which both use the bare command as an execute tool's title. The command is ALSO the **rawInput** for non-terminal UIs that render it (an execute-kind card hides rawInput — Zed shows it only for non-terminal tools — so the command must BE the title to be seen). The model-written `description` rides as a **content** text block shown ABOVE the card. (claude-agent-acp DROPS the description in terminal mode and shows only the card; surfacing it as a content block is a deliberate divergence — we keep the human summary visible alongside the card.) The completed output is wrapped in a fenced ` ```console ` block as the no-terminal-capability fallback — a UI-only affordance, so the model-facing result text stays unfenced. A FOREGROUND `bash` run also flags itself as a **terminal** (the neutral `terminal` field: `presentCall` sets a `cwd` from the model `workdir` when given — absolute as-is, relative for the UI bridge to resolve against the session cwd — else leaves it for the bridge to fill from the session cwd; `presentResult` carries the raw output plus the parsed `exitCode`/`signal`) so a capable client (Zed) renders a terminal card with an exit-status pill instead of the text block — see `packages/ui/acp` ("Terminal card"). A `run_in_background` call is NOT a terminal (it returns a task id immediately and never streams a terminal — poll with `bash_output`), and an `isError` result (spawn failure / abort) carries no exit pill (there is no real process exit); both render as the ordinary execute card / fenced text. `bash_output`/`bash_kill` present a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") with the task id as rawInput. These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/core/tools` ("Tool-owned UI presentation") and `packages/ui/acp` ("Terminal card" / "Tool-call presentation").
|
||||
These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam, each returning a `card`-tagged render intent — a UI never special-cases tool names. A FOREGROUND `bash` run declares a **terminal card**: `presentCall` returns `{ card: 'terminal', title, description?, cwd? }` — the **title** is the exact `command` ("ls -la src"), the model-written `description` rides along (rendered ABOVE the card), and `cwd` comes from the model `workdir` when given (absolute as-is, relative for the UI bridge to resolve against the session cwd; else left for the bridge to fill from the session cwd) — and `presentResult` returns `{ card: 'terminal', title?, output?, exitCode?, signal? }` carrying the raw output plus the parsed `exitCode`/`signal`, so a capable client (Zed) renders a terminal card with an exit-status pill. The result carries the raw `output`; the bridge DERIVES the ` ```console ` fenced fallback for a no-terminal-capability UI (the tool no longer encodes the fences itself), so the model-facing result text stays unfenced. A `run_in_background` call is NOT a terminal (it returns a task id immediately and never streams a terminal — poll with `bash_output`) and instead returns a **generic card** (`{ card: 'generic', title, kind: 'execute', rawInput: command, content: [description] }`); an `isError` result (spawn failure / abort) likewise returns a `generic` result view with no exit pill (there is no real process exit). `bash_output`/`bash_kill` return a `generic` card with a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") and the task id as rawInput. These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/core/tools` ("Tool-owned UI presentation") and `packages/ui/acp` ("Terminal card" / "Tool-call presentation").
|
||||
|
||||
## Background completion notices
|
||||
|
||||
When a background task finishes, a short notice is injected into the owning agent's session (`agent.inject()`, source `{kind: 'plugin', plugin: 'tool-bash'}`). The owning agent is found by its session token: the listener reads `ctx.bash.ownerOf(task.id)` and scans `ctx.get('agents')?.list()` for an agent whose `session.header.id` matches (read via `ctx.get` — `onTaskDone` runs on the bash fiber, a foreign fiber, so the `ctx.agents` proxy would throw). If no live agent carries that token — e.g. the owning session disconnected and its agent was disposed while the task ran on — the notice is dropped cleanly. Injection is **durable context for the next model request, not a wake-up** — an idle agent stays idle until something sends a message. That's why the tool descriptions tell the model to poll with `bash_output`.
|
||||
|
||||
## The tool builds its request from named args only
|
||||
|
||||
The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks bridges to feed a hook command its JSON payload and `CLAUDE_*` env. This tool does **not** expose them as parameters: its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only, so a model that includes `env` or `stdin` keys in its tool arguments has them ignored. This is not a trust boundary — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), and the real defense against leaking the harness's ambient secrets is `dsh-bash-local`'s credential scrub, which works regardless. A regression guard drives the real tool with those extra args and asserts the resulting request carries neither field — its job is to catch a future refactor that blindly spreads `...args` into the request (which would silently forward model input into the post-scrub `env` merge), not to defend a wall. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
|
||||
## Permissions
|
||||
|
||||
`TODO(permissions)`: commands run with the executor's full authority. The permission/sandbox seam is the `tools/execute` waterfall (veto or ask) plus sandboxing `BashExecutor` implementations — see docs/architecture.md. `@cordisjs/plugin-capability` (a named-permission service with a session `test()`) is a candidate building block for that work.
|
||||
`TODO(permissions)`: commands run with the executor's full authority. The permission/sandbox seam is the `tools/pre-execute` waterfall (deny or ask) plus sandboxing `BashExecutor` implementations — see docs/architecture.md. `@cordisjs/plugin-capability` (a named-permission service with a session `test()`) is a candidate building block for that work.
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
|
||||
@@ -31,9 +31,9 @@
|
||||
* pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
|
||||
*
|
||||
* TODO(permissions): commands run with the executor's full authority. The
|
||||
* permission/sandbox seam is the `tools/execute` waterfall (veto/ask) plus
|
||||
* permission/sandbox seam is the `tools/pre-execute` waterfall (deny/ask) plus
|
||||
* sandboxing `BashExecutor` implementations — see docs/architecture.md
|
||||
* § plugin checklist.
|
||||
* § Extending The Harness.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-bash
|
||||
*/
|
||||
@@ -41,13 +41,14 @@
|
||||
import type { Context } from 'cordis'
|
||||
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolCallPresentation, ToolResult, ToolResultPresentation } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
export const name = 'tool-bash'
|
||||
export const inject = ['tools', 'bash']
|
||||
export const inject = ['tools', 'bash', 'systemPrompt']
|
||||
|
||||
/**
|
||||
* Validate the constraints the SchemaSpec can't express. `defineTool` now
|
||||
@@ -158,16 +159,26 @@ export function renderResult(result: BashRunResult): string {
|
||||
*/
|
||||
type BashCallArgs = { command: string; description: string; workdir?: string; run_in_background?: boolean }
|
||||
|
||||
function presentBashCall(args: BashCallArgs): ToolCallPresentation {
|
||||
const base = {
|
||||
title: args.command,
|
||||
kind: 'execute' as const,
|
||||
rawInput: args.command,
|
||||
content: [{ type: 'text' as const, text: args.description }],
|
||||
function presentBashCall(args: BashCallArgs): GenericCallView | TerminalCallView {
|
||||
// A background start is not an interactive terminal — a generic execute card
|
||||
// with the command as rawInput and the description as a content block.
|
||||
if (args.run_in_background === true) {
|
||||
return {
|
||||
card: 'generic',
|
||||
title: args.command,
|
||||
kind: 'execute',
|
||||
rawInput: args.command,
|
||||
content: [{ type: 'text', text: args.description }],
|
||||
}
|
||||
}
|
||||
// A foreground run IS a terminal: the command titles the card, the description
|
||||
// renders above it, and the cwd (when the model gave a workdir) heads it.
|
||||
return {
|
||||
card: 'terminal',
|
||||
title: args.command,
|
||||
description: args.description,
|
||||
...args.workdir !== undefined ? { cwd: args.workdir } : {},
|
||||
}
|
||||
// A background start is not an interactive terminal — no terminal card.
|
||||
if (args.run_in_background === true) return base
|
||||
return { ...base, terminal: args.workdir !== undefined ? { cwd: args.workdir } : {} }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,21 +197,25 @@ function presentBashCall(args: BashCallArgs): ToolCallPresentation {
|
||||
* task-id ack, not a streamed run) and an `isError` result (a spawn failure or
|
||||
* abort — there is no real process exit to pill, and the body is an error
|
||||
* message, not `renderResult` output, so parsing it would be meaningless). Those
|
||||
* fall back to the fenced `content` block with no terminal metadata. The bridge's
|
||||
* orphan guard also drops a result terminal when the call wasn't terminal, so a
|
||||
* background call (not marked terminal in `presentBashCall`) is doubly safe.
|
||||
* A non-text result (unexpected for bash) falls through to `undefined`.
|
||||
* return a `generic` result whose content is the fenced ```console block. A
|
||||
* finished foreground run returns a `terminal` result carrying the RAW output
|
||||
* and the parsed exit status; the BRIDGE derives the fenced fallback from
|
||||
* `output` for a UI without terminal support, so the tool does not double-encode
|
||||
* it. A non-text result (unexpected for bash) falls through to `undefined`.
|
||||
*/
|
||||
function presentBashResult(args: unknown, result: ToolResult): ToolResultPresentation | undefined {
|
||||
function presentBashResult(args: unknown, result: ToolResult): ToolResultView | undefined {
|
||||
const block = result.content.length === 1 ? result.content[0] : undefined
|
||||
if (block === undefined || block.type !== 'text') return undefined
|
||||
const raw = block.text
|
||||
const fenced = raw.replace(/\n+$/, '')
|
||||
const content = [{ type: 'text' as const, text: `\`\`\`console\n${fenced}\n\`\`\`` }]
|
||||
const isBackground = typeof args === 'object' && args !== null && (args as { run_in_background?: unknown }).run_in_background === true
|
||||
// No exit pill / terminal output for a background ack or an errored run.
|
||||
if (isBackground || result.isError) return { content }
|
||||
return { content, terminal: { output: raw, ...parseExitStatus(raw) } }
|
||||
// A background ack or an errored run is not a real terminal exit: render the
|
||||
// fenced ```console fallback as generic content (no exit pill).
|
||||
if (isBackground || result.isError) {
|
||||
return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] }
|
||||
}
|
||||
// A finished foreground run: RAW output + parsed exit for the terminal card.
|
||||
// The bridge derives the no-capability fenced fallback from `output`.
|
||||
return { card: 'terminal', output: raw, ...parseExitStatus(raw) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -237,8 +252,8 @@ function parseExitStatus(text: string): { exitCode: number } | { signal: string
|
||||
}
|
||||
|
||||
/** Pending-state presentation for `bash_output`/`bash_kill` (background-task tools). */
|
||||
function presentTaskCall(verb: string, args: { task_id: string }): ToolCallPresentation {
|
||||
return { title: `${verb} background task ${args.task_id}`, kind: 'execute', rawInput: args.task_id }
|
||||
function presentTaskCall(verb: string, args: { task_id: string }): GenericCallView {
|
||||
return { card: 'generic', title: `${verb} background task ${args.task_id}`, kind: 'execute', rawInput: args.task_id }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -271,6 +286,15 @@ function statusLine(task: BashTask): string {
|
||||
}
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
// The bash tools' cross-call HABIT, which the per-tool descriptions cannot
|
||||
// carry (they describe one call each): the exit-code marker is only useful
|
||||
// if the model actually checks it every time.
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:bash',
|
||||
order: 105,
|
||||
text: 'Check the [exit code: N] marker on every bash result; investigate failures before moving on.',
|
||||
})
|
||||
|
||||
/**
|
||||
* The caller's owner TOKEN — the owning agent's `session.header.id`, or
|
||||
* `undefined` for a non-agent caller. Read `session.header.id` (NOT
|
||||
|
||||
@@ -123,7 +123,10 @@ describe('bash tool through the agent loop', () => {
|
||||
textResponse('Background task finished.'),
|
||||
])
|
||||
// The second tool call needs the REAL task id from the first result;
|
||||
// a tools/execute waterfall listener rewrites the scripted arguments.
|
||||
// a tools/pre-execute listener rewrites the scripted arguments. (This uses
|
||||
// the low-level capability to mutate `exec` before dispatch — the
|
||||
// unadvertised mechanism behind a future first-class input-rewrite decision;
|
||||
// here it is a test shim to thread the generated id, not a product feature.)
|
||||
let taskId = ''
|
||||
|
||||
const ctx = await harness(adapter)
|
||||
@@ -137,7 +140,7 @@ describe('bash tool through the agent loop', () => {
|
||||
if (match) taskId = match[1]!
|
||||
}
|
||||
})
|
||||
ctx.on('tools/execute', async (exec, next) => {
|
||||
ctx.on('tools/pre-execute', async (exec, next) => {
|
||||
if (exec.name === 'bash_output') {
|
||||
exec.arguments = { task_id: taskId }
|
||||
}
|
||||
|
||||
@@ -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: ')
|
||||
@@ -261,6 +261,14 @@ describe('bash tool', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('contributes the exit-code habit as its prompt section (guidance the descriptions cannot carry)', async () => {
|
||||
const ctx = await setup()
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
const section = assembly.sections.find(s => s.name === 'tool:bash')
|
||||
expect(section?.order).toBe(105)
|
||||
expect(section?.text).toContain('[exit code: N]')
|
||||
})
|
||||
|
||||
it('unregisters everything when the plugin fiber is disposed (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -268,8 +276,11 @@ describe('bash tool', () => {
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
const fiber = await ctx.plugin(ToolBash)
|
||||
expect(ctx.tools.schemas()).toHaveLength(3)
|
||||
expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'tool:bash'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
// Only the system-prompt plugin's own built-in sections remain.
|
||||
expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona'])
|
||||
})
|
||||
|
||||
it('tools depend on the executor: no registration without ctx.bash', async () => {
|
||||
@@ -316,8 +327,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 +579,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')
|
||||
@@ -716,45 +727,40 @@ describe('status lines', () => {
|
||||
})
|
||||
|
||||
describe('tool-owned UI presentation (presentCall / presentResult)', () => {
|
||||
it('bash presentCall: title is the command, description as a content block, marks a terminal; workdir → cwd (absolute or relative, bridge resolves)', async () => {
|
||||
it('bash presentCall: a foreground run is a terminal card (command title, description, workdir → cwd absolute or relative)', async () => {
|
||||
const ctx = await setup()
|
||||
// No explicit workdir → the call still flags a terminal, but with no cwd (the
|
||||
// UI bridge fills the session cwd it owns; the pure presenter can't see it).
|
||||
// The command is the title (an execute card hides rawInput); the description
|
||||
// rides as a content text block (shown above the terminal card).
|
||||
// No explicit workdir → a terminal card with no cwd (the UI bridge fills the
|
||||
// session cwd it owns; the pure presenter can't see it).
|
||||
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls -la src', description: 'List files in src' }))
|
||||
.toEqual({ title: 'ls -la src', kind: 'execute', rawInput: 'ls -la src', content: [{ type: 'text', text: 'List files in src' }], terminal: {} })
|
||||
.toEqual({ card: 'terminal', title: 'ls -la src', description: 'List files in src' })
|
||||
// An ABSOLUTE workdir is surfaced verbatim as the terminal cwd header.
|
||||
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd', description: 'Print dir', workdir: '/tmp/x' }))
|
||||
.toEqual({ title: 'pwd', kind: 'execute', rawInput: 'pwd', content: [{ type: 'text', text: 'Print dir' }], terminal: { cwd: '/tmp/x' } })
|
||||
.toEqual({ card: 'terminal', title: 'pwd', description: 'Print dir', cwd: '/tmp/x' })
|
||||
// A RELATIVE workdir is passed through AS-IS (the bridge resolves it against
|
||||
// the session cwd, matching where execution runs) — not dropped.
|
||||
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd', description: 'Print dir', workdir: 'sub' }))
|
||||
.toEqual({ title: 'pwd', kind: 'execute', rawInput: 'pwd', content: [{ type: 'text', text: 'Print dir' }], terminal: { cwd: 'sub' } })
|
||||
.toEqual({ card: 'terminal', title: 'pwd', description: 'Print dir', cwd: 'sub' })
|
||||
})
|
||||
|
||||
it('bash presentResult: console-block content AND terminal.output (RAW newlines) + parsed exit code', async () => {
|
||||
it('bash presentResult: a terminal result carries RAW output (newlines intact) + parsed exit code', async () => {
|
||||
const ctx = await setup()
|
||||
const present = ctx.tools.get('bash')!.presentResult!(
|
||||
{ command: 'echo hi', description: 'echo' },
|
||||
{ content: [{ type: 'text', text: 'hi\n[exit code: 0]\n\n' }], isError: false },
|
||||
)
|
||||
// The fenced ```console content trims trailing blank lines for a tidy block;
|
||||
// terminal.output keeps the RAW bytes (newlines intact) a terminal renderer
|
||||
// needs; exitCode is parsed back from the [exit code: N] marker.
|
||||
expect(present).toEqual({
|
||||
content: [{ type: 'text', text: '```console\nhi\n[exit code: 0]\n```' }],
|
||||
terminal: { output: 'hi\n[exit code: 0]\n\n', exitCode: 0 },
|
||||
})
|
||||
// A terminal result keeps the RAW bytes (newlines intact) a terminal renderer
|
||||
// needs; the bridge derives the fenced fallback. exitCode is parsed back from
|
||||
// the [exit code: N] marker.
|
||||
expect(present).toEqual({ card: 'terminal', output: 'hi\n[exit code: 0]\n\n', exitCode: 0 })
|
||||
})
|
||||
|
||||
it('bash presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => {
|
||||
const ctx = await setup()
|
||||
const args = { command: 'x', description: 'x' }
|
||||
const nonzero = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false })
|
||||
expect(nonzero?.terminal).toEqual({ output: 'oops\n[exit code: 3]', exitCode: 3 })
|
||||
expect(nonzero).toEqual({ card: 'terminal', output: 'oops\n[exit code: 3]', exitCode: 3 })
|
||||
const killed = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false })
|
||||
expect(killed?.terminal).toEqual({ output: 'gone\n[killed by signal: SIGKILL]', signal: 'SIGKILL' })
|
||||
expect(killed).toEqual({ card: 'terminal', output: 'gone\n[killed by signal: SIGKILL]', signal: 'SIGKILL' })
|
||||
})
|
||||
|
||||
it('bash presentResult exit parse is the inverse of renderResult markers (round-trip)', async () => {
|
||||
@@ -779,7 +785,8 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
|
||||
for (const c of cases) {
|
||||
const rendered = renderResult(c.result)
|
||||
const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false })
|
||||
const { output: _o, ...exit } = out?.terminal ?? {}
|
||||
// Drop card + output; the remaining fields are the parsed exit.
|
||||
const { card: _c, output: _o, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string }
|
||||
expect(exit).toEqual(c.expect)
|
||||
}
|
||||
})
|
||||
@@ -793,44 +800,42 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
|
||||
// the marker (renderResult always inserts one before a REAL marker), so this
|
||||
// no-trailing-newline body is NOT mistaken for a failure → exitCode 0.
|
||||
const out = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false })
|
||||
expect(out?.terminal).toEqual({ output: '[exit code: 5]', exitCode: 0 })
|
||||
expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 })
|
||||
// Same for a fake signal marker with no leading newline.
|
||||
const sig = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false })
|
||||
expect(sig?.terminal).toEqual({ output: '[killed by signal: SIGKILL]', exitCode: 0 })
|
||||
expect(sig).toEqual({ card: 'terminal', output: '[killed by signal: SIGKILL]', exitCode: 0 })
|
||||
})
|
||||
|
||||
it('bash presentCall/presentResult: a run_in_background call is NOT a terminal and its ack carries no exit pill', async () => {
|
||||
it('bash presentCall/presentResult: a run_in_background call is a generic card and its ack carries no exit pill', async () => {
|
||||
const ctx = await setup()
|
||||
// The background start returns a task-id ack, not a streamed run — no terminal.
|
||||
// The background start returns a task-id ack, not a streamed run — a generic
|
||||
// execute card with the command as rawInput and the description as content.
|
||||
const call = ctx.tools.get('bash')!.presentCall!({ command: 'sleep 100', description: 'wait', run_in_background: true })
|
||||
expect(call).toEqual({ title: 'sleep 100', kind: 'execute', rawInput: 'sleep 100', content: [{ type: 'text', text: 'wait' }] })
|
||||
expect((call as { terminal?: unknown }).terminal).toBeUndefined()
|
||||
// The ack result is fenced text only — no terminal output / exit pill.
|
||||
expect(call).toEqual({ card: 'generic', title: 'sleep 100', kind: 'execute', rawInput: 'sleep 100', content: [{ type: 'text', text: 'wait' }] })
|
||||
// The ack result is a generic fenced-text card — no terminal output / exit pill.
|
||||
const result = ctx.tools.get('bash')!.presentResult!(
|
||||
{ command: 'sleep 100', description: 'wait', run_in_background: true },
|
||||
{ content: [{ type: 'text', text: 'started background task bash-1' }], isError: false },
|
||||
)
|
||||
expect(result?.terminal).toBeUndefined()
|
||||
expect(result?.content).toEqual([{ type: 'text', text: '```console\nstarted background task bash-1\n```' }])
|
||||
expect(result).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\nstarted background task bash-1\n```' }] })
|
||||
})
|
||||
|
||||
it('bash presentResult: an isError result carries no exit pill (no real process exit to report)', async () => {
|
||||
it('bash presentResult: an isError result is a generic card (no real process exit to report)', async () => {
|
||||
const ctx = await setup()
|
||||
// A spawn failure / abort has no process exit — the body is an error message,
|
||||
// not renderResult output, so no terminal output/exit is emitted.
|
||||
// not renderResult output, so a generic fenced card, no terminal output/exit.
|
||||
const out = ctx.tools.get('bash')!.presentResult!(
|
||||
{ command: 'x', description: 'x' },
|
||||
{ content: [{ type: 'text', text: 'command aborted' }], isError: true },
|
||||
)
|
||||
expect(out?.terminal).toBeUndefined()
|
||||
expect(out?.content).toEqual([{ type: 'text', text: '```console\ncommand aborted\n```' }])
|
||||
expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ncommand aborted\n```' }] })
|
||||
})
|
||||
|
||||
it('bash presentResult: leaves a non-text (unexpected) result untouched → undefined (UI keeps raw content)', async () => {
|
||||
const ctx = await setup()
|
||||
const present = ctx.tools.get('bash')!.presentResult!(
|
||||
{ command: 'x', description: 'x' },
|
||||
{ content: [{ type: 'image', url: 'https://x/y.png' }], isError: false },
|
||||
{ content: [{ type: 'reasoning', text: 'unexpected' }], isError: false },
|
||||
)
|
||||
expect(present).toBeUndefined()
|
||||
})
|
||||
@@ -849,9 +854,9 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
|
||||
it('bash_output / bash_kill presentCall: a readable task-scoped title, task id as rawInput', async () => {
|
||||
const ctx = await setup()
|
||||
expect(ctx.tools.get('bash_output')!.presentCall!({ task_id: 'bash-3' }))
|
||||
.toEqual({ title: 'Read output from background task bash-3', kind: 'execute', rawInput: 'bash-3' })
|
||||
.toEqual({ card: 'generic', title: 'Read output from background task bash-3', kind: 'execute', rawInput: 'bash-3' })
|
||||
expect(ctx.tools.get('bash_kill')!.presentCall!({ task_id: 'bash-3' }))
|
||||
.toEqual({ title: 'Kill background task bash-3', kind: 'execute', rawInput: 'bash-3' })
|
||||
.toEqual({ card: 'generic', title: 'Kill background task bash-3', kind: 'execute', rawInput: 'bash-3' })
|
||||
})
|
||||
|
||||
it('presentCall validates softly: malformed args (missing required description) return undefined, never throw', async () => {
|
||||
@@ -863,3 +868,105 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
|
||||
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the model-facing bash tool builds its request from named args only (no {...args} forward)', () => {
|
||||
/**
|
||||
* Records every {@link BashExecRequest} the consumer hands to `resolve()`, so a
|
||||
* test can assert what the model-facing tool DID and DID NOT forward. The `bash`
|
||||
* tool does not expose `stdin`/`env` as parameters (bash syntax already gives a
|
||||
* model that power), so it must build its request from named args only and
|
||||
* never spread unknown tool-call keys into it. This guard's job is to catch a
|
||||
* future refactor that blindly forwards `...args` — which would silently thread
|
||||
* model input into the post-scrub `env` merge — NOT to defend a trust boundary
|
||||
* (the credential scrub in dsh-bash-local is the security control; see the
|
||||
* bash-stdin-env RFC). Foreground `run()` returns a canned result; `start()` is
|
||||
* unused here.
|
||||
*/
|
||||
class RecordingBashExecutor extends BashExecutor {
|
||||
readonly requests: BashExecRequest[] = []
|
||||
resolve(request: BashExecRequest): BashExecSpec {
|
||||
this.requests.push(request)
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? process.cwd(),
|
||||
timeoutMs: request.timeoutMs ?? 0,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
owner: request.owner,
|
||||
}
|
||||
}
|
||||
run(): Promise<BashRunResult> {
|
||||
return Promise.resolve({
|
||||
exitCode: 0, signal: null, timedOut: false, aborted: false, timeoutMs: 0,
|
||||
stdout: { text: 'ok', truncated: false }, stderr: { text: '', truncated: false },
|
||||
})
|
||||
}
|
||||
start(): BashTask { throw new Error('unused') }
|
||||
get(): BashTask | undefined { return undefined }
|
||||
ownerOf(): OwnerToken | undefined { return undefined }
|
||||
list(): BashTask[] { return [] }
|
||||
readOutput(): BashTaskRead { throw new Error('unused') }
|
||||
kill(): boolean { return false }
|
||||
}
|
||||
|
||||
async function setupRecording() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(RecordingBashExecutor)
|
||||
await ctx.plugin(ToolBash)
|
||||
return { ctx, bash: ctx.bash as RecordingBashExecutor }
|
||||
}
|
||||
|
||||
it('does not forward env/stdin even when the model includes them as extra arguments', async () => {
|
||||
const { ctx, bash } = await setupRecording()
|
||||
// Extra args: the model includes `env` and `stdin` keys hoping they reach the
|
||||
// executor. The bash tool's schema ignores unknown keys, and execute() builds
|
||||
// the request from only command/workdir/timeoutMs/signal — so the recorded
|
||||
// request carries NEITHER. (Not a security wall — the model could set an env
|
||||
// var or feed stdin via shell syntax anyway; this just keeps the request
|
||||
// shape honest so a future `...args` spread can't silently forward input.)
|
||||
await ctx.tools.execute({
|
||||
callId: CallId('no-forward-1'),
|
||||
name: 'bash',
|
||||
arguments: {
|
||||
command: 'echo hi',
|
||||
description: 'echo',
|
||||
env: { SNEAKY_API_KEY: 'leak' },
|
||||
stdin: 'malicious payload',
|
||||
},
|
||||
})
|
||||
expect(bash.requests).toHaveLength(1)
|
||||
const request = bash.requests[0]!
|
||||
expect(request.command).toBe('echo hi')
|
||||
expect('env' in request).toBe(false)
|
||||
expect('stdin' in request).toBe(false)
|
||||
})
|
||||
|
||||
it('a background bash call likewise carries no env/stdin', async () => {
|
||||
const { ctx, bash } = await setupRecording()
|
||||
// start() throws in this recorder, but resolve() runs first and records the
|
||||
// request — which is all this no-forward assertion needs.
|
||||
await ctx.tools.execute({
|
||||
callId: CallId('no-forward-2'),
|
||||
name: 'bash',
|
||||
arguments: {
|
||||
command: 'sleep 1',
|
||||
description: 'sleep',
|
||||
run_in_background: true,
|
||||
env: { TOKEN: 'leak' },
|
||||
stdin: 'x',
|
||||
},
|
||||
})
|
||||
expect(bash.requests).toHaveLength(1)
|
||||
const request = bash.requests[0]!
|
||||
expect('env' in request).toBe(false)
|
||||
expect('stdin' in request).toBe(false)
|
||||
// The owner token IS set on a background call (the isolation fence) — proving
|
||||
// the recorder sees the real request the consumer built, so the absent
|
||||
// env/stdin above is a real negative, not a recorder that drops everything.
|
||||
expect('owner' in request).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ A three-package capability seam (see [capability seams](../../docs/rfc/implement
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` |
|
||||
| `compact-basic/` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
|
||||
| `compact-basic/` | A backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
|
||||
| `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). A tokenizer- or template-based backend would replace `compact-basic` without touching the interface or the tool.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-compact-basic
|
||||
|
||||
The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a char/4 token heuristic, token-budget retention, and summarization routed through the agent request pipeline.
|
||||
The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a chars-per-token heuristic (the `charsPerToken` config, default 4), token-budget retention, and summarization routed through the agent request pipeline.
|
||||
|
||||
This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design.
|
||||
|
||||
@@ -8,10 +8,10 @@ This is the implementation tier of the compaction capability — see the [interf
|
||||
|
||||
The abstract contract states only WHAT compaction does; this backend owns every HOW decision:
|
||||
|
||||
- **Token estimation** — `estimateContentTokens()`: char/4 with per-block structural overhead (`text`/`reasoning` = `ceil(len/4) + 4`, `tool-call` from name + arguments, `tool-result` recursive, `image` = 85, unknown blocks via JSON length).
|
||||
- **Token estimation** — `estimateContentTokens()`: chars divided by the `charsPerToken` config (default 4) with per-block structural overhead (`text`/`reasoning` = `ceil(len/charsPerToken) + 4`, `tool-call` from name + arguments, `tool-result` recursive, unknown blocks via JSON length).
|
||||
- **Retention policy** — `compactIfNeeded()` walks the surface nodes tail→head summing per-node token estimates, and retains the smallest tail-run of WHOLE units (a closed step, or a single no-step node such as a pre-step `user/message` or inter-step `steering/message`) whose total reaches `retainTokens`; everything older is compacted. Retention is **turn-agnostic** — turn boundaries play no role, so a single runaway turn that alone exceeds the window compacts its OWN early closed steps rather than being retained verbatim (the failure mode that motivated dropping turn-protection: a tool-heavy turn must stay compactable or the harness dies exactly when compaction is needed). The only structural guard is **tool-pairing balance**: the compacted region's edges are balanced cuts on the surface (no unanswered tool-call crosses either edge), so it never splits a step's `assistant/message` tool-calls from their `tool/result`s. When the only compactable content left is an un-splittable open tail step, it declines (returns `null`) and retries once an older step closes. **Single-unit overflow is out of scope, by design**: if one retained unit (a single closed step, or a large pasted `user/message`) ALONE exceeds the budget, compaction cannot help and the call may go out over-budget — bounding an individual unit's size is a separate concern. `compactRegion()` enforces tool-pairing balance strictly, throwing on a boundary that would split a step. `dsh-session` exports `isToolPairingBalanced` for the check.
|
||||
- **Dynamic convergence** — no static summary-length config pretends to bound what the model will write. If framing/estimator/system overhead leaves the compacted surface above threshold, `compactIfNeeded()` re-compacts the head checkpoint up to `compactionRetries` extra times; if it still cannot get below threshold, it throws. A summary whose estimated stored size is not smaller than the shadowed content fails closed before it mutates the surface.
|
||||
- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) so the summarizer is told what existed rather than silently dropping it.
|
||||
- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[tool-call: name(args)]`, `[tool-result: …]`, …) so the summarizer is told what existed rather than silently dropping it.
|
||||
- **Checkpoint framing** — the raw summary is not landed directly. `compactRegion()` wraps it in a checkpoint preamble (so a resuming model reads it as a checkpoint, not a fresh user request, and builds on the captured context rather than restating it) plus `<compacted-summary>…</compacted-summary>` tags. Because region compaction can be invoked manually, a surface may hold several checkpoints, so the framing does not claim everything after it is recent or verbatim. The tags make a prior checkpoint detectable in the transcript on the next compaction cycle: the summarization prompt then instructs the model to merge it in place (preserve still-true facts, drop stale ones) rather than re-summarize it verbatim — a cheap incremental merge that needs no extra log/event machinery. The unframed summary stays on the `compact/summary` provenance event.
|
||||
- **Surface mutation** — `compactRegion()` appends the `compact/start` → `compact/summary` → `compact/end` log records and the single `user/message` replace node carrying the framed summary (see the interface README).
|
||||
- **Auto-compaction** — an `agent/pre-step` listener delegates to `compactIfNeeded()` before every step (not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts, and per-step firing is the only moment to rescue it before overflow). `agent/pre-step` is a serial (awaited, in-order) surface-mutation checkpoint that fires after `turn/start` and BEFORE the step opens (`step/start`) and its request history is derived, so compaction mutates the surface — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives once from the result: no double-derive, and the listener cannot see (or need to rewrite) an already-assembled `messages` array. The listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`); because Cordis `serial` bails early on non-void return values, the listener returns `void` and does not use the dispatcher's bail channel as a veto surface.
|
||||
@@ -32,6 +32,7 @@ Every knob is **required** except `auto` — there is no concrete data yet to ju
|
||||
| `maxTokens` | yes | Provider generation cap for the summarization call; may include reasoning tokens. |
|
||||
| `compactionRetries` | yes | Extra compaction attempts after the first if the compacted surface remains over threshold. |
|
||||
| `auto` | no (default `true`) | Register the `agent/pre-step` auto-compaction listener. Set `false` for manual-only. |
|
||||
| `charsPerToken` | no (default `4`) | Token-estimator text density (estimated tokens = chars / `charsPerToken`; may be fractional). The default suits English text; CJK-heavy deployments should set ~1-2 or the estimate undershoots several-fold and compaction fires too late. |
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-compact-basic",
|
||||
"description": "Basic compaction backend (char/4 token estimation + token-budget retention + llm.generate() summarization) for the DeepSeek Harness",
|
||||
"description": "Basic compaction backend (chars-per-token estimation + token-budget retention + llm.generate() summarization) for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* `BasicCompactService`: the first implementation of the
|
||||
* `@deepseek-ai/dsh-compact` seam. It owns the entire compaction strategy:
|
||||
*
|
||||
* - **Token estimation** — char/4 heuristic with per-block structural overhead.
|
||||
* - **Token estimation** — chars/`charsPerToken` heuristic (config, default 4)
|
||||
* with per-block structural overhead.
|
||||
* - **Retention policy** — walk surface nodes tail→head, keep recent nodes up
|
||||
* to a token budget, compact everything older. The cutoff is snapped forward
|
||||
* to the next balanced tool-pairing boundary so a compacted region never
|
||||
@@ -44,9 +45,6 @@ export { resolveConfig } from './types.ts'
|
||||
/** Per-block structural overhead for JSON framing / type tag. */
|
||||
const BLOCK_OVERHEAD = 4
|
||||
|
||||
/** Heuristic token count for an image block (~85 tokens for low-res URL). */
|
||||
const IMAGE_TOKEN_COST = 85
|
||||
|
||||
/** Role-field framing overhead added per message in {@link BasicCompactService.estimateTokens}. */
|
||||
const ROLE_OVERHEAD = 4
|
||||
|
||||
@@ -148,9 +146,11 @@ function finishError(finish: FinishReason): Error | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic, dependency-light compaction backend. Defaults target a 128K context
|
||||
* window, compacting at 80% utilization and retaining ~20K tokens of recent
|
||||
* context.
|
||||
* Basic, dependency-light compaction backend: estimates the surface's token
|
||||
* footprint, summarizes the stale prefix through the model, and shadows it
|
||||
* behind a durable checkpoint. Every threshold/budget knob is required config
|
||||
* ({@link BasicCompactConfig}); the estimator's text density is the
|
||||
* `charsPerToken` knob.
|
||||
*/
|
||||
export class BasicCompactService extends CompactService {
|
||||
static inject = ['llm']
|
||||
@@ -207,36 +207,36 @@ export class BasicCompactService extends CompactService {
|
||||
|
||||
// ---- Token estimation (overridable hooks) ----
|
||||
|
||||
// TODO: char/4 is a coarse heuristic. Replace with an exact count — a real
|
||||
// tokenizer, or the provider's post-response `usage` (input tokens) fed back
|
||||
// as a correction — so threshold decisions match the model's actual budget.
|
||||
// TODO: chars/charsPerToken is a coarse heuristic. Replace with an exact
|
||||
// count — a real tokenizer, or the provider's post-response `usage` (input
|
||||
// tokens) fed back as a correction — so threshold decisions match the
|
||||
// model's actual budget.
|
||||
/**
|
||||
* Estimate the token count of content blocks — char/4 with per-block
|
||||
* overhead. Override in a subclass to plug in a real tokenizer.
|
||||
* Estimate the token count of content blocks — chars divided by the
|
||||
* `charsPerToken` config, with per-block overhead. Override in a subclass to
|
||||
* plug in a real tokenizer.
|
||||
*/
|
||||
estimateContentTokens(blocks: readonly ContentBlock[]): number {
|
||||
const { charsPerToken } = this.config
|
||||
let tokens = 0
|
||||
for (const block of blocks) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
case 'reasoning':
|
||||
tokens += Math.ceil(block.text.length / 4) + BLOCK_OVERHEAD
|
||||
tokens += Math.ceil(block.text.length / charsPerToken) + BLOCK_OVERHEAD
|
||||
break
|
||||
case 'tool-call':
|
||||
tokens += Math.ceil(block.name.length / 4)
|
||||
+ Math.ceil(block.arguments.length / 4)
|
||||
tokens += Math.ceil(block.name.length / charsPerToken)
|
||||
+ Math.ceil(block.arguments.length / charsPerToken)
|
||||
+ BLOCK_OVERHEAD
|
||||
break
|
||||
case 'tool-result':
|
||||
tokens += this.estimateContentTokens(block.content) + BLOCK_OVERHEAD
|
||||
break
|
||||
case 'image':
|
||||
tokens += IMAGE_TOKEN_COST
|
||||
break
|
||||
default:
|
||||
// Unknown block types (merge-extensible ContentBlockMap):
|
||||
// estimate conservatively via JSON stringify.
|
||||
tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / 4)
|
||||
tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / charsPerToken)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
@@ -266,7 +266,7 @@ export class BasicCompactService extends CompactService {
|
||||
total += this.estimateContentTokens(msg.content)
|
||||
total += ROLE_OVERHEAD
|
||||
}
|
||||
if (systemPrompt) total += Math.ceil(systemPrompt.length / 4)
|
||||
if (systemPrompt) total += Math.ceil(systemPrompt.length / this.config.charsPerToken)
|
||||
return total
|
||||
}
|
||||
|
||||
@@ -706,10 +706,10 @@ export class BasicCompactService extends CompactService {
|
||||
/**
|
||||
* Render content blocks to a single plain-text string for the summarization
|
||||
* prompt. Text and reasoning contribute their text; every other block type
|
||||
* contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`,
|
||||
* …) so the summarizer is told what non-text content existed in the region
|
||||
* rather than silently losing it. Blocks join with newlines; empty-text
|
||||
* blocks contribute nothing.
|
||||
* contributes a type-tagged placeholder (`[tool-call: name(args)]`,
|
||||
* `[tool-result: …]`, …) so the summarizer is told what non-text content
|
||||
* existed in the region rather than silently losing it. Blocks join with
|
||||
* newlines; empty-text blocks contribute nothing.
|
||||
*/
|
||||
private _blocksToText(blocks: readonly ContentBlock[]): string {
|
||||
const parts: string[] = []
|
||||
@@ -729,9 +729,6 @@ export class BasicCompactService extends CompactService {
|
||||
parts.push(inner ? `[tool-result: ${inner}]` : '[tool-result]')
|
||||
break
|
||||
}
|
||||
case 'image':
|
||||
parts.push('[image]')
|
||||
break
|
||||
// ContentBlockMap is merge-extensible — render an unknown block as a
|
||||
// bare type-tagged placeholder so a plugin-added block type is still
|
||||
// signalled to the summarizer rather than dropped.
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Backend configuration. Every knob is REQUIRED except `auto`: there is no
|
||||
* concrete data yet to justify default thresholds/budgets, so a consumer must
|
||||
* state each value explicitly rather than inherit a guessed default. `auto`
|
||||
* alone defaults to `true` (auto-compaction is the intended posture).
|
||||
* Backend configuration. Every knob is REQUIRED except `auto` and
|
||||
* `charsPerToken`: there is no concrete data yet to justify default
|
||||
* thresholds/budgets, so a consumer must state each value explicitly rather
|
||||
* than inherit a guessed default. `auto` alone defaults to `true`
|
||||
* (auto-compaction is the intended posture), and `charsPerToken` defaults to
|
||||
* the English-text heuristic its estimator was calibrated on.
|
||||
*/
|
||||
export interface BasicCompactConfig {
|
||||
/** Context window size in tokens. */
|
||||
@@ -30,13 +32,21 @@ export interface BasicCompactConfig {
|
||||
compactionRetries: number
|
||||
/** Enable automatic compaction on the `agent/pre-step` seam (default true). */
|
||||
auto?: boolean
|
||||
/**
|
||||
* Text density for the token estimator: estimated tokens = chars /
|
||||
* `charsPerToken`. Defaults to 4 (typical English text). A CJK-heavy
|
||||
* deployment should set ~1-2 — CJK runs at roughly 1-2 chars per token, so
|
||||
* the default UNDERestimates several-fold and compaction fires far too late.
|
||||
* May be fractional.
|
||||
*/
|
||||
charsPerToken?: number
|
||||
}
|
||||
|
||||
/** Resolved config with `auto` defaulted. */
|
||||
/** Resolved config with `auto` and `charsPerToken` defaulted. */
|
||||
export type ResolvedConfig = Required<BasicCompactConfig>
|
||||
|
||||
/**
|
||||
* Default `auto` when unset and reject nonsensical numeric knobs.
|
||||
* Default `auto`/`charsPerToken` when unset and reject nonsensical numeric knobs.
|
||||
*
|
||||
* Convergence is not a static config invariant: provider generation caps can be
|
||||
* spent on hidden or surfaced reasoning tokens, and the model may emit a summary
|
||||
@@ -46,13 +56,14 @@ export type ResolvedConfig = Required<BasicCompactConfig>
|
||||
* throwing if the surface still exceeds the threshold.
|
||||
*/
|
||||
export function resolveConfig(config: BasicCompactConfig): ResolvedConfig {
|
||||
const resolved: ResolvedConfig = { auto: true, ...config }
|
||||
const resolved: ResolvedConfig = { auto: true, charsPerToken: 4, ...config }
|
||||
|
||||
assertPositiveInteger('contextWindow', resolved.contextWindow)
|
||||
assertRatio('thresholdRatio', resolved.thresholdRatio)
|
||||
assertNonNegativeInteger('retainTokens', resolved.retainTokens)
|
||||
assertPositiveInteger('maxTokens', resolved.maxTokens)
|
||||
assertNonNegativeInteger('compactionRetries', resolved.compactionRetries)
|
||||
assertPositiveFinite('charsPerToken', resolved.charsPerToken)
|
||||
if (typeof resolved.summarizationModel !== 'string') {
|
||||
throw new Error('BasicCompactConfig: summarizationModel must be a string.')
|
||||
}
|
||||
@@ -74,6 +85,12 @@ function assertNonNegativeInteger(name: string, value: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
function assertPositiveFinite(name: string, value: number): void {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive finite number.`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRatio(name: string, value: number): void {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a number in (0, 1].`)
|
||||
|
||||
@@ -811,15 +811,23 @@ describe('BasicCompactService token estimation (char/4 heuristic)', () => {
|
||||
])).toBe(10)
|
||||
})
|
||||
|
||||
it('estimates image blocks at fixed 85 tokens', () => {
|
||||
const svc = new BasicCompactService(new Context(), cfg({ auto: false }))
|
||||
expect(svc.estimateContentTokens([{ type: 'image', url: 'https://example.com/img.png' }])).toBe(85)
|
||||
})
|
||||
|
||||
it('returns 0 for empty content blocks', () => {
|
||||
const svc = new BasicCompactService(new Context(), cfg({ auto: false }))
|
||||
expect(svc.estimateContentTokens([])).toBe(0)
|
||||
})
|
||||
|
||||
it('honors a configured charsPerToken (fractional densities included)', () => {
|
||||
// 'this is a somewhat longer text block' = 36 chars.
|
||||
const blocks: ContentBlock[] = [{ type: 'text', text: 'this is a somewhat longer text block' }]
|
||||
// charsPerToken 2: ceil(36/2)+4 = 22 — a CJK-density config doubles the estimate.
|
||||
const dense = new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 2 }))
|
||||
expect(dense.estimateContentTokens(blocks)).toBe(22)
|
||||
// Fractional density is legal: ceil(36/1.5)+4 = 28.
|
||||
const fractional = new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 1.5 }))
|
||||
expect(fractional.estimateContentTokens(blocks)).toBe(28)
|
||||
// The system-prompt term scales with the same knob: 36-char prompt at density 2 → ceil(36/2) = 18.
|
||||
expect(dense.estimateTokens([], 'this is a somewhat longer text block')).toBe(18)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BasicCompactService HMR safety', () => {
|
||||
@@ -862,6 +870,10 @@ describe('BasicCompactService config validation', () => {
|
||||
)).toThrow(/summarizationModel must be a string/)
|
||||
expect(() => new BasicCompactService(new Context(), cfg({ auto: 'no' } as unknown as Partial<BasicCompactConfig>)))
|
||||
.toThrow(/auto must be a boolean/)
|
||||
expect(() => new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 0 })))
|
||||
.toThrow(/charsPerToken .* positive finite number/)
|
||||
expect(() => new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: Number.NaN })))
|
||||
.toThrow(/charsPerToken .* positive finite number/)
|
||||
})
|
||||
|
||||
it('accepts a large retain budget because convergence is enforced dynamically', () => {
|
||||
@@ -1324,7 +1336,7 @@ describe('BasicCompactService edge cases', () => {
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [
|
||||
{ type: 'tool-result', toolCallId: CallId('n1'), content: [{ type: 'image', url: 'https://x/n.png' }] },
|
||||
{ type: 'tool-result', toolCallId: CallId('n1'), content: [{ type: 'chart', data: 'x' } as unknown as ContentBlock] },
|
||||
{ type: 'custom-widget', payload: 'x' } as unknown as ContentBlock,
|
||||
{ type: 'tool-call', id: CallId('b1'), name: 'bash', arguments: '{}' },
|
||||
],
|
||||
@@ -1343,7 +1355,7 @@ describe('BasicCompactService edge cases', () => {
|
||||
const nodes = s.surface.nodes
|
||||
await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
|
||||
const { text } = svc.summarizeCalls[0]!
|
||||
expect(text).toContain('[tool-result: [image]]') // nested tool-result with content
|
||||
expect(text).toContain('[tool-result: [chart]]') // nested tool-result with content
|
||||
expect(text).toContain('[custom-widget]') // unknown block placeholder
|
||||
expect(text).toContain('Tool result (call b1): [tool-result]') // empty nested → bare placeholder
|
||||
})
|
||||
@@ -1510,25 +1522,28 @@ describe('BasicCompactService edge cases', () => {
|
||||
it('renders non-text blocks as type-tagged placeholders across all message kinds', async () => {
|
||||
const svc = createTestService()
|
||||
const s = new Session(SessionId('placeholders'))
|
||||
// A plugin-added block type (merge-extensible ContentBlockMap) — the
|
||||
// placeholder path must cover every message kind, not just assistant.
|
||||
const chart = (id: string): ContentBlock => ({ type: 'chart', data: id } as unknown as ContentBlock)
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
// user/message with only an image block → '[image]' placeholder.
|
||||
s.append('user/message', { content: [{ type: 'image', url: 'https://x/y.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
// assistant/message with an image block AND the tool-call its tool/result
|
||||
// answers (so the surface is tool-pairing balanced) → '[image]' placeholder.
|
||||
// user/message with only a plugin-added block → '[chart]' placeholder.
|
||||
s.append('user/message', { content: [chart('y')], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
// assistant/message with a plugin-added block AND the tool-call its
|
||||
// tool/result answers (so the surface is tool-pairing balanced).
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [
|
||||
{ type: 'image', url: 'https://x/z.png' },
|
||||
chart('z'),
|
||||
{ type: 'tool-call', id: CallId('e1'), name: 'bash', arguments: '{}' },
|
||||
],
|
||||
}, { surfaceOp: 'append' })
|
||||
// tool/result with an image block → '[image]' placeholder.
|
||||
// tool/result with a plugin-added block → '[chart]' placeholder.
|
||||
s.append('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'bash', arguments: '{}' })
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'image', url: 'https://x/r.png' }], isError: false }, { surfaceOp: 'append' })
|
||||
// context/message and steering/message with image content.
|
||||
s.append('context/message', { content: [{ type: 'image', url: 'https://x/c.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('steering/message', { turn: 1, content: [{ type: 'image', url: 'https://x/s.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [chart('r')], isError: false }, { surfaceOp: 'append' })
|
||||
// context/message and steering/message with plugin-added content.
|
||||
s.append('context/message', { content: [chart('c')], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('steering/message', { turn: 1, content: [chart('s')], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
@@ -1537,11 +1552,11 @@ describe('BasicCompactService edge cases', () => {
|
||||
await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
|
||||
const { text } = svc.summarizeCalls[0]!
|
||||
// Every non-text block surfaces as a placeholder rather than being dropped.
|
||||
expect(text).toContain('User: [image]')
|
||||
expect(text).toContain('Assistant: [image]')
|
||||
expect(text).toContain('Tool result (call e1): [image]')
|
||||
expect(text).toContain('[Context: [image]]')
|
||||
expect(text).toContain('[Steering: [image]]')
|
||||
expect(text).toContain('User: [chart]')
|
||||
expect(text).toContain('Assistant: [chart]')
|
||||
expect(text).toContain('Tool result (call e1): [chart]')
|
||||
expect(text).toContain('[Context: [chart]]')
|
||||
expect(text).toContain('[Steering: [chart]]')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ This package is the interface tier of the compaction capability, split so each c
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` |
|
||||
| `@deepseek-ai/dsh-compact-basic` (deferred) | a backend: char/4 estimation + token-budget retention + `llm.stream()` summarization |
|
||||
| `@deepseek-ai/dsh-compact-basic` (deferred) | a backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization |
|
||||
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
|
||||
|
||||
Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md).
|
||||
@@ -43,13 +43,7 @@ Compaction is serialized via a log-recorded lock: `compactRegion` refuses to sta
|
||||
|
||||
## Events
|
||||
|
||||
The `compact/*` events extend `SessionEventMap` (merge-extensible) via declaration merging — they are session events, not cordis `Events`:
|
||||
|
||||
| Event | Payload | On surface? |
|
||||
|---|---|---|
|
||||
| `compact/start` | `{ turn }` | no (log-only) |
|
||||
| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount }` | no (log-only) |
|
||||
| `compact/end` | `{ turn, error? }` | no (log-only) |
|
||||
The `compact/*` events extend `SessionEventMap` (merge-extensible) via declaration merging — they are session events, not cordis `Events`, and all three are log-only (no `surfaceOp`). Per-event payloads and semantics are in the generated [persistence log event catalog](../../../docs/persistence-catalog/log-events.md).
|
||||
|
||||
## Implementing a backend
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,7 +6,7 @@ The packages every harness build is assembled from: the session log, the system-
|
||||
|---|---|---|
|
||||
| `session/` | Event-sourced session log + in-memory store | `ctx.sessions` |
|
||||
| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
|
||||
| `tools/` | Tool registry + `tools/execute` waterfall | `ctx.tools` |
|
||||
| `tools/` | Tool registry + `tools/pre-execute`/`tools/post-execute` pipeline | `ctx.tools` |
|
||||
| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
|
||||
| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
|
||||
| `agent-core/` | Bundle plugin: the providerless/executor-less/UI-less spine as code | (loads the spine) |
|
||||
|
||||
@@ -13,11 +13,12 @@ This is the package to read to see **the whole plugin tree at once** — the tea
|
||||
@deepseek-ai/dsh-llm abstract LLM service + content-block vocabulary
|
||||
@deepseek-ai/dsh-session event-sourced session log + store
|
||||
@deepseek-ai/dsh-system-prompt prompt-section + tool-schema assembly
|
||||
@deepseek-ai/dsh-tools tool registry + tools/execute waterfall
|
||||
@deepseek-ai/dsh-tools tool registry + tools/pre-execute/post-execute
|
||||
@deepseek-ai/dsh-agent agent registry + agent/* event vocabulary
|
||||
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
|
||||
@deepseek-ai/dsh-tool-bash the model-facing bash/bash_output/bash_kill schemas
|
||||
@deepseek-ai/dsh-agent-loop THE concrete loop (gets the forwarded `agents`)
|
||||
(dsh-system-prompt gets the forwarded `persona`)
|
||||
```
|
||||
|
||||
## What it deliberately leaves OUTSIDE the bundle
|
||||
@@ -34,10 +35,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
|
||||
|
||||
```ts
|
||||
import type { Config } from '@deepseek-ai/dsh-agent-core'
|
||||
// Config === AgentLoop.Config — the `agents` list, default [].
|
||||
// { agents?, persona? } — the schema is z.intersect([AgentLoop.Config, SystemPrompt.Config]),
|
||||
// so validation and defaulting can never drift from the owners'.
|
||||
```
|
||||
|
||||
The bundle FORWARDS `agent-loop`'s `agents` list as its own (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`). Forwarding the list is exactly why the loop can live in the shared spine even though the apps disagree on which agents to pre-create.
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — and `persona` to `dsh-system-prompt` (default `''`), the deployment's persona section. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
|
||||
|
||||
## Why a code bundle, not a shared YAML include
|
||||
|
||||
|
||||
@@ -44,5 +44,8 @@
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,9 +44,10 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import Timer from '@cordisjs/plugin-timer'
|
||||
import z from 'schemastery'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import SystemPrompt, { type Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import * as invariants from '@deepseek-ai/dsh-invariants'
|
||||
@@ -56,33 +57,45 @@ import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agen
|
||||
export const name = 'agent-core'
|
||||
|
||||
/**
|
||||
* Bundle config: the agent-loop `agents` list, forwarded verbatim. Default `[]`
|
||||
* — an app that pre-creates no agents (the ACP bridge creates them on demand at
|
||||
* `session/new`) simply omits it; an app that needs a pre-created `main` (the
|
||||
* stdio chat) supplies one. This IS {@link AgentLoopConfig}, so the schema and
|
||||
* the forwarded shape can never drift.
|
||||
* Bundle config: each field forwarded verbatim to the child that owns it —
|
||||
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
|
||||
* bridge, simply omits it), `persona` to the system-prompt plugin (the
|
||||
* deployment's persona section). Both are optional INPUT here because each
|
||||
* owner's schema supplies the default (`[]` / `''`); the schema is the
|
||||
* INTERSECTION of the owners' own schemas, so validation and defaulting can
|
||||
* never drift from them.
|
||||
*/
|
||||
export type Config = AgentLoopConfig
|
||||
export interface Config {
|
||||
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
|
||||
agents?: AgentLoopConfig['agents']
|
||||
/** The deployment persona (see dsh-system-prompt's `Config`). */
|
||||
persona?: SystemPromptConfig['persona']
|
||||
}
|
||||
|
||||
/** Forward the loop's own schema so validation + defaulting stay identical. */
|
||||
export const Config = AgentLoop.Config
|
||||
/** Intersect the owners' schemas so validation + defaulting stay identical. */
|
||||
export const Config = z.intersect([AgentLoop.Config, SystemPrompt.Config]) as unknown as z<Config>
|
||||
|
||||
/**
|
||||
* Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber;
|
||||
* `agent-loop` receives the forwarded `agents` list. Load order is irrelevant
|
||||
* (cordis pends each fiber on its `inject` until the services it needs exist),
|
||||
* but the listing mirrors the dependency layering for readability: the LLM
|
||||
* vocabulary and core registries first, then the dev tripwire and the bash tool
|
||||
* consumer, then the loop that drives them.
|
||||
* `agent-loop` receives the forwarded `agents` list and `system-prompt` the
|
||||
* forwarded `persona`. Load order is irrelevant (cordis pends each fiber on
|
||||
* its `inject` until the services it needs exist), but the listing mirrors the
|
||||
* dependency layering for readability: the LLM vocabulary and core registries
|
||||
* first, then the dev tripwire and the bash tool consumer, then the loop that
|
||||
* drives them.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(Timer)
|
||||
ctx.plugin(LlmService)
|
||||
ctx.plugin(SessionStore)
|
||||
ctx.plugin(SystemPrompt)
|
||||
// The forwarded fields are validated + defaulted by this bundle's intersected
|
||||
// schema before apply runs, so the ?? fallbacks only narrow the
|
||||
// optional-input TYPES — they mirror the owners' schema defaults, never
|
||||
// introduce different ones.
|
||||
ctx.plugin(SystemPrompt, { persona: config.persona ?? '' })
|
||||
ctx.plugin(ToolRegistry)
|
||||
ctx.plugin(AgentRegistry)
|
||||
ctx.plugin(invariants)
|
||||
ctx.plugin(toolBash)
|
||||
ctx.plugin(AgentLoop, { agents: config.agents })
|
||||
ctx.plugin(AgentLoop, { agents: config.agents ?? [] })
|
||||
}
|
||||
|
||||
@@ -43,11 +43,27 @@ describe('dsh-agent-core bundle', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards a pre-created agent to the loop', async () => {
|
||||
it('forwards a pre-created agent to the loop and the persona to system-prompt', async () => {
|
||||
const ctx = await mount({
|
||||
agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: 'hi' }],
|
||||
agents: [{ id: AgentId('main'), model: 'mock' }],
|
||||
persona: 'You are main.',
|
||||
})
|
||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
expect(assembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are main.')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('tolerates a schema-bypassing direct apply (the ?? fallbacks fire)', async () => {
|
||||
// ctx.plugin validates + defaults the bundle config first; a direct apply
|
||||
// skips the schema, so the forwarding `?? []` / `?? ''` are what fire.
|
||||
const ctx = new Context()
|
||||
agentCore.apply(ctx, {})
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(ctx.get('agentLoop')).toBeDefined()
|
||||
expect(ctx.get('agents')?.list()).toHaveLength(0)
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
expect(assembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/timer"
|
||||
},
|
||||
|
||||
@@ -28,12 +28,11 @@ interface Config {
|
||||
agents: Array<{
|
||||
id: string // required
|
||||
model?: string
|
||||
systemPrompt?: string
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
Agents listed in config are auto-created at startup.
|
||||
Agents listed in config are auto-created at startup. (There is no per-agent persona: the deployment persona is `dsh-system-prompt`'s own `persona` config, shared by every agent in the context.) The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from the `assemble({ agent })` context — runtime facts of the agents THIS loop drives, unlike the `harness:identity`/`deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin.
|
||||
|
||||
### Classes
|
||||
|
||||
@@ -45,23 +44,31 @@ Agents listed in config are auto-created at startup.
|
||||
One invocation of `runLoop()` drives one agent for its whole lifetime:
|
||||
|
||||
```
|
||||
create agent → emit agent/session-start(source) ⟵ once, before turn 1
|
||||
forever:
|
||||
wait for queued messages (idle)
|
||||
TURN (error-contained):
|
||||
drain queued → 'turn/start' → session('user/message')
|
||||
'turn/start'
|
||||
each queued: waterfall agent/prompt-submit → allow (→ session('user/message'),
|
||||
inject additionalContext) | block (→ session('prompt/blocked'), drop)
|
||||
if every prompt blocked: 'turn/end'(rejected), no step ⟵ zero-step turn
|
||||
STEP loop:
|
||||
drain steering
|
||||
assembly = systemPrompt.assemble()
|
||||
assembly = systemPrompt.assemble({agent}) ⟵ renderPrompt(assembly) IS the full prompt
|
||||
await serial agent/pre-step ⟵ surface mutation (compaction) outside the step
|
||||
session('step/start')
|
||||
request = waterfall agent/request
|
||||
stream llm.stream(request) → session('assistant/chunk')
|
||||
message = waterfall agent/step-result
|
||||
session('assistant/message')
|
||||
each tool-call: session('tool/call') → tools.execute() → session('tool/result')
|
||||
each tool-call: session('tool/call')
|
||||
→ tools.execute() [waterfall tools/pre-execute → dispatch → tools/post-execute]
|
||||
→ session('tool/result')
|
||||
append buffered post-execute additionalContext as session('context/message')(s)
|
||||
drain steering → session('steering/message')
|
||||
cont = waterfall agent/turn-continuation
|
||||
if !cont: break
|
||||
cont = waterfall agent/turn-continuation → ContinuationDecision
|
||||
({action:'continue', reason?} records reason as next-step steering)
|
||||
if action==stop (and no pending steering): break
|
||||
session('turn/end')
|
||||
await session/flush
|
||||
re-enqueue leftover steering as queued
|
||||
@@ -75,9 +82,9 @@ Cancellation: `agent.cancel()` is the single public stop primitive — it clears
|
||||
### What is NOT here
|
||||
|
||||
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
|
||||
- Hooks: `agent/pre-step`, `agent/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation`
|
||||
- Hooks: `agent/session-start`, `agent/prompt-submit`, `agent/pre-step`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation`
|
||||
- Compaction: `agent/pre-step`
|
||||
- Sandbox, permission, plan mode: `tools/execute`
|
||||
- Sandbox, permission, plan mode: `tools/pre-execute` (deny/ask gate), `tools/post-execute`
|
||||
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while child streaming/progress and background/poll collection remain deferred.
|
||||
- Persistence: `session/event` + `session/flush`
|
||||
- UI: `agent/stream-chunk` + `agent/*` events
|
||||
- UI: `session/event` (assistant token stream, boundaries, tool activity) + `agent/*` control events (`agent/status`, `agent/created`/`agent/disposed`)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import z from 'schemastery'
|
||||
import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
@@ -72,7 +72,6 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
agents: z.array(z.object({
|
||||
id: z.string().required(),
|
||||
model: z.string(),
|
||||
systemPrompt: z.string(),
|
||||
resumeSessionId: z.string(),
|
||||
})).default([]),
|
||||
}) as unknown as z<Config>
|
||||
@@ -82,6 +81,16 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
// Provide the agent-creation factory to the registry (effect-scoped: the
|
||||
// slot is cleared on dispose).
|
||||
ctx.effect(() => this.ctx.agents.setFactory(this), 'agentLoop.setFactory()')
|
||||
// The prompt variables the shipped loop provides, registered once. The
|
||||
// sections themselves (`harness:identity`, `deployment:persona`) belong to
|
||||
// dsh-system-prompt — they must survive a swapped loop plugin — but
|
||||
// `{{model}}`/`{{cwd}}` are runtime facts of the agents THIS loop drives:
|
||||
// it assembles with `{ agent }` each step (loop.ts), and the variables
|
||||
// project the agent's configured model and its session workspace from that
|
||||
// context. A provider returns undefined when the fact is absent
|
||||
// (renderPrompt then rejects a persona that claims it — fail loud).
|
||||
ctx.systemPrompt.variable('model', context => context.agent?.options.model)
|
||||
ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
|
||||
for (const { id, resumeSessionId, ...options } of config.agents) {
|
||||
if (resumeSessionId !== undefined && resumeSessionId !== '') {
|
||||
// Resume a prior session instead of starting fresh. resume() needs
|
||||
@@ -121,6 +130,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)
|
||||
@@ -129,7 +141,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
// session + agent down as one ordered chain, capturing the loop's closing
|
||||
// flush). The whole effect is owned by THIS fiber; no AgentHandle is needed.
|
||||
const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta: {} })
|
||||
const { agent } = this.start(id, options, session)
|
||||
const { agent } = this.start(id, options, session, 'startup')
|
||||
return agent
|
||||
}
|
||||
|
||||
@@ -142,6 +154,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
|
||||
@@ -152,7 +167,9 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
...options.seed !== undefined ? { seed: options.seed } : {},
|
||||
meta: options.meta ?? {},
|
||||
})
|
||||
return this.startOwned(options.agentId, options.agentOptions ?? {}, session)
|
||||
// A seeded (forked) create is still a fresh start, NOT a resume — `resume`
|
||||
// is reserved for reloading a PERSISTED session via resume()/resumeWith().
|
||||
return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'startup')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,6 +183,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
|
||||
@@ -224,7 +243,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {},
|
||||
},
|
||||
})
|
||||
return this.startOwned(options.agentId, options.agentOptions ?? {}, session)
|
||||
return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'resume')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,14 +280,33 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* so a throwing `session/created`/`agent/created` listener unwinds the
|
||||
* already-yielded disposers instead of leaking.
|
||||
*
|
||||
* `source` says why the session began ({@link SessionStartSource}); it is
|
||||
* emitted as `agent/session-start` once, AFTER the agent is registered (so a
|
||||
* listener can resolve the agent via `ctx.agents.get(id)` and `inject()` into
|
||||
* it) and BEFORE the loop starts its first turn. The emit is contained: a
|
||||
* throwing session-start listener must not abort agent construction — it is
|
||||
* logged, and the agent still starts. (Unlike a turn-boundary throw, there is
|
||||
* no open turn here to balance; the durable evidence of a session-start hook
|
||||
* is whatever it `inject()`ed.)
|
||||
*
|
||||
* Returns the agent plus the composite effect's disposer (`disposeAgent`).
|
||||
*/
|
||||
private start(id: AgentId, options: AgentOptions, session: Session): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
|
||||
private start(
|
||||
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
|
||||
): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
|
||||
const agent = new ReactLoopAgent(this.ctx, id, options, session)
|
||||
const dispose = this.ctx.effect(function* (this: AgentLoop) {
|
||||
yield this.ctx.sessions.enter(session)
|
||||
this.ctx.sessions.announce(session)
|
||||
yield this.ctx.agents.register(agent)
|
||||
// Fire AFTER register (a listener can ctx.agents.get(id) + inject()) and
|
||||
// BEFORE the loop's first turn. Contained: a throwing listener is logged,
|
||||
// never aborts construction (no open turn to balance here).
|
||||
try {
|
||||
this.ctx.emit('agent/session-start', agent, source)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`agent "${id}": agent/session-start listener threw: ${String(error)}`)
|
||||
}
|
||||
const stop = agent.start()
|
||||
// Disposed FIRST (LIFO): request loop stop (sync), then AWAIT the loop's
|
||||
// actual exit so its closing flush lands while onAppend (yielded above,
|
||||
@@ -295,8 +333,8 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* `AgentHandle.dispose(): Promise<void>` contract (mirrors the ACP `quiesce()`
|
||||
* helper).
|
||||
*/
|
||||
private startOwned(id: AgentId, options: AgentOptions, session: Session): AgentHandle {
|
||||
const { agent, disposeAgent } = this.start(id, options, session)
|
||||
private startOwned(id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource): AgentHandle {
|
||||
const { agent, disposeAgent } = this.start(id, options, session, source)
|
||||
let disposing: Promise<void> | undefined
|
||||
return { agent, dispose: () => (disposing ??= disposeAgent()) }
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { BlockAssembler, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
|
||||
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -142,30 +143,38 @@ export interface LoopHandle {
|
||||
* The agent loop. One invocation drives one agent for its whole lifetime:
|
||||
*
|
||||
* ```
|
||||
* create agent → emit agent/session-start(source) ⟵ once, before turn 1
|
||||
* forever:
|
||||
* wait for queued messages (idle)
|
||||
* TURN (error-contained — a throwing plugin ends the turn, never the loop):
|
||||
* drain queued → 'turn/start' → session('user/message'…) → emit agent/turn-start
|
||||
* 'turn/start'; each queued msg: waterfall agent/prompt-submit ⟵ durable turn boundary (no agent/* mirror)
|
||||
* allow → session('user/message'…) (+ inject additionalContext) | block → drop
|
||||
* every prompt blocked → 'turn/end'(rejected), 0 steps
|
||||
* STEP loop:
|
||||
* drain steering → session('steering/message') ⟵ catches late steering
|
||||
* assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble
|
||||
* assembly = ctx.systemPrompt.assemble({agent}) ⟵ waterfall system-prompt/assemble; renderPrompt
|
||||
* (persona section + {{variables}}) IS the full prompt
|
||||
* await ctx.serial('agent/pre-step') ⟵ surface mutation (compaction) OUTSIDE the step
|
||||
* session('step/start'); emit agent/step-start ⟵ append before emit (the event-sourcing RFC)
|
||||
* session('step/start') ⟵ durable step boundary (no agent/* mirror)
|
||||
* req = {model, system, tools, messages: session.deriveMessages(), signal}
|
||||
* req = waterfall agent/request ⟵ hooks/model-switch
|
||||
* stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks)
|
||||
* session('assistant/chunk'); emit agent/stream-chunk
|
||||
* session('assistant/chunk')
|
||||
* msg = waterfall agent/step-result ⟵ BEFORE the log append, so the
|
||||
* session('assistant/message' {content, usage?}) session records what actually ran
|
||||
* each tool-call in msg (sequential, abort-checked):
|
||||
* session('tool/call'); ctx.tools.execute() ⟵ waterfall tools/execute
|
||||
* session('tool/call'); ctx.tools.execute() ⟵ tools/pre-execute (allow/deny/ask)
|
||||
* → dispatch → tools/post-execute
|
||||
* session('tool/result')
|
||||
* drain steering → session('steering/message'); emit agent/steering
|
||||
* emit agent/step-end
|
||||
* cont = waterfall agent/turn-continuation(default = hadToolCalls || steered)
|
||||
* if !cont && steering arrived from step-end/continuation listeners: cont = true
|
||||
* if !cont: break
|
||||
* session('turn/end'); emit agent/turn-end
|
||||
* append buffered post-execute additionalContext → session('context/message')(s)
|
||||
* drain steering → session('steering/message')
|
||||
* session('step/end') ⟵ durable step boundary (no agent/* mirror)
|
||||
* cont = waterfall agent/turn-continuation ⟵ ContinuationDecision; default
|
||||
* {action: hadToolCalls||steered ? 'continue':'stop'}; a continue.reason is
|
||||
* recorded as next-step steering
|
||||
* if action==stop && steering arrived (step/end/continuation listeners): continue anyway
|
||||
* if action==stop: break
|
||||
* session('turn/end') ⟵ durable turn boundary (no agent/* mirror)
|
||||
* await ctx.parallel('session/flush', session) ⟵ durability checkpoint
|
||||
* re-enqueue leftover steering as queued ⟵ steering is never stranded
|
||||
* idle (emit agent/status) unless more queued
|
||||
@@ -277,37 +286,32 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
|
||||
let reason: TurnEndReason = { kind: 'completed' }
|
||||
let step = 0
|
||||
let turnEnded = false
|
||||
let stepOpen = false
|
||||
let errorReported = false
|
||||
|
||||
// Close the open step exactly once (idempotent via stepOpen). The
|
||||
// agent/step-end emit is contained: a throwing step-end listener must not
|
||||
// abort finalization and strand the turn open (turn/end balance > notifying
|
||||
// one bad listener). Appended before the emit (the event-sourcing RFC append-before-emit).
|
||||
// Close the open step exactly once (idempotent via stepOpen). Step boundaries
|
||||
// are durable session events only — there is no agent/* step emit to mirror
|
||||
// them (see the agent event-domain rule). A throwing step/end session-event
|
||||
// listener must not abort finalization and strand the turn open (turn/end
|
||||
// balance > notifying one bad listener); it is contained and surfaced as a
|
||||
// turn error below.
|
||||
const closeStep = (): boolean => {
|
||||
if (!stepOpen) return false
|
||||
stepOpen = false
|
||||
// Session.append pushes step/end BEFORE notifying session/event listeners,
|
||||
// so a throwing listener leaves step/end in the log (balance holds) but
|
||||
// would otherwise abort finalization. Contain it and surface it as a turn
|
||||
// error below — the same outcome as a throwing agent/step-end listener.
|
||||
// error below.
|
||||
let failure: unknown
|
||||
try {
|
||||
session.append('step/end', { turn, step })
|
||||
} catch (error: unknown) {
|
||||
failure = error
|
||||
}
|
||||
try {
|
||||
ctx.emit('agent/step-end', agent, turn, step)
|
||||
} catch (error: unknown) {
|
||||
failure ??= error
|
||||
}
|
||||
// A throwing step/end session-event listener OR a throwing agent/step-end
|
||||
// listener surfaces as a turn error via failTurn (idempotent). This prevents
|
||||
// a throwing listener from producing a silent "completed" turn when the step
|
||||
// itself succeeded, AND keeps finalization going when closeStep runs from
|
||||
// the outer catch.
|
||||
// A throwing step/end session-event listener surfaces as a turn error via
|
||||
// failTurn (idempotent). This prevents a throwing listener from producing a
|
||||
// silent "completed" turn when the step itself succeeded, AND keeps
|
||||
// finalization going when closeStep runs from the outer catch.
|
||||
if (failure !== undefined) {
|
||||
failTurn(toError(failure))
|
||||
return true
|
||||
@@ -324,47 +328,38 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
const failTurn = (err: CodedError): void => {
|
||||
if (errorReported) return
|
||||
errorReported = true
|
||||
// Set the error reason ONLY while the turn is still open — closeTurn appends
|
||||
// turn/end with it. If the turn has already ended (the only way here: a
|
||||
// throwing agent/turn-end listener after closeTurn(true) already appended
|
||||
// turn/end), the reason can no longer affect the durable log, so log the late
|
||||
// throw directly instead — otherwise the listener exception would vanish.
|
||||
if (!turnEnded) {
|
||||
reason = { kind: 'error', step, ...errorData(err) }
|
||||
} else {
|
||||
ctx.logger.warn(`agent "${agent.id}": agent/turn-end listener threw after turn ${turn} closed: ${err.message}`)
|
||||
}
|
||||
// The turn is always still open here: the only failure that can reach
|
||||
// failTurn once turn/end is appended would be a throwing turn-boundary
|
||||
// listener, and turn boundaries are durable session events with no agent/*
|
||||
// mirror to throw. A throwing `turn/end` session-event listener is already
|
||||
// contained inside closeTurn (append pushes before notifying, so the
|
||||
// boundary is durable). So set the error reason for closeTurn to append.
|
||||
reason = { kind: 'error', step, ...errorData(err) }
|
||||
try {
|
||||
ctx.emit('agent/error', agent, turn, step, err)
|
||||
} catch {
|
||||
// contained: the error is already captured (on `reason`, or via the logger
|
||||
// above); a throwing agent/error listener must not prevent the turn from
|
||||
// closing.
|
||||
// contained: the error is already captured on `reason`; a throwing
|
||||
// agent/error listener must not prevent the turn from closing.
|
||||
}
|
||||
}
|
||||
|
||||
// Close the turn exactly once (idempotent via turnEnded). `emit` is false on
|
||||
// the error path (the failure was already surfaced via agent/error) and true
|
||||
// on the normal/inline-error path. A throwing agent/turn-end listener on the
|
||||
// normal path escapes to the outer catch, which surfaces it via failTurn —
|
||||
// turn/end is already appended, so balance holds either way.
|
||||
const closeTurn = (emit: boolean): void => {
|
||||
if (turnEnded) return
|
||||
turnEnded = true
|
||||
// Close the turn. Called exactly once per turn — the normal loop exit and the
|
||||
// outer catch are mutually exclusive paths, and this never throws (the append
|
||||
// is contained below), so there is no re-entry to guard against (unlike
|
||||
// closeStep, which the cancel branches and the outer catch can both reach).
|
||||
// Turn boundaries are durable session events only — there is no agent/* turn
|
||||
// emit to mirror them (see the agent event-domain rule).
|
||||
const closeTurn = (): void => {
|
||||
// Session.append pushes turn/end BEFORE notifying session/event listeners,
|
||||
// so a throwing listener leaves turn/end in the log (the turn is balanced)
|
||||
// but would otherwise escape — from the outer catch's closeTurn(false) it
|
||||
// would propagate to the runLoop backstop, and from the normal-path
|
||||
// closeTurn(true) it would skip the agent/turn-end emit. Contain it: the
|
||||
// boundary is durable either way, and finalization must not abort on a bad
|
||||
// listener. (On the normal path the outer catch also re-runs closeTurn,
|
||||
// which is an idempotent no-op once turnEnded is set.)
|
||||
// but would otherwise escape — from the outer catch it would propagate to
|
||||
// the runLoop backstop. Contain it: the boundary is durable either way, and
|
||||
// finalization must not abort on a bad listener.
|
||||
try {
|
||||
session.append('turn/end', { turn, reason })
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`agent "${agent.id}": session/event listener threw on turn/end at turn ${turn}: ${toError(error).message}`)
|
||||
}
|
||||
if (emit) ctx.emit('agent/turn-end', agent, turn, reason)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -373,20 +368,60 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
// decides "owed" from the log via isTurnOpen, so even a throwing turn/start
|
||||
// listener — append pushes before notifying — still gets its turn/end).
|
||||
session.append('turn/start', { turn, trigger })
|
||||
// Record the queued user messages INSIDE the turn (after turn/start), so
|
||||
// every event in the log is turn-enclosed. turn/end is now owed, so a throw
|
||||
// while appending these is caught below and the turn is still closed.
|
||||
// Each drained queued message runs the `agent/prompt-submit` waterfall before
|
||||
// it becomes a `user/message` — a hook can rewrite the prompt or block it.
|
||||
// Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed;
|
||||
// turn/end is now owed, so a throwing prompt-submit listener (the waterfall
|
||||
// throws) is caught below and the turn still closes.
|
||||
let anyAllowed = false
|
||||
// Seeded with a floor (only observable if the batch were empty, which
|
||||
// runTurn never allows — it is called with ≥1 queued message); each `block`
|
||||
// decision carries a required `reason` and overwrites it, so a fully-blocked
|
||||
// batch always reports the last vetoing reason.
|
||||
let lastBlockReason = 'prompt blocked by hook'
|
||||
for (const message of queued) {
|
||||
session.append('user/message', { content: message.content, source: message.source }, { surfaceOp: 'append' })
|
||||
const decision = await ctx.waterfall(
|
||||
'agent/prompt-submit', agent, message.content, message.source,
|
||||
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
|
||||
)
|
||||
if (decision.kind === 'block') {
|
||||
lastBlockReason = decision.reason
|
||||
// Record the veto durably: `PromptDecision.reason` is the durable record
|
||||
// of why a prompt was blocked, but a fully-blocked batch's `rejected`
|
||||
// turn/end only preserves the LAST reason, and a MIXED batch (this prompt
|
||||
// blocked, another allowed) does not end `rejected` at all — so without
|
||||
// this append a blocked prompt would vanish from the log whenever any
|
||||
// sibling prompt is allowed. `prompt/blocked` sits in the open turn in
|
||||
// place of the `user/message` this prompt would have become.
|
||||
session.append('prompt/blocked', { content: message.content, source: message.source, reason: decision.reason })
|
||||
continue
|
||||
}
|
||||
anyAllowed = true
|
||||
// `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them.
|
||||
const content = decision.content ?? message.content
|
||||
session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' })
|
||||
// `allow.additionalContext` is a SEPARATE context/message the next request
|
||||
// also sees. The turn is open, so inject() appends it into THIS turn.
|
||||
if (decision.additionalContext) {
|
||||
agent.inject(decision.additionalContext.content, { source: decision.additionalContext.source })
|
||||
}
|
||||
}
|
||||
ctx.emit('agent/turn-start', agent, turn)
|
||||
|
||||
while (true) {
|
||||
// A fully-blocked batch (every prompt vetoed by prompt-submit) opens a
|
||||
// zero-step turn that ends `rejected`: break BEFORE the first step so the
|
||||
// boundary stays balanced (turn/start → turn/end) and the block is a
|
||||
// durable in-turn fact. `anyAllowed` never changes inside the loop, so this
|
||||
// only ever fires on the first iteration.
|
||||
if (!anyAllowed) {
|
||||
reason = { kind: 'rejected', reason: lastBlockReason }
|
||||
break
|
||||
}
|
||||
step += 1
|
||||
|
||||
// Steering from the previous round's step-end/continuation listeners
|
||||
// (or turn-start listeners on the first step) joins before the request.
|
||||
drainSteering(ctx, agent, turn)
|
||||
// Steering from the previous round's continuation listeners joins before
|
||||
// the request.
|
||||
drainSteering(agent, turn)
|
||||
|
||||
// The step's AbortController exists BEFORE any async pre-step work so a
|
||||
// dispose() or cancel() — in a synchronous turn-start listener or an
|
||||
@@ -400,11 +435,11 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
// because the pre-step seam needs it: compaction measures token pressure
|
||||
// against the system prompt (it counts toward the budget). runStep reuses
|
||||
// this same assembly for the request, so the prompt is assembled once per
|
||||
// step.
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
const fullSystemPrompt = [renderPrompt(assembly), agent.options.systemPrompt ?? '']
|
||||
.filter(text => text.length > 0)
|
||||
.join('\n\n')
|
||||
// step. renderPrompt IS the full prompt — the persona is the order-0
|
||||
// section (registered by the AgentLoop plugin) and `{{variable}}`
|
||||
// interpolation happens in the render, so there is no separate join.
|
||||
const assembly = await ctx.systemPrompt.assemble({ agent })
|
||||
const fullSystemPrompt = renderPrompt(assembly)
|
||||
|
||||
// Interruption landing after assembly: dispose() or cancel() in a
|
||||
// turn-start listener (or a listener whose promise resolved before the
|
||||
@@ -432,24 +467,25 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
// pre-step plugin ends the turn, not the loop.
|
||||
await ctx.serial('agent/pre-step', agent, turn, step, fullSystemPrompt, abort.signal)
|
||||
|
||||
// Interruption landing during the pre-step seam: do not open an empty
|
||||
// step. `agent/step-start` listeners get their own check below because
|
||||
// they necessarily run after step/start is appended/emitted.
|
||||
// Interruption landing during the pre-step seam: do not open an empty step.
|
||||
if (handle.isCancelled() || handle.isDisposed()) {
|
||||
handle.setAbort(undefined)
|
||||
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
|
||||
break
|
||||
}
|
||||
|
||||
session.append('step/start', { turn, step })
|
||||
// Mark the step open BEFORE the append: Session.append pushes the event
|
||||
// to the log before notifying session/event listeners, so a THROWING
|
||||
// step/start listener leaves step/start in the log. Setting stepOpen first
|
||||
// means the outer catch's closeStep() then appends the balancing step/end
|
||||
// (turn stays enclosed) instead of stranding an open step under turn/end.
|
||||
stepOpen = true
|
||||
ctx.emit('agent/step-start', agent, turn, step)
|
||||
session.append('step/start', { turn, step })
|
||||
|
||||
// Cancel landing in the step-start window: a synchronous
|
||||
// `agent/step-start` listener can cancel after the step is already open.
|
||||
// Check AFTER step/start append + emit and before `runStep`: drop the
|
||||
// step, end the turn accordingly. closeStep balances the already-appended
|
||||
// step/start.
|
||||
// Cancel landing in the step-start window: a synchronous `session/event`
|
||||
// step/start listener can cancel after the step is already open. Check
|
||||
// AFTER the step/start append and before `runStep`: drop the step, end the
|
||||
// turn accordingly. closeStep balances the already-appended step/start.
|
||||
if (handle.isCancelled() || handle.isDisposed()) {
|
||||
handle.setAbort(undefined)
|
||||
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
|
||||
@@ -494,14 +530,14 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
if (stepReason) reason = stepReason
|
||||
|
||||
// Steering that arrived during streaming/tool execution.
|
||||
const steered = drainSteering(ctx, agent, turn)
|
||||
const steered = drainSteering(agent, turn)
|
||||
|
||||
if (closeStep()) break
|
||||
|
||||
const defaultDecision = stepOutcome.hadToolCalls || steered
|
||||
let shouldContinue: boolean
|
||||
const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' }
|
||||
let decision: ContinuationDecision
|
||||
try {
|
||||
shouldContinue = await ctx.waterfall(
|
||||
decision = await ctx.waterfall(
|
||||
'agent/turn-continuation', agent, turn, defaultDecision,
|
||||
() => Promise.resolve(defaultDecision),
|
||||
)
|
||||
@@ -511,9 +547,18 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
break
|
||||
}
|
||||
|
||||
// Steering from step-end/continuation listeners (the /goal pattern)
|
||||
// demands the model see it — it overrides a negative decision; the
|
||||
// next iteration's drain records it.
|
||||
// A forced `continue` may carry model-facing context: record it as
|
||||
// next-STEP steering (the steering channel), so the continued turn's next
|
||||
// iteration drains it before its request — the typed twin of the /goal
|
||||
// step/end-steer pattern.
|
||||
if (decision.action === 'continue' && decision.reason) {
|
||||
agent.inbox.steer({ content: decision.reason.content, source: decision.reason.source })
|
||||
}
|
||||
let shouldContinue = decision.action === 'continue'
|
||||
|
||||
// Steering from step/end session-event or continuation listeners (the
|
||||
// /goal pattern) demands the model see it — it overrides a stop decision;
|
||||
// the next iteration's drain records it.
|
||||
if (!shouldContinue && agent.inbox.hasSteering) shouldContinue = true
|
||||
|
||||
// A cancel that landed during the continuation window — after the step's
|
||||
@@ -533,8 +578,8 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
}
|
||||
}
|
||||
|
||||
// Normal / inline-error loop exit: close the turn and notify.
|
||||
closeTurn(true)
|
||||
// Normal / inline-error loop exit: close the turn.
|
||||
closeTurn()
|
||||
} catch (error: unknown) {
|
||||
// Decide whether this turn was ever opened from the LOG, not a flag.
|
||||
// Session.append pushes the event BEFORE notifying session/event listeners,
|
||||
@@ -543,28 +588,29 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
// Gating on a "turn started" boolean would skip turn/end and leave a
|
||||
// permanently OPEN turn that poisons the next turn/replay (the turn-enclosure RFC). We
|
||||
// check the log for THIS turn's turn/start: present means a turn/end is owed
|
||||
// (or was already appended — closeTurn/failTurn are idempotent, so running
|
||||
// them again is a safe no-op that still preserves the disposed/error reason
|
||||
// chosen below). Absent means the turn/start append threw BEFORE its push (a
|
||||
// non-serializable trigger — impossible for our fixed trigger); nothing was
|
||||
// opened, so rethrow to the runLoop backstop.
|
||||
// and the normal-exit `closeTurn()` did NOT run (we are here because a throw
|
||||
// preceded it — the two `closeTurn()` sites are on mutually exclusive paths),
|
||||
// so this catch appends turn/end with the disposed/error reason chosen below.
|
||||
// `closeStep()` IS idempotent (guarded by `stepOpen`) — it may have run
|
||||
// already in a step branch, so running it again is a safe no-op. Absent
|
||||
// turn/start means the append threw BEFORE its push (a non-serializable
|
||||
// trigger — impossible for our fixed trigger); nothing was opened, so rethrow
|
||||
// to the runLoop backstop.
|
||||
const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
|
||||
if (!turnStartLogged) throw error
|
||||
closeStep()
|
||||
// Choose the close reason. Disposal wins only if no error was already
|
||||
// reported: a turn disposed mid-step sets reason=disposed in the step-error
|
||||
// branch (without reporting an error), and if closeTurn(true)'s turn-end
|
||||
// emit then throws, we land here and must PRESERVE disposed rather than
|
||||
// overwrite it with the listener's throw. Otherwise a boundary-emit throw
|
||||
// on a live agent is a real failure → failTurn. (errorReported is mutated
|
||||
// only inside the failTurn closure, which the analyzer can't follow, hence
|
||||
// the inline lint-disable.)
|
||||
// branch (without reporting an error), so preserve disposed rather than
|
||||
// overwrite it. Otherwise a mid-step throw on a live agent is a real
|
||||
// failure → failTurn. (errorReported is mutated only inside the failTurn
|
||||
// closure, which the analyzer can't follow, hence the inline lint-disable.)
|
||||
if (handle.isDisposed() && !errorReported) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
|
||||
reason = { kind: 'disposed' }
|
||||
} else {
|
||||
failTurn(toError(error))
|
||||
}
|
||||
closeTurn(false)
|
||||
closeTurn()
|
||||
}
|
||||
|
||||
// Durability checkpoint: persistence plugins drain write-behind buffers.
|
||||
@@ -590,11 +636,10 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
}
|
||||
|
||||
/** Drain the steering queue into the session. Returns whether any arrived. */
|
||||
function drainSteering(ctx: Context, agent: ReactLoopAgent, turn: number): boolean {
|
||||
function drainSteering(agent: ReactLoopAgent, turn: number): boolean {
|
||||
const messages = agent.inbox.drainSteering()
|
||||
for (const message of messages) {
|
||||
agent.session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
|
||||
ctx.emit('agent/steering', agent, turn, message.content, message.source)
|
||||
}
|
||||
return messages.length > 0
|
||||
}
|
||||
@@ -636,7 +681,6 @@ async function runStep(
|
||||
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
||||
const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
|
||||
chunkSeqs.push(chunkEvent.seq)
|
||||
ctx.emit('agent/stream-chunk', agent, turn, step, chunk)
|
||||
assembler.push(chunk)
|
||||
}
|
||||
|
||||
@@ -695,6 +739,12 @@ async function runStep(
|
||||
// ToolRegistry.execute converts tool failures (including aborts) into
|
||||
// isError results, so abort is re-checked around every call here.
|
||||
const toolCalls = message.content.filter(block => block.type === 'tool-call')
|
||||
// Per-step buffer of `additionalContext` attached by tools/post-execute
|
||||
// listeners. Appended as context/message(s) only AFTER every tool/result for
|
||||
// the step, so a multi-call step keeps tool-call/result adjacency
|
||||
// (interleaving context between a call's result and the next call's would
|
||||
// break the pairing the next model request relies on).
|
||||
const pendingContext: HookContext[] = []
|
||||
for (const call of toolCalls) {
|
||||
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
|
||||
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
||||
@@ -705,6 +755,12 @@ async function runStep(
|
||||
} catch {
|
||||
parsedArguments = call.arguments
|
||||
}
|
||||
// TODO(pre-tool-input-rewrite): tools/pre-execute deliberately cannot rewrite
|
||||
// `arguments` — tool/call (the audit record) and assistant/message (the
|
||||
// model-history source) are logged BEFORE execute, and live consumers (ACP,
|
||||
// tool-bash presentation) read the pre-execution args, so an execution-only
|
||||
// rewrite would desync the UI from what ran. Designing that consistently is
|
||||
// its own proposed RFC (docs/rfc/proposed/feature/…-pre-tool-input-rewrite.md).
|
||||
const result = await ctx.tools.execute({
|
||||
callId: call.id,
|
||||
name: call.name,
|
||||
@@ -716,7 +772,7 @@ async function runStep(
|
||||
turn, step,
|
||||
// The correlation id MUST be the loop's authoritative call.id (the
|
||||
// model-transcript id that deriveMessages turns into toolCallId), NOT
|
||||
// result.callId — a tools/execute waterfall listener returning a
|
||||
// result.callId — a post-execute waterfall listener returning a
|
||||
// mismatched id would otherwise orphan the call↔result pairing in the
|
||||
// next model request. A listener-internal id, if ever needed, belongs in
|
||||
// a separate diagnostic field, never overloaded onto callId.
|
||||
@@ -724,7 +780,12 @@ async function runStep(
|
||||
content: result.content,
|
||||
isError: result.isError,
|
||||
...result.error ? { error: result.error } : {},
|
||||
// The tool's private presentation payload (e.g. a result-time diff),
|
||||
// persisted so a UI bridge reproduces the card on replay.
|
||||
...result.meta !== undefined ? { meta: result.meta } : {},
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] })
|
||||
// Buffer (don't append yet) any post-execute additionalContext for this call.
|
||||
if (result.additionalContext) pendingContext.push(result.additionalContext)
|
||||
// signal CAN flip during the await above (abort() inside a tool);
|
||||
// the analyzer can't see through the await boundary.
|
||||
/* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */
|
||||
@@ -733,6 +794,13 @@ async function runStep(
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
// Append buffered post-execute context AFTER every tool/result, preserving
|
||||
// tool-call/result adjacency across the whole batch. inject() appends into the
|
||||
// open turn (a context/message at its chronological position).
|
||||
for (const context of pendingContext) {
|
||||
agent.inject(context.content, { source: context.source })
|
||||
}
|
||||
|
||||
return { hadToolCalls: toolCalls.length > 0, finish: assembler.finish }
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ describe('Agent.cancel()', () => {
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
@@ -134,7 +134,7 @@ describe('Agent.cancel()', () => {
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
@@ -166,22 +166,23 @@ describe('Agent.cancel()', () => {
|
||||
expect(reasons.length).toBe(2)
|
||||
})
|
||||
|
||||
it('cancel from a synchronous agent/turn-start listener drops the step (step-start window)', async () => {
|
||||
it('cancel from a synchronous turn/start session-event listener drops the step (step-start window)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not stream')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
// A turn-start listener fires BEFORE any AbortController is installed for the
|
||||
// step. Cancelling there must still drop the step (the turn-scoped marker,
|
||||
// not the step AbortController, is what catches this) — no model step runs.
|
||||
// A turn/start listener fires right after turn/start is appended, BEFORE any
|
||||
// AbortController is installed for the step. Cancelling there must still drop
|
||||
// the step (the turn-scoped marker, not the step AbortController, is what
|
||||
// catches this) — no model step runs.
|
||||
let streamed = false
|
||||
ctx.on('agent/stream-chunk', () => { streamed = true })
|
||||
const dispose = ctx.on('agent/turn-start', (subject) => {
|
||||
if (subject === agent) agent.cancel('from turn-start')
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
|
||||
const dispose = ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'turn/start') agent.cancel('from turn-start')
|
||||
})
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -194,23 +195,23 @@ describe('Agent.cancel()', () => {
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from turn-start' }])
|
||||
})
|
||||
|
||||
it('cancel from a synchronous agent/step-start listener drops the step (post-step-start window)', async () => {
|
||||
it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not stream')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
// A step-start listener fires AFTER step/start is appended (and after the
|
||||
// pre-step seam), so cancelling there lands in the SECOND cancel check (the
|
||||
// one that must closeStep() to balance the already-open step) — distinct
|
||||
// from a turn-start cancel, which is caught before the step opens.
|
||||
// A step/start session-event listener fires AFTER step/start is appended
|
||||
// (and after the pre-step seam), so cancelling there lands in the SECOND
|
||||
// cancel check (the one that must closeStep() to balance the already-open
|
||||
// step) — distinct from a turn-start cancel, caught before the step opens.
|
||||
let streamed = false
|
||||
ctx.on('agent/stream-chunk', () => { streamed = true })
|
||||
const dispose = ctx.on('agent/step-start', (subject) => {
|
||||
if (subject === agent) agent.cancel('from step-start')
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
|
||||
const dispose = ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'step/start') agent.cancel('from step-start')
|
||||
})
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -224,7 +225,7 @@ describe('Agent.cancel()', () => {
|
||||
expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length)
|
||||
})
|
||||
|
||||
it('disposal from a synchronous agent/step-start listener closes the open step as disposed', async () => {
|
||||
it('disposal from a synchronous step/start session-event listener closes the open step as disposed', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not stream')])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -244,9 +245,9 @@ describe('Agent.cancel()', () => {
|
||||
|
||||
let disposalDone: Promise<void> | undefined
|
||||
let streamed = false
|
||||
ctx.on('agent/stream-chunk', () => { streamed = true })
|
||||
ctx.on('agent/step-start', (subject) => {
|
||||
if (subject === agent) disposalDone = handle.dispose()
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'step/start') disposalDone = handle.dispose()
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
@@ -271,16 +272,18 @@ describe('Agent.cancel()', () => {
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let steps = 0
|
||||
ctx.on('agent/step-start', () => { steps += 1 })
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'step/start') steps += 1
|
||||
if (event.type === 'turn/end') reasons.push(event.data.reason)
|
||||
})
|
||||
|
||||
let continued = false
|
||||
ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => {
|
||||
if (subject === agent && !continued) {
|
||||
continued = true
|
||||
agent.cancel('from continuation')
|
||||
return true // vote to continue — the post-waterfall marker check must override
|
||||
return { action: 'continue' as const } // vote to continue — the post-waterfall marker check must override
|
||||
}
|
||||
return next()
|
||||
})
|
||||
@@ -305,7 +308,7 @@ describe('Agent.cancel()', () => {
|
||||
// runTurn. The second check (after the running flip) must drop the turn —
|
||||
// runTurn would otherwise throw on the now-empty queue.
|
||||
let streamed = false
|
||||
ctx.on('agent/stream-chunk', () => { streamed = true })
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'running') agent.cancel('from running listener')
|
||||
})
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('config-driven session id', () => {
|
||||
await ctx1.plugin(SystemPrompt)
|
||||
await ctx1.plugin(ToolRegistry)
|
||||
await ctx1.plugin(AgentRegistry)
|
||||
await ctx1.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock', systemPrompt: '' }] })
|
||||
await ctx1.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock' }] })
|
||||
await ctx1.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')]))
|
||||
const a1 = ctx1.agents.get(AgentId('cfg')) as ReactLoopAgent
|
||||
@@ -52,7 +52,7 @@ describe('config-driven session id', () => {
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock', systemPrompt: '' }] })
|
||||
await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock' }] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')]))
|
||||
const a2 = ctx2.agents.get(AgentId('cfg')) as ReactLoopAgent
|
||||
@@ -92,7 +92,7 @@ describe('config-driven session id', () => {
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: '', resumeSessionId: SessionId('sticky-1') }] })
|
||||
await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('sticky-1') }] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')]))
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('config-driven session id', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: '', resumeSessionId: SessionId('does-not-exist') }] })
|
||||
await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('does-not-exist') }] })
|
||||
const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn')
|
||||
.mockImplementation(() => undefined)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
|
||||
@@ -36,69 +36,6 @@ function send(agent: ReactLoopAgent, text: string) {
|
||||
}
|
||||
|
||||
describe('turn boundary listener throws (handled in-turn, loop survives)', () => {
|
||||
it('a throwing agent/turn-start listener surfaces via agent/error and the loop survives', async () => {
|
||||
// The agent/turn-start emit happens AFTER turn/start is appended to the log,
|
||||
// so a throwing listener is handled inside runTurn (the turn is balanced and
|
||||
// closed via failTurn → agent/error), NOT rethrown to the runLoop backstop.
|
||||
// The second turn should proceed normally and consume the first script entry.
|
||||
const adapter = new MockAdapter([textResponse('turn 2')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('agent/turn-start', () => {
|
||||
if (!threwOnce) {
|
||||
threwOnce = true
|
||||
throw new Error('broken turn-start listener')
|
||||
}
|
||||
})
|
||||
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors.map(e => e.message)).toEqual(['broken turn-start listener'])
|
||||
// The turn is balanced: its turn/start was logged, so a turn/end was owed
|
||||
// and appended (decided from the log, not a flag).
|
||||
expect(agent.session.events.at(-1)?.type).toBe('turn/end')
|
||||
|
||||
// loop survives: second turn works fine and makes the model call
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(adapter.requests[0]!.messages.some(m => m.content.some(b => 'text' in b && b.text === 'second'))).toBe(true)
|
||||
})
|
||||
|
||||
it('a throwing agent/turn-end listener surfaces via agent/error and the loop survives', async () => {
|
||||
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('agent/turn-end', () => {
|
||||
if (!threwOnce) {
|
||||
threwOnce = true
|
||||
throw new Error('broken turn-end listener')
|
||||
}
|
||||
})
|
||||
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
// The turn-end throw happens after the model call is complete, so turn 1's
|
||||
// request is consumed. turn/end is already in the log (append pushes before
|
||||
// notifying), so the turn is balanced; the error is surfaced via agent/error.
|
||||
expect(errors.map(e => e.message)).toEqual(['broken turn-end listener'])
|
||||
|
||||
// loop survives: second turn works fine
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('a pre-push turn/start failure (non-serializable source) is rethrown to the runLoop backstop', async () => {
|
||||
// A non-serializable message source makes the turn/start append throw BEFORE
|
||||
// the event is pushed (Session.append validates before push), so turn/start
|
||||
@@ -192,14 +129,14 @@ describe('tool JSON parse', () => {
|
||||
})
|
||||
|
||||
describe('toError normalization', () => {
|
||||
it('normalizes non-Error throws from turn-start listeners via toError', async () => {
|
||||
it('normalizes non-Error throws from a turn/start session-event listener via toError', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('agent/turn-start', () => {
|
||||
if (!threwOnce) {
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'turn/start' && !threwOnce) {
|
||||
threwOnce = true
|
||||
throw 'naked string error' // non-Error throw, normalized via toError
|
||||
}
|
||||
@@ -287,7 +224,7 @@ describe('disposed vs aborted branching', () => {
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
|
||||
529
packages/core/agent-loop/tests/interception.spec.ts
Normal file
529
packages/core/agent-loop/tests/interception.spec.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, {
|
||||
AgentId,
|
||||
type ContinuationDecision,
|
||||
type PromptDecision,
|
||||
type SessionStartSource,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
/**
|
||||
* The interception seams introduced by the hooks taxonomy: `agent/prompt-submit`,
|
||||
* `agent/session-start`, the reshaped `agent/turn-continuation`
|
||||
* ({@link ContinuationDecision}), and the `tools/pre-execute` / `tools/post-execute`
|
||||
* split with `additionalContext` buffering. These verify the canonical event
|
||||
* surface a hook bridge (or a native plugin) programs against, WITHOUT any
|
||||
* external protocol — a native plugin uses the typed decisions directly.
|
||||
*/
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
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: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function send(agent: ReactLoopAgent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
function events(agent: ReactLoopAgent): SessionEvent[] {
|
||||
return [...agent.session.events]
|
||||
}
|
||||
|
||||
describe('agent/prompt-submit', () => {
|
||||
it('allow (default via next) records the user/message unchanged', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const seen: string[] = []
|
||||
ctx.on('agent/prompt-submit', async (_agent, content, _source, next) => {
|
||||
seen.push(content.map(b => (b.type === 'text' ? b.text : '')).join(''))
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'hello')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(seen).toEqual(['hello'])
|
||||
const userMsg = events(agent).find(e => e.type === 'user/message')
|
||||
expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'hello' }])
|
||||
})
|
||||
|
||||
it('allow with content REWRITES the prompt before it is recorded', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
|
||||
({ kind: 'allow', content: [{ type: 'text', text: 'REWRITTEN' }] }))
|
||||
|
||||
send(agent, 'original')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const userMsg = events(agent).find(e => e.type === 'user/message')
|
||||
expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'REWRITTEN' }])
|
||||
// the rewritten prompt is what reached the model
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('REWRITTEN')
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original')
|
||||
})
|
||||
|
||||
it('allow with additionalContext injects a separate context/message into the turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
|
||||
({
|
||||
kind: 'allow',
|
||||
additionalContext: { content: [{ type: 'text', text: 'extra ctx' }], source: { kind: 'plugin', plugin: 'test' } },
|
||||
}))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = events(agent)
|
||||
const userMsg = log.find(e => e.type === 'user/message')
|
||||
const ctxMsg = log.find(e => e.type === 'context/message')
|
||||
expect(userMsg).toBeDefined()
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }])
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
|
||||
// both the prompt and the injected context reach the model
|
||||
const sent = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(sent).toContain('extra ctx')
|
||||
})
|
||||
|
||||
it('a prompt-submit rewrite + additionalContext is VISIBLE to the agent/pre-step seam (merged ordering)', async () => {
|
||||
// The merge of the interception seams with master's compaction seam pins one
|
||||
// ordering: `agent/prompt-submit` runs (rewriting the prompt and injecting
|
||||
// context) BEFORE the step loop, and `agent/pre-step` fires INSIDE the step
|
||||
// before the single deriveMessages(). So a compaction listener on
|
||||
// `agent/pre-step` must observe the surface AFTER the prompt rewrite/inject —
|
||||
// otherwise it would measure/compact stale history. This cross-test proves
|
||||
// the two seams compose in the right order (each is covered in isolation
|
||||
// elsewhere; this asserts they see each other's effects on the same turn).
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
|
||||
({
|
||||
kind: 'allow',
|
||||
content: [{ type: 'text', text: 'REWRITTEN prompt' }],
|
||||
additionalContext: { content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' } },
|
||||
}))
|
||||
|
||||
// The pre-step seam (where compaction lives) derives the surface it would act
|
||||
// on. Capture what it sees on the first step.
|
||||
let preStepDerived: string | undefined
|
||||
ctx.on('agent/pre-step', (subject, _turn, step) => {
|
||||
if (subject === agent && step === 1) preStepDerived = JSON.stringify(subject.session.deriveMessages())
|
||||
})
|
||||
|
||||
send(agent, 'ORIGINAL prompt')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The pre-step seam ran and saw BOTH the rewrite (not the original) and the
|
||||
// injected context — i.e. the prompt-submit effects landed before it.
|
||||
expect(preStepDerived).toBeDefined()
|
||||
expect(preStepDerived).toContain('REWRITTEN prompt')
|
||||
expect(preStepDerived).toContain('injected ctx')
|
||||
expect(preStepDerived).not.toContain('ORIGINAL prompt')
|
||||
})
|
||||
|
||||
it('block drops the (only) prompt → zero-step turn ends rejected, model never called', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
|
||||
({ kind: 'block', reason: 'blocked by policy' }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'do something')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// the model was never called
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
// the turn opened and closed balanced, with no user/message and no step
|
||||
const log = events(agent)
|
||||
expect(log.some(e => e.type === 'turn/start')).toBe(true)
|
||||
expect(log.some(e => e.type === 'turn/end')).toBe(true)
|
||||
expect(log.some(e => e.type === 'user/message')).toBe(false)
|
||||
expect(log.some(e => e.type === 'step/start')).toBe(false)
|
||||
// the veto is recorded durably as a prompt/blocked in the open turn
|
||||
const blocked = log.find(e => e.type === 'prompt/blocked')
|
||||
expect(blocked?.type === 'prompt/blocked' && blocked.data).toMatchObject({
|
||||
content: [{ type: 'text', text: 'do something' }],
|
||||
reason: 'blocked by policy',
|
||||
})
|
||||
// ended rejected with the block reason
|
||||
expect(reasons).toEqual([{ kind: 'rejected', reason: 'blocked by policy' }])
|
||||
const turnEnd = log.findLast(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'rejected', reason: 'blocked by policy' })
|
||||
})
|
||||
|
||||
it('a mixed batch records a prompt/blocked for the vetoed prompt while the allowed one runs', async () => {
|
||||
// Two prompts queued into ONE turn: block "secret", allow "safe". The turn is
|
||||
// NOT rejected (a prompt was allowed), so without a durable prompt/blocked the
|
||||
// vetoed prompt and its reason would vanish from the log entirely.
|
||||
const adapter = new MockAdapter([textResponse('ran once')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise<PromptDecision> => {
|
||||
const text = content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
return text === 'secret' ? { kind: 'block', reason: 'policy: no secrets' } : next()
|
||||
})
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
// both sends land before the loop drains → one batched turn
|
||||
send(agent, 'secret')
|
||||
send(agent, 'safe')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = events(agent)
|
||||
// the allowed prompt became a user/message and drove exactly one model call
|
||||
const userMsgs = log.filter(e => e.type === 'user/message')
|
||||
expect(userMsgs).toHaveLength(1)
|
||||
expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }])
|
||||
expect(adapter.requests.length).toBeGreaterThanOrEqual(1)
|
||||
// the blocked prompt is durably recorded, with its content + reason
|
||||
const blocked = log.filter(e => e.type === 'prompt/blocked')
|
||||
expect(blocked).toHaveLength(1)
|
||||
expect(blocked[0]?.type === 'prompt/blocked' && blocked[0].data).toMatchObject({
|
||||
content: [{ type: 'text', text: 'secret' }],
|
||||
reason: 'policy: no secrets',
|
||||
})
|
||||
// the turn did NOT reject — a sibling was allowed — so the boundary reason
|
||||
// alone would not have preserved the block
|
||||
expect(reasons.some(r => r.kind === 'rejected')).toBe(false)
|
||||
})
|
||||
|
||||
it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => {
|
||||
const adapter = new MockAdapter([textResponse('after')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let threw = false
|
||||
ctx.on('agent/prompt-submit', async () => {
|
||||
if (!threw) { threw = true; throw new Error('prompt hook broke') }
|
||||
return { kind: 'allow' as const }
|
||||
})
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors.map(e => e.message)).toEqual(['prompt hook broke'])
|
||||
// turn balanced
|
||||
const log = events(agent)
|
||||
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
|
||||
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1)
|
||||
|
||||
// loop survives: a second prompt runs normally
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent/session-start', () => {
|
||||
it('fires once with source "startup" for a fresh create, before the first turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
const sources: SessionStartSource[] = []
|
||||
ctx.on('agent/session-start', (_agent, source) => void sources.push(source))
|
||||
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
// fires synchronously at create, before any turn
|
||||
expect(sources).toEqual(['startup'])
|
||||
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
// still only one session-start
|
||||
expect(sources).toEqual(['startup'])
|
||||
})
|
||||
|
||||
it('a session-start listener can inject context the first request sees', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
agent.inject([{ type: 'text', text: 'session preamble' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
})
|
||||
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// the injected context reached the model on the first (only) request
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
|
||||
// and is recorded with the plugin source, never mislabeled as a user prompt
|
||||
const ctxMsg = events(agent).find(e => e.type === 'context/message')
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
|
||||
})
|
||||
|
||||
it('a throwing session-start listener does not abort agent construction', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
ctx.on('agent/session-start', () => { throw new Error('session-start hook broke') })
|
||||
|
||||
// create must not throw — the listener error is contained/logged
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
expect(agent.id).toBe(AgentId('a1'))
|
||||
|
||||
// and the agent still runs
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent/turn-continuation (ContinuationDecision)', () => {
|
||||
it('a continue decision with a reason records next-step steering in the same turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('step 1 no tools'), textResponse('step 2')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let forced = false
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _default, next): Promise<ContinuationDecision> => {
|
||||
if (!forced) {
|
||||
forced = true
|
||||
return { action: 'continue', reason: { content: [{ type: 'text', text: 'keep going on the goal' }], source: { kind: 'plugin', plugin: 'goal' } } }
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = events(agent)
|
||||
// same turn, two steps
|
||||
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
|
||||
expect(log.filter(e => e.type === 'step/start')).toHaveLength(2)
|
||||
// the reason was recorded as steering BEFORE step 2, with its plugin source
|
||||
const steering = log.find(e => e.type === 'steering/message')
|
||||
expect(steering?.type === 'steering/message' && steering.data.content).toEqual([{ type: 'text', text: 'keep going on the goal' }])
|
||||
expect(steering?.type === 'steering/message' && steering.data.source).toEqual({ kind: 'plugin', plugin: 'goal' })
|
||||
// and reached the next request
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going on the goal')
|
||||
})
|
||||
|
||||
it('a stop decision ends the turn even when the step had tool calls', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' })])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
ctx.on('agent/turn-continuation', async (): Promise<ContinuationDecision> => ({ action: 'stop' }))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// default would have continued (had tool calls), but the stop decision wins
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(events(agent).some(e => e.type === 'tool/result')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tools/post-execute additionalContext buffering across a multi-call step', () => {
|
||||
it('appends each call\'s additionalContext only AFTER all tool/results, preserving adjacency', async () => {
|
||||
// One assistant step with TWO tool calls; the second model response stops.
|
||||
const twoCalls = [
|
||||
{ type: 'block-start' as const, index: 0, blockType: 'tool-call' as const },
|
||||
{ type: 'block-end' as const, index: 0, block: { type: 'tool-call' as const, id: CallId('c1'), name: 'echo', arguments: '{"text":"a"}' } },
|
||||
{ type: 'block-start' as const, index: 1, blockType: 'tool-call' as const },
|
||||
{ type: 'block-end' as const, index: 1, block: { type: 'tool-call' as const, id: CallId('c2'), name: 'echo', arguments: '{"text":"b"}' } },
|
||||
{ type: 'usage' as const, usage: { inputTokens: 5, outputTokens: 5 } },
|
||||
{ type: 'finish' as const, reason: { kind: 'tool-calls' as const } },
|
||||
]
|
||||
const adapter = new MockAdapter([twoCalls, textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
// Each call attaches additionalContext naming itself.
|
||||
ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> =>
|
||||
({ kind: 'accept', additionalContext: { content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } } }))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Event order in the log: both tool/results, THEN both context/messages —
|
||||
// never interleaved (which would break tool-call/result adjacency).
|
||||
const types = events(agent).map(e => e.type)
|
||||
const firstResult = types.indexOf('tool/result')
|
||||
const lastResult = types.lastIndexOf('tool/result')
|
||||
const firstCtx = types.indexOf('context/message')
|
||||
expect(firstResult).toBeGreaterThanOrEqual(0)
|
||||
expect(lastResult).toBeGreaterThan(firstResult) // two results
|
||||
expect(firstCtx).toBeGreaterThan(lastResult) // context only after ALL results
|
||||
// both contexts present
|
||||
const ctxTexts = events(agent)
|
||||
.filter(e => e.type === 'context/message')
|
||||
.flatMap(e => (e.type === 'context/message' ? e.data.content : []))
|
||||
.map(b => (b.type === 'text' ? b.text : ''))
|
||||
expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end through the loop)', () => {
|
||||
it('deny short-circuits dispatch into an isError result the model sees', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'danger', description: 'danger', parameters: {},
|
||||
async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
||||
if (exec.name === 'danger') return { kind: 'deny', reason: 'blocked dangerous tool' }
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(ran).toBe(false)
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
|
||||
expect(result?.type === 'tool/result'
|
||||
&& result.data.content.some(b => b.type === 'text' && b.text.includes('blocked dangerous tool'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('worked example: a native hook plugin is just a cordis plugin on the seams', () => {
|
||||
// The whole point of the interception taxonomy: a "native hook" needs no
|
||||
// dsh-hook-protocol, no external command, no hook/* log — it is an ordinary
|
||||
// cordis plugin subscribing to the canonical events and returning typed
|
||||
// decisions. This proves all four seams compose end-to-end through the REAL
|
||||
// loop, with NO hook/* SessionEvents involved (those belong to the bridge lib).
|
||||
const NativeGuard = {
|
||||
name: 'native-guard',
|
||||
apply(ctx: Context) {
|
||||
// 1. SessionStart: seed a standing instruction.
|
||||
ctx.on('agent/session-start', (agent, source) => {
|
||||
agent.inject(
|
||||
[{ type: 'text', text: `policy active (started: ${source})` }],
|
||||
{ source: { kind: 'plugin', plugin: 'native-guard' } },
|
||||
)
|
||||
})
|
||||
// 2. PromptSubmit: block a forbidden prompt, annotate the rest.
|
||||
ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise<PromptDecision> => {
|
||||
const text = content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
if (text.includes('rm -rf')) return { kind: 'block', reason: 'destructive prompt blocked' }
|
||||
return next()
|
||||
})
|
||||
// 3. PreToolUse: deny a dangerous tool by name.
|
||||
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
||||
if (exec.name === 'danger') return { kind: 'deny', reason: 'danger tool denied' }
|
||||
return next()
|
||||
})
|
||||
// 4. PostToolUse: attach context after a tool runs.
|
||||
ctx.on('tools/post-execute', async (_exec, _result, next): Promise<PostToolDecision> => {
|
||||
const decision = await next()
|
||||
if (decision.kind === 'accept') {
|
||||
return { kind: 'accept', additionalContext: { content: [{ type: 'text', text: 'audited' }], source: { kind: 'plugin', plugin: 'native-guard' } } }
|
||||
}
|
||||
return decision
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
it('all four seams fire for a real allowed turn with a tool call', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' }), textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
await ctx.plugin(NativeGuard)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
send(agent, 'please echo hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = events(agent)
|
||||
// session-start preamble injected
|
||||
expect(log.some(e => e.type === 'context/message'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes('policy active (started: startup)')))).toBe(true)
|
||||
// prompt allowed → user/message recorded
|
||||
expect(log.some(e => e.type === 'user/message')).toBe(true)
|
||||
// tool ran (echo allowed) and post-execute attached "audited" context
|
||||
expect(log.some(e => e.type === 'tool/result' && !e.data.isError)).toBe(true)
|
||||
expect(log.some(e => e.type === 'context/message'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text === 'audited'))).toBe(true)
|
||||
// NO hook/* events — a native plugin needs none
|
||||
expect(log.some(e => e.type.startsWith('hook/'))).toBe(false)
|
||||
})
|
||||
|
||||
it('the same plugin blocks a destructive prompt → rejected turn, model never called', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
await ctx.plugin(NativeGuard)
|
||||
const agent = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'run rm -rf /')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(reasons).toEqual([{ kind: 'rejected', reason: 'destructive prompt blocked' }])
|
||||
})
|
||||
|
||||
it('HMR-safety: disposing the plugin fiber removes all four listeners', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const fiber = await ctx.plugin(NativeGuard)
|
||||
await fiber.dispose()
|
||||
|
||||
// After disposal, a destructive prompt is NOT blocked (the listener is gone).
|
||||
const agent = ctx.agentLoop.create(AgentId('a3'), { model: 'mock' })
|
||||
send(agent, 'run rm -rf /')
|
||||
await waitForIdle(ctx, agent)
|
||||
// the prompt ran (not rejected) — proving the prompt-submit listener was disposed
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(events(agent).some(e => e.type === 'user/message')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -8,11 +8,11 @@ import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
async function harness(adapter: MockAdapter, persona = '') {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(SystemPrompt, { persona })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
@@ -46,15 +46,20 @@ describe('agent loop', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
// All boundaries — turn and step — are durable session events on the
|
||||
// session/event feed (no agent/* mirror). Record them in fire order to
|
||||
// assert the full boundary nesting.
|
||||
const order: string[] = []
|
||||
for (const name of ['agent/turn-start', 'agent/step-start', 'agent/step-end', 'agent/turn-end'] as const) {
|
||||
ctx.on(name, () => void order.push(name))
|
||||
}
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'turn/start' || event.type === 'step/start' || event.type === 'step/end' || event.type === 'turn/end') {
|
||||
order.push(event.type)
|
||||
}
|
||||
})
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(order).toEqual(['agent/turn-start', 'agent/step-start', 'agent/step-end', 'agent/turn-end'])
|
||||
expect(order).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end'])
|
||||
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
// turn/start opens the turn, THEN the queued user message is recorded inside
|
||||
@@ -110,10 +115,38 @@ describe('agent loop', () => {
|
||||
expect(types).toContain('tool/result')
|
||||
})
|
||||
|
||||
it('passes assembled system prompt and tool schemas into the request', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
it('threads a tool-attached meta (execute object return) onto the tool/result event', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'writer', { path: 'a.txt' }, 'writing'),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'You are a test agent.' })
|
||||
// A tool that returns the { content, meta } object form: the loop must
|
||||
// persist `meta` on the tool/result event so a UI reproduces the card on replay.
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'writer',
|
||||
description: 'writes a file',
|
||||
parameters: { path: { type: 'string' } },
|
||||
async execute() {
|
||||
return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] } }
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
send(agent, 'use the tool')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const toolResult = agent.session.events.find(e => e.type === 'tool/result')
|
||||
expect(toolResult?.type === 'tool/result' && toolResult.data.meta)
|
||||
.toEqual({ diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] })
|
||||
})
|
||||
|
||||
it('renders harness identity, then the persona, then tool guidance — with {{variables}} resolved', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
// The persona is a TEMPLATE: {{model}} is the loop-registered variable
|
||||
// projecting this agent's configured model, so the model knows its own name.
|
||||
const ctx = await harness(adapter, 'You are a test agent on {{model}}.')
|
||||
ctx.systemPrompt.section({ name: 'tool:noop', order: 100, text: 'Use the noop tool wisely.' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'noop',
|
||||
description: 'does nothing',
|
||||
@@ -122,31 +155,122 @@ describe('agent loop', () => {
|
||||
return []
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', systemPrompt: 'Agent-specific suffix.' })
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const request = adapter.requests[0]
|
||||
expect(request!.system).toBe('You are a test agent.\n\nAgent-specific suffix.')
|
||||
expect(request!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a test agent on mock.\n\nUse the noop tool wisely.')
|
||||
expect(request!.tools?.map(t => t.name)).toEqual(['noop'])
|
||||
})
|
||||
|
||||
it('records raw chunks for replay and emits agent/stream-chunk', async () => {
|
||||
it('resolves {{cwd}} from the agent session workspace (factory create with meta.cwd)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter, 'Working in {{cwd}}.')
|
||||
const handle = ctx.agents.create({
|
||||
agentId: AgentId('a-cwd'),
|
||||
sessionId: SessionId('s-cwd'),
|
||||
meta: { cwd: '/work/space' },
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
|
||||
const agent = handle.agent as ReactLoopAgent
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nWorking in /work/space.')
|
||||
})
|
||||
|
||||
it('contains a strict-variable render failure: the turn errors, the loop keeps serving turns', async () => {
|
||||
// A persona claiming {{cwd}} on a session with NO cwd is a deployment
|
||||
// authoring error — renderPrompt throws, the turn ends with an error, and
|
||||
// the same agent must then RUN a later turn to completion (not merely
|
||||
// report idle status): a rescue listener supplies the variable and the
|
||||
// follow-up prompt reaches the model.
|
||||
const adapter = new MockAdapter([textResponse('ok after rescue')])
|
||||
const ctx = await harness(adapter, 'In {{cwd}}.')
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(0) // the request was never sent
|
||||
expect(errors.some(e => e.message.includes('no value for this assembly'))).toBe(true)
|
||||
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error')
|
||||
|
||||
// The loop survived: a waterfall listener rescues {{cwd}} and the SAME
|
||||
// agent completes a real model turn.
|
||||
ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
|
||||
assembly.variables['cwd'] = '/rescued'
|
||||
return next()
|
||||
})
|
||||
send(agent, 'again')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nIn /rescued.')
|
||||
const turnEnds = agent.session.events.filter(e => e.type === 'turn/end')
|
||||
expect(turnEnds).toHaveLength(2)
|
||||
expect(turnEnds[1]?.type === 'turn/end' && turnEnds[1].data.reason.kind).toBe('completed')
|
||||
})
|
||||
|
||||
it('supports the model-via-agent/request path with a {{model}} persona: the supplier states it via the assemble waterfall', async () => {
|
||||
// AgentOptions.model unset: the model arrives in the agent/request
|
||||
// waterfall (the loop's documented fallback — see runStep's no-model
|
||||
// error). {{model}} renders BEFORE that waterfall, so the SAME plugin
|
||||
// states the fact early on system-prompt/assemble — the owner of a
|
||||
// late-bound fact owns stating it wherever it is claimed.
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter, 'You run on {{model}}.')
|
||||
ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
|
||||
assembly.variables['model'] = 'mock'
|
||||
return next()
|
||||
})
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, options, next) => {
|
||||
options.model = 'mock'
|
||||
return next()
|
||||
})
|
||||
const agent = ctx.agentLoop.create(AgentId('a-late-model'), {})
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(adapter.requests[0]!.model).toBe('mock')
|
||||
expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou run on mock.')
|
||||
})
|
||||
|
||||
it('omits the system field when a system-prompt/assemble veto empties the assembly', async () => {
|
||||
// The documented escape valve: a deployment that must drop the harness
|
||||
// openers short-circuits the assemble waterfall; the request then carries
|
||||
// NO system field at all (not an empty string).
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.on('system-prompt/assemble', async () => ({ sections: [], tools: [], variables: {} }))
|
||||
const agent = ctx.agentLoop.create(AgentId('a-no-system'), { model: 'mock' })
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect('system' in adapter.requests[0]!).toBe(false)
|
||||
})
|
||||
|
||||
it('records raw chunks for replay as assistant/chunk session events', async () => {
|
||||
const adapter = new MockAdapter([textResponse('abc')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const streamed: StreamChunk[] = []
|
||||
ctx.on('agent/stream-chunk', (_agent, _turn, _step, chunk) => void streamed.push(chunk))
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const chunkEvents = agent.session.events.filter(e => e.type === 'assistant/chunk')
|
||||
// textResponse('abc') = block-start + 3 deltas + block-end + usage + finish = 7
|
||||
expect(chunkEvents).toHaveLength(7)
|
||||
expect(streamed).toHaveLength(7)
|
||||
// replay: chunk events alone re-assemble to the recorded assistant message
|
||||
const deltaText = chunkEvents
|
||||
.flatMap(e => e.type === 'assistant/chunk' ? [e.data.chunk] : [])
|
||||
@@ -269,9 +393,9 @@ describe('agent loop', () => {
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let steps = 0
|
||||
ctx.on('agent/step-end', () => void steps++)
|
||||
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => {
|
||||
if (steps < 3) return true
|
||||
if (steps < 3) return { action: 'continue' as const }
|
||||
return next()
|
||||
})
|
||||
|
||||
@@ -294,7 +418,7 @@ describe('agent loop', () => {
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
ctx.on('agent/turn-continuation', async () => false as const)
|
||||
ctx.on('agent/turn-continuation', async () => ({ action: 'stop' }) as const)
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -344,10 +468,12 @@ describe('agent loop', () => {
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// One fire per step, in order, each with the assembled system prompt.
|
||||
// One fire per step, in order, each with the assembled system prompt
|
||||
// (here just the loop's own harness-identity section — no persona set).
|
||||
const HARNESS = 'You are an AI agent powered by the DeepSeek Harness SDK.'
|
||||
expect(fires).toEqual([
|
||||
{ turn: 1, step: 1, fullSystemPrompt: '' },
|
||||
{ turn: 1, step: 2, fullSystemPrompt: '' },
|
||||
{ turn: 1, step: 1, fullSystemPrompt: HARNESS },
|
||||
{ turn: 1, step: 2, fullSystemPrompt: HARNESS },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -430,7 +556,7 @@ describe('agent loop', () => {
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
// wait until the stream is hanging, then cancel
|
||||
@@ -450,7 +576,7 @@ describe('agent loop', () => {
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -475,16 +601,16 @@ describe('agent loop', () => {
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let steps = 0
|
||||
ctx.on('agent/step-end', () => void steps++)
|
||||
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
|
||||
// Force exactly one continuation (step 1 → step 2), then defer to default
|
||||
// (step 2 is a plain stop with no tool calls → stops).
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => {
|
||||
if (steps < 2) return true
|
||||
if (steps < 2) return { action: 'continue' as const }
|
||||
return next()
|
||||
})
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -506,7 +632,7 @@ describe('agent loop', () => {
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -539,7 +665,7 @@ describe('agent loop', () => {
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -581,7 +707,7 @@ describe('agent loop', () => {
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -600,7 +726,7 @@ describe('agent loop', () => {
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -640,7 +766,7 @@ describe('agent loop', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('stops the turn when agent/step-end listener failure has recorded an error', async () => {
|
||||
it('stops the turn when a step/end session-event listener failure has recorded an error', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', { text: 'x' }),
|
||||
textResponse('should not run'),
|
||||
@@ -656,8 +782,11 @@ describe('agent loop', () => {
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
let threw = false
|
||||
ctx.on('agent/step-end', () => {
|
||||
if (!threw) { threw = true; throw new Error('bad step-end listener') }
|
||||
// A throwing step/end session-event listener is the surviving boundary-listener
|
||||
// failure path (step boundaries have no agent/* mirror): closeStep contains it
|
||||
// and surfaces it as a turn error rather than stranding the turn open.
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'step/end' && !threw) { threw = true; throw new Error('bad step/end listener') }
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
@@ -674,13 +803,13 @@ describe('agent loop', () => {
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const turns: number[] = []
|
||||
ctx.on('agent/turn-start', (_agent, turn) => void turns.push(turn))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
|
||||
|
||||
// queue two messages while idle — first starts turn 1 immediately;
|
||||
// queue the second during turn 1 via a stream-chunk hook
|
||||
// queue the second during turn 1 when the first assistant chunk streams
|
||||
let queued = false
|
||||
ctx.on('agent/stream-chunk', () => {
|
||||
if (!queued) {
|
||||
ctx.on('session/event', (_s, event) => {
|
||||
if (event.type === 'assistant/chunk' && !queued) {
|
||||
queued = true
|
||||
send(agent, 'second message')
|
||||
}
|
||||
@@ -721,7 +850,7 @@ describe('agent loop', () => {
|
||||
const errors: Error[] = []
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -766,7 +895,7 @@ describe('agent loop', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: AgentId('config-agent'), model: 'mock', systemPrompt: 'Config prompt' }],
|
||||
agents: [{ id: AgentId('config-agent'), model: 'mock' }],
|
||||
})
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
|
||||
@@ -94,6 +94,36 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('agent/session-start fires "startup" for createAgent and "resume" for resume()', async () => {
|
||||
// Lifecycle 1: a fresh createAgent emits session-start with source 'startup'.
|
||||
const adapter1 = new MockAdapter([textResponse('a')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const sources1: string[] = []
|
||||
ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
|
||||
const a1 = ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') }).agent as ReactLoopAgent
|
||||
expect(sources1).toEqual(['startup'])
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
// Lifecycle 2: resuming the persisted session emits session-start 'resume'.
|
||||
const adapter2 = new MockAdapter([textResponse('b')])
|
||||
const ctx2 = new Context()
|
||||
await ctx2.plugin(LlmService)
|
||||
await ctx2.plugin(SessionStore)
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentLoop, { agents: [] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
||||
const sources2: string[] = []
|
||||
ctx2.on('agent/session-start', (_agent, source) => void sources2.push(source))
|
||||
await ctx2.agents.resume({ agentId: AgentId('s'), resumeSessionId: SessionId('start-sess') })
|
||||
expect(sources2).toEqual(['resume'])
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => {
|
||||
// Lifecycle 1: persist a FORKED session (carries parentSession + seedLength
|
||||
// in its header) by creating it with a complete-turn seed — the write path
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
@@ -132,7 +132,7 @@ describe('HIGH: abort during tool execution ends the turn', () => {
|
||||
}))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -144,36 +144,6 @@ describe('HIGH: abort during tool execution ends the turn', () => {
|
||||
})
|
||||
|
||||
describe('HIGH: steering from late extension points is never stranded', () => {
|
||||
it('steer() from an agent/step-end listener reaches the next request (/goal pattern)', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', { text: 'x' }),
|
||||
textResponse('after steering'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: '',
|
||||
parameters: { text: { type: 'string' } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: String(args.text) }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let steeredOnce = false
|
||||
ctx.on('agent/step-end', () => {
|
||||
if (steeredOnce) return
|
||||
steeredOnce = true
|
||||
agent.steer([{ type: 'text', text: 'goal reminder from step-end' }])
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('goal reminder from step-end')
|
||||
})
|
||||
|
||||
it('steer() from an agent/turn-continuation listener overrides a stop decision', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('no tools, would stop here'),
|
||||
@@ -199,20 +169,69 @@ describe('HIGH: steering from late extension points is never stranded', () => {
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('one more thing')
|
||||
})
|
||||
|
||||
it('steer() from an agent/turn-end listener becomes a queued message for the next turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
|
||||
it('steer() from a step/end session-event listener forces a SAME-TURN next step (/goal pattern)', async () => {
|
||||
// The /goal pattern steers from a step boundary so the model addresses a
|
||||
// standing goal before stopping. Step boundaries have no agent/* mirror, so
|
||||
// the surviving hook point is the durable step/end session event. With a
|
||||
// no-tools first step the default continuation is stop; the steering queued
|
||||
// here must force the `!shouldContinue && hasSteering` override so the SAME
|
||||
// turn runs another step.
|
||||
//
|
||||
// The override is what this test guards, so it asserts the same-turn shape —
|
||||
// NOT merely that the content reaches requests[1]. Without the override the
|
||||
// turn would stop, and leftover steering is re-enqueued as a next-turn queued
|
||||
// message, which ALSO lands in requests[1] (just one turn later). So a
|
||||
// content-only assertion passes with the override disabled and guards
|
||||
// nothing. The discriminator is the turn/step shape: override ⇒ ONE turn with
|
||||
// TWO steps and the steering recorded as a `steering/message` BEFORE step 2;
|
||||
// re-enqueue fallback ⇒ TWO turns.
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('no tools, would stop'),
|
||||
textResponse('after goal reminder'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let steeredOnce = false
|
||||
ctx.on('agent/turn-end', () => {
|
||||
if (steeredOnce) return
|
||||
ctx.on('session/event', (subject, event) => {
|
||||
if (subject !== agent.session || event.type !== 'step/end' || steeredOnce) return
|
||||
steeredOnce = true
|
||||
agent.steer([{ type: 'text', text: 'too late for this turn' }])
|
||||
agent.steer([{ type: 'text', text: 'goal reminder from step/end' }])
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Same-turn continuation: the steering forced step 2 within turn 1.
|
||||
const events = [...agent.session.events]
|
||||
expect(events.filter(e => e.type === 'turn/start')).toHaveLength(1)
|
||||
expect(events.filter(e => e.type === 'step/start')).toHaveLength(2)
|
||||
// The steered content is recorded as steering (same turn), BEFORE step 2 —
|
||||
// not as a fresh turn's user/message. This is the mechanism the override uses.
|
||||
const steeringIdx = events.findIndex(e => e.type === 'steering/message')
|
||||
const step2Idx = events.map(e => e.type).lastIndexOf('step/start')
|
||||
expect(steeringIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(steeringIdx).toBeLessThan(step2Idx)
|
||||
// and it reached the next model request.
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('goal reminder from step/end')
|
||||
})
|
||||
|
||||
it('steer() from a turn/end session-event listener becomes a queued message for the next turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const turns: number[] = []
|
||||
ctx.on('agent/turn-start', (_agent, turn) => void turns.push(turn))
|
||||
let steeredOnce = false
|
||||
ctx.on('session/event', (subject, event) => {
|
||||
if (subject !== agent.session) return
|
||||
if (event.type === 'turn/start') turns.push(event.data.turn)
|
||||
if (event.type === 'turn/end' && !steeredOnce) {
|
||||
steeredOnce = true
|
||||
agent.steer([{ type: 'text', text: 'too late for this turn' }])
|
||||
}
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -253,12 +272,12 @@ describe('HIGH: plugin exceptions are contained', () => {
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('agent/turn-continuation', async (): Promise<boolean> => {
|
||||
ctx.on('agent/turn-continuation', async (): Promise<ContinuationDecision> => {
|
||||
if (!threwOnce) {
|
||||
threwOnce = true
|
||||
throw new Error('broken continuation plugin')
|
||||
}
|
||||
return false
|
||||
return { action: 'stop' }
|
||||
})
|
||||
|
||||
const errors: Error[] = []
|
||||
@@ -314,7 +333,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
|
||||
const statuses: string[] = []
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/status', (_agent, status) => void statuses.push(status))
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
@@ -391,7 +410,7 @@ describe('MEDIUM: misc registry and config fixes', () => {
|
||||
expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }])
|
||||
})
|
||||
|
||||
it('agent/queued carries the resolved source; agent/steering carries its source', async () => {
|
||||
it('agent/queued carries the resolved source; steering/message records its source', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
@@ -406,15 +425,16 @@ describe('MEDIUM: misc registry and config fixes', () => {
|
||||
}))
|
||||
|
||||
const queuedSources: { source: MessageSource; steering: boolean }[] = []
|
||||
const steeringSources: MessageSource[] = []
|
||||
ctx.on('agent/queued', (_agent, _content, info) => void queuedSources.push(info))
|
||||
ctx.on('agent/steering', (_agent, _turn, _content, source) => void steeringSources.push(source))
|
||||
|
||||
send(agent, 'go') // no explicit source → default {kind:'user'} must be visible
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(queuedSources[0]).toEqual({ source: { kind: 'user' }, steering: false })
|
||||
expect(queuedSources[1]).toEqual({ source: { kind: 'plugin', plugin: 'goal' }, steering: true })
|
||||
// The drain appends the durable steering/message with the caller's source
|
||||
// intact — the log, not a transient emit, is where consumers read it.
|
||||
const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.source] : [])
|
||||
expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }])
|
||||
})
|
||||
})
|
||||
@@ -443,7 +463,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
|
||||
ctx2.effect(() => forked.start())
|
||||
|
||||
const turns: number[] = []
|
||||
ctx2.on('agent/turn-start', (_agent, turn) => void turns.push(turn))
|
||||
ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
|
||||
forked.send([{ type: 'text', text: 'continue' }])
|
||||
await new Promise<void>((resolve) => {
|
||||
ctx2.on('agent/status', (subject, status) => {
|
||||
@@ -487,7 +507,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
|
||||
const agent = ctx.agentLoop.create(AgentId('a-finish-error'), { model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -512,7 +532,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
|
||||
const agent = ctx.agentLoop.create(AgentId('a-finish-aborted'), { model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -530,7 +550,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
|
||||
const agent = ctx.agentLoop.create(AgentId('a-finish-error-nocode'), { model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -539,24 +559,26 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
|
||||
})
|
||||
})
|
||||
|
||||
describe('P1-6: step/start is appended before agent/step-start is emitted', () => {
|
||||
it('a step-start listener sees the step/start event already in session.events', async () => {
|
||||
describe('P1-6: a step/start session-event listener sees the event already in the log', () => {
|
||||
it('the step/start event is in session.events when its session/event listener fires', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-step-order'), { model: 'mock' })
|
||||
|
||||
// Capture, at the moment agent/step-start fires, whether the matching
|
||||
// step/start event is already in the log (append-before-emit, the event-sourcing RFC).
|
||||
// Session.append pushes the event BEFORE notifying session/event listeners,
|
||||
// so a step/start listener always finds the matching event already in the
|
||||
// log. (Step boundaries have no agent/* mirror — the session log is the live
|
||||
// feed.)
|
||||
const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = []
|
||||
ctx.on('agent/step-start', (subject, turn, step) => {
|
||||
if (subject !== agent) return
|
||||
const events = [...subject.session.events]
|
||||
ctx.on('session/event', (subject, event) => {
|
||||
if (subject !== agent.session || event.type !== 'step/start') return
|
||||
const events = [...subject.events]
|
||||
const last = events.at(-1)
|
||||
observed.push({
|
||||
turn,
|
||||
step,
|
||||
turn: event.data.turn,
|
||||
step: event.data.step,
|
||||
lastEventType: last?.type,
|
||||
sawStepStart: events.some(e => e.type === 'step/start' && e.data.turn === turn && e.data.step === step),
|
||||
sawStepStart: events.some(e => e.type === 'step/start' && e.data.turn === event.data.turn && e.data.step === event.data.step),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -599,35 +621,23 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
}
|
||||
}
|
||||
|
||||
it('a throwing agent/turn-start listener still closes the turn with exactly one error and one turn/end, no step', async () => {
|
||||
const adapter = new MockAdapter([textResponse('never reached')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-turnstart'), { model: 'mock' })
|
||||
|
||||
let threw = false
|
||||
ctx.on('agent/turn-start', () => { if (!threw) { threw = true; throw new Error('boom turn-start') } })
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const c = boundaryCounts(agent)
|
||||
// turn opened and closed; no step ran; exactly one error turn-end + emitted.
|
||||
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 0, stepEnd: 0, errors: 1 })
|
||||
expect(errors.map(e => e.message)).toEqual(['boom turn-start'])
|
||||
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toEqual({ kind: 'error', step: 0, message: 'boom turn-start' })
|
||||
// model was never called (we threw before the step's request).
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a throwing agent/step-start listener closes the open step then the turn (step/end before turn/end)', async () => {
|
||||
it('a throwing step/start session-event listener closes the open step then the turn (step/end before turn/end)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('never reached')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { model: 'mock' })
|
||||
|
||||
// Step boundaries have no agent/* mirror; a throwing step/start session-event
|
||||
// listener is the surviving step-boundary-listener failure. The loop marks
|
||||
// the step open BEFORE appending step/start (Session.append pushes before
|
||||
// notifying, so a post-push listener throw still leaves stepOpen=true), so
|
||||
// the outer catch's closeStep() appends the balancing step/end — the turn
|
||||
// stays enclosed. The invariants oracle (balancedHarness) rejects any
|
||||
// imbalance, so a green run proves turn/start → step/start → step/end →
|
||||
// turn/end nesting holds.
|
||||
let threw = false
|
||||
ctx.on('agent/step-start', () => { if (!threw) { threw = true; throw new Error('boom step-start') } })
|
||||
ctx.on('session/event', (_s, event) => {
|
||||
if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') }
|
||||
})
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
|
||||
|
||||
@@ -638,7 +648,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
const c = boundaryCounts(agent)
|
||||
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 1 })
|
||||
expect(errors.map(x => x.message)).toEqual(['boom step-start'])
|
||||
// step/end must precede turn/end (the invariants oracle would reject
|
||||
// step/end precedes turn/end (the invariants oracle would reject
|
||||
// turn/end-while-step-open, but assert the order explicitly too).
|
||||
const stepEndIdx = e.findIndex(x => x.type === 'step/end')
|
||||
const turnEndIdx = e.findIndex(x => x.type === 'turn/end')
|
||||
@@ -690,7 +700,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
@@ -707,46 +717,46 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves reason disposed when the turn-end emit throws during disposal (outer-catch disposed branch)', async () => {
|
||||
// Dispose mid-step → the step-error branch sets reason=disposed (no error
|
||||
// reported). closeTurn(true) then emits agent/turn-end, whose listener
|
||||
// throws → control reaches the outer catch with isDisposed() && !errorReported,
|
||||
// which must PRESERVE disposed rather than overwrite it with the listener's
|
||||
// throw. This is the only path that exercises that catch sub-branch.
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => {
|
||||
// Reach the OUTER catch while disposed: an `agent/pre-step` listener requests
|
||||
// disposal AND throws. The throw escapes the pre-step `await` (line ~419) to
|
||||
// the loop's outer catch — BEFORE the post-pre-step disposal check at ~422
|
||||
// gets to run — so the catch sees `isDisposed() && !errorReported` and must
|
||||
// PRESERVE reason=disposed rather than overwrite it with the listener's throw
|
||||
// (disposal is not a failure). This is the surviving path to that sub-branch
|
||||
// now that there is no turn-boundary emit to throw from.
|
||||
const adapter = new MockAdapter([textResponse('never reached')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(AgentId('a-dispose-emit-throw'), { model: 'mock' })
|
||||
agent = inner.agentLoop.create(AgentId('a-prestep-dispose-throw'), { model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
// The FIRST agent/turn-end emit throws (the disposal-driven turn end).
|
||||
let threw = false
|
||||
ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end during disposal') } })
|
||||
// Collect agent/error emissions to prove none is surfaced through that
|
||||
// channel either (the listener throw must be fully contained).
|
||||
ctx.on('agent/pre-step', () => {
|
||||
if (threw) return
|
||||
threw = true
|
||||
// Request disposal, then throw in the same synchronous tick: status flips
|
||||
// to 'disposed' (the disposer aborts the step controller) and the throw
|
||||
// drives control into the outer catch with isDisposed() already true.
|
||||
void fiber.dispose()
|
||||
throw new Error('boom pre-step during disposal')
|
||||
})
|
||||
const errorEmits: Error[] = []
|
||||
ctx.on('agent/error', (_a, _t, _s, error) => void errorEmits.push(error))
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await fiber.dispose() // dispose during the hanging step
|
||||
await agent.done
|
||||
|
||||
// The throwing turn-end listener actually fired — proving the outer-catch
|
||||
// path was exercised, not skipped.
|
||||
expect(threw).toBe(true)
|
||||
|
||||
const e = [...agent.session.events]
|
||||
// Exactly one turn/start and one turn/end (balanced); the turn/end carries
|
||||
// the disposed reason, NOT an error reason from the throwing listener.
|
||||
// Balanced: one turn/start, one turn/end carrying disposed (NOT error).
|
||||
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
|
||||
const turnEnd = e.findLast(x => x.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
|
||||
// The throwing turn-end listener is contained: the turn/end carries the
|
||||
// disposed reason (not an error) and no agent/error is emitted (disposal is
|
||||
// not a failure; the throw is swallowed).
|
||||
expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false)
|
||||
// No step opened (the throw was before step/start) and disposal is not a
|
||||
// failure, so no agent/error for the contained throw.
|
||||
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
||||
expect(errorEmits).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -792,54 +802,20 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a throwing turn-end listener on a SUCCESSFUL turn leaves no event after turn/end (loadable log)', async () => {
|
||||
// Regression: a normal turn completes, closeTurn(true) appends turn/end and
|
||||
// emits agent/turn-end whose listener throws. The error must NOT be appended
|
||||
// as a session event after turn/end — that would sit past the commit
|
||||
// boundary and be dropped as a crash tail on resume (the turn-enclosure RFC). It is
|
||||
// surfaced via agent/error instead, and the log's last event is turn/end.
|
||||
const adapter = new MockAdapter([textResponse('done'), textResponse('next ok')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-tend'), { model: 'mock' })
|
||||
|
||||
let threw = false
|
||||
ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end') } })
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const c = boundaryCounts(agent)
|
||||
expect(c.turnEnd).toBe(1)
|
||||
expect(c.errors).toBe(0) // NO session error event (it would be post-turn/end)
|
||||
expect(agent.session.events.at(-1)?.type).toBe('turn/end') // last event is the boundary
|
||||
expect(errors.map(e => e.message)).toEqual(['boom turn-end']) // surfaced via agent/error
|
||||
// The late throw is also logged directly: failTurn's turn-already-ended
|
||||
// branch warns so a throwing turn-end listener after turn/end never vanishes.
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/turn-end listener threw after turn 1 closed'))
|
||||
// The whole log is loadable (nothing dropped): a fresh replay sees the turn.
|
||||
const replay = new Session(SessionId('replay'), [...agent.session.events])
|
||||
expect(replay.deriveMessages().map(m => m.role)).toEqual(['user', 'assistant'])
|
||||
|
||||
// loop survives.
|
||||
send(agent, 'again')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(boundaryCounts(agent).turnEnd).toBe(2)
|
||||
})
|
||||
|
||||
it('a throwing agent/step-end listener during a successful step ends the turn as error, not completed', async () => {
|
||||
// closeStep() must surface a throwing step-end listener via failTurn so the
|
||||
it('a throwing step/end session-event listener during a successful step ends the turn as error, not completed', async () => {
|
||||
// closeStep() must surface a throwing step/end listener via failTurn so the
|
||||
// turn ends with reason error, not a silent "completed" with the throw
|
||||
// swallowed. Regression test for the closeStep() catch that previously
|
||||
// swallowed the throw in the normal (no-tool, no-steering) path.
|
||||
// swallowed the throw in the normal (no-tool, no-steering) path. (Step
|
||||
// boundaries have no agent/* mirror; the session-event listener is the path.)
|
||||
const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { model: 'mock' })
|
||||
|
||||
let threw = false
|
||||
ctx.on('agent/step-end', () => { if (!threw) { threw = true; throw new Error('boom step-end') } })
|
||||
ctx.on('session/event', (_s, event) => {
|
||||
if (event.type === 'step/end' && !threw) { threw = true; throw new Error('boom step-end') }
|
||||
})
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
|
||||
|
||||
@@ -869,52 +845,19 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
expect(c2.stepStart).toBe(c2.stepEnd)
|
||||
})
|
||||
|
||||
it('a step error followed by a throwing turn-end listener logs the error exactly once (no double-report)', async () => {
|
||||
// The step fails (finish-error) → failTurn records ONE error and sets the
|
||||
// error reason. closeTurn(true) then appends turn/end and emits
|
||||
// agent/turn-end, whose listener throws → the outer catch calls failTurn
|
||||
// again, but its errorReported guard makes it a no-op. Trap #1: exactly one
|
||||
// error, the turn stays balanced.
|
||||
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider down' } }]
|
||||
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-double'), { model: 'mock' })
|
||||
|
||||
let threw = false
|
||||
ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end') } })
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const c = boundaryCounts(agent)
|
||||
// exactly one error turn-end + one agent/error emit, despite two failTurn calls.
|
||||
expect(c.errors).toBe(1)
|
||||
expect(errors.map(e => e.message)).toEqual(['provider down'])
|
||||
expect(c.turnStart).toBe(1)
|
||||
expect(c.turnEnd).toBe(1) // single turn/end, balanced
|
||||
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({ kind: 'error', step: 1, message: 'provider down' })
|
||||
|
||||
// loop survives the compound failure.
|
||||
send(agent, 'again')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(boundaryCounts(agent).turnEnd).toBe(2)
|
||||
})
|
||||
|
||||
it('a throwing session/event listener on step/end during finalization still appends turn/end', async () => {
|
||||
// A throwing agent/step-start listener drives the outer catch, which calls
|
||||
// closeStep() during finalization. closeStep appends step/end; a
|
||||
// A finish-error stream opens a step then fails it, driving finalization
|
||||
// through closeStep() with the step open. closeStep appends step/end; a
|
||||
// session/event listener throwing on THAT must not abort the catch before
|
||||
// closeTurn(false) — step/end is already logged (balance holds) and the
|
||||
// throw is contained + surfaced via failTurn, so turn/end is still appended.
|
||||
const adapter = new MockAdapter([textResponse('never reached')])
|
||||
// closeTurn — step/end is already logged (balance holds) and the throw is
|
||||
// contained + surfaced via failTurn, so turn/end is still appended. (The
|
||||
// failed step itself also routes through failTurn; the step/end-listener
|
||||
// throw is the second, contained, failure.)
|
||||
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
|
||||
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-stependthrow'), { model: 'mock' })
|
||||
|
||||
// Open a step, then make the agent/step-start emit throw (boundary throw →
|
||||
// outer catch → closeStep during finalization).
|
||||
ctx.on('agent/step-start', () => { throw new Error('boom step-start') })
|
||||
let threw = false
|
||||
ctx.on('session/event', (_s, event) => {
|
||||
if (!threw && event.type === 'step/end') { threw = true; throw new Error('boom step/end listener') }
|
||||
@@ -941,11 +884,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
it('a throwing session/event listener on turn/end is contained (turn still balanced, loop survives)', async () => {
|
||||
// closeTurn appends turn/end; Session.append pushes it BEFORE notifying
|
||||
// session/event listeners, so a throwing listener leaves turn/end in the log
|
||||
// (the turn is balanced) but must not escape — from the normal-path
|
||||
// closeTurn(true) it would otherwise propagate; the append is contained so
|
||||
// the turn/end emit + loop continue. (A throwing agent/turn-end LISTENER is
|
||||
// a separate, already-tested path; here the session/event append notify is
|
||||
// what throws.)
|
||||
// (the turn is balanced) but must not escape — from the normal-path closeTurn
|
||||
// it would otherwise propagate; the append is contained so the loop continues.
|
||||
// Turn boundaries are durable session events only (no agent/* mirror), so this
|
||||
// session/event append-notify throw is the sole turn-end-listener failure path.
|
||||
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { model: 'mock' })
|
||||
@@ -972,7 +914,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
})
|
||||
|
||||
describe('P1-7: tool/result is logged under the originating call.id, not result.callId', () => {
|
||||
it('a tools/execute listener returning a mismatched callId cannot orphan the call↔result pairing', async () => {
|
||||
it('the loop records tool/result under the model call.id even when a post-execute listener replaces content', async () => {
|
||||
// Model emits a tool-call with id "c1", then a final text turn.
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', { x: 1 }),
|
||||
@@ -986,12 +928,13 @@ describe('P1-7: tool/result is logged under the originating call.id, not result.
|
||||
async execute() { return [{ type: 'text', text: 'ok' }] },
|
||||
}))
|
||||
|
||||
// A waterfall listener short-circuits with a result carrying the WRONG
|
||||
// callId (a listener-internal/proxy id). The loop must still record the
|
||||
// tool/result under the model's authoritative call.id.
|
||||
ctx.on('tools/execute', (exec) => {
|
||||
// A post-execute listener transforms the result (accept-with-replacement).
|
||||
// The loop must still record the tool/result under the model's authoritative
|
||||
// call.id (the loop ignores result.callId — which the registry always sets to
|
||||
// exec.callId anyway — and uses call.id, the model-transcript id).
|
||||
ctx.on('tools/post-execute', (exec, _result) => {
|
||||
expect(exec.callId).toBe(CallId('c1')) // the loop passed the real id in
|
||||
return Promise.resolve({ callId: CallId('wrong-proxy-id'), content: [{ type: 'text', text: 'ok' }], isError: false })
|
||||
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] })
|
||||
}, { prepend: true })
|
||||
|
||||
const agent = ctx.agentLoop.create(AgentId('a-callid'), { model: 'mock' })
|
||||
@@ -1073,7 +1016,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
// Blocking listener on the parent context (survives fiber disposal).
|
||||
const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, next) {
|
||||
const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
|
||||
await blocked
|
||||
return next()
|
||||
})
|
||||
@@ -1084,7 +1027,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
// Give the loop time to enter the step and reach assemble().
|
||||
@@ -1110,10 +1053,8 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
|
||||
// No step was opened, no LLM call was made.
|
||||
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
|
||||
// agent/turn-end may not fire when disposal happens during assembly: the
|
||||
// fiber's disposer (stop→status=disposed) runs before closeTurn(true)'s
|
||||
// emit, and the LIFO chain disposes effects in reverse registration order.
|
||||
// The turn/end durable record is the one that matters.
|
||||
// The durable turn/end record is the authoritative turn-boundary signal
|
||||
// (turn boundaries have no agent/* mirror), so this asserts on the log.
|
||||
})
|
||||
|
||||
it('cancel during system-prompt assembly drops the about-to-start step as aborted', { timeout: 30000 }, async () => {
|
||||
@@ -1131,7 +1072,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
|
||||
await ctx.plugin(Invariants, { freeze: false })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, next) {
|
||||
const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
|
||||
await blocker
|
||||
return next()
|
||||
})
|
||||
@@ -1142,7 +1083,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
@@ -1197,7 +1138,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
@@ -1218,9 +1159,8 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
|
||||
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
|
||||
// agent/turn-end may not fire when disposal happens during pre-step: the
|
||||
// fiber's disposer runs before closeTurn(true)'s emit. The durable turn/end
|
||||
// is the authoritative record.
|
||||
// The durable turn/end record is the authoritative turn-boundary signal
|
||||
// (turn boundaries have no agent/* mirror).
|
||||
})
|
||||
|
||||
it('cancel during agent/pre-step seam ends the turn aborted', { timeout: 15000 }, async () => {
|
||||
@@ -1250,7 +1190,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
@@ -1289,7 +1229,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
|
||||
await ctx.plugin(Invariants, { freeze: false })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
ctx.on('system-prompt/assemble', async function (_assembly, next) {
|
||||
ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
|
||||
await blocker
|
||||
return next()
|
||||
})
|
||||
@@ -1315,7 +1255,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
|
||||
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
|
||||
expect(e.some(x => x.type === 'assistant/message')).toBe(false)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
// The durable turn/end reason is the authoritative record; agent/turn-end
|
||||
// may not fire when disposal interleaves with closeTurn(true)'s emit.
|
||||
// The durable turn/end reason is the authoritative turn-boundary record
|
||||
// (turn boundaries have no agent/* mirror).
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,25 +31,31 @@ The full `agent/*` event taxonomy is declared via declaration merging in `dsh-ag
|
||||
- `agent/created`, `agent/disposed` — registration/deregistration
|
||||
- `agent/status` — idle / running / disposed transition
|
||||
- `agent/queued` — message entered inbox (source-resolved, steering flag)
|
||||
- `agent/session-start` — the session lifecycle began (once, before turn 1), carrying a `SessionStartSource` (`startup` for a fresh or forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it cannot block startup; a listener seeds context via `agent.inject()` (a `context/message` the first request sees).
|
||||
|
||||
#### Turn/step boundaries (emit)
|
||||
#### Boundaries are durable session events, not `agent/*` emits
|
||||
|
||||
- `agent/turn-start`, `agent/turn-end` (carries `TurnEndReason`)
|
||||
- `agent/step-start`, `agent/step-end`
|
||||
Turn and step boundaries are NOT mirrored as `agent/*` emits: a consumer that needs them reads the durable `turn/start`/`turn/end`/`step/start`/`step/end` events off the `session/event` feed (the session log is the live boundary feed, carrying the `Session` — the turn/step numbers and reasons ride on the event data). See [the event-domain-semantics RFC](../../../docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md) and [the remove-boundary-mirror-events RFC](../../../docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md).
|
||||
|
||||
#### Interception seams
|
||||
|
||||
`agent/pre-step` is a **serial** surface-mutation checkpoint; the rest are **waterfalls** that return a small, seam-specific typed **Decision** union (the unified idiom across the taxonomy — a CC/Codex bridge maps its `permissionDecision`/`decision`/`continue` fields onto these, a native plugin returns them directly):
|
||||
|
||||
- `agent/session-start` (emit) — fired once before the first turn; a listener seeds context via `agent.inject()` (it cannot veto startup).
|
||||
- `agent/prompt-submit` — decide what happens to one drained queued message before it becomes a `user/message`: `PromptDecision` = `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (drop it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`). Maps onto Claude Code's `UserPromptSubmit`.
|
||||
- `agent/pre-step` (serial) — mutate the session surface before the step opens and history is derived (compaction). Fires after `turn/start` and before `step/start`, so a listener's appended events land outside the step.
|
||||
- `agent/request` (waterfall) — mutate `GenerateOptions` before the model call (hooks, model switching, tool filtering)
|
||||
- `agent/step-result` (waterfall) — post-process the assembled assistant message before tool dispatch (validates what the log records)
|
||||
- `agent/turn-continuation` (waterfall) — override the continue/stop decision (force-continue /loop, force-stop budget guard)
|
||||
- `agent/request` — mutate `GenerateOptions` before the model call (hooks, model switching, tool filtering)
|
||||
- `agent/step-result` — post-process the assembled assistant message before tool dispatch (validates what the log records)
|
||||
- `agent/turn-continuation` — override the continue/stop decision via `ContinuationDecision` = `{action:'stop'}` or `{action:'continue', reason?}` (a `continue` `reason` is recorded as next-step steering in the same turn — the typed `/goal` pattern). Force-continue `/loop`, force-stop budget guard.
|
||||
|
||||
#### Streaming + tool (emit)
|
||||
Tool interception is the `tools/pre-execute` / `tools/post-execute` pair in [`dsh-tools`](../tools/README.md) (`PreToolDecision` allow/deny/ask, `PostToolDecision` accept/block) — same typed-Decision idiom, owned there because it is the tool registry's seam.
|
||||
|
||||
#### Error notifications (emit)
|
||||
|
||||
- `agent/stream-chunk` — raw chunk from the model (token-level UI/log feed)
|
||||
- `agent/steering` — steering content injected mid-turn
|
||||
- `agent/error` — step/turn error
|
||||
|
||||
The model's token stream is NOT an `agent/*` event: read it off the durable `session/event` feed as `assistant/chunk` (the same feed persistence and the ACP bridge use).
|
||||
|
||||
### Agent interface (`types.ts`)
|
||||
|
||||
The handle every plugin programs against:
|
||||
|
||||
@@ -25,12 +25,14 @@
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export interface CreateAgentOptions {
|
||||
* for a fresh (spawn) child.
|
||||
*/
|
||||
seed?: SessionEvent[]
|
||||
/** Per-agent options (model, system prompt). */
|
||||
/** Per-agent options (model, …). */
|
||||
agentOptions?: AgentOptions
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export interface ResumeAgentOptions {
|
||||
agentId: AgentId
|
||||
/** The persisted session id to load and resume on. */
|
||||
resumeSessionId: SessionId
|
||||
/** Per-agent options (model, system prompt). */
|
||||
/** Per-agent options (model, …). */
|
||||
agentOptions?: AgentOptions
|
||||
}
|
||||
|
||||
@@ -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()]
|
||||
}
|
||||
|
||||
@@ -6,11 +6,46 @@
|
||||
* Merge-extensible: `AgentOptions` supports declaration merging for
|
||||
* plugin-specific creation options.
|
||||
*
|
||||
* ## Event-domain semantics (the boundary rule)
|
||||
*
|
||||
* The harness has three event domains, each with one job:
|
||||
*
|
||||
* - **`session/*`** (`@deepseek-ai/dsh-session`) — the DURABLE, replayable FACT
|
||||
* log. Owns `SessionEventMap`; every entry is JSON-only (no live objects).
|
||||
* One `session/event` emit per append, plus the `session/flush` parallel
|
||||
* durability checkpoint. Answers "what happened, durably/replayably." A
|
||||
* consumer that wants the live transcript subscribes here.
|
||||
* - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the
|
||||
* live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/
|
||||
* `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls and
|
||||
* the serial `agent/pre-step`) that mutate/veto, and TRANSIENT emits
|
||||
* (`agent/status`, `agent/error`, `agent/created`/
|
||||
* `agent/disposed`, `agent/queued`, `agent/session-start`)
|
||||
* that notify with the `Agent` in hand. Turn/step boundaries are NOT here —
|
||||
* they are durable `session/event` records. Answers "right now, with the agent
|
||||
* object — intercept or observe."
|
||||
* - **`tools/*`** (`@deepseek-ai/dsh-tools`) — the tool registry + execution.
|
||||
*
|
||||
* **The rule:** a durable, replayable fact is a SessionEvent; a live
|
||||
* interception or a transient/live-object signal is an `agent`/`tools` Cordis
|
||||
* event. A turn/step boundary is a durable fact: it lives in the session log
|
||||
* and is read off the `session/event` feed — it is NOT mirrored as an `agent/*`
|
||||
* emit. A consumer that needs the `Agent` handle (or its short id) at a boundary
|
||||
* keeps a session-id→agent map from `agent/created`/`agent/disposed`.
|
||||
* See `docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md`
|
||||
* and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`.
|
||||
*
|
||||
* The interception waterfalls here (`agent/prompt-submit`, `agent/request`,
|
||||
* `agent/step-result`, `agent/turn-continuation`) each return a typed Decision —
|
||||
* the convention pinned by
|
||||
* `docs/rfc/implemented/feature/2026-06-30-interception-seams.md`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-agent/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { ContentBlock, GenerateOptions, Message, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
/** Identifies one live agent in the registry. */
|
||||
export type AgentId = Branded<'AgentId'>
|
||||
@@ -19,17 +54,30 @@ export type AgentId = Branded<'AgentId'>
|
||||
export function AgentId(id: string): AgentId {
|
||||
return id as AgentId
|
||||
}
|
||||
import type { Session, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
|
||||
declare module '@deepseek-ai/dsh-system-prompt' {
|
||||
interface AssembleContext {
|
||||
/**
|
||||
* The agent this assembly is for. The agent loop passes it on every
|
||||
* per-step `assemble({ agent })`; variable providers project per-agent
|
||||
* facts from it (`options.model` → `{{model}}`, `session.header.cwd` →
|
||||
* `{{cwd}}`). Optional because a bare `assemble()` (tests, diagnostics)
|
||||
* has no agent — providers must tolerate its absence.
|
||||
*/
|
||||
agent?: Agent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options an agent is created with.
|
||||
* Options an agent is created with. The persona is NOT here — it is the
|
||||
* deployment's `persona` config on the dsh-system-prompt plugin, shared by
|
||||
* every agent in the context.
|
||||
* Merge-extensible: plugins declare extra fields via declaration merging.
|
||||
*/
|
||||
export interface AgentOptions {
|
||||
/** Model name (must have a registered adapter at call time). */
|
||||
model?: string
|
||||
/** Per-agent system prompt appended after the assembled sections. */
|
||||
systemPrompt?: string
|
||||
}
|
||||
|
||||
export interface SendOptions {
|
||||
@@ -38,6 +86,68 @@ export interface SendOptions {
|
||||
|
||||
export type AgentStatus = 'idle' | 'running' | 'disposed'
|
||||
|
||||
/**
|
||||
* Model-facing context an interception listener wants the agent to SEE on the
|
||||
* next request — the canonical shape behind every "inject extra context"
|
||||
* decision ({@link PromptDecision}, {@link PostToolDecision},
|
||||
* {@link ContinuationDecision}). It is `agent.inject()`ed as a
|
||||
* `context/message`, so it carries a REQUIRED {@link MessageSource}: `inject()`
|
||||
* defaults a missing source to `{kind:'user'}`, which would MISLABEL plugin
|
||||
* context as a user prompt and corrupt derived history. A bridge sets
|
||||
* `{kind:'plugin', plugin:'…'}`; a native plugin names itself. Required, not
|
||||
* optional — the label is load-bearing, never defaulted here.
|
||||
*/
|
||||
export interface HookContext {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
}
|
||||
|
||||
/**
|
||||
* The decision an {@link Agent} `agent/prompt-submit` waterfall listener returns
|
||||
* for ONE drained queued message, before it becomes a `user/message`. Maps onto
|
||||
* the Claude Code `UserPromptSubmit` hook's allow/block + `additionalContext`.
|
||||
*
|
||||
* - `allow` proceeds with the prompt; optional `content` REPLACES the prompt
|
||||
* bytes (a rewrite), and optional `additionalContext` is `inject()`ed as a
|
||||
* separate `context/message` the next request also sees.
|
||||
* - `block` drops the prompt (it never becomes a `user/message`); `reason` is
|
||||
* the durable record of why. The loop appends a `prompt/blocked` session event
|
||||
* (carrying the original content, source, and `reason`) in place of the
|
||||
* dropped `user/message`, so the veto survives replay even in a MIXED batch
|
||||
* where a sibling prompt is allowed. A batch whose EVERY prompt is blocked
|
||||
* additionally opens a zero-step turn that ends with {@link TurnEndReason}
|
||||
* `rejected` (so the boundary stays balanced and a UI can render "blocked by
|
||||
* hook").
|
||||
*/
|
||||
export type PromptDecision =
|
||||
| { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext }
|
||||
| { kind: 'block'; reason: string }
|
||||
|
||||
/**
|
||||
* The decision an {@link Agent} `agent/turn-continuation` waterfall listener
|
||||
* returns. The loop computes the default (`continue` when the step had tool
|
||||
* calls or steering was injected, else `stop`); listeners override it to
|
||||
* force-continue (`/goal`, `/loop`) or force-stop (budget guards).
|
||||
*
|
||||
* A `continue` may carry a `reason`: model-facing context recorded as next-STEP
|
||||
* steering within the SAME turn (the loop enqueues it through the steering
|
||||
* channel, so the continued turn's next step sees it). This is the typed twin of
|
||||
* the existing "steer from a step/end listener" `/goal` pattern.
|
||||
*/
|
||||
export type ContinuationDecision =
|
||||
| { action: 'stop' }
|
||||
| { action: 'continue'; reason?: HookContext }
|
||||
|
||||
/**
|
||||
* Why an agent's session lifecycle began, carried by `agent/session-start`. A
|
||||
* bridge keys its SessionStart hook's matcher on this (Claude Code's
|
||||
* `startup`/`resume`/`clear`/`compact` source set). `startup` = a fresh create
|
||||
* (including a seeded/forked create — a seed is NOT a resume); `resume` = a
|
||||
* persisted session reloaded via `ctx.agents.resume()`. `clear`/`compact` are
|
||||
* driven by those subsystems (compact = `TODO(compaction)`).
|
||||
*/
|
||||
export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
|
||||
|
||||
/**
|
||||
* The agent handle — the surface every plugin (UI, hooks, orchestrators)
|
||||
* programs against. The concrete implementation lives in
|
||||
@@ -132,12 +242,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
|
||||
@@ -145,39 +257,41 @@ 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
|
||||
|
||||
// ---- turn/step boundaries (emit) ----
|
||||
// ---- session lifecycle (emit) ----
|
||||
/**
|
||||
* A turn began. `turn` is the 1-based turn number within the session.
|
||||
* The agent's session lifecycle began, fired once before its first turn.
|
||||
* `source` says why ({@link SessionStartSource}: fresh startup, a resumed
|
||||
* persisted session, …). A pure NOTIFICATION (emit, not waterfall): it
|
||||
* carries no veto — a session-start listener that wants to seed context does
|
||||
* 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/turn-start'(agent: Agent, turn: number): void
|
||||
/**
|
||||
* A turn ended. `reason` distinguishes a clean stop from a truncated or
|
||||
* aborted one (`completed` | `aborted` | `error` | `disposed` | `max-tokens`).
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/turn-end'(agent: Agent, turn: number, reason: TurnEndReason): void
|
||||
/**
|
||||
* A step (one model call plus its tool dispatch) began. `step` is 1-based
|
||||
* within the turn; a turn runs one or more steps.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/step-start'(agent: Agent, turn: number, step: number): void
|
||||
/**
|
||||
* A step ended.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/step-end'(agent: Agent, turn: number, step: number): void
|
||||
'agent/session-start'(agent: Agent, source: SessionStartSource): void
|
||||
|
||||
// Turn and step boundaries are NOT mirrored as agent/* emits: a consumer
|
||||
// that needs them reads the durable `turn/start`/`turn/end`/`step/start`/
|
||||
// `step/end` session events off the `session/event` feed (the session log is
|
||||
// the live transcript feed). See the module doc's three-domain rule and the
|
||||
// "remove agent boundary mirror events" RFC.
|
||||
|
||||
// ---- step/request extension seams (serial + waterfall) ----
|
||||
/**
|
||||
@@ -204,6 +318,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
|
||||
@@ -212,43 +331,64 @@ declare module 'cordis' {
|
||||
// prompt provider, or move token-pressure measurement behind a
|
||||
// compaction-specific seam instead of the shared pre-step checkpoint.
|
||||
'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise<void> | void
|
||||
/**
|
||||
* Waterfall: decide what happens to ONE drained queued message before it
|
||||
* becomes a `user/message` — allow (optionally rewriting the prompt bytes or
|
||||
* attaching `additionalContext`) or block it. Fires inside the already-open
|
||||
* 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>
|
||||
/**
|
||||
* Waterfall: mutate the fully-assembled {@link GenerateOptions} before the
|
||||
* model call (hooks, model switching, tool filtering, …). Call `next()` to
|
||||
* 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>
|
||||
/**
|
||||
* Waterfall: override the turn-continuation decision. The default
|
||||
* (computed by the loop) is `hadToolCalls || steeringInjected`. Listeners
|
||||
* can force-continue (/goal, /loop) or force-stop (budget guards).
|
||||
* Waterfall: override the turn-continuation decision via a typed
|
||||
* {@link ContinuationDecision}. The loop's `defaultDecision` is `continue`
|
||||
* when the step had tool calls or steering was injected, else `stop`.
|
||||
* 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: boolean, next: () => Promise<boolean>): Promise<boolean>
|
||||
'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
|
||||
|
||||
// ---- streaming + tool notifications (emit) ----
|
||||
/**
|
||||
* A raw {@link StreamChunk} arrived from the model (token-level UI/log feed).
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/stream-chunk'(agent: Agent, turn: number, step: number, chunk: StreamChunk): void
|
||||
/**
|
||||
* Steering content was injected into a running turn.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/steering'(agent: Agent, turn: number, content: ContentBlock[], source: MessageSource): void
|
||||
// ---- error notifications (emit) ----
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -49,9 +49,9 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
|
||||
### Session event vocabulary (`types.ts`)
|
||||
|
||||
The append-only log: `turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/message`, `assistant/chunk`, `tool/call`, `tool/result`, `steering/message`, `context/message`, `todo/write`. Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
|
||||
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog/log-events.md). Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
|
||||
|
||||
Merge-extensible via `SessionEventMap` — the compaction seam adds `compact/start`, `compact/summary`, and `compact/end`.
|
||||
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, the hook bridges' `hook/*`); merged members appear in the same catalog.
|
||||
|
||||
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings).
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { SurfaceManager, isSurfaceEligibleType } from './surface.ts'
|
||||
|
||||
export * from './types.ts'
|
||||
export { isJsonValue } from './json.ts'
|
||||
export type { JsonValue } from './json.ts'
|
||||
export { interruptedTurnClosers } from './repair.ts'
|
||||
export type { SurfaceNode } from './surface.ts'
|
||||
export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
@@ -29,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
|
||||
@@ -44,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
|
||||
@@ -341,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).
|
||||
*/
|
||||
@@ -366,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.
|
||||
*/
|
||||
@@ -403,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 {
|
||||
@@ -417,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()]
|
||||
}
|
||||
|
||||
@@ -13,6 +13,16 @@
|
||||
* @module @deepseek-ai/dsh-session/json
|
||||
*/
|
||||
|
||||
/**
|
||||
* A value that round-trips losslessly through JSON: `null`, a boolean, a finite
|
||||
* number, a string, an array of such values, or a plain object whose values are
|
||||
* such values. The static type companion to {@link isJsonValue} (which validates
|
||||
* the same shape at runtime). Use it to type a payload that must survive
|
||||
* session-log persistence and replay byte-identically — e.g. a tool's private
|
||||
* presentation `meta`.
|
||||
*/
|
||||
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
||||
|
||||
/**
|
||||
* Whether `value` is losslessly JSON-serializable: only `null`, finite numbers,
|
||||
* booleans, strings, plain arrays, and plain objects of such values. Rejects
|
||||
|
||||
@@ -91,7 +91,6 @@ export interface CreateSessionOptions {
|
||||
*/
|
||||
export interface TurnTriggerMap {
|
||||
message: { kind: 'message'; source: MessageSource }
|
||||
continuation: { kind: 'continuation' }
|
||||
/**
|
||||
* An out-of-band context injection (`agent.inject()`) made while the agent
|
||||
* was idle. The loop wraps the injected `context/message` in a one-shot turn
|
||||
@@ -134,6 +133,16 @@ export interface TurnEndReasonMap {
|
||||
error: { kind: 'error'; step: number; message: string; code?: string }
|
||||
disposed: { kind: 'disposed' }
|
||||
'max-tokens': { kind: 'max-tokens' }
|
||||
/**
|
||||
* The turn's entire prompt batch was BLOCKED before any step ran — every
|
||||
* drained queued message was vetoed by an `agent/prompt-submit` listener (a
|
||||
* hook). The turn still opened (so the boundary stays balanced and the block
|
||||
* is a durable in-turn fact), but ran zero steps. `reason` carries the block
|
||||
* message from the vetoing decision. Distinct from `aborted` (a user-driven
|
||||
* cancel) and `error` (a failure): the prompt was rejected by policy, not
|
||||
* interrupted or broken. A UI renders it as "prompt blocked by hook".
|
||||
*/
|
||||
rejected: { kind: 'rejected'; reason: string }
|
||||
/**
|
||||
* The turn never ended on its own: the process crashed mid-turn and a
|
||||
* persistence backend later closed the orphaned (open) turn on reload so the
|
||||
@@ -188,12 +197,36 @@ export interface TodoItem {
|
||||
* the invariants plugin checks, is a breaking change to the on-disk format.
|
||||
*/
|
||||
export interface SessionEventMap {
|
||||
/**
|
||||
* Opens turn `turn`. `trigger` records what started it — a drained message
|
||||
* batch or an idle-time injection. The turn is the durability/replay
|
||||
* boundary: every event sits between a `turn/start` and its matching
|
||||
* `turn/end` (the turn-enclosure invariant).
|
||||
*/
|
||||
'turn/start': { turn: number; trigger: TurnTrigger }
|
||||
/**
|
||||
* Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop
|
||||
* fires the awaited `session/flush` checkpoint at every turn end, so the turn
|
||||
* boundary is also the durable-commit boundary.
|
||||
*/
|
||||
'turn/end': { turn: number; reason: TurnEndReason }
|
||||
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
|
||||
'step/start': { turn: number; step: number }
|
||||
/** Closes step `step` of turn `turn`. */
|
||||
'step/end': { turn: number; step: number }
|
||||
/** A user-visible prompt (queued message drained at turn start). */
|
||||
'user/message': { content: ContentBlock[]; source: MessageSource }
|
||||
/**
|
||||
* A queued prompt an `agent/prompt-submit` listener VETOED — the durable
|
||||
* record of a blocked prompt and why. Appended in place of the `user/message`
|
||||
* the prompt would have become, so the block survives replay even in a MIXED
|
||||
* batch where another queued prompt is allowed (there the turn does not end
|
||||
* `rejected`, so the boundary reason alone would not preserve it). `content`
|
||||
* is the original prompt the listener rejected; `reason` is the veto text
|
||||
* ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a
|
||||
* blocked prompt produces no LLM message and never reaches `deriveMessages()`.
|
||||
*/
|
||||
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
|
||||
/**
|
||||
* In-session context injection (file-change notices, subdir AGENTS.md,
|
||||
* skill content, cron notifications, …). Rendered into the derived history
|
||||
@@ -209,8 +242,22 @@ export interface SessionEventMap {
|
||||
* usage record). `usage` is absent when the adapter reported none.
|
||||
*/
|
||||
'assistant/message': { turn: number; step: number; content: ContentBlock[]; usage?: TokenUsage }
|
||||
/**
|
||||
* The model requested one tool invocation: `name` with the raw `arguments`
|
||||
* JSON string exactly as the model produced it (unparsed). `callId` pairs the
|
||||
* call with its `tool/result`.
|
||||
*/
|
||||
'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
|
||||
'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string } }
|
||||
/**
|
||||
* A completed tool call's model-facing result, plus an optional tool-private
|
||||
* `meta` presentation payload. `meta` is opaque to the core (`unknown` — the
|
||||
* producing tool owns its shape and reads it back in `presentResult`) but MUST
|
||||
* be JSON-serializable: `Session.append` runtime-validates all event data with
|
||||
* `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the
|
||||
* durable log reproduces the identical card on replay. Absent unless the tool
|
||||
* attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here).
|
||||
*/
|
||||
'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown }
|
||||
/** Steering content injected between steps of a running turn. */
|
||||
'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource }
|
||||
/**
|
||||
|
||||
225
packages/core/session/tests/gen-persistence-catalog.spec.ts
Normal file
225
packages/core/session/tests/gen-persistence-catalog.spec.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Negative-path tests for the persistence log catalog generator
|
||||
* (`scripts/gen-persistence-catalog.ts`).
|
||||
*
|
||||
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
|
||||
* the freshness half is exercised by `pnpm run verify-persistence-catalog` in
|
||||
* CI. What a freshness diff CANNOT prove is that the generator REJECTS
|
||||
* malformed source the way it promises to — a member without description
|
||||
* prose, a forbidden `@mode` tag, a non-literal member name, a duplicate event
|
||||
* declaration, a missing or ambiguous `SurfaceEventType` union, a stale union
|
||||
* member. These tests drive the exported collectors against synthetic fixture
|
||||
* packages to prove each guard fires (and that well-formed declarations pass),
|
||||
* mirroring the gen-cordis-catalog negative tests.
|
||||
*/
|
||||
|
||||
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 {
|
||||
annotateSurface,
|
||||
collectLogEvents,
|
||||
collectSurfaceEventTypes,
|
||||
render,
|
||||
} from '../../../../scripts/gen-persistence-catalog.ts'
|
||||
|
||||
/** Create a fixture scan root; `files` maps `packages/…`-relative paths to source. */
|
||||
function fixtureRoot(files: Record<string, string>): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'persistence-catalog-'))
|
||||
for (const [rel, source] of Object.entries(files)) {
|
||||
const abs = join(root, rel)
|
||||
mkdirSync(join(abs, '..'), { recursive: true })
|
||||
writeFileSync(abs, source)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
const roots: string[] = []
|
||||
const make = (files: Record<string, string>): string => {
|
||||
const r = fixtureRoot(files)
|
||||
roots.push(r)
|
||||
return r
|
||||
}
|
||||
|
||||
/** A merge-form declaration file wrapping `members` in the session module. */
|
||||
const merge = (members: string): string =>
|
||||
`declare module '@deepseek-ai/dsh-session' {\n interface SessionEventMap {\n${members}\n }\n}\n`
|
||||
|
||||
afterEach(() => {
|
||||
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** The manifest that marks a fixture package as the owning session package. */
|
||||
const OWNER_MANIFEST = '{ "name": "@deepseek-ai/dsh-session" }\n'
|
||||
|
||||
describe('gen-persistence-catalog collectLogEvents', () => {
|
||||
it('extracts a documented member of the owning top-level interface', () => {
|
||||
const events = collectLogEvents(make({
|
||||
'packages/core/fix/package.json': OWNER_MANIFEST,
|
||||
'packages/core/fix/src/types.ts':
|
||||
'export interface SessionEventMap {\n /** A thing was recorded. */\n \'fix/happened\': { turn: number }\n}\n',
|
||||
}))
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({
|
||||
name: 'fix/happened',
|
||||
scope: 'fix',
|
||||
doc: 'A thing was recorded.',
|
||||
payload: '{ turn: number }',
|
||||
source: 'packages/core/fix/src/types.ts:3',
|
||||
})
|
||||
})
|
||||
|
||||
it('hard-errors on a top-level interface outside the owning package', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/alien/package.json': '{ "name": "@deepseek-ai/dsh-alien" }\n',
|
||||
'packages/group/alien/src/types.ts':
|
||||
'export interface SessionEventMap {\n /** Not the real vocabulary. */\n \'alien/event\': { turn: number }\n}\n',
|
||||
}))).toThrow(/top-level interface SessionEventMap .* is outside @deepseek-ai\/dsh-session \(package @deepseek-ai\/dsh-alien\)/)
|
||||
})
|
||||
|
||||
it('hard-errors on a non-exported top-level interface even in the owning package', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/core/fix/package.json': OWNER_MANIFEST,
|
||||
'packages/core/fix/src/helper.ts':
|
||||
'interface SessionEventMap {\n /** A local helper, not the vocabulary. */\n \'fix/local\': { turn: number }\n}\nexport const use: SessionEventMap | null = null\n',
|
||||
}))).toThrow(/is not exported; the owning vocabulary is the single exported declaration/)
|
||||
})
|
||||
|
||||
it('hard-errors when the owning interface is exported from two files', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/core/fix/package.json': OWNER_MANIFEST,
|
||||
'packages/core/fix/src/a.ts': 'export interface SessionEventMap {\n /** First home. */\n \'fix/a\': { turn: number }\n}\n',
|
||||
'packages/core/fix/src/b.ts': 'export interface SessionEventMap {\n /** Second home. */\n \'fix/b\': { turn: number }\n}\n',
|
||||
}))).toThrow(/is already declared at packages\/core\/fix\/src\/a\.ts:1; the owning vocabulary has exactly one home/)
|
||||
})
|
||||
|
||||
it('hard-errors on an extends clause (inherited keys would escape the catalog)', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts':
|
||||
'interface Extra { \'fix/hidden\': { turn: number } }\ndeclare module \'@deepseek-ai/dsh-session\' {\n interface SessionEventMap extends Extra {\n /** Declared directly. */\n \'fix/direct\': { turn: number }\n }\n}\n',
|
||||
}))).toThrow(/uses extends; inherited keys would join keyof SessionEventMap without a catalog row/)
|
||||
})
|
||||
|
||||
it('extracts a member declaration-merged via the session module', () => {
|
||||
const events = collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' /** Merged provenance. */\n \'fix/merged\': { id: string }'),
|
||||
}))
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({ name: 'fix/merged', doc: 'Merged provenance.' })
|
||||
})
|
||||
|
||||
it('collapses a newline-separated multi-line payload to a valid one-line fragment', () => {
|
||||
const events = collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(
|
||||
' /** Wide payload. */\n \'fix/wide\': {\n alpha: string[]\n range: { start: number; end: number }\n count: number\n }',
|
||||
),
|
||||
}))
|
||||
expect(events[0]?.payload).toBe('{ alpha: string[]; range: { start: number; end: number }; count: number }')
|
||||
})
|
||||
|
||||
it('hard-errors on a member with no description prose', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' \'fix/undocumented\': { turn: number }'),
|
||||
}))).toThrow(/no description prose/)
|
||||
})
|
||||
|
||||
it('hard-errors on an @mode tag (a log event has no dispatch mode)', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' /**\n * Documented, but mistagged.\n * @mode emit\n */\n \'fix/tagged\': { turn: number }'),
|
||||
}))).toThrow(/carries an @mode tag/)
|
||||
})
|
||||
|
||||
it('hard-errors on an extra-indented @mode tag (does not leak into prose)', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' /**\n * Documented, but mistagged.\n * @mode emit\n */\n \'fix/indented\': { turn: number }'),
|
||||
}))).toThrow(/carries an @mode tag/)
|
||||
})
|
||||
|
||||
it('hard-errors on a method-form member (it still joins keyof SessionEventMap)', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' /** Documented, wrong shape. */\n \'fix/method\'(turn: number): void'),
|
||||
}))).toThrow(/not a property signature with an explicit payload type/)
|
||||
})
|
||||
|
||||
it('hard-errors on a property member with no payload type annotation', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' /** Documented, no payload. */\n \'fix/bare\''),
|
||||
}))).toThrow(/not a property signature with an explicit payload type/)
|
||||
})
|
||||
|
||||
it('hard-errors on a non-literal member name', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' /** Not a literal. */\n unquoted: { turn: number }'),
|
||||
}))).toThrow(/non-literal name/)
|
||||
})
|
||||
|
||||
it('hard-errors when the same event is declared twice', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/a.ts': merge(' /** First. */\n \'fix/dup\': { turn: number }'),
|
||||
'packages/group/fix/src/b.ts': merge(' /** Second. */\n \'fix/dup\': { turn: number }'),
|
||||
}))).toThrow(/already declared at packages\/group\/fix\/src\/a\.ts/)
|
||||
})
|
||||
|
||||
it('aggregates every violation into one error instead of failing fast', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' \'fix/one\': { turn: number }\n \'fix/two\': { turn: number }'),
|
||||
}))).toThrow(/2 JSDoc completeness violation\(s\)[\s\S]*fix\/one[\s\S]*fix\/two/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gen-persistence-catalog collectSurfaceEventTypes', () => {
|
||||
it('parses the literal union', () => {
|
||||
const types = collectSurfaceEventTypes(make({
|
||||
'packages/core/fix/src/types.ts': 'export type SurfaceEventType = \'fix/a\' | \'fix/b\'\n',
|
||||
}))
|
||||
expect(types).toEqual(['fix/a', 'fix/b'])
|
||||
})
|
||||
|
||||
it('hard-errors when no union is declared', () => {
|
||||
expect(() => collectSurfaceEventTypes(make({
|
||||
'packages/core/fix/src/types.ts': 'export const unrelated = 1\n',
|
||||
}))).toThrow(/no SurfaceEventType union found/)
|
||||
})
|
||||
|
||||
it('hard-errors when the union is declared more than once', () => {
|
||||
expect(() => collectSurfaceEventTypes(make({
|
||||
'packages/core/fix/src/a.ts': 'export type SurfaceEventType = \'fix/a\'\n',
|
||||
'packages/core/fix/src/b.ts': 'export type SurfaceEventType = \'fix/b\'\n',
|
||||
}))).toThrow(/declared more than once/)
|
||||
})
|
||||
|
||||
it('hard-errors on a non-string-literal union member', () => {
|
||||
expect(() => collectSurfaceEventTypes(make({
|
||||
'packages/core/fix/src/types.ts': 'export type SurfaceEventType = \'fix/a\' | number\n',
|
||||
}))).toThrow(/non-string-literal member/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gen-persistence-catalog annotateSurface + render', () => {
|
||||
const entry = (name: string) => ({
|
||||
name,
|
||||
scope: name.split('/')[0] ?? name,
|
||||
payload: '{ turn: number }',
|
||||
doc: `Records ${name}.`,
|
||||
source: 'packages/core/fix/src/types.ts:3',
|
||||
})
|
||||
|
||||
it('badges union members surface and everything else log-only', () => {
|
||||
const annotated = annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message'])
|
||||
expect(annotated.map(e => [e.name, e.surface])).toEqual([['fix/message', true], ['fix/marker', false]])
|
||||
})
|
||||
|
||||
it('hard-errors on a union member naming no declared event', () => {
|
||||
expect(() => annotateSurface([entry('fix/marker')], ['fix/ghost']))
|
||||
.toThrow(/'fix\/ghost' name no declared log event/)
|
||||
})
|
||||
|
||||
it('renders badges, payload fences, and the generated-file header', () => {
|
||||
const out = render(annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message']))
|
||||
expect(out).toContain('Generated by scripts/gen-persistence-catalog.ts')
|
||||
expect(out).toContain('#### `fix/message` — surface')
|
||||
expect(out).toContain('#### `fix/marker` — log-only')
|
||||
expect(out).toContain('```ts persistence-catalog\n\'fix/marker\': { turn: number }\n```')
|
||||
})
|
||||
})
|
||||
@@ -1,37 +1,48 @@
|
||||
# dsh-system-prompt
|
||||
|
||||
System prompt assembly registry. Plugins contribute ordered text sections and tool-schema providers; the agent loop calls `assemble()` once per step.
|
||||
System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the deployment's `deployment:persona` section — so they exist for every agent regardless of which loop plugin drives it.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `persona` | `''` | The deployment persona: the ONE deployment-authored prompt fragment, rendered as the order-0 `deployment:persona` section and shared by every agent in the context (subagents included). A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. |
|
||||
|
||||
## Service: `SystemPrompt` (ctx key: `systemPrompt`)
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. Duplicate names throw. Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.tools(provider: () => ToolSchema[]): () => void` Contribute tool schemas (evaluated at each assembly). Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.assemble(): Promise<PromptAssembly>` Assemble the current prompt. Runs through the `system-prompt/assemble` waterfall.
|
||||
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Duplicate or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller. Runs through the `system-prompt/assemble` waterfall.
|
||||
|
||||
### Events
|
||||
|
||||
| Event | Mode | Purpose |
|
||||
|---|---|---|
|
||||
| `system-prompt/assemble` | waterfall | Mutate/extend the assembly before it reaches the model |
|
||||
| `system-prompt/change` | emit | A section or tool provider was registered or unregistered |
|
||||
| `system-prompt/assemble` | waterfall | Mutate/extend the assembly (with the caller's context) before it reaches the model |
|
||||
| `system-prompt/change` | emit | A section, tool provider, or variable was registered or unregistered |
|
||||
|
||||
### Key types
|
||||
|
||||
- `PromptSection` — `{ name, order, text: string | (() => string) }`. Sections are concatenated in ascending `order`.
|
||||
- `PromptAssembly` — `{ sections: PromptSection[], tools: ToolSchema[] }`. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field.
|
||||
- `renderPrompt(assembly)` — joins section texts with blank lines.
|
||||
- `AssembleContext` — what one `assemble()` call is FOR. Declared empty here and merge-extensible; `dsh-agent` declares `agent?: Agent`, so providers project per-agent facts. Providers must tolerate absent fields (a bare `assemble()` carries an empty context).
|
||||
- `PromptSection` — `{ name, order, text: string | ((context) => string) }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona (both registered by this plugin), tool guidance uses `100–199`; other negative orders also render before the persona.
|
||||
- `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field.
|
||||
- `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned.
|
||||
|
||||
Merge-extensible: plugins can declare extra fields on `PromptAssembly` via declaration merging.
|
||||
Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `AssembleContext` via declaration merging.
|
||||
|
||||
### Extension points
|
||||
|
||||
- Section providers: AGENTS.md reader, cwd notifier, persona config, etc.
|
||||
- Section providers: tool packages own their cross-call guidance (`tool:bash`, `tool:read`, …); this plugin owns `harness:identity` and `deployment:persona`.
|
||||
- Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …).
|
||||
- Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically.
|
||||
- The `system-prompt/assemble` waterfall: mutate or replace the assembly (system-prompt configurability, dynamic tool filtering).
|
||||
- The `system-prompt/assemble` waterfall: mutate or replace the assembly per caller (dynamic tool filtering, extra variables).
|
||||
|
||||
### What is NOT here
|
||||
|
||||
- Any hardcoded prompt text — every section comes from plugins.
|
||||
- Any deployment-authored prompt text outside config — the persona is this plugin's `persona` config, and every other section comes from the plugin that owns the fact. (The `harness:identity` line is deliberately a code literal: a harness fact, not a deployment choice; the `system-prompt/assemble` waterfall is the escape valve for a deployment that must drop it.)
|
||||
- Prompt compaction (belongs on the `agent/pre-step` seam in `dsh-agent`).
|
||||
|
||||
Design rationale: [the prompt-variables RFC](../../../docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md).
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
/**
|
||||
* System prompt assembly registry. Plugins contribute ordered text sections and
|
||||
* tool schema providers; `assemble()` collates them through a waterfall that
|
||||
* runs once per step.
|
||||
* System prompt assembly registry. Plugins contribute ordered text sections,
|
||||
* tool schema providers, and named prompt variables; `assemble(context)`
|
||||
* collates them through a waterfall that runs once per step, and
|
||||
* `renderPrompt` interpolates `{{variable}}` references into the final text.
|
||||
*
|
||||
* The harness-owned prompt openers live here too: this plugin registers the
|
||||
* static `harness:identity` section (order −100) and the deployment's
|
||||
* `deployment:persona` section (order 0, from its `persona` config), so they
|
||||
* exist for every agent regardless of which loop plugin drives it.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-system-prompt
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -17,28 +24,63 @@ declare module 'cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* 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.
|
||||
* {@link PromptAssembly} (sections + tools + variables) before it is
|
||||
* rendered. Bound to the {@link SystemPrompt} service; call `next()` to
|
||||
* delegate.
|
||||
* @param assembly - the assembly built from the registered sections, tool
|
||||
* providers, and variable providers; listeners may mutate it or return a
|
||||
* replacement.
|
||||
* @param context - the per-assembly {@link AssembleContext} the caller
|
||||
* passed to {@link SystemPrompt.assemble} (e.g. which agent the prompt
|
||||
* is for), so a listener can filter or extend per agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
|
||||
'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
|
||||
/**
|
||||
* A section or tool provider was registered or unregistered (the assembly
|
||||
* inputs changed).
|
||||
* A section, tool provider, or variable provider was registered or
|
||||
* unregistered (the assembly inputs changed).
|
||||
* @mode emit
|
||||
*/
|
||||
'system-prompt/change'(): void
|
||||
}
|
||||
}
|
||||
|
||||
/** One contributed section of the system prompt. */
|
||||
/**
|
||||
* Per-assembly input: what one {@link SystemPrompt.assemble} call is FOR.
|
||||
* Declared empty here so this package stays agnostic of who assembles;
|
||||
* merge-extensible — `@deepseek-ai/dsh-agent` declares the `agent` field, so
|
||||
* section text and variable providers can be functions of the calling agent.
|
||||
* Every field is optional by nature: a bare `assemble()` (tests, diagnostics)
|
||||
* carries an empty context, and providers must tolerate absent fields.
|
||||
*/
|
||||
export interface AssembleContext {}
|
||||
|
||||
/** One contributed section of the system prompt (registry input). */
|
||||
export interface PromptSection {
|
||||
/** Unique name (diagnostics / dedup). */
|
||||
/** Unique name — a duplicate registration throws (see {@link SystemPrompt.section}). */
|
||||
name: string
|
||||
/** Sections are concatenated in ascending order. */
|
||||
/**
|
||||
* Sections are concatenated in ascending order. Convention: `-100` is the
|
||||
* harness identity, `0` the deployment persona, tool guidance uses 100–199;
|
||||
* other negative orders also render before the persona.
|
||||
*/
|
||||
order: number
|
||||
/** Static text or a provider evaluated at each assembly. */
|
||||
text: string | (() => string)
|
||||
/**
|
||||
* Static text or a provider evaluated at each assembly with that assembly's
|
||||
* {@link AssembleContext}. The text may reference `{{variable}}`s — they are
|
||||
* interpolated later, by {@link renderPrompt}.
|
||||
*/
|
||||
text: string | ((context: AssembleContext) => string)
|
||||
}
|
||||
|
||||
/** One section of an assembly: {@link PromptSection} with its text resolved. */
|
||||
export interface AssembledSection {
|
||||
/** The contributing section's unique name. */
|
||||
name: string
|
||||
/** The contributing section's order (sections arrive sorted ascending). */
|
||||
order: number
|
||||
/** The resolved (but not yet interpolated) section text. */
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,41 +90,156 @@ export interface PromptSection {
|
||||
* can do" is one coherent thing managed here, even though adapters transmit
|
||||
* `tools` as a separate wire field rather than prompt text.
|
||||
*
|
||||
* `variables` carries every registered prompt variable resolved against this
|
||||
* assembly's context — key present means registered, `undefined` value means
|
||||
* "no value for this assembly" (referencing it renders an error). Section
|
||||
* texts are resolved but NOT yet interpolated; {@link renderPrompt} applies
|
||||
* the variables, so waterfall listeners can still add sections or variables.
|
||||
*
|
||||
* Merge-extensible: plugins can declare extra fields on this interface.
|
||||
*/
|
||||
export interface PromptAssembly {
|
||||
sections: PromptSection[]
|
||||
sections: AssembledSection[]
|
||||
tools: ToolSchema[]
|
||||
variables: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
/** Renders the text part of an assembly (sections joined by blank lines). */
|
||||
/** Valid variable names: how they are written between the braces. */
|
||||
const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
|
||||
|
||||
/** A complete `{{...}}` reference group at the scan position (validated after). */
|
||||
const GROUP_AT = /^\{\{([^{}]*)\}\}/
|
||||
|
||||
export interface Config {
|
||||
/**
|
||||
* The deployment's persona — the ONE deployment-authored fragment of the
|
||||
* system prompt, rendered as the order-0 `deployment:persona` section
|
||||
* (after the harness identity, before all tool guidance). Every agent in
|
||||
* the context shares it, subagents included. Template, not free-form text:
|
||||
* every complete `{{…}}` group is interpreted strictly against the
|
||||
* registered prompt variables (the shipped agent loop registers `{{model}}`
|
||||
* and `{{cwd}}`), and there is no escape syntax for literal `{{…}}` prose
|
||||
* yet (a deliberate deferral; see the prompt-variables RFC). Defaults to
|
||||
* `''` — the empty section is dropped at render, so a persona-less
|
||||
* deployment opens with the harness identity alone.
|
||||
*/
|
||||
persona?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the text part of an assembly: interpolates `{{variable}}`
|
||||
* references in each section from `assembly.variables`, drops empty sections,
|
||||
* and joins the rest with blank lines.
|
||||
*
|
||||
* Strict by design (fail loud beats shipping a malformed prompt): a reference
|
||||
* to an unregistered variable, to a registered variable with no value for
|
||||
* this assembly, a complete `{{…}}` group that is not a well-formed variable
|
||||
* name (e.g. `{{ model }}`), or a `{{` that does not open a complete group
|
||||
* while a `}}` still follows (e.g. `{{{model}}}`, `{{a{b}}`) all throw. A
|
||||
* lone `{{` with no `}}` anywhere after it is ordinary prose and passes
|
||||
* through verbatim. Substituted values are never re-scanned.
|
||||
*/
|
||||
export function renderPrompt(assembly: PromptAssembly): string {
|
||||
return assembly.sections
|
||||
.map(section => typeof section.text === 'function' ? section.text() : section.text)
|
||||
.map(section => interpolate(section, assembly.variables))
|
||||
.filter(text => text.length > 0)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
/** Interpolate one section's `{{variable}}` references (see {@link renderPrompt}). */
|
||||
function interpolate(section: AssembledSection, variables: Record<string, string | undefined>): string {
|
||||
const text = section.text
|
||||
let result = ''
|
||||
let last = 0
|
||||
for (let open = text.indexOf('{{'); open >= 0; open = text.indexOf('{{', last)) {
|
||||
const group = GROUP_AT.exec(text.slice(open))
|
||||
if (group === null) {
|
||||
// No complete simple group starts at this `{{`. A `}}` further on means
|
||||
// a mangled reference (extra or nested braces) — fail loud. With no
|
||||
// closing `}}` anywhere after, it is ordinary prose (shell, JSON) and
|
||||
// passes through verbatim.
|
||||
if (text.indexOf('}}', open + 2) >= 0) {
|
||||
throw new Error(`malformed prompt variable reference at "${text.slice(open, open + 16)}…" in section "${section.name}" (references are complete simple {{name}} groups)`)
|
||||
}
|
||||
result += text.slice(last, open + 2)
|
||||
last = open + 2
|
||||
continue
|
||||
}
|
||||
// group[0] is the whole `{{...}}` match (a plain string, no optional
|
||||
// index): the name is its interior. `{{}}` yields '' → the malformed path.
|
||||
const name = group[0].slice(2, -2)
|
||||
if (!VARIABLE_NAME.test(name)) {
|
||||
throw new Error(`malformed prompt variable reference "{{${name}}}" in section "${section.name}" (variable names match ${String(VARIABLE_NAME)})`)
|
||||
}
|
||||
// Object.hasOwn, NOT `in`: `in` walks the prototype chain, so an
|
||||
// unregistered `{{constructor}}` would resolve to Object.prototype's and
|
||||
// splice a function's source text into the prompt instead of throwing.
|
||||
if (!Object.hasOwn(variables, name)) {
|
||||
const known = Object.keys(variables)
|
||||
throw new Error(`unknown prompt variable "{{${name}}}" in section "${section.name}"; registered variables: ${known.length > 0 ? known.join(', ') : '(none)'}`)
|
||||
}
|
||||
const value = variables[name]
|
||||
if (value === undefined) {
|
||||
throw new Error(`prompt variable "{{${name}}}" has no value for this assembly (section "${section.name}")`)
|
||||
}
|
||||
result += text.slice(last, open) + value
|
||||
last = open + group[0].length
|
||||
}
|
||||
return result + text.slice(last)
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry service (`ctx.systemPrompt`): plugins contribute ordered text
|
||||
* sections and tool-schema providers; the agent loop calls `assemble()` once
|
||||
* per step.
|
||||
* sections, tool-schema providers, and named prompt variables; the agent loop
|
||||
* calls `assemble(context)` once per step. Registers the harness-owned
|
||||
* `harness:identity` and `deployment:persona` sections itself (see
|
||||
* {@link Config.persona}).
|
||||
*/
|
||||
export class SystemPrompt extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
persona: z.string().default(''),
|
||||
})
|
||||
|
||||
private sections: PromptSection[] = []
|
||||
private toolProviders: (() => ToolSchema[])[] = []
|
||||
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'systemPrompt')
|
||||
// The harness-owned openers. They live HERE (not on the loop plugin) so a
|
||||
// deployment that swaps in a different loop keeps them: the identity is a
|
||||
// harness fact stated ahead of everything, and the persona is the
|
||||
// deployment's config, one section of the full prompt, never the whole.
|
||||
// An empty persona still RESERVES the section name (one owner — a plugin
|
||||
// re-registering it throws); renderPrompt drops the empty text.
|
||||
this.section({
|
||||
name: 'harness:identity',
|
||||
order: -100,
|
||||
text: 'You are an AI agent powered by the DeepSeek Harness SDK.',
|
||||
})
|
||||
this.section({
|
||||
name: 'deployment:persona',
|
||||
order: 0,
|
||||
// The schema already defaulted an omitted persona to ''; the ?? only
|
||||
// narrows the optional-input TYPE, it never supplies a different value.
|
||||
text: config.persona ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Contribute a text section to the system prompt. Order is determined by
|
||||
* `section.order` (ascending). The section is removed when the calling
|
||||
* `section.order` (ascending). Throws if a section with the same name is
|
||||
* already registered (a duplicate would silently double prompt text — e.g.
|
||||
* a double-loaded tool plugin). 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) {
|
||||
if (this.sections.some(existing => existing.name === section.name)) {
|
||||
throw new Error(`prompt section "${section.name}" is already registered`)
|
||||
}
|
||||
this.sections.push(section)
|
||||
// Yield the rollback BEFORE emitting `system-prompt/change`: a generator
|
||||
// effect collects each yielded disposer before the next step runs, so a
|
||||
@@ -105,6 +262,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) {
|
||||
@@ -124,24 +283,71 @@ export class SystemPrompt extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the current prompt (sections sorted by order, tools collected
|
||||
* from all providers). Section records are top-level clones (the `text`
|
||||
* provider may be a function and is intentionally shared); tool schemas are
|
||||
* deep-cloned because adapters and request waterfalls may mutate schema
|
||||
* objects. Runs through the `system-prompt/assemble` waterfall, giving
|
||||
* 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.
|
||||
* Contribute a named prompt variable, referenced from section text as
|
||||
* `{{name}}`. The provider is evaluated at each assembly with that
|
||||
* assembly's {@link AssembleContext}; returning `undefined` means "no value
|
||||
* for this assembly" (a section referencing it then fails to render — a
|
||||
* deployment must not claim facts it does not have). Throws on a name that
|
||||
* does not match `[a-z][a-z0-9_]*` (it could never be referenced) or is
|
||||
* already registered. Removed when the calling fiber is disposed; emits
|
||||
* `system-prompt/change` on register/unregister.
|
||||
* @param name - the reference name (matches `[a-z][a-z0-9_]*`).
|
||||
* @param provider - evaluated at every {@link assemble} for the value.
|
||||
* @returns the disposer that removes the variable.
|
||||
*/
|
||||
assemble(): Promise<PromptAssembly> {
|
||||
variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
if (!VARIABLE_NAME.test(name)) {
|
||||
throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`)
|
||||
}
|
||||
if (this.variableProviders.has(name)) {
|
||||
throw new Error(`prompt variable "${name}" is already registered`)
|
||||
}
|
||||
this.variableProviders.set(name, provider)
|
||||
// Yield the rollback BEFORE emitting `system-prompt/change` (see section()).
|
||||
yield () => {
|
||||
this.variableProviders.delete(name)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}.bind(this), 'systemPrompt.variable()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the current prompt for one caller: section texts are resolved
|
||||
* against `context` and sorted by order, tools collected from all
|
||||
* providers, and every registered variable resolved against `context` into
|
||||
* `assembly.variables`. Tool schemas are deep-cloned because adapters and
|
||||
* request waterfalls may mutate schema objects. Runs through the
|
||||
* `system-prompt/assemble` waterfall, giving 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. Interpolation happens later, in {@link renderPrompt}.
|
||||
* @param context - what this assembly is for (defaults to an empty context;
|
||||
* see {@link AssembleContext}).
|
||||
* @returns the assembly after the waterfall has run.
|
||||
*/
|
||||
assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
|
||||
const variables: Record<string, string | undefined> = {}
|
||||
for (const [name, provider] of this.variableProviders) {
|
||||
variables[name] = provider(context)
|
||||
}
|
||||
const assembly: PromptAssembly = {
|
||||
sections: this.sections
|
||||
.map(section => ({ ...section }))
|
||||
.map(section => ({
|
||||
name: section.name,
|
||||
order: section.order,
|
||||
text: typeof section.text === 'function' ? section.text(context) : section.text,
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order),
|
||||
tools: this.toolProviders.flatMap(provider =>
|
||||
provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))),
|
||||
variables,
|
||||
}
|
||||
return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, () => Promise.resolve(assembly))
|
||||
return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt, { PromptAssembly, PromptSection, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import SystemPrompt, { AssembleContext, PromptAssembly, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
/**
|
||||
* Every assembly carries the plugin's own built-ins — `harness:identity`
|
||||
* (order −100) and `deployment:persona` (order 0, from config). Tests about
|
||||
* registry MECHANICS strip them with {@link contributed} to stay focused on
|
||||
* their own sections; the built-ins' behavior is pinned by its own describe.
|
||||
*/
|
||||
const BUILT_IN = ['harness:identity', 'deployment:persona']
|
||||
const IDENTITY = 'You are an AI agent powered by the DeepSeek Harness SDK.'
|
||||
function contributed(assembly: PromptAssembly): PromptAssembly['sections'] {
|
||||
return assembly.sections.filter(section => !BUILT_IN.includes(section.name))
|
||||
}
|
||||
|
||||
describe('SystemPrompt', () => {
|
||||
it('assembles sections in order with dynamic text and collected tools', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
describe('built-in sections', () => {
|
||||
it('registers the harness identity and the configured deployment persona', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt, { persona: 'You are DeepSeek Harness SDK.' })
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections.map(s => [s.name, s.order])).toEqual([
|
||||
['harness:identity', -100],
|
||||
['deployment:persona', 0],
|
||||
])
|
||||
expect(renderPrompt(assembly)).toBe(`${IDENTITY}\n\nYou are DeepSeek Harness SDK.`)
|
||||
// The names are reserved by the plugin — one owner per section.
|
||||
expect(() => ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'imposter' }))
|
||||
.toThrow('prompt section "deployment:persona" is already registered')
|
||||
})
|
||||
|
||||
it('renders no persona section for a persona-less deployment (empty default)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(IDENTITY)
|
||||
})
|
||||
|
||||
it('tolerates a schema-bypassing direct construction (persona omitted)', async () => {
|
||||
// ctx.plugin validates + defaults the config first; a direct construction
|
||||
// skips the schema, so the ctor's `?? ''` narrowing is what fires.
|
||||
const ctx = new Context()
|
||||
const service = new SystemPrompt(ctx, {})
|
||||
expect(renderPrompt(await service.assemble())).toBe(IDENTITY)
|
||||
})
|
||||
})
|
||||
|
||||
it('assembles sections in order with context-resolved text and collected tools', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt, { persona: 'You are DeepSeek Harness SDK.' })
|
||||
|
||||
ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'You are DeepSeek Code.' })
|
||||
ctx.systemPrompt.section({ name: 'cwd', order: 20, text: () => 'cwd: /tmp' })
|
||||
ctx.systemPrompt.section({ name: 'rules', order: 10, text: 'Be precise.' })
|
||||
ctx.systemPrompt.tools(() => [{ name: 'echo', description: 'echo back', parameters: {} }])
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections.map(s => s.name)).toEqual(['persona', 'rules', 'cwd'])
|
||||
expect(assembly.sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'rules', 'cwd'])
|
||||
expect(assembly.sections.map(s => s.text)).toEqual([IDENTITY, 'You are DeepSeek Harness SDK.', 'Be precise.', 'cwd: /tmp'])
|
||||
expect(assembly.tools).toEqual([{ name: 'echo', description: 'echo back', parameters: {} }])
|
||||
expect(renderPrompt(assembly)).toBe('You are DeepSeek Code.\n\nBe precise.\n\ncwd: /tmp')
|
||||
expect(assembly.variables).toEqual({})
|
||||
expect(renderPrompt(assembly)).toBe(`${IDENTITY}\n\nYou are DeepSeek Harness SDK.\n\nBe precise.\n\ncwd: /tmp`)
|
||||
})
|
||||
|
||||
it('resolves section text providers against the assemble context, at each assemble call', async () => {
|
||||
// The context is HOW per-agent sections work (the loop passes { agent });
|
||||
// this spec stays agent-agnostic and smuggles a marker through a plain field.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
let calls = 0
|
||||
ctx.systemPrompt.section({
|
||||
name: 'dynamic',
|
||||
order: 0,
|
||||
text: (context: AssembleContext) => `call ${++calls} for ${(context as { who?: string }).who ?? 'nobody'}`,
|
||||
})
|
||||
|
||||
expect(contributed(await ctx.systemPrompt.assemble({ who: 'alice' } as AssembleContext))[0]!.text).toBe('call 1 for alice')
|
||||
expect(contributed(await ctx.systemPrompt.assemble())[0]!.text).toBe('call 2 for nobody')
|
||||
})
|
||||
|
||||
it('removes contributions when the contributing fiber is disposed (HMR safety)', async () => {
|
||||
@@ -25,13 +85,30 @@ describe('SystemPrompt', () => {
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.systemPrompt.section({ name: 'scoped', order: 0, text: 'scoped section' })
|
||||
inner.systemPrompt.tools(() => [{ name: 'scoped-tool', description: '', parameters: {} }])
|
||||
inner.systemPrompt.variable('scoped_var', () => 'v')
|
||||
}, { inject: ['systemPrompt'] }))
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(1)
|
||||
const before = await ctx.systemPrompt.assemble()
|
||||
expect(contributed(before)).toHaveLength(1)
|
||||
expect(before.variables).toEqual({ scoped_var: 'v' })
|
||||
await fiber.dispose()
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections).toHaveLength(0)
|
||||
expect(contributed(assembly)).toHaveLength(0)
|
||||
// The built-ins belong to the service fiber, so they survive the plugin's disposal.
|
||||
expect(assembly.sections.map(s => s.name)).toEqual(BUILT_IN)
|
||||
expect(assembly.tools).toHaveLength(0)
|
||||
expect(assembly.variables).toEqual({})
|
||||
})
|
||||
|
||||
it('rejects a duplicate section name (a double-loaded plugin must fail, not double its text)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 'dup', order: 0, text: 'first' })
|
||||
expect(() => ctx.systemPrompt.section({ name: 'dup', order: 1, text: 'second' }))
|
||||
.toThrow('prompt section "dup" is already registered')
|
||||
// The failed registration leaked nothing; the original stays intact.
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(contributed(assembly).map(s => s.text)).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('rolls back a section when a system-prompt/change listener throws (P1-1)', async () => {
|
||||
@@ -47,12 +124,12 @@ describe('SystemPrompt', () => {
|
||||
})
|
||||
|
||||
expect(() => ctx.systemPrompt.section({ name: 'p', order: 0, text: 'persona' })).toThrow('boom change listener')
|
||||
expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0) // nothing leaked
|
||||
expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(0) // nothing leaked
|
||||
|
||||
// Subsequent listener-free register contributes exactly once.
|
||||
off()
|
||||
ctx.systemPrompt.section({ name: 'p', order: 0, text: 'persona' })
|
||||
expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['p'])
|
||||
expect(contributed(await ctx.systemPrompt.assemble()).map(s => s.name)).toEqual(['p'])
|
||||
})
|
||||
|
||||
it('rolls back a tool provider when a system-prompt/change listener throws (P1-1)', async () => {
|
||||
@@ -72,26 +149,47 @@ describe('SystemPrompt', () => {
|
||||
expect((await ctx.systemPrompt.assemble()).tools.map(t => t.name)).toEqual(['t'])
|
||||
})
|
||||
|
||||
it('composes multiple system-prompt/assemble waterfall listeners in order', async () => {
|
||||
it('rolls back a variable when a system-prompt/change listener throws (P1-1)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
let threw = false
|
||||
const off = ctx.on('system-prompt/change', () => {
|
||||
if (!threw) { threw = true; throw new Error('boom change listener') }
|
||||
})
|
||||
|
||||
expect(() => ctx.systemPrompt.variable('v', () => 'x')).toThrow('boom change listener')
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) // nothing leaked
|
||||
|
||||
off()
|
||||
ctx.systemPrompt.variable('v', () => 'x')
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({ v: 'x' })
|
||||
})
|
||||
|
||||
it('composes multiple system-prompt/assemble waterfall listeners in order, with the context', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 'base', order: 0, text: 'base' })
|
||||
|
||||
// Listener A appends a section, then delegates.
|
||||
ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, next) => {
|
||||
const contexts: AssembleContext[] = []
|
||||
ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, context, next) => {
|
||||
contexts.push(context)
|
||||
assembly.sections.push({ name: 'from-a', order: 100, text: 'a' })
|
||||
return next()
|
||||
})
|
||||
// Listener B (registered later, runs after A) sees A's contribution.
|
||||
const seen: string[][] = []
|
||||
ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, next) => {
|
||||
ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, _context, next) => {
|
||||
seen.push(assembly.sections.map(s => s.name))
|
||||
return next()
|
||||
})
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(seen).toEqual([['base', 'from-a']])
|
||||
expect(assembly.sections.map(s => s.name)).toEqual(['base', 'from-a'])
|
||||
const passed: AssembleContext = {}
|
||||
const assembly = await ctx.systemPrompt.assemble(passed)
|
||||
expect(seen).toEqual([['harness:identity', 'deployment:persona', 'base', 'from-a']])
|
||||
expect(assembly.sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'base', 'from-a'])
|
||||
expect(contexts[0]).toBe(passed) // the caller's context reaches listeners
|
||||
})
|
||||
|
||||
it('lets a waterfall listener short-circuit by not calling next()', async () => {
|
||||
@@ -100,7 +198,7 @@ describe('SystemPrompt', () => {
|
||||
ctx.systemPrompt.section({ name: 'real', order: 0, text: 'real' })
|
||||
|
||||
ctx.on('system-prompt/assemble', async () => {
|
||||
return { sections: [], tools: [] } satisfies PromptAssembly
|
||||
return { sections: [], tools: [], variables: {} } satisfies PromptAssembly
|
||||
})
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
@@ -115,40 +213,33 @@ describe('SystemPrompt', () => {
|
||||
|
||||
const first = await ctx.systemPrompt.assemble()
|
||||
first.sections[0]!.name = 'mutated'
|
||||
first.sections[0]!.text = 'mutated'
|
||||
first.tools[0]!.description = 'mutated'
|
||||
const firstParameters = first.tools[0]!.parameters as { properties: Record<string, unknown> }
|
||||
firstParameters.properties['leak'] = { type: 'string' }
|
||||
|
||||
const second = await ctx.systemPrompt.assemble()
|
||||
expect(second.sections.map(section => section.name)).toEqual(['base'])
|
||||
expect(second.sections.map(section => section.name)).toEqual(['harness:identity', 'deployment:persona', 'base'])
|
||||
expect(second.sections[0]!.text).toBe(IDENTITY)
|
||||
expect(second.tools).toEqual([{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }])
|
||||
})
|
||||
|
||||
it('filters out empty section text from renderPrompt', () => {
|
||||
// Direct test of renderPrompt: function returning empty string, and empty static text
|
||||
const result = renderPrompt({
|
||||
sections: [
|
||||
{ name: 'empty-fn', order: 0, text: () => '' },
|
||||
{ name: 'empty', order: 0, text: '' },
|
||||
{ name: 'real', order: 1, text: 'content' },
|
||||
{ name: 'empty-static', order: 2, text: '' },
|
||||
],
|
||||
tools: [],
|
||||
variables: {},
|
||||
})
|
||||
expect(result).toBe('content')
|
||||
})
|
||||
|
||||
it('evaluates dynamic function-text sections at each renderPrompt call', () => {
|
||||
let counter = 0
|
||||
const section: PromptSection = { name: 'dynamic', order: 0, text: () => `call ${++counter}` }
|
||||
expect(renderPrompt({ sections: [section], tools: [] })).toBe('call 1')
|
||||
expect(renderPrompt({ sections: [section], tools: [] })).toBe('call 2')
|
||||
})
|
||||
|
||||
it('emits system-prompt/change when a tool provider is registered and disposed', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
const changes: number = 0
|
||||
let changeCount = 0
|
||||
ctx.on('system-prompt/change', () => void changeCount++)
|
||||
|
||||
@@ -159,7 +250,6 @@ describe('SystemPrompt', () => {
|
||||
dispose()
|
||||
// disposal emits change again
|
||||
expect(changeCount).toBe(2)
|
||||
void changes // silence unused
|
||||
})
|
||||
|
||||
it('cleans up tool providers on fiber dispose', async () => {
|
||||
@@ -180,10 +270,10 @@ describe('SystemPrompt', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
const dispose = ctx.systemPrompt.section({ name: 'direct', order: 0, text: 'direct section' })
|
||||
expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(1)
|
||||
expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(1)
|
||||
|
||||
dispose()
|
||||
expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0)
|
||||
expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('removes tool provider when returned disposer is called directly', async () => {
|
||||
@@ -196,4 +286,133 @@ describe('SystemPrompt', () => {
|
||||
dispose()
|
||||
expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(0)
|
||||
})
|
||||
|
||||
describe('prompt variables', () => {
|
||||
it('resolves each variable against the assemble context and emits change on register/unregister', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
let changeCount = 0
|
||||
ctx.on('system-prompt/change', () => void changeCount++)
|
||||
|
||||
const dispose = ctx.systemPrompt.variable('who', context => (context as { who?: string }).who)
|
||||
expect(changeCount).toBe(1)
|
||||
|
||||
expect((await ctx.systemPrompt.assemble({ who: 'alice' } as AssembleContext)).variables).toEqual({ who: 'alice' })
|
||||
// A provider returning undefined records "registered but no value here".
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({ who: undefined })
|
||||
|
||||
dispose()
|
||||
expect(changeCount).toBe(2)
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
|
||||
})
|
||||
|
||||
it('rejects a duplicate variable name and an unreferenceable name', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.variable('model', () => 'm1')
|
||||
expect(() => ctx.systemPrompt.variable('model', () => 'm2'))
|
||||
.toThrow('prompt variable "model" is already registered')
|
||||
expect(() => ctx.systemPrompt.variable('Not Valid', () => 'x'))
|
||||
.toThrow('invalid prompt variable name "Not Valid"')
|
||||
// Neither failed registration leaked.
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({ model: 'm1' })
|
||||
})
|
||||
|
||||
it('interpolates {{name}} references in section text at render — the persona included', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt, { persona: 'You run on {{model}} in {{cwd}}.' })
|
||||
ctx.systemPrompt.variable('model', () => 'deepseek-v4')
|
||||
ctx.systemPrompt.variable('cwd', () => '/work')
|
||||
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nYou run on deepseek-v4 in /work.`)
|
||||
})
|
||||
|
||||
it('lets a waterfall listener add or override variables before render', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 's', order: 0, text: '{{extra}}' })
|
||||
ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, _context, next) => {
|
||||
assembly.variables['extra'] = 'from-waterfall'
|
||||
return next()
|
||||
})
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nfrom-waterfall`)
|
||||
})
|
||||
|
||||
it('throws on a reference to an unregistered variable, listing what exists', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'on {{modle}}' })
|
||||
ctx.systemPrompt.variable('model', () => 'm')
|
||||
await expect(async () => renderPrompt(await ctx.systemPrompt.assemble()))
|
||||
.rejects.toThrow('unknown prompt variable "{{modle}}" in section "persona"; registered variables: model')
|
||||
})
|
||||
|
||||
it('names "(none)" when no variables are registered at all', () => {
|
||||
expect(() => renderPrompt({ sections: [{ name: 's', order: 0, text: '{{x}}' }], tools: [], variables: {} }))
|
||||
.toThrow('unknown prompt variable "{{x}}" in section "s"; registered variables: (none)')
|
||||
})
|
||||
|
||||
it('throws when a referenced variable has no value for this assembly', () => {
|
||||
expect(() => renderPrompt({
|
||||
sections: [{ name: 'persona', order: 0, text: 'in {{cwd}}' }],
|
||||
tools: [],
|
||||
variables: { cwd: undefined },
|
||||
})).toThrow('prompt variable "{{cwd}}" has no value for this assembly (section "persona")')
|
||||
})
|
||||
|
||||
it('throws on a malformed complete reference, e.g. inner spaces', () => {
|
||||
expect(() => renderPrompt({
|
||||
sections: [{ name: 's', order: 0, text: 'on {{ model }}' }],
|
||||
tools: [],
|
||||
variables: { model: 'm' },
|
||||
})).toThrow('malformed prompt variable reference "{{ model }}" in section "s"')
|
||||
})
|
||||
|
||||
it('leaves a lone {{ verbatim only when NO }} follows anywhere after it', () => {
|
||||
const text = renderPrompt({
|
||||
sections: [{ name: 's', order: 0, text: 'shell ${X:-{{fallback} stays' }],
|
||||
tools: [],
|
||||
variables: {},
|
||||
})
|
||||
expect(text).toBe('shell ${X:-{{fallback} stays')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ text: '{{{model}}}', label: 'extra outer braces' },
|
||||
{ text: 'x {{a{b}} y {{model}}', label: 'nested brace inside a would-be group' },
|
||||
])('throws on a mangled reference with a }} still following ($label)', ({ text }) => {
|
||||
expect(() => renderPrompt({
|
||||
sections: [{ name: 's', order: 0, text }],
|
||||
tools: [],
|
||||
variables: { model: 'm' },
|
||||
})).toThrow('malformed prompt variable reference at')
|
||||
})
|
||||
|
||||
it('rejects {{constructor}} as UNKNOWN — prototype properties are not variables', () => {
|
||||
// `in` would find Object.prototype.constructor and splice function
|
||||
// source into the prompt; Object.hasOwn must reject it instead.
|
||||
expect(() => renderPrompt({
|
||||
sections: [{ name: 's', order: 0, text: 'on {{constructor}}' }],
|
||||
tools: [],
|
||||
variables: { model: 'm' },
|
||||
})).toThrow('unknown prompt variable "{{constructor}}"')
|
||||
})
|
||||
|
||||
it('a variable NAMED like a prototype property works once actually registered', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 's', order: 0, text: '{{constructor}}' })
|
||||
ctx.systemPrompt.variable('constructor', () => 'own-value')
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nown-value`)
|
||||
})
|
||||
|
||||
it('never re-scans substituted values (a value containing {{sneaky}} stays literal)', () => {
|
||||
const text = renderPrompt({
|
||||
sections: [{ name: 's', order: 0, text: 'v = {{model}}!' }],
|
||||
tools: [],
|
||||
variables: { model: 'literal {{sneaky}} inside' },
|
||||
})
|
||||
expect(text).toBe('v = literal {{sneaky}} inside!')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# dsh-tools
|
||||
|
||||
Tool registry and execution waterfall. Tool plugins register their schemas and executors; the agent loop executes calls through the `tools/execute` waterfall.
|
||||
Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the allow/deny gate) → core dispatch → `tools/post-execute` (inspect/replace the result, attach context).
|
||||
|
||||
## Service: `ToolRegistry` (ctx key: `tools`)
|
||||
|
||||
@@ -9,7 +9,7 @@ Tool registry and execution waterfall. Tool plugins register their schemas and e
|
||||
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a tool. Disposed with the calling fiber.
|
||||
- `ctx.tools.get(name: string): ToolDefinition | undefined`
|
||||
- `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog/tools.md](../../../docs/tool-catalog/tools.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
|
||||
- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/execute` waterfall.
|
||||
- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/pre-execute` → dispatch → `tools/post-execute` pipeline.
|
||||
|
||||
### Injected services
|
||||
|
||||
@@ -19,20 +19,23 @@ Tool registry and execution waterfall. Tool plugins register their schemas and e
|
||||
|
||||
| Event | Mode | Purpose |
|
||||
|---|---|---|
|
||||
| `tools/execute` | waterfall | Wrap/veto tool execution (sandbox, permission, hooks, plan mode) |
|
||||
| `tools/pre-execute` | waterfall | Allow/deny gate BEFORE a tool runs (sandbox, permission, hooks); returns `PreToolDecision` |
|
||||
| `tools/post-execute` | waterfall | Inspect/replace the result AFTER a tool runs, attach context; returns `PostToolDecision` |
|
||||
| `tools/change` | emit | A tool was registered or unregistered |
|
||||
|
||||
### Key types
|
||||
|
||||
- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise<ContentBlock[]>`, plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below).
|
||||
- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below).
|
||||
- `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`.
|
||||
- `ToolExecutionResult` — outcome: `{ callId, content, isError, error? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay).
|
||||
- `ToolCallPresentation` / `ToolResultPresentation` — provider-neutral shapes a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation").
|
||||
- `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering.
|
||||
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` degrades to `deny` until the permission system lands.
|
||||
- `PostToolDecision` — `{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns.
|
||||
- `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation").
|
||||
|
||||
### Extension points
|
||||
|
||||
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
|
||||
- The `tools/execute` waterfall is the single seam for sandbox, permission, hooks, and plan-mode plugins to wrap or veto a call. Listeners receive `(exec, next)`: call `next()` to proceed, or return a result without calling `next()` to short-circuit (veto).
|
||||
- `tools/pre-execute` is the allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny`/`ask` skips dispatch and yields an `isError` result. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch sits between them as plain code; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. Both follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)).
|
||||
- MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
|
||||
|
||||
### Typed tool parameter schemas
|
||||
@@ -70,12 +73,18 @@ See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, an
|
||||
|
||||
### Tool-owned UI presentation
|
||||
|
||||
A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods:
|
||||
A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods that return a **`card`-tagged render intent** (a discriminated union — a tool declares its card kind once and a UI bridge switches on `card`):
|
||||
|
||||
- `presentCall(args): ToolCallPresentation | undefined` — the PENDING state: a human-readable `title` (always-visible label), an optional `kind` (`read`/`edit`/`execute`/… for icon/treatment, default `other`), an optional `rawInput` (the salient input to show in a detail view — e.g. a shell command as a string, NOT the whole args object), an optional `content` (UI content shown alongside the title/card — e.g. a bash `description` as a text block above the terminal card), and an optional `terminal` (a neutral `{ cwd? }` asking a capable UI to render this call as a TERMINAL, e.g. for `bash`).
|
||||
- `presentResult(args, result): ToolResultPresentation | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError }` result: an optional replacement `title`, reformatted `content` (e.g. wrap command output in a fenced ` ```console ` block — a UI-only affordance that must NOT appear in the model-facing `execute` result), and an optional `terminal` (the `{ output?, exitCode?, signal? }` for a terminal-rendered call). The `ToolTerminal` shape is provider-neutral; a UI bridge (the ACP bridge) maps it to a terminal card (with an exit-status pill) and a UI that can't ignores it and uses `content`.
|
||||
- `presentCall(args): ToolCallView | undefined` — the PENDING state, one of:
|
||||
- `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default card: a human-readable `title`, an optional `kind` (`read`/`edit`/`execute`/… for icon/treatment, default `other`), an optional `rawInput` (the salient input to show in a detail view — e.g. a background task id, NOT the whole args object), optional `content` (extra UI content blocks), and optional `locations` (`{ path, line? }[]` — files this call reads/modifies, so a capable UI can follow along; the ACP bridge forwards them as `tool_call.locations`).
|
||||
- `{ card: 'terminal', title, description?, cwd? }` — a shell command: a capable UI renders a terminal card (the `title` is the command, `description` renders above it, `cwd` heads it); an incapable UI falls back to a generic execute card.
|
||||
- `{ card: 'diff', title, diffs, locations? }` — a file create/modify: a capable UI renders an inline diff card from `diffs` (`{ path, oldText, newText }[]`; `oldText: null` for a new file). Used by `write`/`edit`.
|
||||
- `presentResult(args, result): ToolResultView | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError, meta? }` result, one of:
|
||||
- `{ card: 'generic', title?, content? }` — an optional replacement `title` and reformatted `content`.
|
||||
- `{ card: 'terminal', title?, output?, exitCode?, signal? }` — a terminal run's captured `output` and exit status. A capable UI shows an exit-status pill; an incapable UI gets a fenced ` ```console ` fallback the BRIDGE derives from `output` (the tool does not encode the fences).
|
||||
- `{ card: 'diff', title?, diffs }` — a completed file mutation as an inline diff. `diffs` is `FileDiff[]` — typically the applied hunks with surrounding context computed from the before/after content, or a whole-file diff (`oldText: null`) when there is no before-image (a file create). Used by `write`/`edit`; a `tool_call_update.content` replaces the call's content, so a mutation tool returns this even when it duplicates the call-time snippet (else the result text would clobber the pending diff).
|
||||
|
||||
Returning `undefined` (or omitting a method) tells a UI to fall back to a generic presentation (title = tool name, raw args as input, raw result content). Both methods must be **pure and side-effect-free**: a UI may call them during live streaming AND during a session-log replay, so they depend only on their arguments. With `defineTool`, `args` is the typed `InferArgs<S>` shape; the helper soft-validates before calling (a malformed/older logged arg shape yields `undefined` rather than throwing, since display must never crash a replay). The shapes are provider-neutral — the ACP bridge (`dsh-acp`) maps them to ACP `tool_call`/`tool_call_update` wire fields, and `dsh-tool-bash` is the reference implementation.
|
||||
Returning `undefined` (or omitting a method) tells a UI to fall back to a generic presentation (title = tool name, raw args as input, raw result content). Both methods must be **pure and side-effect-free**: a UI may call them during live streaming AND during a session-log replay, so they depend only on their arguments. `result.meta` is the tool's own optional presentation payload (opaque `unknown`, JSON-serializable), attached by `execute` (see below) and persisted on the `tool/result` event, so a `presentResult` reading it stays replay-deterministic (the same `meta` is read back from the log). With `defineTool`, `args` is the typed `InferArgs<S>` shape; the helper soft-validates before calling (a malformed/older logged arg shape yields `undefined` rather than throwing, since display must never crash a replay). The views are provider-neutral — the ACP bridge (`dsh-acp`) maps each `card` to ACP `tool_call`/`tool_call_update` wire fields (a `diff` card to a `{ type: 'diff' }` content block, a `terminal` card to the `_meta` terminal convention), and relativizes a file card's title against the session cwd. See the render-intent-union RFC (`docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md`) and the applied-hunk-diffs RFC (`docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md`); `dsh-tool-bash` (terminal) and `dsh-tool-fs` (diff/generic) are the reference implementations.
|
||||
|
||||
```ts
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
@@ -90,13 +99,13 @@ const bash = defineTool({
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: `ran: ${args.command}` }]
|
||||
},
|
||||
// The command is the readable title; the description rides as a content block.
|
||||
presentCall: args => ({ title: args.command, kind: 'execute', rawInput: args.command, content: [{ type: 'text', text: args.description }] }),
|
||||
// Wrap the output as a console block for the UI (not in the model-facing result).
|
||||
// A terminal card: the command is the title, the description renders above it.
|
||||
presentCall: args => ({ card: 'terminal', title: args.command, description: args.description }),
|
||||
// A terminal result: the raw output + exit; the bridge derives the fenced fallback.
|
||||
presentResult: (_args, result) => {
|
||||
const block = result.content.length === 1 ? result.content[0] : undefined
|
||||
if (block === undefined || block.type !== 'text') return undefined
|
||||
return { content: [{ type: 'text', text: '```console\n' + block.text + '\n```' }] }
|
||||
return { card: 'terminal', output: block.text }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Tool registry and execution waterfall. Plugins register tools; the registry
|
||||
* Tool registry and execution pipeline. Plugins register tools; the registry
|
||||
* feeds schemas into the system prompt, and `execute()` dispatches each call
|
||||
* through the `tools/execute` waterfall for sandbox, permission, and hook
|
||||
* plugins to wrap or veto.
|
||||
* through `tools/pre-execute` (the allow/deny gate) → core dispatch →
|
||||
* `tools/post-execute` (inspect/replace the result, attach context) for
|
||||
* sandbox, permission, and hook plugins to gate or transform a call.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tools
|
||||
*/
|
||||
@@ -10,8 +11,9 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { ToolCallView, ToolResultView } from './presentation.ts'
|
||||
|
||||
export {
|
||||
defineTool,
|
||||
@@ -26,6 +28,23 @@ export {
|
||||
type JsonSchemaObject,
|
||||
} from './schema.ts'
|
||||
|
||||
// The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
|
||||
// lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools`
|
||||
// stays the single public surface for consumers (producers + the ACP bridge).
|
||||
export type {
|
||||
ToolCallKind,
|
||||
FileLocation,
|
||||
FileDiff,
|
||||
ToolCallView,
|
||||
GenericCallView,
|
||||
TerminalCallView,
|
||||
DiffCallView,
|
||||
ToolResultView,
|
||||
GenericResultView,
|
||||
TerminalResultView,
|
||||
DiffResultView,
|
||||
} from './presentation.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
tools: ToolRegistry
|
||||
@@ -33,14 +52,34 @@ declare module 'cordis' {
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* Waterfall around every tool execution — the single seam where sandbox,
|
||||
* permission, hook, and plan-mode plugins wrap or veto a call. Listeners
|
||||
* receive `(exec, next)`: call `next()` to proceed (possibly around your
|
||||
* own logic), or return a {@link ToolExecutionResult} without calling
|
||||
* `next()` to short-circuit (veto).
|
||||
* Waterfall BEFORE a tool runs — the gate where sandbox, permission, and
|
||||
* hook plugins allow or deny a call (Claude Code's `PreToolUse`). Listeners
|
||||
* receive `(exec, next)`: call `next()` to delegate to the default (allow),
|
||||
* or return a {@link PreToolDecision} without calling `next()` to
|
||||
* short-circuit. A `deny` skips dispatch and yields an `isError` result; the
|
||||
* 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/execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
|
||||
'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
|
||||
/**
|
||||
* Waterfall AFTER a tool runs — where hook plugins inspect the result and
|
||||
* accept it (optionally REPLACING the model-facing content, and/or attaching
|
||||
* `additionalContext` for the next request) or block it with corrective
|
||||
* `feedback` (Claude Code's `PostToolUse`). Listeners receive
|
||||
* `(exec, result, next)`: call `next()` to delegate to the default (accept
|
||||
* unchanged), or return a {@link PostToolDecision} to override. The core tool
|
||||
* dispatch sits between the two waterfalls as plain code, all inside
|
||||
* `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>
|
||||
/**
|
||||
* A tool was registered or unregistered (the available tool set changed).
|
||||
* @mode emit
|
||||
@@ -55,147 +94,37 @@ declare module 'cordis' {
|
||||
// executes sequentially).
|
||||
|
||||
/**
|
||||
* Category of a tool call, used by a UI to pick an icon / treatment. A neutral
|
||||
* vocabulary owned here (NOT an ACP type) so tools describe themselves without
|
||||
* depending on any client protocol; a UI bridge maps it to its own enum. The
|
||||
* member set mirrors the common ACP `ToolKind` values; `other` is the default.
|
||||
* What a tool's `execute` returns. The bare {@link ContentBlock}`[]` form is the
|
||||
* common case (model-facing content only); the object form additionally attaches
|
||||
* a tool-private `meta` presentation payload that the registry threads onto the
|
||||
* `tool/result` session event and hands back to the tool's `presentResult`.
|
||||
* `meta` is opaque to the core (`unknown` — the tool owns and narrows its shape),
|
||||
* and MUST be JSON-serializable: it persists on the durable log (the session
|
||||
* enforces this at `append`), so replay reproduces the card.
|
||||
*/
|
||||
export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'
|
||||
|
||||
// FIXME(tool-presentation): the ToolCallPresentation / ToolResultPresentation /
|
||||
// ToolTerminal shapes need a rethink. They grew incrementally (title/kind/
|
||||
// rawInput, then a `content` block, then a `terminal` sub-shape carrying cwd/
|
||||
// output/exit) and the split of responsibility is now muddy: the call vs result
|
||||
// terminal fields overlap, the bridge has to reconcile a `content` block AND a
|
||||
// `terminal` block AND `rawInput` per call, and the "pending vs completed"
|
||||
// boundary doesn't cleanly map to how editors actually render (terminal card,
|
||||
// diff, generic card). Before more tools/UIs depend on this, redesign the type
|
||||
// so a tool declares its render INTENT once (e.g. a tagged union over card
|
||||
// kinds) rather than a bag of optional fields the bridge stitches together.
|
||||
// Pin the design in an RFC and migrate dsh-tool-bash + the ACP bridge together.
|
||||
|
||||
/**
|
||||
* How a tool wants ONE of its calls shown in a UI (an editor's tool-call card,
|
||||
* a CLI log line) BEFORE the result is known — the *pending* state. Provider-
|
||||
* neutral: a tool returns this from {@link ToolDefinition.presentCall} and a UI
|
||||
* plugin (e.g. the ACP bridge) maps it to its own wire shape. The tool owns its
|
||||
* own presentation — the UI must not special-case tool names.
|
||||
*/
|
||||
export interface ToolCallPresentation {
|
||||
/**
|
||||
* Human-readable, always-visible label describing what THIS call does (e.g.
|
||||
* the model-written one-line summary of a bash command). Keep it short — a UI
|
||||
* shows it as a card header / log line. Required: a presentation must have a
|
||||
* title (a UI falls back to the tool name only when `presentCall` is absent).
|
||||
*/
|
||||
title: string
|
||||
/** Category for icon/treatment; defaults to `other` when omitted. */
|
||||
kind?: ToolCallKind
|
||||
/**
|
||||
* The salient input to surface in a detail/expanded view — e.g. the bash
|
||||
* COMMAND itself (as a string), so the title can stay a readable summary
|
||||
* while the exact command is still visible. Omit to show nothing; a string is
|
||||
* rendered as-is, an object as pretty JSON. NOT the full raw args object
|
||||
* unless that is genuinely what a reader wants.
|
||||
*/
|
||||
rawInput?: unknown
|
||||
/**
|
||||
* UI-facing content to show on the PENDING call alongside the title/card —
|
||||
* harness {@link ContentBlock}s, in render order. A terminal tool uses this to
|
||||
* surface its human-readable `description` as a text block ABOVE the terminal
|
||||
* card (the card itself is requested via {@link terminal} and labelled by the
|
||||
* command in `title`), since the card has no description slot. Omit to show no
|
||||
* extra content. A UI maps these to its own content blocks and renders a
|
||||
* {@link terminal} block (if any) as a terminal card.
|
||||
*/
|
||||
content?: ContentBlock[]
|
||||
/**
|
||||
* Ask a capable UI to render this call as a TERMINAL (a command running in a
|
||||
* working directory), not a generic tool card — set by a tool whose call IS a
|
||||
* shell command (e.g. `bash`). Provider-neutral; a UI bridge maps it to its
|
||||
* own terminal affordance and a UI that can't falls back to the normal card.
|
||||
* Pair with {@link ToolResultPresentation.terminal} for the output/exit.
|
||||
*/
|
||||
terminal?: ToolTerminal
|
||||
}
|
||||
|
||||
/**
|
||||
* A request to render a tool call as a terminal. The pending presentation
|
||||
* supplies the working directory; the result presentation (see
|
||||
* {@link ToolResultPresentation.terminal}) supplies the captured output and exit
|
||||
* status. Provider-neutral — no client-protocol types. A UI that supports
|
||||
* terminals shows a cwd-headed terminal card with the command, its output, and
|
||||
* an exit-status pill; a UI that does not ignores this and renders the ordinary
|
||||
* card/content.
|
||||
*/
|
||||
export interface ToolTerminal {
|
||||
/**
|
||||
* Working directory the command ran in, shown as the terminal header. An
|
||||
* ABSOLUTE path is used as-is; a RELATIVE path is resolved by the UI bridge
|
||||
* against the session workspace (the pure tool presenter can't see the
|
||||
* session cwd). Omit entirely to let the bridge use the session workspace.
|
||||
*/
|
||||
cwd?: string
|
||||
/** Captured command output (stdout+stderr as the tool chooses to combine them). Result-state only. */
|
||||
output?: string
|
||||
/**
|
||||
* Process exit code, when the run ended by exiting (not a signal). Result-state
|
||||
* only; lets a capable UI show an exit-status pill on the terminal card. Omit
|
||||
* when the command was killed by a signal or the exit code is unknown.
|
||||
*/
|
||||
exitCode?: number
|
||||
/**
|
||||
* Signal name that killed the process (e.g. `SIGTERM`), when it died by signal
|
||||
* rather than exiting. Result-state only; mutually exclusive with `exitCode`.
|
||||
*/
|
||||
signal?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* How a tool wants the COMPLETED call shown — the *result* state, after
|
||||
* `execute` returns. Lets the tool reformat its result for a UI distinctly from
|
||||
* the model-facing text it returned from `execute` (e.g. wrap command output in
|
||||
* a fenced ```console block for monospace rendering, which the model-facing
|
||||
* result must NOT carry). All fields optional: a UI keeps the pending-state
|
||||
* title and renders the raw result content for anything left unset.
|
||||
*/
|
||||
export interface ToolResultPresentation {
|
||||
/** Replacement title for the completed call (e.g. append an exit status). Omit to keep the pending-state title. */
|
||||
title?: string
|
||||
/**
|
||||
* UI-facing result content (harness {@link ContentBlock}s), reformatted from
|
||||
* the model-facing result. Omit to let the UI render the raw result content.
|
||||
* Stays in harness vocabulary; the UI maps these to its own content blocks.
|
||||
*/
|
||||
content?: ContentBlock[]
|
||||
/**
|
||||
* Terminal output/exit for a call the pending presentation marked as a
|
||||
* terminal (see {@link ToolCallPresentation.terminal}). A capable UI renders
|
||||
* `output` in the terminal card and shows the exit status; an incapable UI
|
||||
* uses `content` (the tool should supply a text fallback there too).
|
||||
*/
|
||||
terminal?: ToolTerminal
|
||||
}
|
||||
export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown }
|
||||
|
||||
/** A registered tool: its schema plus the execution function. */
|
||||
export interface ToolDefinition extends ToolSchema {
|
||||
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]>
|
||||
execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn>
|
||||
/**
|
||||
* Optional: how to present the PENDING state of one call in a UI, derived
|
||||
* from the call's `args` (parsed arguments, `unknown` — the tool validates/
|
||||
* narrows its own input). Returning `undefined` (or omitting the method) tells
|
||||
* a UI to fall back to a generic presentation (title = tool name, raw args as
|
||||
* input). Pure and side-effect-free: a UI may call it during live streaming
|
||||
* AND a session-log replay, so it must depend only on `args`.
|
||||
* Optional: how to present the PENDING state of one call in a UI, derived from
|
||||
* the call's `args` (parsed arguments, `unknown` — the tool validates/narrows
|
||||
* its own input). Returns a {@link ToolCallView} (a `card`-tagged render intent),
|
||||
* or `undefined` (or omit the method) to fall back to a generic presentation
|
||||
* (title = tool name, raw args as input). Pure and side-effect-free: a UI may
|
||||
* call it during live streaming AND a session-log replay, so it must depend
|
||||
* only on `args`.
|
||||
*/
|
||||
presentCall?(args: unknown): ToolCallPresentation | undefined
|
||||
presentCall?(args: unknown): ToolCallView | undefined
|
||||
/**
|
||||
* Optional: how to present the COMPLETED state, given the same `args` and the
|
||||
* `result` (`execute`'s content + whether it errored). Returning `undefined`
|
||||
* (or omitting the method) tells a UI to keep the pending title and render the
|
||||
* raw result content. Pure and side-effect-free for the same replay reason.
|
||||
* `result` (`execute`'s content + whether it errored). Returns a
|
||||
* {@link ToolResultView}, or `undefined` (or omit the method) to keep the
|
||||
* pending title and render the raw result content. Pure and side-effect-free
|
||||
* for the same replay reason.
|
||||
*/
|
||||
presentResult?(args: unknown, result: ToolResult): ToolResultPresentation | undefined
|
||||
presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined
|
||||
}
|
||||
|
||||
/** The completed outcome handed to {@link ToolDefinition.presentResult}. */
|
||||
@@ -204,9 +133,16 @@ export interface ToolResult {
|
||||
content: ContentBlock[]
|
||||
/** Whether the call failed. */
|
||||
isError: boolean
|
||||
/**
|
||||
* The tool-private presentation payload the tool attached from `execute` (via
|
||||
* the object return form), threaded verbatim from the `tool/result` event.
|
||||
* Opaque (`unknown`); the tool narrows it back to its own shape. Absent when
|
||||
* the tool attached none.
|
||||
*/
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
/** One pending tool call, as it flows through the execution waterfall. */
|
||||
/** One pending tool call, as it flows through the execution pipeline (`tools/pre-execute` → dispatch → `tools/post-execute`). */
|
||||
export interface ToolExecution {
|
||||
callId: CallId
|
||||
name: string
|
||||
@@ -247,8 +183,62 @@ export interface ToolExecutionResult {
|
||||
* text in `content` is always present; this is extra structure for code.
|
||||
*/
|
||||
error?: ToolErrorInfo
|
||||
/**
|
||||
/**
|
||||
* Extra model-facing context a `tools/post-execute` listener attached for the
|
||||
* NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part
|
||||
* of this call's `content` — `content`/`feedback` shape the tool RESULT, but
|
||||
* `additionalContext` is a SEPARATE `context/message`. A step can carry
|
||||
* multiple tool calls, so the loop BUFFERS every call's `additionalContext`
|
||||
* and appends them only AFTER all `tool/result`s for the step, keeping
|
||||
* tool-call/result adjacency intact. Carried on the result purely to ferry it
|
||||
* from `execute()` up to the loop's per-step buffer.
|
||||
*/
|
||||
additionalContext?: HookContext
|
||||
/**
|
||||
* The tool-private presentation payload from a successful `execute` (the object
|
||||
* return form). Threaded onto the `tool/result` session event and back into
|
||||
* {@link ToolResult} for `presentResult`. Opaque (`unknown`); absent when the
|
||||
* tool attached none or the call failed.
|
||||
*/
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* The decision a `tools/pre-execute` listener returns for one pending call.
|
||||
* Maps onto Claude Code's `PreToolUse` `permissionDecision`.
|
||||
*
|
||||
* - `allow` proceeds to dispatch. (Input rewrite — changing `exec.arguments` —
|
||||
* is deliberately NOT offered: `tool/call` and `assistant/message` are logged
|
||||
* BEFORE execution and live consumers, e.g. the ACP bridge and `dsh-tool-bash`
|
||||
* presentation, read the pre-execution arguments, so an execution-only rewrite
|
||||
* would desync the UI from what RAN. That consistency redesign is its own
|
||||
* `proposed` RFC; `TODO(pre-tool-input-rewrite)` anchors it at the call site.)
|
||||
* - `deny` skips dispatch; the loop records an `isError` result carrying `reason`.
|
||||
* - `ask` is the permission-prompt intent; until the permission system exists it
|
||||
* degrades to `deny` (`FIXME(permissions)`).
|
||||
*/
|
||||
export type PreToolDecision =
|
||||
| { kind: 'allow' }
|
||||
| { kind: 'deny'; reason: string }
|
||||
| { kind: 'ask'; reason?: string }
|
||||
|
||||
/**
|
||||
* The decision a `tools/post-execute` listener returns for one finished call.
|
||||
* Maps onto Claude Code's `PostToolUse` decision.
|
||||
*
|
||||
* - `accept` keeps the call successful; optional `content` REPLACES the
|
||||
* model-facing result (clean: `tool/result` is logged AFTER `execute()`
|
||||
* returns, so a replaced result is the single source of truth for both derived
|
||||
* history and UI). Optional `additionalContext` rides to the next request.
|
||||
* - `block` turns the call into an `isError` result whose content is the
|
||||
* corrective `feedback` (the model is told the call was rejected and why),
|
||||
* optionally also attaching `additionalContext`.
|
||||
*/
|
||||
export type PostToolDecision =
|
||||
| { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext }
|
||||
| { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext }
|
||||
|
||||
/**
|
||||
* Best-effort human-readable message from an arbitrary thrown value: Error
|
||||
* instances use `.message`; non-Error objects with a string `message`
|
||||
@@ -271,8 +261,9 @@ function errorInfo(error: unknown): ToolErrorInfo | undefined {
|
||||
|
||||
/**
|
||||
* Tool registry (`ctx.tools`): tool plugins register definitions; the agent
|
||||
* loop executes calls through the `tools/execute` waterfall. The registry
|
||||
* contributes its schemas into the system-prompt assembly.
|
||||
* loop executes calls through the `tools/pre-execute` → dispatch →
|
||||
* `tools/post-execute` pipeline. The registry contributes its schemas into the
|
||||
* system-prompt assembly.
|
||||
*/
|
||||
export class ToolRegistry extends Service {
|
||||
static inject = ['systemPrompt']
|
||||
@@ -289,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) {
|
||||
@@ -312,55 +306,144 @@ 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all registered tool schemas — exactly the model-facing fields
|
||||
* (`name`, `description`, `parameters`, and `strict` when set), as sent to the
|
||||
* model via the system-prompt assembly. Constructed EXPLICITLY rather than by
|
||||
* stripping known non-schema members: a `ToolDefinition` also carries
|
||||
* `execute` and the optional `presentCall`/`presentResult` UI callbacks, and
|
||||
* 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.
|
||||
* (`name`, `description`, `parameters`), as sent to the model via the
|
||||
* system-prompt assembly. Constructed EXPLICITLY rather than by stripping
|
||||
* known non-schema members: a `ToolDefinition` also carries `execute` and the
|
||||
* optional `presentCall`/`presentResult` UI callbacks, and 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 => ({
|
||||
return [...this.store.values()].map(({ name, description, parameters }): ToolSchema => ({
|
||||
name,
|
||||
description,
|
||||
parameters: structuredClone(parameters),
|
||||
...strict !== undefined ? { strict } : {},
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one tool call through the `tools/execute` waterfall. If the tool is
|
||||
* not registered, the result is an `isError` carrying a `UNKNOWN_TOOL`
|
||||
* structured error. If the tool or a waterfall listener throws, the error is
|
||||
* caught and returned as an `isError` result so the loop records a failed tool
|
||||
* call instead of failing the whole turn; a thrown {@link HarnessError}
|
||||
* Execute one tool call through the `tools/pre-execute` → dispatch →
|
||||
* `tools/post-execute` pipeline. The two waterfalls are the gate (allow/deny)
|
||||
* and the inspect/transform seam; core dispatch sits between them as plain
|
||||
* code. The whole thing is wrapped in one outer try/catch so a throwing
|
||||
* listener (in either waterfall) becomes an `isError` result instead of
|
||||
* failing the turn; the tool body ALSO keeps its own inner try/catch, so a
|
||||
* thrown tool becomes an `isError` result that `post-execute` listeners can
|
||||
* 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 {
|
||||
return await this.ctx.waterfall(this, 'tools/execute', exec, async (): Promise<ToolExecutionResult> => {
|
||||
try {
|
||||
const tool = this.store.get(exec.name)
|
||||
// Unknown tool routes through the same catch as a tool-thrown error, so
|
||||
// both failure classes get structured `{ name, code }` from one path.
|
||||
if (!tool) throw new ToolNotFoundError(exec.name)
|
||||
const content = await tool.execute(exec.arguments, exec)
|
||||
return { callId: exec.callId, content, isError: false }
|
||||
} catch (error: unknown) {
|
||||
return toolErrorResult(exec.callId, error)
|
||||
// --- Gate: tools/pre-execute. A deny (or an ask, which degrades to deny
|
||||
// until the permission system lands) skips dispatch entirely. ---
|
||||
const decision = await this.ctx.waterfall(
|
||||
this, 'tools/pre-execute', exec,
|
||||
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
|
||||
)
|
||||
if (decision.kind !== 'allow') {
|
||||
// deny → isError. ask has no permission UI yet, so degrade to deny
|
||||
// (FIXME(permissions)): a forthcoming permission system turns `ask` into
|
||||
// a real prompt; today it is the conservative "not allowed".
|
||||
const reason = decision.kind === 'deny'
|
||||
? decision.reason
|
||||
: decision.reason ?? `tool "${exec.name}" requires approval (not yet supported)`
|
||||
const denied: ToolExecutionResult = {
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: `Error: ${reason}` }],
|
||||
isError: true,
|
||||
}
|
||||
})
|
||||
return await this.postExecute(exec, denied)
|
||||
}
|
||||
|
||||
// --- Core dispatch (plain code between the waterfalls). The tool body's
|
||||
// own try/catch turns a throw into an isError result so post-execute can
|
||||
// inspect it; an unknown tool routes through the same catch. ---
|
||||
let result: ToolExecutionResult
|
||||
try {
|
||||
const tool = this.store.get(exec.name)
|
||||
if (!tool) throw new ToolNotFoundError(exec.name)
|
||||
// Normalize the two `execute` return shapes: a bare ContentBlock[] (no
|
||||
// meta) or a { content, meta } object (a tool attaching a private
|
||||
// presentation payload). An array IS the content; the object carries it.
|
||||
const returned = await tool.execute(exec.arguments, exec)
|
||||
const content = Array.isArray(returned) ? returned : returned.content
|
||||
const meta = Array.isArray(returned) ? undefined : returned.meta
|
||||
result = { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
|
||||
} catch (error: unknown) {
|
||||
result = toolErrorResult(exec.callId, error)
|
||||
}
|
||||
|
||||
return await this.postExecute(exec, result)
|
||||
} catch (error: unknown) {
|
||||
// Outer backstop: a throwing pre/post-execute listener (or the waterfall
|
||||
// machinery) becomes an isError result, never a turn failure.
|
||||
return toolErrorResult(exec.callId, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the `tools/post-execute` waterfall over a dispatched `result` and apply
|
||||
* its {@link PostToolDecision}: `accept` keeps the call successful (replacing
|
||||
* `content` when given), `block` turns it into an `isError` whose content is
|
||||
* the corrective `feedback`. Either decision may attach `additionalContext`,
|
||||
* which is ferried on the returned result for the loop's per-step buffer.
|
||||
* Runs inside `execute`'s outer try/catch (a throwing listener → isError).
|
||||
*/
|
||||
private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult> {
|
||||
// Snapshot the protected outcome BEFORE the waterfall. A listener receives
|
||||
// the same `result` reference, so a post-waterfall read of `result.callId`/
|
||||
// `.isError`/`.error` could carry a listener's mutation — violating the
|
||||
// authoritative-call-id requirement and the "preserve the dispatched
|
||||
// isError/error" contract. The decision is the ONLY sanctioned channel for a
|
||||
// listener to change the outcome (block, or accept-with-replacement); the
|
||||
// call id is always the authoritative `exec.callId`. `content` is copied into
|
||||
// a fresh array so a listener's in-place `push`/`splice` on `result.content`
|
||||
// cannot leak into the returned content either (the elements are the same
|
||||
// references — the snapshot guards the array structure, not deep immutability).
|
||||
const dispatched = {
|
||||
callId: exec.callId,
|
||||
content: [...result.content],
|
||||
isError: result.isError,
|
||||
...result.error ? { error: result.error } : {},
|
||||
...result.meta !== undefined ? { meta: result.meta } : {},
|
||||
}
|
||||
const decision = await this.ctx.waterfall(
|
||||
this, 'tools/post-execute', exec, result,
|
||||
() => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
|
||||
)
|
||||
const additionalContext = decision.additionalContext
|
||||
if (decision.kind === 'block') {
|
||||
return {
|
||||
callId: dispatched.callId,
|
||||
content: decision.feedback,
|
||||
isError: true,
|
||||
...additionalContext ? { additionalContext } : {},
|
||||
}
|
||||
}
|
||||
// accept: replace content if supplied, preserve the dispatched isError/error.
|
||||
return {
|
||||
...dispatched,
|
||||
...decision.content ? { content: decision.content } : {},
|
||||
...additionalContext ? { additionalContext } : {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toolErrorResult(callId: ToolExecution['callId'], error: unknown): ToolExecutionResult {
|
||||
|
||||
206
packages/core/tools/src/presentation.ts
Normal file
206
packages/core/tools/src/presentation.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Tool render-intent vocabulary: the provider-neutral types a tool declares via
|
||||
* `ToolDefinition.presentCall`/`ToolDefinition.presentResult` to say
|
||||
* how ONE of its calls renders in a UI (an editor's tool-call card, a CLI log
|
||||
* line). A UI bridge switches on the `card` tag to map each intent to its own
|
||||
* wire shape, so a UI never special-cases tool names.
|
||||
*
|
||||
* This is the UI-facing surface of `dsh-tools`, kept separate from the registry
|
||||
* and execution core in `index.ts`: this module owns ONLY presentation
|
||||
* vocabulary and references none of the execution types, so the dependency runs
|
||||
* one way (`index.ts` imports these views for the `ToolDefinition` method
|
||||
* signatures). The opaque `meta` presentation channel is execution plumbing and
|
||||
* lives with the registry in `index.ts`, not here.
|
||||
*
|
||||
* See the render-intent-union RFC
|
||||
* (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tools/src/presentation
|
||||
*/
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/**
|
||||
* Category of a tool call, used by a UI to pick an icon / treatment. A neutral
|
||||
* vocabulary owned here (NOT an ACP type) so tools describe themselves without
|
||||
* depending on any client protocol; a UI bridge maps it to its own enum. The
|
||||
* member set mirrors the common ACP `ToolKind` values; `other` is the default.
|
||||
*/
|
||||
export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'
|
||||
|
||||
/**
|
||||
* A file location a tool reads or modifies, so a capable UI can "follow along" —
|
||||
* highlight or jump to the file (and line) as the tool runs. Provider-neutral;
|
||||
* a UI bridge maps it to its own affordance (the ACP bridge forwards it as
|
||||
* `tool_call.locations`). `path` is what the tool operated on (the model-facing
|
||||
* path); `line` is an optional 1-based line to focus (e.g. a read's offset).
|
||||
*/
|
||||
export interface FileLocation {
|
||||
path: string
|
||||
line?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A single-file change a tool is about to make, for a UI that renders inline
|
||||
* diffs (an editor's diff card). Provider-neutral; the ACP bridge forwards it as
|
||||
* a `{ type: 'diff' }` tool-call content block. `oldText` is `null` for a
|
||||
* new-file create (nothing to diff against); an overwrite also uses `null`,
|
||||
* because a call-time presenter has no access to the file's prior content.
|
||||
*/
|
||||
export interface FileDiff {
|
||||
path: string
|
||||
/** Prior content, or `null` for a new file / an overwrite (no prior content available at call time). */
|
||||
oldText: string | null
|
||||
/** Content after the change. */
|
||||
newText: string
|
||||
}
|
||||
|
||||
/**
|
||||
* How a tool wants ONE of its calls shown in a UI (an editor's tool-call card, a
|
||||
* CLI log line) BEFORE the result is known — the *pending* state. A `card`-tagged
|
||||
* discriminated union: a tool declares its render INTENT once and a UI bridge
|
||||
* switches on `card` to map it to the bridge's own wire shape. Provider-neutral —
|
||||
* the tool owns its presentation, so a UI never special-cases tool names.
|
||||
*
|
||||
* Returned by `ToolDefinition.presentCall`. See the render-intent-union
|
||||
* RFC (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md).
|
||||
*/
|
||||
export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView
|
||||
|
||||
/**
|
||||
* The default card: a titled tool-call row with an optional category icon, a
|
||||
* salient raw input, extra content blocks, and follow-along file locations. Any
|
||||
* tool whose call is not a terminal or a diff uses this.
|
||||
*/
|
||||
export interface GenericCallView {
|
||||
card: 'generic'
|
||||
/**
|
||||
* Human-readable, always-visible label describing what THIS call does. Keep it
|
||||
* short — a UI shows it as a card header / log line.
|
||||
*/
|
||||
title: string
|
||||
/** Category for icon/treatment; defaults to `other` when omitted. */
|
||||
kind?: ToolCallKind
|
||||
/**
|
||||
* The salient input to surface in a detail/expanded view (e.g. a background
|
||||
* task id). Omit to show nothing; a string renders as-is, an object as pretty
|
||||
* JSON. NOT the full raw args object unless that is genuinely what a reader wants.
|
||||
*/
|
||||
rawInput?: unknown
|
||||
/**
|
||||
* UI-facing content blocks to show on the pending call alongside the title.
|
||||
* Omit to show none. A UI maps these to its own content blocks.
|
||||
*/
|
||||
content?: ContentBlock[]
|
||||
/** Files this call reads/modifies, for editor follow-along. Omit for a call that touches no file. */
|
||||
locations?: FileLocation[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A call that IS a shell command running in a working directory: a capable UI
|
||||
* renders it as a terminal card (cwd-headed, with the command as the title and
|
||||
* live/afterward output from the {@link TerminalResultView}); an incapable UI
|
||||
* falls back to a generic card whose body is the fenced command output. Set by a
|
||||
* tool whose call is a foreground command (e.g. `bash`).
|
||||
*/
|
||||
export interface TerminalCallView {
|
||||
card: 'terminal'
|
||||
/** The command, shown as the terminal card's title / header line. */
|
||||
title: string
|
||||
/**
|
||||
* A human-readable one-line summary of what the command does, rendered ABOVE
|
||||
* the terminal card (the card itself has no description slot). Omit for none.
|
||||
*/
|
||||
description?: string
|
||||
/**
|
||||
* Working directory the command runs in, shown as the terminal header. An
|
||||
* ABSOLUTE path is used as-is; a RELATIVE path is resolved by the UI bridge
|
||||
* against the session workspace (the pure presenter can't see the session cwd).
|
||||
* Omit entirely to let the bridge use the session workspace.
|
||||
*/
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A call that creates or modifies files, rendered as an inline diff card by a
|
||||
* capable UI. Set by a tool whose call writes/edits a file (e.g. `write`,
|
||||
* `edit`). The diffs are derived from the call ARGUMENTS (a create's `oldText` is
|
||||
* `null`); the tool emits a separate {@link DiffResultView} after `execute` — the
|
||||
* applied change (an edit/overwrite hunk with context, or a whole-file diff for a
|
||||
* create).
|
||||
*/
|
||||
export interface DiffCallView {
|
||||
card: 'diff'
|
||||
/** Card header (e.g. `Write foo.txt`). */
|
||||
title: string
|
||||
/** One entry per file the call changes. */
|
||||
diffs: FileDiff[]
|
||||
/** Files this call modifies, for editor follow-along (usually the diffs' paths). */
|
||||
locations?: FileLocation[]
|
||||
}
|
||||
|
||||
/**
|
||||
* How a tool wants the COMPLETED call shown — the *result* state, after `execute`
|
||||
* returns. A `card`-tagged union mirroring {@link ToolCallView}: a UI switches on
|
||||
* `card`. Lets the tool reformat its result for a UI distinctly from the
|
||||
* model-facing text it returned from `execute`. Returned by
|
||||
* `ToolDefinition.presentResult`; omitting the method keeps the pending
|
||||
* title and renders the raw result content.
|
||||
*/
|
||||
export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView
|
||||
|
||||
/**
|
||||
* The default completed card: an optional replacement title and reformatted
|
||||
* content. Omit a field to keep the pending title / render the raw result content.
|
||||
*/
|
||||
export interface GenericResultView {
|
||||
card: 'generic'
|
||||
/** Replacement title for the completed call. Omit to keep the pending-state title. */
|
||||
title?: string
|
||||
/**
|
||||
* UI-facing result content (harness {@link ContentBlock}s), reformatted from
|
||||
* the model-facing result. Omit to let the UI render the raw result content.
|
||||
*/
|
||||
content?: ContentBlock[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The completed state of a {@link TerminalCallView}: the captured output and exit
|
||||
* status. A capable UI renders `output` in the terminal card and shows an
|
||||
* exit-status pill; an incapable UI gets a fenced ```console fallback the BRIDGE
|
||||
* derives from `output` (the tool does not double-encode it).
|
||||
*/
|
||||
export interface TerminalResultView {
|
||||
card: 'terminal'
|
||||
/** Replacement title for the completed call. Omit to keep the pending-state title. */
|
||||
title?: string
|
||||
/** Captured command output (stdout+stderr as the tool chooses to combine them). */
|
||||
output?: string
|
||||
/**
|
||||
* Process exit code, when the run ended by exiting (not a signal). Lets a
|
||||
* capable UI show an exit-status pill. Omit when killed by a signal or unknown.
|
||||
*/
|
||||
exitCode?: number
|
||||
/** Signal name that killed the process (e.g. `SIGTERM`). Mutually exclusive with `exitCode`. */
|
||||
signal?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A completed file mutation rendered as an inline diff card, the *result-time*
|
||||
* analogue of {@link DiffCallView}. Set by a tool whose `execute` applied a file
|
||||
* change (e.g. `write`, `edit`): `diffs` are the change to show — typically the
|
||||
* APPLIED hunks computed from the before/after content (one entry per hunk, each
|
||||
* with surrounding context lines), so the editor shows the real change in place;
|
||||
* a tool with no before-image (e.g. a file create) may instead give a whole-file
|
||||
* diff (`oldText: null`). A `tool_call_update`'s content REPLACES the call's
|
||||
* content in an editor, so a mutation tool returns this even when it duplicates
|
||||
* the call-time snippet — otherwise the model-facing result text would replace
|
||||
* (clobber) the pending diff card.
|
||||
*/
|
||||
export interface DiffResultView {
|
||||
card: 'diff'
|
||||
/** Replacement title for the completed call. Omit to keep the pending-state title. */
|
||||
title?: string
|
||||
/** The change to show, in file order — applied contextual hunks, or a whole-file diff when there is no before-image. */
|
||||
diffs: FileDiff[]
|
||||
}
|
||||
@@ -19,9 +19,9 @@
|
||||
* @module dsh-tools/schema
|
||||
*/
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolCallPresentation, ToolDefinition, ToolExecution, ToolResult, ToolResultPresentation } from './index.ts'
|
||||
import type { ToolDefinition, ToolExecuteReturn, ToolExecution, ToolResult } from './index.ts'
|
||||
import type { ToolCallView, ToolResultView } from './presentation.ts'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SchemaSpec — the author-facing per-property type
|
||||
@@ -182,7 +182,7 @@ export function schemaSpecToJsonSchema(spec: SchemaSpec): JsonSchemaObject {
|
||||
/**
|
||||
* Thrown by a {@link defineTool} tool when the model-generated arguments don't
|
||||
* match the declared {@link SchemaSpec}. Extends {@link HarnessError}
|
||||
* (`code: 'INVALID_ARGS'`); the registry's execute waterfall catches it and
|
||||
* (`code: 'INVALID_ARGS'`); the registry's execution pipeline catches it and
|
||||
* returns an `isError` ToolExecutionResult carrying the structured error, so
|
||||
* the model can self-correct and downstream plugins can route on the code.
|
||||
*/
|
||||
@@ -291,27 +291,27 @@ export interface DefineToolOptions<S extends SchemaSpec> {
|
||||
parameters: S
|
||||
/**
|
||||
* Tool execution function. `args` is typed as {@link InferArgs<S>} — zero
|
||||
* casts needed.
|
||||
* casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing
|
||||
* content only) or a `{ content, meta }` object to also attach a tool-private
|
||||
* presentation payload (see {@link ToolExecuteReturn}).
|
||||
*/
|
||||
execute(args: InferArgs<S>, exec: ToolExecution): Promise<ContentBlock[]>
|
||||
execute(args: InferArgs<S>, exec: ToolExecution): Promise<ToolExecuteReturn>
|
||||
/**
|
||||
* Optional: how to present the PENDING state of one call in a UI (an editor
|
||||
* tool-call card, a CLI log line). `args` is the typed, schema-validated
|
||||
* argument shape — zero casts. Pure and side-effect-free: a UI may call it
|
||||
* during live streaming AND a session-log replay, so depend only on `args`.
|
||||
* The tool owns its presentation so a UI never special-cases tool names. See
|
||||
* {@link ToolCallPresentation}.
|
||||
* {@link ToolCallView}.
|
||||
*/
|
||||
presentCall?(args: InferArgs<S>): ToolCallPresentation | undefined
|
||||
presentCall?(args: InferArgs<S>): ToolCallView | undefined
|
||||
/**
|
||||
* Optional: how to present the COMPLETED state, given the typed `args` and the
|
||||
* `result`. Use it to reformat result content for a UI distinctly from the
|
||||
* model-facing text (e.g. a fenced ```console block). Pure and side-effect-
|
||||
* free for the same replay reason. See {@link ToolResultPresentation}.
|
||||
* free for the same replay reason. See {@link ToolResultView}.
|
||||
*/
|
||||
presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultPresentation | undefined
|
||||
/** Whether the tool requires structured output (default false). */
|
||||
strict?: boolean
|
||||
presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultView | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -353,8 +353,7 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
|
||||
name: options.name,
|
||||
description: options.description,
|
||||
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
|
||||
...options.strict !== undefined ? { strict: options.strict } : {},
|
||||
async execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
|
||||
async execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> {
|
||||
// Validate the model-generated args before the typed body runs. On
|
||||
// mismatch we throw ToolArgsError; the registry turns it into an
|
||||
// isError result so the model can self-correct. After this guard, the
|
||||
@@ -369,13 +368,13 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
|
||||
// fall back to `undefined` (a generic UI presentation) on any mismatch, rather
|
||||
// than the hard `ToolArgsError` the execute path raises.
|
||||
if (userPresentCall) {
|
||||
tool.presentCall = (args: unknown): ToolCallPresentation | undefined => {
|
||||
tool.presentCall = (args: unknown): ToolCallView | undefined => {
|
||||
if (validateArgs(options.parameters, args).length > 0) return undefined
|
||||
return userPresentCall(args as InferArgs<S>)
|
||||
}
|
||||
}
|
||||
if (userPresentResult) {
|
||||
tool.presentResult = (args: unknown, result: ToolResult): ToolResultPresentation | undefined => {
|
||||
tool.presentResult = (args: unknown, result: ToolResult): ToolResultView | undefined => {
|
||||
if (validateArgs(options.parameters, args).length > 0) return undefined
|
||||
return userPresentResult(args as InferArgs<S>, result)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
|
||||
expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'subagent', 'todo_write'])
|
||||
expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write'])
|
||||
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
|
||||
for (const entry of catalog) {
|
||||
for (const schema of entry.schemas) {
|
||||
@@ -93,25 +93,17 @@ describe('gen-tool-catalog render', () => {
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-demo',
|
||||
source: 'packages/demo/tool-demo/src/index.ts',
|
||||
requires: ['ctx.tools'],
|
||||
writes: ['tool/result'],
|
||||
schemas: [{ name: 'demo', description: 'A demo tool.', parameters: { type: 'object', properties: {} } }],
|
||||
},
|
||||
]
|
||||
const md = render(catalog)
|
||||
expect(md).toContain('| `@deepseek-ai/dsh-tool-demo` | `demo` | `ctx.tools` | `tool/result` |')
|
||||
expect(md).toContain('## `@deepseek-ai/dsh-tool-demo`')
|
||||
expect(md).toContain('### `demo`')
|
||||
expect(md).toContain('A demo tool.')
|
||||
expect(md).toContain('```json')
|
||||
expect(md).toContain('Source: [`packages/demo/tool-demo/src/index.ts`]')
|
||||
})
|
||||
|
||||
it('renders the strict flag when a schema sets it', () => {
|
||||
const catalog: ToolCatalog = [
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-demo',
|
||||
source: 'packages/demo/tool-demo/src/index.ts',
|
||||
schemas: [{ name: 'demo', description: '', parameters: { type: 'object', properties: {} }, strict: true }],
|
||||
},
|
||||
]
|
||||
expect(render(catalog)).toContain('Strict: `true`')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, {
|
||||
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
|
||||
type InferArgs, type SchemaSpec, type ToolExecutionResult,
|
||||
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
|
||||
} from '@deepseek-ai/dsh-tools'
|
||||
|
||||
async function setup() {
|
||||
@@ -52,8 +52,8 @@ describe('ToolRegistry', () => {
|
||||
description: 'has presenters',
|
||||
parameters: { x: { type: 'string', required: true } },
|
||||
async execute() { return [] },
|
||||
presentCall: args => ({ title: args.x }),
|
||||
presentResult: (args, result) => ({ title: args.x, content: result.content }),
|
||||
presentCall: args => ({ card: 'generic', title: args.x }),
|
||||
presentResult: (args, result) => ({ card: 'generic', title: args.x, content: result.content }),
|
||||
}))
|
||||
const schema = ctx.tools.schemas()[0] as unknown as Record<string, unknown>
|
||||
expect(Object.keys(schema).sort()).toEqual(['description', 'name', 'parameters'])
|
||||
@@ -62,18 +62,6 @@ describe('ToolRegistry', () => {
|
||||
expect(schema.execute).toBeUndefined()
|
||||
})
|
||||
|
||||
it('schemas() preserves `strict` when set (allowlist keeps the model-facing fields)', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'strict-tool',
|
||||
description: 'd',
|
||||
parameters: { x: { type: 'string', required: true } },
|
||||
strict: true,
|
||||
async execute() { return [] },
|
||||
}))
|
||||
expect(ctx.tools.schemas()[0]).toMatchObject({ name: 'strict-tool', strict: true })
|
||||
})
|
||||
|
||||
it('executes a tool and returns its content', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
@@ -81,6 +69,38 @@ describe('ToolRegistry', () => {
|
||||
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false })
|
||||
})
|
||||
|
||||
it('threads a tool-attached meta (object return form) onto the result', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'meta-tool',
|
||||
async execute() {
|
||||
return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] } }
|
||||
},
|
||||
})
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} })
|
||||
expect(result).toEqual({
|
||||
callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
isError: false,
|
||||
meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] },
|
||||
})
|
||||
})
|
||||
|
||||
it('omits meta when the object return form supplies none', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'no-meta-tool',
|
||||
async execute() {
|
||||
return { content: [{ type: 'text', text: 'ok' }] }
|
||||
},
|
||||
})
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'no-meta-tool', arguments: {} })
|
||||
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false })
|
||||
expect('meta' in result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns isError results for unknown tools and throwing tools', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
@@ -112,53 +132,150 @@ describe('ToolRegistry', () => {
|
||||
expect(err.message).toBe('unknown tool "ghost"')
|
||||
})
|
||||
|
||||
it('lets tools/execute waterfall listeners veto a call (permission pattern)', async () => {
|
||||
it('lets a tools/pre-execute listener deny a call (permission pattern)', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
|
||||
if (exec.name === 'echo') {
|
||||
return {
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: 'denied by policy' }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
||||
if (exec.name === 'echo') return { kind: 'deny', reason: 'denied by policy' }
|
||||
return next()
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: 'denied by policy' })
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' })
|
||||
})
|
||||
|
||||
it('composes multiple tools/execute listeners (sandbox-wrap pattern)', async () => {
|
||||
it('an ask decision degrades to deny until the permission system lands', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> =>
|
||||
({ kind: 'ask', reason: 'needs approval' }))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: needs approval' })
|
||||
})
|
||||
|
||||
it('an ask decision with no reason degrades to deny with a default message', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval (not yet supported)' })
|
||||
})
|
||||
|
||||
it('a tools/post-execute listener can replace the result content (accept) ', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
|
||||
({ kind: 'accept', content: [{ type: 'text', text: 'rewritten' }] }))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content[0]).toMatchObject({ text: 'rewritten' })
|
||||
})
|
||||
|
||||
it('a tools/post-execute block turns the call into an isError with corrective feedback', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
|
||||
({ kind: 'block', feedback: [{ type: 'text', text: 'output rejected: try again' }] }))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: 'output rejected: try again' })
|
||||
})
|
||||
|
||||
it('a block decision can ALSO attach additionalContext', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
|
||||
({
|
||||
kind: 'block',
|
||||
feedback: [{ type: 'text', text: 'rejected' }],
|
||||
additionalContext: { content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } },
|
||||
}))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: 'rejected' })
|
||||
expect(result.additionalContext).toMatchObject({ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } })
|
||||
})
|
||||
|
||||
it('a post-execute additionalContext rides on the result for the loop to buffer', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
|
||||
({ kind: 'accept', additionalContext: { content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } } }))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
expect(result.additionalContext).toMatchObject({ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } })
|
||||
})
|
||||
|
||||
it('a post-execute listener mutating the result object cannot corrupt callId/isError/error', async () => {
|
||||
// The decision is the ONLY sanctioned channel to change the outcome. A
|
||||
// listener that reaches in and mutates the passed result reference (flipping
|
||||
// isError, rewriting callId, attaching a bogus error) must NOT affect what
|
||||
// execute() returns — the registry snapshots the authoritative fields before
|
||||
// the waterfall and rebuilds from the snapshot + decision.
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
ctx.on('tools/post-execute', async (_exec, result, next) => {
|
||||
const mutable = result as { callId: string; isError: boolean; error?: unknown; content: unknown[] }
|
||||
mutable.callId = 'hijacked'
|
||||
mutable.isError = true
|
||||
mutable.error = { name: 'Evil', code: 'EVIL' }
|
||||
mutable.content.push({ type: 'text', text: 'INJECTED' }) // in-place array mutation
|
||||
return next() // delegate to the default accept — no decision-level override
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
expect(result.callId).toBe(CallId('c1')) // authoritative exec.callId, not 'hijacked'
|
||||
expect(result.isError).toBe(false) // the real (successful) dispatch outcome
|
||||
expect(result.error).toBeUndefined() // no listener-injected error
|
||||
expect(result.content).toHaveLength(1) // the in-place push did not leak in
|
||||
expect(result.content[0]).toMatchObject({ text: 'hi' })
|
||||
expect(result.content.some(b => (b as { text?: string }).text === 'INJECTED')).toBe(false)
|
||||
})
|
||||
|
||||
it('composes pre + post waterfalls around dispatch (sandbox-wrap pattern)', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
const order: string[] = []
|
||||
ctx.on('tools/execute', async (_exec, next) => {
|
||||
order.push('first:before')
|
||||
const result = await next()
|
||||
order.push('first:after')
|
||||
return result
|
||||
ctx.on('tools/pre-execute', async (_exec, next) => {
|
||||
order.push('pre:before')
|
||||
const decision = await next()
|
||||
order.push('pre:after')
|
||||
return decision
|
||||
})
|
||||
ctx.on('tools/execute', async (_exec, next) => {
|
||||
order.push('second:before')
|
||||
const result = await next()
|
||||
order.push('second:after')
|
||||
return result
|
||||
ctx.on('tools/post-execute', async (_exec, _result, next) => {
|
||||
order.push('post:before')
|
||||
const decision = await next()
|
||||
order.push('post:after')
|
||||
return decision
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'x' } })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(order).toEqual(['first:before', 'second:before', 'second:after', 'first:after'])
|
||||
// pre runs fully (gate) before dispatch, then post runs over the result.
|
||||
expect(order).toEqual(['pre:before', 'pre:after', 'post:before', 'post:after'])
|
||||
})
|
||||
|
||||
it('returns an isError result when a tools/execute listener throws', async () => {
|
||||
it('returns an isError result when a tools/pre-execute listener throws', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.on('tools/execute', async () => {
|
||||
ctx.on('tools/pre-execute', async () => {
|
||||
throw new Error('permission hook broke')
|
||||
})
|
||||
|
||||
@@ -171,10 +288,26 @@ describe('ToolRegistry', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves structured error info when a tools/execute listener throws HarnessError', async () => {
|
||||
it('returns an isError result when a tools/post-execute listener throws', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.on('tools/execute', async () => {
|
||||
ctx.on('tools/post-execute', async () => {
|
||||
throw new Error('post hook broke')
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
|
||||
expect(result).toEqual({
|
||||
callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: 'Error: post hook broke' }],
|
||||
isError: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves structured error info when a tools/pre-execute listener throws HarnessError', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.on('tools/pre-execute', async () => {
|
||||
throw new HarnessError('denied', 'DENIED')
|
||||
})
|
||||
|
||||
@@ -466,44 +599,6 @@ describe('schema DSL edge cases', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('defineTool passes through strict flag when set to true', () => {
|
||||
const tool = defineTool({
|
||||
name: 'strict-tool',
|
||||
description: 'A strict tool',
|
||||
parameters: { input: { type: 'string' } },
|
||||
strict: true,
|
||||
async execute(args) {
|
||||
return [{ type: 'text' as const, text: args.input ?? '' }]
|
||||
},
|
||||
})
|
||||
expect(tool.strict).toBe(true)
|
||||
})
|
||||
|
||||
it('defineTool omits strict when not provided', () => {
|
||||
const tool = defineTool({
|
||||
name: 'non-strict-tool',
|
||||
description: 'A non-strict tool',
|
||||
parameters: { input: { type: 'string' } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text' as const, text: args.input ?? '' }]
|
||||
},
|
||||
})
|
||||
expect('strict' in tool).toBe(false)
|
||||
})
|
||||
|
||||
it('defineTool strict=false is included', () => {
|
||||
const tool = defineTool({
|
||||
name: 'explicitly-non-strict',
|
||||
description: 'Explicitly non-strict',
|
||||
parameters: { input: { type: 'string' } },
|
||||
strict: false,
|
||||
async execute(args) {
|
||||
return [{ type: 'text' as const, text: args.input ?? '' }]
|
||||
},
|
||||
})
|
||||
expect(tool.strict).toBe(false)
|
||||
})
|
||||
|
||||
it('handles enum and default together in one property', () => {
|
||||
const spec = {
|
||||
level: { type: 'string', enum: ['low', 'high'], default: 'low' },
|
||||
@@ -906,15 +1001,15 @@ describe('defineTool presentation (presentCall / presentResult)', () => {
|
||||
presentCall(args) {
|
||||
// args is typed { path: string; n?: number } — zero casts.
|
||||
expectTypeOf(args).toEqualTypeOf<{ path: string; n?: number }>()
|
||||
return { title: `Open ${args.path}`, kind: 'read', rawInput: args.path }
|
||||
return { card: 'generic', title: `Open ${args.path}`, kind: 'read', rawInput: args.path }
|
||||
},
|
||||
presentResult(args, result) {
|
||||
return { title: `Opened ${args.path}`, content: result.content }
|
||||
return { card: 'generic', title: `Opened ${args.path}`, content: result.content }
|
||||
},
|
||||
})
|
||||
expect(tool.presentCall!({ path: '/a', n: 2 })).toEqual({ title: 'Open /a', kind: 'read', rawInput: '/a' })
|
||||
expect(tool.presentCall!({ path: '/a', n: 2 })).toEqual({ card: 'generic', title: 'Open /a', kind: 'read', rawInput: '/a' })
|
||||
expect(tool.presentResult!({ path: '/a' }, { content: [{ type: 'text', text: 'x' }], isError: false }))
|
||||
.toEqual({ title: 'Opened /a', content: [{ type: 'text', text: 'x' }] })
|
||||
.toEqual({ card: 'generic', title: 'Opened /a', content: [{ type: 'text', text: 'x' }] })
|
||||
})
|
||||
|
||||
it('a tool without presentCall/presentResult leaves them undefined (UI falls back generically)', () => {
|
||||
@@ -934,8 +1029,8 @@ describe('defineTool presentation (presentCall / presentResult)', () => {
|
||||
description: 'demo',
|
||||
parameters: { path: { type: 'string', required: true } },
|
||||
async execute() { return [] },
|
||||
presentCall: args => ({ title: args.path }),
|
||||
presentResult: (args, result) => ({ title: args.path, content: result.content }),
|
||||
presentCall: args => ({ card: 'generic', title: args.path }),
|
||||
presentResult: (args, result) => ({ card: 'generic', title: args.path, content: result.content }),
|
||||
})
|
||||
// Unlike execute (which throws ToolArgsError on a mismatch), the display
|
||||
// methods soft-validate and fall back to undefined so a UI never crashes
|
||||
|
||||
12
packages/fs/README.md
Normal file
12
packages/fs/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# fs/ - filesystem capability family
|
||||
|
||||
The filesystem stack: a provider seam (text IO + atomic mutation with an optional version guard), a local implementation, a policy gate plugin (observed-state + read-before-edit + version-guarded write/edit), and the model-facing file tools + executor. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `fs/` | Provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` policy events | `ctx.fs` |
|
||||
| `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) |
|
||||
| `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) |
|
||||
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it.
|
||||
26
packages/fs/fs-local/README.md
Normal file
26
packages/fs/fs-local/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# @deepseek-ai/dsh-fs-local
|
||||
|
||||
The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the seven `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
|
||||
|
||||
```ts ignore-check
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
// ctx.fs is now the local backend; load @deepseek-ai/dsh-fs-policy for the
|
||||
// freshness policy gate and @deepseek-ai/dsh-tool-fs to expose read/write/edit.
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path.
|
||||
- **`stat`** — returns `FsInfo` (`version` = `mtimeMs:size`, `type` of `file`/`directory`/`other`, byte `size`) or `undefined` when the target is absent.
|
||||
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing.
|
||||
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`.
|
||||
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
|
||||
- **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`).
|
||||
|
||||
## `cwd` is not a sandbox
|
||||
|
||||
`config.cwd` is a resolution default, not a containment boundary — absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall. See [the filesystem capability-seam RFC's Consequences section](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences).
|
||||
|
||||
The raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring.
|
||||
36
packages/fs/fs-local/package.json
Normal file
36
packages/fs/fs-local/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-fs-local",
|
||||
"description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
503
packages/fs/fs-local/src/fsio.ts
Normal file
503
packages/fs/fs-local/src/fsio.ts
Normal file
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* Cordis-free local-filesystem I/O for `@deepseek-ai/dsh-fs-local`. Kept
|
||||
* separate from the service class (mirroring `dsh-bash-local`'s `run.ts`) so
|
||||
* the raw stat/read/write/edit mechanics can be unit-tested without a Context.
|
||||
*
|
||||
* This is the PROVIDER layer: it hands back decoded whole-file text (validated
|
||||
* UTF-8, binary rejected) — never line windows or numbered lines, which are
|
||||
* model-facing read policy owned by `@deepseek-ai/dsh-fs-policy`. Large files
|
||||
* stream their text in chunks so a huge file never has to be held whole in
|
||||
* memory; the binary/NUL sample and cross-chunk UTF-8 decoding stay here.
|
||||
*
|
||||
* Writes are atomic: content goes to a temp file opened exclusively (`wx`,
|
||||
* `0o600`, so a pre-existing path can never be clobbered and write-in-progress
|
||||
* bytes stay owner-only) inside a randomly-named private staging directory
|
||||
* (`0o700`) next to the target, then `rename`d over the target. Edits are
|
||||
* read-modify-write over the same atomic primitive.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs-local/fsio
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { chmod, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
|
||||
import type { Dirent, Stats } from 'node:fs'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { TextDecoder } from 'node:util'
|
||||
import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
const BINARY_SAMPLE_BYTES = 8192
|
||||
|
||||
function isENOENT(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
|
||||
}
|
||||
|
||||
/**
|
||||
* A path component that is expected to be a directory is a regular file (e.g.
|
||||
* resolving `afile/child.txt` when `afile` is a file). Like `ENOENT`, the target
|
||||
* cannot exist — so the resolution/probe paths treat it as "absent" rather than
|
||||
* letting a raw Node error escape without the structured `FsError` taxonomy.
|
||||
*/
|
||||
function isENOTDIR(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOTDIR'
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError'
|
||||
}
|
||||
|
||||
/* v8 ignore start -- composes secondary cleanup-failure messages, which require a filesystem/kernel fault after the primary failure. */
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
function isPermissionError(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && (error.code === 'EACCES' || error.code === 'EPERM')
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal | undefined, verb: string): void {
|
||||
if (signal?.aborted) throw new FsError(`${verb} aborted`, 'FS_ABORTED')
|
||||
}
|
||||
|
||||
/**
|
||||
* `readFile` with the supplied signal, translating a mid-read `AbortError` into
|
||||
* the seam's structured `FsError('FS_ABORTED')` (Node rejects an aborted
|
||||
* `readFile` with a bare `AbortError`, which would otherwise escape the seam's
|
||||
* error taxonomy — the streaming/write paths translate it the same way).
|
||||
*/
|
||||
async function readFileAbortable(absolutePath: string, verb: 'read' | 'edit', signal?: AbortSignal): Promise<Buffer> {
|
||||
try {
|
||||
return await readFile(absolutePath, signal ? { signal } : {})
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 2 -- a non-abort readFile rejection needs a permission/IO fault racing an open file. */
|
||||
if (!isAbortError(error)) throw error
|
||||
throw new FsError(`${verb} aborted`, 'FS_ABORTED')
|
||||
}
|
||||
}
|
||||
|
||||
/** Opaque version token from a stat: mtime (ns precision) + size. */
|
||||
function versionOf(info: Stats): FsVersion {
|
||||
return FsVersion(`${info.mtimeMs}:${info.size}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seam: lets specs pin the atomic-write temp names (to prove
|
||||
* exclusive-open behavior without a name race) and observe the staged temp
|
||||
* file before it is renamed over the target.
|
||||
*/
|
||||
export interface FsIoInternals {
|
||||
/** Override the generated private staging-dir name (relative to the target dir). */
|
||||
tempDirName?: (writePath: string) => string
|
||||
/** Override the generated temp-file name (relative to the private staging dir). */
|
||||
tempName?: (writePath: string) => string
|
||||
/** Test hook after the temp file is written/synced but before final chmod+rename. */
|
||||
inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** A resolved local path: the absolute path shown to callers and its realpath identity. */
|
||||
export interface LocalTarget {
|
||||
/** Absolute path (symlinks not resolved) — used for display. */
|
||||
displayPath: string
|
||||
/** Realpath identity — used as the stable target key and the I/O path. */
|
||||
targetKey: FsTargetKey
|
||||
}
|
||||
|
||||
/** Result of probing a path: null when it does not exist. */
|
||||
export interface PathInfo {
|
||||
version: FsVersion
|
||||
mode: number
|
||||
type: 'file' | 'directory' | 'other'
|
||||
size: number
|
||||
}
|
||||
|
||||
/** One local directory child with a resolved target and cheap metadata. */
|
||||
export interface LocalDirEntry {
|
||||
name: string
|
||||
type: 'file' | 'directory' | 'other'
|
||||
target: LocalTarget
|
||||
version?: FsVersion
|
||||
size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path to its absolute display path and realpath identity. Relative
|
||||
* paths are based on `cwd`. When the file itself does not yet exist, the
|
||||
* `targetKey` realpaths the nearest EXISTING ancestor directory and re-appends
|
||||
* the still-missing suffix, so a not-yet-created file gets the same stable key
|
||||
* it will have after creation — even when an ancestor (e.g. `cwd`) is a symlink
|
||||
* and intermediate directories are created by the write. Two input paths
|
||||
* reaching the same file via symlinks share one key. Falls back to the absolute
|
||||
* path only when no ancestor (not even the filesystem root) can be resolved.
|
||||
*/
|
||||
export async function resolveLocalTarget(cwd: string, path: string): Promise<LocalTarget> {
|
||||
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
|
||||
const displayPath = resolve(cwd, path)
|
||||
try {
|
||||
// Prefer the file's own realpath (resolves a symlinked file to its target).
|
||||
return { displayPath, targetKey: FsTargetKey(await realpath(displayPath)) }
|
||||
} catch (error: unknown) {
|
||||
// A path component is a file, not a directory (e.g. "afile/child.txt" where
|
||||
// "afile" is a regular file): the target can neither exist nor be created,
|
||||
// so surface the structured taxonomy instead of a raw Node ENOTDIR.
|
||||
if (isENOTDIR(error)) throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, 'FS_NOT_FOUND')
|
||||
/* v8 ignore next -- non-ENOENT realpath failure needs a permission/IO fault; ENOENT falls through to ancestor resolution. */
|
||||
if (!isENOENT(error)) throw error
|
||||
}
|
||||
// File absent: realpath the nearest existing ancestor and re-append the
|
||||
// missing suffix (the file basename plus any not-yet-created intermediate
|
||||
// dirs), so the key is stable across creation of those dirs.
|
||||
const missing = [basename(displayPath)]
|
||||
let ancestor = dirname(displayPath)
|
||||
while (true) {
|
||||
try {
|
||||
const realAncestor = await realpath(ancestor)
|
||||
return { displayPath, targetKey: FsTargetKey(join(realAncestor, ...missing)) }
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- a non-ENOENT realpath failure needs a permission/IO fault. */
|
||||
if (!isENOENT(error)) throw error
|
||||
const parent = dirname(ancestor)
|
||||
/* v8 ignore next -- the filesystem root always realpaths, so the walk terminates before parent === ancestor. */
|
||||
if (parent === ancestor) return { displayPath, targetKey: FsTargetKey(displayPath) }
|
||||
missing.unshift(basename(ancestor))
|
||||
ancestor = parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Probe a path for its version, mode, type, and size. Null if absent. */
|
||||
export async function probe(absolutePath: string): Promise<PathInfo | null> {
|
||||
try {
|
||||
const info = await stat(absolutePath)
|
||||
const type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other'
|
||||
return { version: versionOf(info), mode: info.mode & 0o777, type, size: info.size }
|
||||
} catch (error: unknown) {
|
||||
// ENOENT (no such file) and ENOTDIR (a parent segment is a file) both mean
|
||||
// the target is absent; any other stat failure is a real permission/IO fault.
|
||||
/* v8 ignore next -- a non-ENOENT/ENOTDIR stat failure needs a permission/IO fault; surface it. */
|
||||
if (!isENOENT(error) && !isENOTDIR(error)) throw error
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// --- Directory listing ---
|
||||
|
||||
function listingIoError(displayPath: string, error: unknown): FsError {
|
||||
/* v8 ignore next -- defensive pass-through for races where a child resolver has already produced a structured FsError. */
|
||||
if (error instanceof FsError) return error
|
||||
/* v8 ignore next -- requires the listed target/parent to disappear between successful preflight and listing/child resolution. */
|
||||
if (isENOENT(error) || isENOTDIR(error)) return new FsError(`cannot list "${displayPath}": not found`, 'FS_NOT_FOUND', { cause: error })
|
||||
if (isPermissionError(error)) return new FsError(`cannot list "${displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error })
|
||||
return new FsError(`cannot list "${displayPath}": ${errorMessage(error)}`, 'FS_IO_ERROR', { cause: error })
|
||||
}
|
||||
|
||||
async function resolveListedChildTarget(parent: LocalTarget, name: string): Promise<LocalTarget> {
|
||||
const identity = await resolveLocalTarget(parent.targetKey, name)
|
||||
return { displayPath: join(parent.displayPath, name), targetKey: identity.targetKey }
|
||||
}
|
||||
|
||||
/**
|
||||
* List direct children of a directory in stable name order. Each child includes
|
||||
* a resolved target plus stat metadata when still available; file contents are
|
||||
* never read.
|
||||
*/
|
||||
export async function listDirectory(target: LocalTarget, signal?: AbortSignal): Promise<LocalDirEntry[]> {
|
||||
throwIfAborted(signal, 'list')
|
||||
let info: PathInfo | null
|
||||
try {
|
||||
info = await probe(target.targetKey)
|
||||
} catch (error: unknown) {
|
||||
throw listingIoError(target.displayPath, error)
|
||||
}
|
||||
if (!info) throw new FsError(`cannot list "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
||||
if (info.type !== 'directory') throw new FsError(`cannot list "${target.displayPath}": not a directory`, 'FS_NOT_DIRECTORY')
|
||||
|
||||
let entries: Dirent[]
|
||||
try {
|
||||
entries = await readdir(target.targetKey, { withFileTypes: true, encoding: 'utf8' })
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- requires permission/kernel failure from readdir after a successful directory stat. */
|
||||
throw listingIoError(target.displayPath, error)
|
||||
}
|
||||
throwIfAborted(signal, 'list')
|
||||
|
||||
const result: LocalDirEntry[] = []
|
||||
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
throwIfAborted(signal, 'list')
|
||||
try {
|
||||
const childTarget = await resolveListedChildTarget(target, entry.name)
|
||||
const childInfo = await probe(childTarget.targetKey)
|
||||
result.push({
|
||||
name: entry.name,
|
||||
type: childInfo?.type ?? 'other',
|
||||
target: childTarget,
|
||||
...(childInfo ? { version: childInfo.version } : {}),
|
||||
...(childInfo?.type === 'file' ? { size: childInfo.size } : {}),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
throw listingIoError(join(target.displayPath, entry.name), error)
|
||||
}
|
||||
throwIfAborted(signal, 'list')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// --- Reading ---
|
||||
|
||||
function notTextError(verb: 'read' | 'edit', displayPath: string): FsError {
|
||||
return new FsError(`cannot ${verb} "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT')
|
||||
}
|
||||
|
||||
function decodeUtf8(buffer: Uint8Array, verb: 'read' | 'edit', displayPath: string): string {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(buffer)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
|
||||
if (!(error instanceof TypeError)) throw error
|
||||
throw notTextError(verb, displayPath)
|
||||
}
|
||||
}
|
||||
|
||||
function decodeUtf8Stream(
|
||||
decoder: TextDecoder,
|
||||
chunk: Uint8Array | undefined,
|
||||
verb: 'read' | 'edit',
|
||||
displayPath: string,
|
||||
): string {
|
||||
try {
|
||||
return chunk ? decoder.decode(chunk, { stream: true }) : decoder.decode()
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
|
||||
if (!(error instanceof TypeError)) throw error
|
||||
throw notTextError(verb, displayPath)
|
||||
}
|
||||
}
|
||||
|
||||
async function statRegularFile(target: LocalTarget, verb: 'read', signal?: AbortSignal): Promise<Stats> {
|
||||
throwIfAborted(signal, verb)
|
||||
let info: Stats
|
||||
try {
|
||||
info = await stat(target.targetKey)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; only the not-found path is reachable in tests. */
|
||||
if (!isENOENT(error)) throw error
|
||||
throw new FsError(`cannot ${verb} "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
||||
}
|
||||
if (!info.isFile()) throw new FsError(`cannot ${verb} "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
return info
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a whole regular UTF-8 text file into a single decoded string. Rejects
|
||||
* non-regular files, invalid UTF-8, and NUL-byte binary samples.
|
||||
*/
|
||||
export async function readWholeText(target: LocalTarget, signal?: AbortSignal): Promise<string> {
|
||||
await statRegularFile(target, 'read', signal)
|
||||
const raw = await readFileAbortable(target.targetKey, 'read', signal)
|
||||
throwIfAborted(signal, 'read')
|
||||
if (raw.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT')
|
||||
}
|
||||
return decodeUtf8(raw, 'read', target.displayPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a whole regular UTF-8 text file as decoded text chunks. Same text
|
||||
* semantics as {@link readWholeText} (regular-file check, binary/NUL rejection,
|
||||
* cross-chunk UTF-8 decoding), but never holds the whole file in memory.
|
||||
*/
|
||||
export async function* streamWholeText(target: LocalTarget, signal?: AbortSignal): AsyncIterable<string> {
|
||||
await statRegularFile(target, 'read', signal)
|
||||
const stream = createReadStream(target.targetKey, signal ? { signal } : {})
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true })
|
||||
let sampledBytes = 0
|
||||
|
||||
function scanBinarySample(chunk: Buffer): void {
|
||||
if (sampledBytes >= BINARY_SAMPLE_BYTES) return
|
||||
const sample = chunk.subarray(0, Math.min(chunk.length, BINARY_SAMPLE_BYTES - sampledBytes))
|
||||
if (sample.includes(0)) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT')
|
||||
}
|
||||
sampledBytes += sample.length
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const chunk of stream as AsyncIterable<Buffer>) {
|
||||
scanBinarySample(chunk)
|
||||
yield decodeUtf8Stream(decoder, chunk, 'read', target.displayPath)
|
||||
}
|
||||
yield decodeUtf8Stream(decoder, undefined, 'read', target.displayPath)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 4 -- mid-stream errors need an abort/IO fault racing the loop; pre-abort is caught by throwIfAborted. */
|
||||
if (isAbortError(error)) throw new FsError('read aborted', 'FS_ABORTED')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// --- Writing ---
|
||||
|
||||
async function removeStagingDirOrThrow(stagingDir: string, originalError: unknown): Promise<never> {
|
||||
try {
|
||||
await rm(stagingDir, { recursive: true, force: true })
|
||||
} catch (cleanupError: unknown) {
|
||||
/* v8 ignore next 1 -- cleanup failure here needs a second filesystem fault after the primary write failure. */
|
||||
throw new FsError(`write failed (${errorMessage(originalError)}) and temp cleanup failed (${errorMessage(cleanupError)})`, 'FS_NOT_FOUND', { cause: originalError })
|
||||
}
|
||||
throw originalError
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically write `content` to `absolutePath`: create parent dirs, write to a
|
||||
* randomly-named temp file opened exclusively (`wx`, `0o600`) inside a private
|
||||
* (`0o700`) staging directory, fsync, optionally chmod to the final mode while
|
||||
* still private, then rename over the target. `mode` (when given) preserves an
|
||||
* existing file's permissions across the replace.
|
||||
*/
|
||||
export async function writeFileAtomic(
|
||||
absolutePath: string,
|
||||
content: string,
|
||||
mode: number | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
internals: FsIoInternals = {},
|
||||
): Promise<void> {
|
||||
throwIfAborted(signal, 'write')
|
||||
const directory = dirname(absolutePath)
|
||||
await mkdir(directory, { recursive: true })
|
||||
|
||||
throwIfAborted(signal, 'write')
|
||||
const stagingDirName = internals.tempDirName?.(absolutePath) ?? `.${basename(absolutePath)}.${process.pid}.${randomUUID()}.tmpdir`
|
||||
const stagingDir = join(directory, stagingDirName)
|
||||
const tempName = internals.tempName?.(absolutePath) ?? `${basename(absolutePath)}.tmp`
|
||||
const tempPath = join(stagingDir, tempName)
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined
|
||||
let stagingCreated = false
|
||||
try {
|
||||
await mkdir(stagingDir, { mode: 0o700 })
|
||||
stagingCreated = true
|
||||
await chmod(stagingDir, 0o700)
|
||||
|
||||
handle = await open(tempPath, 'wx', 0o600)
|
||||
await handle.chmod(0o600)
|
||||
await handle.writeFile(content, { encoding: 'utf8', ...signal ? { signal } : {} })
|
||||
await handle.sync()
|
||||
await internals.inspectTemp?.({ stagingDir, tempPath })
|
||||
if (mode !== undefined) await handle.chmod(mode)
|
||||
await handle.close()
|
||||
handle = undefined
|
||||
|
||||
throwIfAborted(signal, 'write')
|
||||
await rename(tempPath, absolutePath)
|
||||
await rm(stagingDir, { recursive: true, force: true })
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- abort-mid-write needs a writeFile/signal race; the non-abort (rename/open) side is tested. */
|
||||
let failure: unknown = isAbortError(error) ? new FsError('write aborted', 'FS_ABORTED') : error
|
||||
/* v8 ignore next 8 -- reached only if writeFile/sync throws with the handle open (IO fault); close-failure is a double fault. */
|
||||
if (handle) {
|
||||
try {
|
||||
await handle.close()
|
||||
} catch (closeError: unknown) {
|
||||
failure = new FsError(`write failed (${errorMessage(failure)}) and temp close failed (${errorMessage(closeError)})`, 'FS_NOT_FOUND', { cause: failure })
|
||||
}
|
||||
}
|
||||
if (!stagingCreated) throw failure
|
||||
return removeStagingDirOrThrow(stagingDir, failure)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Editing ---
|
||||
|
||||
/** Line ending style detected before LF normalization. */
|
||||
export type LineEndings = 'LF' | 'CRLF'
|
||||
|
||||
function normalizeLineEndings(content: string): string {
|
||||
return content.replaceAll('\r\n', '\n')
|
||||
}
|
||||
|
||||
function detectLineEndings(raw: string): LineEndings {
|
||||
const sample = raw.slice(0, 4096)
|
||||
const crlfCount = sample.split('\r\n').length - 1
|
||||
const lfCount = sample.split('\n').length - 1 - crlfCount
|
||||
return crlfCount > lfCount ? 'CRLF' : 'LF'
|
||||
}
|
||||
|
||||
function restoreLineEndings(content: string, lineEndings: LineEndings): string {
|
||||
return lineEndings === 'LF' ? content : normalizeLineEndings(content).split('\n').join('\r\n')
|
||||
}
|
||||
|
||||
function countOccurrences(content: string, needle: string): number {
|
||||
let count = 0
|
||||
let index = 0
|
||||
while (true) {
|
||||
const found = content.indexOf(needle, index)
|
||||
if (found === -1) return count
|
||||
count += 1
|
||||
index = found + needle.length
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and decode a file for editing: rejects binaries, returns LF-normalized
|
||||
* content plus the original line-ending style for write-back.
|
||||
*/
|
||||
export async function readForEdit(
|
||||
absolutePath: string,
|
||||
displayPath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ content: string; lineEndings: LineEndings }> {
|
||||
throwIfAborted(signal, 'edit')
|
||||
const buffer = await readFileAbortable(absolutePath, 'edit', signal)
|
||||
throwIfAborted(signal, 'edit')
|
||||
if (buffer.includes(0)) throw new FsError(`cannot edit "${displayPath}": binary file`, 'FS_NOT_TEXT')
|
||||
const raw = decodeUtf8(buffer, 'edit', displayPath)
|
||||
return { content: normalizeLineEndings(raw), lineEndings: detectLineEndings(raw) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort read of a file's current text for a before/after diff basis, used
|
||||
* by an overwrite. Returns the LF-normalized decoded content, or `null` when the
|
||||
* file is binary or not valid UTF-8 — a write must succeed regardless of the
|
||||
* prior bytes, so an undiffable prior file simply yields no contextual-hunk basis
|
||||
* (the caller treats `null` the same as an absent file: the result renders a
|
||||
* whole-file diff rather than an applied hunk).
|
||||
*/
|
||||
export async function readTextForDiff(absolutePath: string, signal?: AbortSignal): Promise<string | null> {
|
||||
const buffer = await readFileAbortable(absolutePath, 'read', signal)
|
||||
if (buffer.includes(0)) return null
|
||||
try {
|
||||
return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(buffer))
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
|
||||
if (!(error instanceof TypeError)) throw error
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a literal replacement to LF-normalized content. Throws
|
||||
* `FS_EDIT_NOT_FOUND` on empty `oldString` or zero matches and
|
||||
* `FS_AMBIGUOUS_EDIT` on multiple matches when `replaceAll` is false. Returns
|
||||
* the edited content (still LF-normalized) and the replacement count.
|
||||
*/
|
||||
export function applyLiteralEdit(
|
||||
content: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
replaceAll: boolean,
|
||||
displayPath: string,
|
||||
): { content: string; replacements: number } {
|
||||
const oldNorm = normalizeLineEndings(oldString)
|
||||
if (oldNorm.length === 0) {
|
||||
throw new FsError('old_string must be a non-empty string', 'FS_EDIT_NOT_FOUND')
|
||||
}
|
||||
const newNorm = normalizeLineEndings(newString)
|
||||
const replacements = countOccurrences(content, oldNorm)
|
||||
if (replacements === 0) {
|
||||
throw new FsError(`old_string was not found in "${displayPath}"`, 'FS_EDIT_NOT_FOUND')
|
||||
}
|
||||
if (!replaceAll && replacements > 1) {
|
||||
throw new FsError(`old_string matched ${replacements} times in "${displayPath}"; provide a more specific old_string or set replace_all to true`, 'FS_AMBIGUOUS_EDIT')
|
||||
}
|
||||
return { content: content.split(oldNorm).join(newNorm), replacements }
|
||||
}
|
||||
|
||||
export { normalizeLineEndings, restoreLineEndings }
|
||||
230
packages/fs/fs-local/src/index.ts
Normal file
230
packages/fs/fs-local/src/index.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Local-filesystem implementation of the `ctx.fs` provider seam.
|
||||
* {@link LocalFileSystem} subclasses {@link FileSystem} and backs the seven
|
||||
* text-storage primitives with the host filesystem via
|
||||
* {@link module:@deepseek-ai/dsh-fs-local/fsio}. Path resolution uses
|
||||
* `realpath`, so the stable `targetKey` is the real file identity (two input
|
||||
* paths reaching the same file through symlinks share one key, and writes land
|
||||
* on the link target — preserving the link).
|
||||
*
|
||||
* Future sandboxed/remote/virtual backends are sibling packages implementing
|
||||
* the same interface; loading this one populates `ctx.fs`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs-local
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsDirEntry,
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsTarget,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
} from '@deepseek-ai/dsh-fs'
|
||||
import {
|
||||
applyLiteralEdit,
|
||||
listDirectory,
|
||||
normalizeLineEndings,
|
||||
probe,
|
||||
readForEdit,
|
||||
readTextForDiff,
|
||||
readWholeText,
|
||||
resolveLocalTarget,
|
||||
restoreLineEndings,
|
||||
streamWholeText,
|
||||
writeFileAtomic,
|
||||
} from './fsio.ts'
|
||||
import type { FsIoInternals } from './fsio.ts'
|
||||
|
||||
export {
|
||||
applyLiteralEdit,
|
||||
listDirectory,
|
||||
probe,
|
||||
readForEdit,
|
||||
readTextForDiff,
|
||||
readWholeText,
|
||||
resolveLocalTarget,
|
||||
restoreLineEndings,
|
||||
streamWholeText,
|
||||
writeFileAtomic,
|
||||
} from './fsio.ts'
|
||||
export type { FsIoInternals, LineEndings, LocalDirEntry, LocalTarget, PathInfo } from './fsio.ts'
|
||||
|
||||
/** Configuration for the local filesystem backend. */
|
||||
export interface Config {
|
||||
/** Base directory for relative paths. Defaults to `process.cwd()`. */
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/**
|
||||
* The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd}
|
||||
* (a resolution default, NOT a containment boundary — see the filesystem
|
||||
* capability-seam RFC); enforce
|
||||
* containment with a stricter backend or a `tools/execute` permission plugin.
|
||||
*/
|
||||
export class LocalFileSystem extends FileSystem {
|
||||
static Config: z<Config> = z.object({
|
||||
cwd: z.string().default(process.cwd()),
|
||||
})
|
||||
|
||||
readonly config: ResolvedConfig
|
||||
/** Test seam forwarded to fsio (force streaming path, pin temp names). */
|
||||
internals: FsIoInternals = {}
|
||||
/** Per-targetKey tail promise: serializes mutating ops so the read→guard→write
|
||||
* window can't interleave, making concurrent writes/edits deterministically
|
||||
* ordered (one wins, the rest see the new version and reject as stale). */
|
||||
private locks = new Map<string, Promise<unknown>>()
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
this.config = config as ResolvedConfig
|
||||
}
|
||||
|
||||
/** Run `op` with exclusive access to `targetKey` (FIFO per key). */
|
||||
private async withLock<T>(targetKey: string, op: () => Promise<T>): Promise<T> {
|
||||
const prior = this.locks.get(targetKey) ?? Promise.resolve()
|
||||
const run = prior.then(op, op)
|
||||
// Keep the chain alive but swallow this op's result/throw for the *next* waiter.
|
||||
const tail = run.then(() => undefined, () => undefined)
|
||||
this.locks.set(targetKey, tail)
|
||||
try {
|
||||
return await run
|
||||
} finally {
|
||||
if (this.locks.get(targetKey) === tail) {
|
||||
this.locks.delete(targetKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override async resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget> {
|
||||
const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)
|
||||
return { targetKey: local.targetKey, displayPath: local.displayPath }
|
||||
}
|
||||
|
||||
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
|
||||
if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED')
|
||||
const info = await probe(target.targetKey)
|
||||
if (!info) return undefined
|
||||
return { version: info.version, type: info.type, size: info.size }
|
||||
}
|
||||
|
||||
override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
|
||||
return readWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
|
||||
}
|
||||
|
||||
override streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
|
||||
return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal))
|
||||
}
|
||||
|
||||
override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> {
|
||||
const entries = await listDirectory({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
|
||||
return entries.map(entry => ({
|
||||
name: entry.name,
|
||||
type: entry.type,
|
||||
target: { targetKey: entry.target.targetKey, displayPath: entry.target.displayPath },
|
||||
...(entry.version !== undefined ? { version: entry.version } : {}),
|
||||
...(entry.size !== undefined ? { size: entry.size } : {}),
|
||||
}))
|
||||
}
|
||||
|
||||
override async writeText(
|
||||
target: FsTarget,
|
||||
content: string,
|
||||
expected?: FsWriteIntent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<FsWriteOutcome> {
|
||||
return this.withLock(target.targetKey, async () => {
|
||||
const existing = await probe(target.targetKey)
|
||||
if (existing && existing.type !== 'file') {
|
||||
throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
}
|
||||
|
||||
if (expected?.kind === 'replaceIfVersion') {
|
||||
// Stale guard: the file must still exist at the version the owner observed.
|
||||
if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, 'FS_STALE_VERSION')
|
||||
if (existing.version !== expected.version) {
|
||||
throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
|
||||
}
|
||||
} else if (expected?.kind === 'createIfAbsent' && existing) {
|
||||
// createIfAbsent onto an existing file: a blind overwrite — require a read first.
|
||||
throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')
|
||||
}
|
||||
// expected === undefined: unconditional create-or-overwrite (the bare
|
||||
// provider) — no version guard, no read-first requirement. Still atomic
|
||||
// (the per-target lock is unconditional), so the write is never torn.
|
||||
|
||||
// Capture the prior text (the before/after diff basis) BEFORE the write.
|
||||
// `null` for a create (no existing file) OR an existing-but-undiffable
|
||||
// file (binary/invalid-UTF-8) — a null `before` gives no contextual-hunk
|
||||
// basis, so a consumer falls back to a whole-file diff (the tool still
|
||||
// renders a result-time diff card, not the raw result text).
|
||||
// TODO(overwrite-diff-bound): this reads the whole prior file into memory
|
||||
// for a UI-only diff; bound the pre-read and fall back to no contextual
|
||||
// basis above a size threshold (see the applied-hunk-diffs RFC non-goals).
|
||||
const before = existing ? await readTextForDiff(target.targetKey, signal) : null
|
||||
await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals)
|
||||
const after = await probe(target.targetKey)
|
||||
return {
|
||||
operation: existing ? 'update' : 'create',
|
||||
version: this.versionAfterWrite(after, target),
|
||||
before,
|
||||
// LF-normalized to share the diff basis with `before` (also LF): a CRLF
|
||||
// overwrite must not read as every line changed. Line-ending restoration
|
||||
// is a storage detail the applied-hunk diff ignores.
|
||||
after: normalizeLineEndings(content),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override async editText(
|
||||
target: FsTarget,
|
||||
edit: FsEditRequest,
|
||||
expected?: { version: FsVersion },
|
||||
signal?: AbortSignal,
|
||||
): Promise<FsEditOutcome> {
|
||||
return this.withLock(target.targetKey, async () => {
|
||||
const existing = await probe(target.targetKey)
|
||||
// Stale guard BEFORE literal matching: an edit based on an old read reports
|
||||
// FS_STALE_VERSION, not FS_EDIT_NOT_FOUND/FS_AMBIGUOUS_EDIT against newer content.
|
||||
// A missing target reports FS_STALE_VERSION on BOTH paths (guarded and
|
||||
// unconditional) — one "cannot edit this target now" code.
|
||||
if (!existing) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
|
||||
if (existing.type !== 'file') throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
// expected === undefined: unconditional edit of the current content — no
|
||||
// version guard. Still inside the per-target lock, so the read→match→write
|
||||
// window is serialized and atomic.
|
||||
if (expected && existing.version !== expected.version) {
|
||||
throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
|
||||
}
|
||||
|
||||
const original = await readForEdit(target.targetKey, target.displayPath, signal)
|
||||
const edited = applyLiteralEdit(original.content, edit.oldString, edit.newString, edit.replaceAll, target.displayPath)
|
||||
const content = restoreLineEndings(edited.content, original.lineEndings)
|
||||
await writeFileAtomic(target.targetKey, content, existing.mode, signal, this.internals)
|
||||
|
||||
const after = await probe(target.targetKey)
|
||||
return {
|
||||
version: this.versionAfterWrite(after, target),
|
||||
// The LF-normalized before/after text (the applied-hunk diff basis);
|
||||
// line-ending restoration is a storage detail the diff ignores.
|
||||
before: original.content,
|
||||
after: edited.content,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* v8 ignore next 5 -- the post-write probe finding the file absent requires a
|
||||
* concurrent unlink between rename and stat; fall back to a sentinel version. */
|
||||
private versionAfterWrite(after: { version: FsVersion } | null, target: FsTarget): FsVersion {
|
||||
if (after) return after.version
|
||||
return FsVersion(`missing:${target.targetKey}`)
|
||||
}
|
||||
}
|
||||
|
||||
export default LocalFileSystem
|
||||
507
packages/fs/fs-local/tests/filesystem.spec.ts
Normal file
507
packages/fs/fs-local/tests/filesystem.spec.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* Tests for the local backend through the `ctx.fs` provider seam: stat, whole-
|
||||
* file/streamed text reads, atomic guarded writes (createIfAbsent /
|
||||
* replaceIfVersion), version-guarded literal edits, concurrency races, symlink
|
||||
* identity, and HMR/disposal. Read WINDOWING is policy and lives in
|
||||
* `dsh-fs-policy`, so it is not exercised here.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile, unlink } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
import { FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsTarget } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
let dir: string
|
||||
let ctx: Context
|
||||
let fs: LocalFileSystem
|
||||
let fiber: Awaited<ReturnType<Context['plugin']>>
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-fs-'))
|
||||
ctx = new Context()
|
||||
fiber = await ctx.plugin(LocalFileSystem, { cwd: dir })
|
||||
fs = ctx.fs as LocalFileSystem
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fiber.dispose()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function lockCount(localFs: LocalFileSystem): number {
|
||||
return (localFs as unknown as { locks: Map<string, Promise<unknown>> }).locks.size
|
||||
}
|
||||
|
||||
/** The version the backend currently reports for a resolved target. */
|
||||
async function versionOf(target: FsTarget): Promise<FsVersion> {
|
||||
const info = await fs.stat(target)
|
||||
if (!info) throw new Error('expected target to exist')
|
||||
return info.version
|
||||
}
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers LocalFileSystem as ctx.fs with a default cwd', async () => {
|
||||
const bare = new Context()
|
||||
const bareFiber = await bare.plugin(LocalFileSystem)
|
||||
expect((bare.fs as LocalFileSystem).config.cwd).toBe(process.cwd())
|
||||
await bareFiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolve', () => {
|
||||
it('resolves a relative path against opts.cwd, not config.cwd', async () => {
|
||||
// config.cwd is `dir`; a call supplying a DIFFERENT cwd bases the relative
|
||||
// path there (the per-session-workspace seam — mirrors tool-bash workdir).
|
||||
const other = await mkdtemp(join(tmpdir(), 'dsh-fs-other-'))
|
||||
try {
|
||||
await writeFile(join(other, 'x.txt'), 'in other')
|
||||
const viaOther = await fs.resolve('x.txt', { cwd: other })
|
||||
expect(await fs.readText(viaOther)).toBe('in other')
|
||||
// Same relative path with no opts falls back to config.cwd (= dir), where
|
||||
// x.txt does not exist.
|
||||
await expect(fs.readText(await fs.resolve('x.txt'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
} finally {
|
||||
await rm(other, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores opts.cwd for an ABSOLUTE path', async () => {
|
||||
await writeFile(join(dir, 'abs.txt'), 'absolute')
|
||||
const target = await fs.resolve(join(dir, 'abs.txt'), { cwd: '/nonexistent-base' })
|
||||
expect(await fs.readText(target)).toBe('absolute')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stat', () => {
|
||||
it('returns file metadata, directory type, and undefined for absent', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello')
|
||||
const fileInfo = await fs.stat(await fs.resolve('a.txt'))
|
||||
expect(fileInfo?.type).toBe('file')
|
||||
expect(fileInfo?.size).toBe(5)
|
||||
expect(typeof fileInfo?.version).toBe('string')
|
||||
|
||||
expect((await fs.stat(await fs.resolve('.')))?.type).toBe('directory')
|
||||
expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal', async () => {
|
||||
await expect(fs.stat(await fs.resolve('a.txt'), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('readText / streamText', () => {
|
||||
it('reads whole-file text', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree')
|
||||
expect(await fs.readText(await fs.resolve('a.txt'))).toBe('one\ntwo\nthree')
|
||||
})
|
||||
|
||||
it('streams the same text', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree')
|
||||
const target = await fs.resolve('a.txt')
|
||||
let streamed = ''
|
||||
for await (const chunk of await fs.streamText(target)) streamed += chunk
|
||||
expect(streamed).toBe('one\ntwo\nthree')
|
||||
})
|
||||
|
||||
it('rejects a missing file, a directory, binary, and invalid UTF-8', async () => {
|
||||
await expect(fs.readText(await fs.resolve('nope'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
await expect(fs.readText(await fs.resolve('.'))).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
|
||||
await expect(fs.readText(await fs.resolve('bin'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
|
||||
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
|
||||
await expect(fs.readText(await fs.resolve('bad'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('listDir', () => {
|
||||
it('lists files and directories in stable name order with resolved child targets', async () => {
|
||||
await mkdir(join(dir, 'skills', 'dir-skill'), { recursive: true })
|
||||
await writeFile(join(dir, 'skills', 'zeta.md'), 'zeta')
|
||||
await writeFile(join(dir, 'skills', 'alpha.md'), 'alpha')
|
||||
await symlink(join(dir, 'skills', 'missing-target'), join(dir, 'skills', 'broken-link'))
|
||||
|
||||
const entries = await fs.listDir(await fs.resolve('skills'))
|
||||
expect(entries.map(entry => [entry.name, entry.type])).toEqual([
|
||||
['alpha.md', 'file'],
|
||||
['broken-link', 'other'],
|
||||
['dir-skill', 'directory'],
|
||||
['zeta.md', 'file'],
|
||||
])
|
||||
expect(entries.map(entry => entry.target.displayPath)).toEqual([
|
||||
join(dir, 'skills', 'alpha.md'),
|
||||
join(dir, 'skills', 'broken-link'),
|
||||
join(dir, 'skills', 'dir-skill'),
|
||||
join(dir, 'skills', 'zeta.md'),
|
||||
])
|
||||
const materializedEntries = entries.filter(entry => entry.version !== undefined)
|
||||
expect(materializedEntries.map(entry => entry.target.targetKey))
|
||||
.toEqual(await Promise.all(materializedEntries.map(entry => realpath(entry.target.displayPath))))
|
||||
expect(entries.find(entry => entry.name === 'alpha.md')?.size).toBe(5)
|
||||
expect(typeof entries.find(entry => entry.name === 'alpha.md')?.version).toBe('string')
|
||||
expect(entries.find(entry => entry.name === 'broken-link')?.version).toBeUndefined()
|
||||
expect(entries.find(entry => entry.name === 'dir-skill')?.size).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports a missing directory as FS_NOT_FOUND', async () => {
|
||||
await expect(fs.listDir(await fs.resolve('missing'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('reports a file target as FS_NOT_DIRECTORY', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'text')
|
||||
await expect(fs.listDir(await fs.resolve('a.txt'))).rejects.toMatchObject({ code: 'FS_NOT_DIRECTORY' })
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal', async () => {
|
||||
await mkdir(join(dir, 'skills'), { recursive: true })
|
||||
await expect(fs.listDir(await fs.resolve('skills'), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeText', () => {
|
||||
it('createIfAbsent creates a new file', async () => {
|
||||
const target = await fs.resolve('new.txt')
|
||||
const outcome = await fs.writeText(target, 'fresh', { kind: 'createIfAbsent' })
|
||||
expect(outcome.operation).toBe('create')
|
||||
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
|
||||
})
|
||||
|
||||
it('createIfAbsent rejects an existing file as FS_NOT_OBSERVED', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'old')
|
||||
const target = await fs.resolve('a.txt')
|
||||
await expect(fs.writeText(target, 'new', { kind: 'createIfAbsent' }))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('old')
|
||||
})
|
||||
|
||||
it('replaceIfVersion replaces when the version matches', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'old')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const outcome = await fs.writeText(target, 'new', { kind: 'replaceIfVersion', version: await versionOf(target) })
|
||||
expect(outcome.operation).toBe('update')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('new')
|
||||
})
|
||||
|
||||
it('replaceIfVersion rejects a stale version', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'v1')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const stale = await versionOf(target)
|
||||
await writeFile(join(dir, 'a.txt'), 'changed-externally')
|
||||
await expect(fs.writeText(target, 'v2', { kind: 'replaceIfVersion', version: stale }))
|
||||
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('replaceIfVersion rejects a deleted target as stale, without recreating it', async () => {
|
||||
const path = join(dir, 'a.txt')
|
||||
await writeFile(path, 'v1')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const version = await versionOf(target)
|
||||
await unlink(path)
|
||||
await expect(fs.writeText(target, 'v2', { kind: 'replaceIfVersion', version }))
|
||||
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('rejects writing onto a directory', async () => {
|
||||
const target = await fs.resolve('.')
|
||||
await expect(fs.writeText(target, 'x', { kind: 'createIfAbsent' }))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('unconditionally creates a new file with no expectation (bare provider)', async () => {
|
||||
const target = await fs.resolve('new.txt')
|
||||
const outcome = await fs.writeText(target, 'fresh')
|
||||
expect(outcome.operation).toBe('create')
|
||||
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
|
||||
})
|
||||
|
||||
it('unconditionally OVERWRITES an existing file with no expectation (bare provider)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'old')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const outcome = await fs.writeText(target, 'clobbered')
|
||||
expect(outcome.operation).toBe('update')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('clobbered')
|
||||
})
|
||||
|
||||
it('rejects writing onto a directory even with no expectation', async () => {
|
||||
const target = await fs.resolve('.')
|
||||
await expect(fs.writeText(target, 'x')).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('a create reports before:null and after = the written content (no prior file)', async () => {
|
||||
const target = await fs.resolve('new.txt')
|
||||
const outcome = await fs.writeText(target, 'fresh')
|
||||
expect(outcome.before).toBeNull()
|
||||
expect(outcome.after).toBe('fresh')
|
||||
})
|
||||
|
||||
it('an overwrite reports before = the OLD content and after = the new content', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'old body')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const outcome = await fs.writeText(target, 'new body')
|
||||
expect(outcome.before).toBe('old body')
|
||||
expect(outcome.after).toBe('new body')
|
||||
})
|
||||
|
||||
it('an overwrite returns LF-normalized before AND after (a CRLF rewrite is not every-line-changed)', async () => {
|
||||
// The applied-hunk diff bases on `before`/`after`; if `after` kept CRLF while
|
||||
// `before` is LF-normalized, a CRLF rewrite would read as every line changed.
|
||||
// Both sides are LF so only the genuinely-changed line diffs.
|
||||
await writeFile(join(dir, 'a.txt'), 'a\r\nb\r\nc\r\n')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const outcome = await fs.writeText(target, 'a\r\nB\r\nc\r\n')
|
||||
expect(outcome.before).toBe('a\nb\nc\n')
|
||||
expect(outcome.after).toBe('a\nB\nc\n')
|
||||
})
|
||||
|
||||
it('an overwrite of a BINARY prior file reports before:null (undiffable), still succeeds', async () => {
|
||||
await writeFile(join(dir, 'a.bin'), Buffer.from([0x00, 0x01, 0x02]))
|
||||
const target = await fs.resolve('a.bin')
|
||||
const outcome = await fs.writeText(target, 'now text')
|
||||
expect(outcome.operation).toBe('update')
|
||||
expect(outcome.before).toBeNull()
|
||||
expect(outcome.after).toBe('now text')
|
||||
})
|
||||
|
||||
it('an overwrite of an INVALID-UTF-8 (non-NUL) prior file reports before:null, still succeeds', async () => {
|
||||
// 0xff is never valid UTF-8 but is not a NUL, so it exercises the decoder's
|
||||
// fatal-throw path (not the NUL-scan short-circuit): an undiffable prior file
|
||||
// still yields a successful write with no before-content basis.
|
||||
await writeFile(join(dir, 'a.bin'), Buffer.from([0x68, 0xff, 0x69]))
|
||||
const target = await fs.resolve('a.bin')
|
||||
const outcome = await fs.writeText(target, 'now valid')
|
||||
expect(outcome.operation).toBe('update')
|
||||
expect(outcome.before).toBeNull()
|
||||
expect(outcome.after).toBe('now valid')
|
||||
})
|
||||
|
||||
it('releases per-target mutation locks after success and failure', async () => {
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.writeText(target, 'created', { kind: 'createIfAbsent' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
await expect(fs.writeText(target, 'again', { kind: 'createIfAbsent' }))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
|
||||
it('replaceIfVersion returns the post-write version (matches a fresh stat)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'v1')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const before = await versionOf(target)
|
||||
// Change the byte length so the mtimeMs:size token provably differs (a
|
||||
// same-size same-tick rewrite can collide — the documented version-token
|
||||
// limitation; not what this test is about).
|
||||
const outcome = await fs.writeText(target, 'a much longer replacement body', { kind: 'replaceIfVersion', version: before })
|
||||
expect(outcome.version).not.toBe(before)
|
||||
expect(outcome.version).toBe(await versionOf(target))
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal without creating the file', async () => {
|
||||
const target = await fs.resolve('aborted.txt')
|
||||
await expect(fs.writeText(target, 'x', undefined, AbortSignal.abort()))
|
||||
.rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
await expect(stat(join(dir, 'aborted.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
|
||||
it('two concurrent guarded writes: one updates, the other is rejected as stale', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'base')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const version = await versionOf(target)
|
||||
const results = await Promise.allSettled([
|
||||
fs.writeText(target, 'one', { kind: 'replaceIfVersion', version }),
|
||||
fs.writeText(target, 'two', { kind: 'replaceIfVersion', version }),
|
||||
])
|
||||
expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1)
|
||||
const rejected = results.filter(r => r.status === 'rejected')
|
||||
expect(rejected).toHaveLength(1)
|
||||
expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('editText', () => {
|
||||
it('applies a literal edit at the matching version', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }, { version: await versionOf(target) })
|
||||
expect(outcome.after).toBe('hello there')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
|
||||
})
|
||||
|
||||
it('reports before/after content (the applied-hunk basis), LF-normalized', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'a\r\nOLD\r\nb\r\n')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const outcome = await fs.editText(target, { oldString: 'OLD', newString: 'NEW', replaceAll: false })
|
||||
expect(outcome.before).toBe('a\nOLD\nb\n')
|
||||
expect(outcome.after).toBe('a\nNEW\nb\n')
|
||||
// The written file keeps the original CRLF endings (before/after are the
|
||||
// LF-normalized diff basis, not the on-disk bytes).
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a\r\nNEW\r\nb\r\n')
|
||||
})
|
||||
|
||||
it('checks the stale version BEFORE literal matching', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const stale = await versionOf(target)
|
||||
// Change the file so 'world' is gone — a stale edit must report STALE, not NOT_FOUND.
|
||||
await writeFile(join(dir, 'a.txt'), 'goodbye')
|
||||
await expect(fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }, { version: stale }))
|
||||
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('unconditionally edits the current content with no expectation (bare provider)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const target = await fs.resolve('a.txt')
|
||||
// No version guard: any current content is edited, regardless of version.
|
||||
const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false })
|
||||
expect(outcome.after).toBe('hello there')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
|
||||
})
|
||||
|
||||
it('reports a missing target as FS_STALE_VERSION even with no expectation (bare provider)', async () => {
|
||||
const target = await fs.resolve('missing.txt')
|
||||
await expect(fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: false }))
|
||||
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('still reports literal-match codes with no expectation (FS_EDIT_NOT_FOUND, unrelated to freshness)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const target = await fs.resolve('a.txt')
|
||||
await expect(fs.editText(target, { oldString: 'absent', newString: 'x', replaceAll: false }))
|
||||
.rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('rejects a deleted target as stale (before matching)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const version = await versionOf(target)
|
||||
await unlink(join(dir, 'a.txt'))
|
||||
await expect(fs.editText(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version }))
|
||||
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('rejects a non-regular target', async () => {
|
||||
const target = await fs.resolve('.')
|
||||
await expect(fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: false }, { version: FsVersion('v') }))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('rejects zero matches and ambiguous matches at the right version', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'a a a')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const version = await versionOf(target)
|
||||
await expect(fs.editText(target, { oldString: 'z', newString: 'X', replaceAll: false }, { version }))
|
||||
.rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
|
||||
await expect(fs.editText(target, { oldString: 'a', newString: 'X', replaceAll: false }, { version }))
|
||||
.rejects.toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' })
|
||||
})
|
||||
|
||||
it('replaces all matches with replaceAll', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'a a a')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const outcome = await fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: true }, { version: await versionOf(target) })
|
||||
expect(outcome.after).toBe('b b b')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b')
|
||||
})
|
||||
|
||||
it('rejects invalid UTF-8 without rewriting the file', async () => {
|
||||
const path = join(dir, 'bad.txt')
|
||||
const bytes = Buffer.from([0x68, 0xff, 0x69])
|
||||
await writeFile(path, bytes)
|
||||
const target = await fs.resolve('bad.txt')
|
||||
const version = await versionOf(target)
|
||||
await expect(fs.editText(target, { oldString: 'h', newString: 'H', replaceAll: false }, { version }))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
expect(await readFile(path)).toEqual(bytes)
|
||||
})
|
||||
|
||||
it('two concurrent edits: one wins, the other is rejected as stale', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'base')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const version = await versionOf(target)
|
||||
const results = await Promise.allSettled([
|
||||
fs.editText(target, { oldString: 'base', newString: 'one', replaceAll: false }, { version }),
|
||||
fs.editText(target, { oldString: 'base', newString: 'two', replaceAll: false }, { version }),
|
||||
])
|
||||
expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1)
|
||||
const rejected = results.filter(r => r.status === 'rejected')
|
||||
expect(rejected).toHaveLength(1)
|
||||
expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal without rewriting the file', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'keep')
|
||||
const target = await fs.resolve('a.txt')
|
||||
await expect(fs.editText(target, { oldString: 'keep', newString: 'x', replaceAll: false }, undefined, AbortSignal.abort()))
|
||||
.rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('keep')
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
|
||||
it('a successful edit refreshes the version so an immediate follow-up edit proceeds', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'one two')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const first = await fs.editText(target, { oldString: 'one', newString: 'ONE', replaceAll: false }, { version: await versionOf(target) })
|
||||
// The version the first edit returned is a valid guard for a second edit —
|
||||
// no intervening re-stat needed.
|
||||
const second = await fs.editText(target, { oldString: 'two', newString: 'TWO', replaceAll: false }, { version: first.version })
|
||||
expect(second.after).toBe('ONE TWO')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('ONE TWO')
|
||||
})
|
||||
|
||||
it('concurrent write vs edit at the same version: one wins, the other is stale', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'base')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const version = await versionOf(target)
|
||||
const results = await Promise.allSettled([
|
||||
fs.writeText(target, 'written', { kind: 'replaceIfVersion', version }),
|
||||
fs.editText(target, { oldString: 'base', newString: 'edited', replaceAll: false }, { version }),
|
||||
])
|
||||
expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1)
|
||||
const rejected = results.filter(r => r.status === 'rejected')
|
||||
expect(rejected).toHaveLength(1)
|
||||
expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('symlink targetKey identity', () => {
|
||||
it('two paths to the same file via a symlink share one version and write the real target', async () => {
|
||||
await writeFile(join(dir, 'real.txt'), 'hello')
|
||||
await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
|
||||
const viaReal = await fs.resolve('real.txt')
|
||||
const viaLink = await fs.resolve('link.txt')
|
||||
expect(viaLink.targetKey).toBe(viaReal.targetKey)
|
||||
|
||||
const version = await versionOf(viaReal)
|
||||
await fs.editText(viaLink, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version })
|
||||
expect(await readFile(join(dir, 'real.txt'), 'utf8')).toBe('bye') // link preserved
|
||||
})
|
||||
|
||||
it('a stale change is detected across both paths', async () => {
|
||||
await writeFile(join(dir, 'real.txt'), 'hello')
|
||||
await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
|
||||
const viaReal = await fs.resolve('real.txt')
|
||||
const stale = await versionOf(viaReal)
|
||||
await writeFile(join(dir, 'real.txt'), 'changed')
|
||||
const viaLink = await fs.resolve('link.txt')
|
||||
await expect(fs.editText(viaLink, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version: stale }))
|
||||
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('HMR / disposal', () => {
|
||||
it('disposing the fiber withdraws ctx.fs', async () => {
|
||||
const local = new Context()
|
||||
const localFiber = await local.plugin(LocalFileSystem, { cwd: dir })
|
||||
expect(local.fs).toBeDefined()
|
||||
await localFiber.dispose()
|
||||
expect(local.fs).toBeUndefined()
|
||||
})
|
||||
})
|
||||
468
packages/fs/fs-local/tests/fsio.spec.ts
Normal file
468
packages/fs/fs-local/tests/fsio.spec.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Cordis-free tests for the raw local-filesystem I/O: path resolution, probe,
|
||||
* whole-file/streamed text reads, binary/UTF-8 rejection, atomic-write temp
|
||||
* safety, literal edit matching, and line-ending handling. Line WINDOWING is
|
||||
* policy and lives in `dsh-fs-policy`, so it is not tested here.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { chmod, mkdtemp, readFile, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createServer } from 'node:net'
|
||||
import {
|
||||
applyLiteralEdit,
|
||||
listDirectory,
|
||||
probe,
|
||||
readForEdit,
|
||||
readWholeText,
|
||||
resolveLocalTarget,
|
||||
restoreLineEndings,
|
||||
streamWholeText,
|
||||
writeFileAtomic,
|
||||
} from '@deepseek-ai/dsh-fs-local'
|
||||
import type { LocalTarget } from '@deepseek-ai/dsh-fs-local'
|
||||
import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
let dir: string
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-fsio-'))
|
||||
})
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const localTarget = (path: string): LocalTarget => ({ displayPath: path, targetKey: FsTargetKey(path) })
|
||||
|
||||
async function collect(chunks: AsyncIterable<string>): Promise<string> {
|
||||
let out = ''
|
||||
for await (const chunk of chunks) out += chunk
|
||||
return out
|
||||
}
|
||||
|
||||
describe('resolveLocalTarget', () => {
|
||||
it('resolves a relative path from cwd and realpaths it', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'hi')
|
||||
const target = await resolveLocalTarget(dir, 'a.txt')
|
||||
expect(target.displayPath).toBe(file)
|
||||
expect(target.targetKey).toBe(await realpath(file))
|
||||
})
|
||||
|
||||
it('uses the realpathed parent + basename when the file does not exist (stable across create)', async () => {
|
||||
const target = await resolveLocalTarget(dir, 'missing.txt')
|
||||
expect(target.targetKey).toBe(join(await realpath(dir), 'missing.txt'))
|
||||
})
|
||||
|
||||
it('two paths to the same file via a symlink share one targetKey', async () => {
|
||||
const real = join(dir, 'real.txt')
|
||||
await writeFile(real, 'hi')
|
||||
const link = join(dir, 'link.txt')
|
||||
await symlink(real, link)
|
||||
const viaReal = await resolveLocalTarget(dir, 'real.txt')
|
||||
const viaLink = await resolveLocalTarget(dir, 'link.txt')
|
||||
expect(viaLink.targetKey).toBe(viaReal.targetKey)
|
||||
expect(viaLink.displayPath).toBe(link)
|
||||
})
|
||||
|
||||
it('realpaths the nearest existing ancestor when intermediate dirs are missing', async () => {
|
||||
const target = await resolveLocalTarget(dir, 'no-such-dir/child.txt')
|
||||
expect(target.targetKey).toBe(join(await realpath(dir), 'no-such-dir', 'child.txt'))
|
||||
})
|
||||
|
||||
it('keeps the key stable across create when an ancestor is a symlink', async () => {
|
||||
// A symlinked workspace root with a not-yet-created subdirectory: the
|
||||
// pre-create key (via the symlink, missing parent) must equal the
|
||||
// post-create key (file exists, realpathed) so observed-state survives.
|
||||
const realRoot = join(dir, 'real-root')
|
||||
await mkdir(realRoot)
|
||||
const linkRoot = join(dir, 'link-root')
|
||||
await symlink(realRoot, linkRoot)
|
||||
|
||||
const before = await resolveLocalTarget(linkRoot, 'sub/file.txt')
|
||||
await mkdir(join(realRoot, 'sub'), { recursive: true })
|
||||
await writeFile(join(realRoot, 'sub', 'file.txt'), 'hi') // create through the real path
|
||||
const after = await resolveLocalTarget(linkRoot, 'sub/file.txt')
|
||||
expect(before.targetKey).toBe(after.targetKey)
|
||||
})
|
||||
|
||||
it('rejects a blank path', async () => {
|
||||
await expect(resolveLocalTarget(dir, ' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('rejects a path whose ancestor is a file with a structured FsError (ENOTDIR)', async () => {
|
||||
// "afile" is a regular file, so "afile/child.txt" hits ENOTDIR on realpath;
|
||||
// the raw Node error must be translated into the FsError taxonomy so the tool
|
||||
// result keeps its { name, code } metadata.
|
||||
await writeFile(join(dir, 'afile'), 'i am a file')
|
||||
const err = await resolveLocalTarget(dir, 'afile/child.txt').then(() => undefined, (e: unknown) => e)
|
||||
expect(err).toBeInstanceOf(FsError)
|
||||
expect(err).toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('probe', () => {
|
||||
it('returns null for a missing path and metadata for a file', async () => {
|
||||
expect(await probe(join(dir, 'nope'))).toBeNull()
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'hi')
|
||||
const info = await probe(file)
|
||||
expect(info?.type).toBe('file')
|
||||
expect(info?.size).toBe(2)
|
||||
expect(typeof info?.version).toBe('string')
|
||||
})
|
||||
|
||||
it('reports a directory and a non-regular type', async () => {
|
||||
const sub = join(dir, 'sub')
|
||||
await mkdir(sub)
|
||||
expect((await probe(sub))?.type).toBe('directory')
|
||||
})
|
||||
|
||||
it('reports a socket/special file as type "other"', async () => {
|
||||
const sockPath = join(dir, 'sock')
|
||||
const server = createServer()
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(sockPath, () => { resolve() })
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
// A restricted sandbox may forbid unix-domain sockets; that is an
|
||||
// environment limit, not a filesystem regression — skip rather than fail.
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'EPERM' || code === 'EACCES' || code === 'ENOTSUP') return
|
||||
throw error
|
||||
}
|
||||
try {
|
||||
expect((await probe(sockPath))?.type).toBe('other')
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => { server.close(() => { resolve() }) })
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null when an ancestor path segment is a file (ENOTDIR), not a raw throw', async () => {
|
||||
await writeFile(join(dir, 'afile'), 'i am a file')
|
||||
expect(await probe(join(dir, 'afile', 'child.txt'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('listDirectory', () => {
|
||||
it('lists direct children in stable order without reading content', async () => {
|
||||
const root = join(dir, 'skills')
|
||||
await mkdir(join(root, 'dir-skill'), { recursive: true })
|
||||
await writeFile(join(root, 'zeta.md'), 'zeta')
|
||||
await writeFile(join(root, 'alpha.md'), 'alpha')
|
||||
await symlink(join(root, 'missing-target'), join(root, 'broken-link'))
|
||||
|
||||
const entries = await listDirectory(localTarget(root))
|
||||
expect(entries.map(entry => [entry.name, entry.type])).toEqual([
|
||||
['alpha.md', 'file'],
|
||||
['broken-link', 'other'],
|
||||
['dir-skill', 'directory'],
|
||||
['zeta.md', 'file'],
|
||||
])
|
||||
expect(entries.find(entry => entry.name === 'alpha.md')?.size).toBe(5)
|
||||
expect(typeof entries.find(entry => entry.name === 'alpha.md')?.version).toBe('string')
|
||||
expect(entries.find(entry => entry.name === 'broken-link')?.version).toBeUndefined()
|
||||
expect(entries.find(entry => entry.name === 'dir-skill')?.size).toBeUndefined()
|
||||
})
|
||||
|
||||
it('derives child target keys from the listed parent identity', async () => {
|
||||
const realOne = join(dir, 'real-one')
|
||||
const realTwo = join(dir, 'real-two')
|
||||
const link = join(dir, 'link')
|
||||
await mkdir(realOne)
|
||||
await mkdir(realTwo)
|
||||
await writeFile(join(realOne, 'same.txt'), 'one')
|
||||
await writeFile(join(realTwo, 'same.txt'), 'different two')
|
||||
await symlink(realOne, link)
|
||||
const target = await resolveLocalTarget(dir, 'link')
|
||||
|
||||
await unlink(link)
|
||||
await symlink(realTwo, link)
|
||||
|
||||
const entries = await listDirectory(target)
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]).toMatchObject({
|
||||
name: 'same.txt',
|
||||
target: {
|
||||
displayPath: join(link, 'same.txt'),
|
||||
targetKey: await realpath(join(realOne, 'same.txt')),
|
||||
},
|
||||
size: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects missing, non-directory, and aborted listing requests', async () => {
|
||||
await expect(listDirectory(localTarget(join(dir, 'missing')))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'hi')
|
||||
await expect(listDirectory(localTarget(file))).rejects.toMatchObject({ code: 'FS_NOT_DIRECTORY' })
|
||||
await expect(listDirectory(localTarget(dir), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
|
||||
it('translates directory permission failures into FS_PERMISSION_DENIED', async () => {
|
||||
const root = join(dir, 'restricted')
|
||||
await mkdir(root)
|
||||
await chmod(root, 0o000)
|
||||
try {
|
||||
const error = await listDirectory(localTarget(root)).then(() => undefined, (caught: unknown) => caught)
|
||||
// Root-like environments may still be able to list mode-000 directories.
|
||||
if (error === undefined) return
|
||||
expect(error).toBeInstanceOf(FsError)
|
||||
expect(error).toMatchObject({ code: 'FS_PERMISSION_DENIED' })
|
||||
} finally {
|
||||
await chmod(root, 0o700)
|
||||
}
|
||||
})
|
||||
|
||||
it('translates preflight metadata IO failures into FS_IO_ERROR', async () => {
|
||||
const loop = join(dir, 'loop')
|
||||
await symlink(loop, loop)
|
||||
await expect(listDirectory(localTarget(loop))).rejects.toMatchObject({ code: 'FS_IO_ERROR' })
|
||||
})
|
||||
|
||||
it('translates child resolution failures into structured listing errors', async () => {
|
||||
const root = join(dir, 'listed')
|
||||
await mkdir(root)
|
||||
const loop = join(root, 'loop')
|
||||
await symlink(loop, loop)
|
||||
await expect(listDirectory(localTarget(root))).rejects.toMatchObject({ code: 'FS_IO_ERROR' })
|
||||
})
|
||||
|
||||
it('translates child permission failures into FS_PERMISSION_DENIED', async () => {
|
||||
const root = join(dir, 'listed')
|
||||
const protectedRoot = join(dir, 'protected')
|
||||
const secret = join(protectedRoot, 'secret')
|
||||
await mkdir(root)
|
||||
await mkdir(secret, { recursive: true })
|
||||
await symlink(secret, join(root, 'secret-link'))
|
||||
await chmod(protectedRoot, 0o000)
|
||||
try {
|
||||
const error = await listDirectory(localTarget(root)).then(() => undefined, (caught: unknown) => caught)
|
||||
// Root-like environments may still resolve through mode-000 directories.
|
||||
if (error === undefined) return
|
||||
expect(error).toBeInstanceOf(FsError)
|
||||
expect(error).toMatchObject({ code: 'FS_PERMISSION_DENIED' })
|
||||
} finally {
|
||||
await chmod(protectedRoot, 0o700)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('readWholeText', () => {
|
||||
it('reads a small file', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo\nthree')
|
||||
expect(await readWholeText(localTarget(file))).toBe('one\ntwo\nthree')
|
||||
})
|
||||
|
||||
it('rejects a missing file and a directory', async () => {
|
||||
await expect(readWholeText(localTarget(join(dir, 'nope')))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
await expect(readWholeText(localTarget(dir))).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('rejects binary and invalid UTF-8', async () => {
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
|
||||
await expect(readWholeText(localTarget(join(dir, 'bin')))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
|
||||
await expect(readWholeText(localTarget(join(dir, 'bad')))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one')
|
||||
await expect(readWholeText(localTarget(file), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
expect(await readWholeText(localTarget(file), new AbortController().signal)).toBe('one\ntwo')
|
||||
})
|
||||
|
||||
it('translates a mid-read AbortError into FS_ABORTED', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
const ac = new AbortController()
|
||||
// Abort after the synchronous entry check but before readFile runs (the
|
||||
// stat await yields control back here), so readFile rejects AbortError.
|
||||
const pending = readWholeText(localTarget(file), ac.signal)
|
||||
ac.abort()
|
||||
await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('streamWholeText', () => {
|
||||
it('streams the whole file as decoded text', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo\nthree')
|
||||
expect(await collect(streamWholeText(localTarget(file)))).toBe('one\ntwo\nthree')
|
||||
})
|
||||
|
||||
it('streams a large multi-chunk file correctly', async () => {
|
||||
const file = join(dir, 'big.txt')
|
||||
const content = Array.from({ length: 50 }, (_, i) => `line ${i}: ${'x'.repeat(3000)}`).join('\n')
|
||||
await writeFile(file, content)
|
||||
expect(await collect(streamWholeText(localTarget(file)))).toBe(content)
|
||||
})
|
||||
|
||||
it('rejects a missing file, directory, binary, and invalid UTF-8', async () => {
|
||||
await expect(collect(streamWholeText(localTarget(join(dir, 'nope'))))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
await expect(collect(streamWholeText(localTarget(dir)))).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
|
||||
await expect(collect(streamWholeText(localTarget(join(dir, 'bin'))))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
|
||||
await expect(collect(streamWholeText(localTarget(join(dir, 'bad'))))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one')
|
||||
await expect(collect(streamWholeText(localTarget(file), AbortSignal.abort()))).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through the stream', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
expect(await collect(streamWholeText(localTarget(file), new AbortController().signal))).toBe('one\ntwo')
|
||||
})
|
||||
|
||||
it('translates a mid-stream abort into FS_ABORTED', async () => {
|
||||
// A multi-chunk file so the stream yields more than once; abort after the
|
||||
// first chunk and assert the structured code, not a raw AbortError.
|
||||
const file = join(dir, 'big.txt')
|
||||
await writeFile(file, 'x'.repeat(256 * 1024))
|
||||
const ac = new AbortController()
|
||||
const run = async (): Promise<void> => {
|
||||
let seen = 0
|
||||
for await (const _chunk of streamWholeText(localTarget(file), ac.signal)) {
|
||||
seen += 1
|
||||
if (seen === 1) ac.abort()
|
||||
}
|
||||
}
|
||||
await expect(run()).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeFileAtomic — temp-file safety', () => {
|
||||
it('writes through a private staging dir and owner-only temp file', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
let inspected = false
|
||||
await writeFileAtomic(file, 'hello', 0o640, undefined, {
|
||||
inspectTemp: async ({ stagingDir, tempPath }) => {
|
||||
inspected = true
|
||||
expect((await stat(stagingDir)).mode & 0o777).toBe(0o700)
|
||||
expect((await stat(tempPath)).mode & 0o777).toBe(0o600)
|
||||
},
|
||||
})
|
||||
expect(inspected).toBe(true)
|
||||
expect(await readFile(file, 'utf8')).toBe('hello')
|
||||
expect((await stat(file)).mode & 0o777).toBe(0o640)
|
||||
expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([])
|
||||
})
|
||||
|
||||
it('creates new files owner-only by default', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFileAtomic(file, 'hello', undefined, undefined)
|
||||
expect((await stat(file)).mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
it('opens staging paths exclusively — a pre-existing path is never clobbered', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
const tempDirName = '.fixed-temp.tmpdir'
|
||||
await mkdir(join(dir, tempDirName))
|
||||
await writeFile(join(dir, tempDirName, 'PRECIOUS'), 'keep')
|
||||
await expect(
|
||||
writeFileAtomic(file, 'hello', undefined, undefined, { tempDirName: () => tempDirName }),
|
||||
).rejects.toMatchObject({ code: 'EEXIST' })
|
||||
expect(await readFile(join(dir, tempDirName, 'PRECIOUS'), 'utf8')).toBe('keep')
|
||||
await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('creates parent directories as needed', async () => {
|
||||
const file = join(dir, 'nested', 'deep', 'a.txt')
|
||||
await writeFileAtomic(file, 'hi', undefined, undefined)
|
||||
expect(await readFile(file, 'utf8')).toBe('hi')
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through the write', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFileAtomic(file, 'hi', undefined, new AbortController().signal)
|
||||
expect(await readFile(file, 'utf8')).toBe('hi')
|
||||
})
|
||||
|
||||
it('aborts before writing when the signal is already aborted', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await expect(writeFileAtomic(file, 'hi', undefined, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('cleans up the temp file when the final rename fails', async () => {
|
||||
const sub = join(dir, 'occupied')
|
||||
await mkdir(sub)
|
||||
await expect(writeFileAtomic(sub, 'hi', undefined, undefined)).rejects.toBeInstanceOf(Error)
|
||||
expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyLiteralEdit', () => {
|
||||
it('replaces a unique match', () => {
|
||||
expect(applyLiteralEdit('a b c', 'b', 'X', false, 'f')).toEqual({ content: 'a X c', replacements: 1 })
|
||||
})
|
||||
|
||||
it('rejects zero matches', () => {
|
||||
expect(() => applyLiteralEdit('a b c', 'z', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_EDIT_NOT_FOUND' }))
|
||||
})
|
||||
|
||||
it('rejects an empty oldString without scanning forever', () => {
|
||||
expect(() => applyLiteralEdit('a b c', '', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_EDIT_NOT_FOUND' }))
|
||||
})
|
||||
|
||||
it('rejects multiple matches without replaceAll', () => {
|
||||
expect(() => applyLiteralEdit('a a a', 'a', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_AMBIGUOUS_EDIT' }))
|
||||
})
|
||||
|
||||
it('replaces all matches with replaceAll', () => {
|
||||
expect(applyLiteralEdit('a a a', 'a', 'X', true, 'f')).toEqual({ content: 'X X X', replacements: 3 })
|
||||
})
|
||||
|
||||
it('matches across normalized line endings', () => {
|
||||
expect(applyLiteralEdit('one\ntwo', 'one\ntwo', 'x', false, 'f').replacements).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readForEdit + restoreLineEndings', () => {
|
||||
it('round-trips CRLF: matches on LF, writes back CRLF', async () => {
|
||||
const file = join(dir, 'crlf.txt')
|
||||
await writeFile(file, 'one\r\ntwo\r\n')
|
||||
const original = await readForEdit(file, file)
|
||||
expect(original.lineEndings).toBe('CRLF')
|
||||
const edited = applyLiteralEdit(original.content, 'two', 'TWO', false, file)
|
||||
expect(restoreLineEndings(edited.content, original.lineEndings)).toBe('one\r\nTWO\r\n')
|
||||
})
|
||||
|
||||
it('rejects a binary file and invalid UTF-8', async () => {
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01]))
|
||||
await expect(readForEdit(join(dir, 'bin'), join(dir, 'bin'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
|
||||
await expect(readForEdit(join(dir, 'bad'), join(dir, 'bad'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through the read', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
const original = await readForEdit(file, file, new AbortController().signal)
|
||||
expect(original.content).toBe('one\ntwo')
|
||||
})
|
||||
|
||||
it('translates a mid-read AbortError into FS_ABORTED', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
const ac = new AbortController()
|
||||
// Abort after the synchronous entry check, while readFile is pending.
|
||||
const pending = readForEdit(file, file, ac.signal)
|
||||
ac.abort()
|
||||
await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
})
|
||||
15
packages/fs/fs-local/tsconfig.json
Normal file
15
packages/fs/fs-local/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../fs" }
|
||||
]
|
||||
}
|
||||
48
packages/fs/fs-policy/README.md
Normal file
48
packages/fs/fs-policy/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# @deepseek-ai/dsh-fs-policy
|
||||
|
||||
The **fs-policy plugin**: it adds observed-state, read-before-edit, and version-guarded write/edit on top of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fsPolicy` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class.
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
|
||||
declare const ctx: Context
|
||||
|
||||
// No service to inject — this plugin only registers the three fs/* listeners.
|
||||
// Load it alongside a ctx.fs provider (e.g. @deepseek-ai/dsh-fs-local) and the
|
||||
// @deepseek-ai/dsh-tool-fs tools; the tools dispatch the fs/* events this plugin
|
||||
// decides. Order does not matter for resolution (no inject), but the policy
|
||||
// listener should be the first decider registered for the fs/*-intent slots.
|
||||
await ctx.plugin(FsPolicy)
|
||||
```
|
||||
|
||||
## The four-layer split
|
||||
|
||||
| Layer | Package | Role |
|
||||
|---|---|---|
|
||||
| tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events |
|
||||
| policy | `@deepseek-ai/dsh-fs-policy` (this) | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) |
|
||||
| provider seam | `@deepseek-ai/dsh-fs` | `ctx.fs`: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary |
|
||||
| provider | `@deepseek-ai/dsh-fs-local` | local implementation of `ctx.fs` |
|
||||
|
||||
## How the gate participates
|
||||
|
||||
Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek-ai/dsh-tool-fs`):
|
||||
|
||||
| Event | This plugin's listener |
|
||||
|---|---|
|
||||
| `fs/write-intent` | No prior observation → `{ kind: 'createIfAbsent' }`; a prior observation → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. |
|
||||
| `fs/edit-intent` | Requires a prior observation by this owner (else throws `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. |
|
||||
| `fs/observed` | Records `{ version }` for this owner+target. Synchronous, side-effect-only `WeakMap.set`. |
|
||||
|
||||
## Observed state is the prior-observation record; freshness is provider CAS
|
||||
|
||||
Observed state is a `WeakMap<owner, Map<targetKey, FsVersion>>`. An entry exists **iff** the owner has read, written, OR edited that target (every success emits `fs/observed`), so its presence is the prior-observation record — there is no `hasRead` flag and no `full`/`partial` view. This plugin does **no** filesystem I/O: "have you observed this file?" is a `WeakMap` lookup, and "is the version you read still current?" is decided inside `ctx.fs.editText`/`writeText` in the same atomic lock that performs the mutation — this plugin only supplies `vObserved` as the basis. A windowed read of lines 100-150 records the file's version, and a later edit of line 120 is authorized as long as the file is unchanged. State is held weakly and dropped on disposal (HMR safety); persistence across sessions is deferred.
|
||||
|
||||
## Single-slot, first-wins
|
||||
|
||||
The `fs/write-intent`/`fs/edit-intent` slots hold exactly one decider — this plugin fully decides and does not call `next()`. The slot is first-wins by registration order; this plugin owning it is the default-deployment convention, not an event-enforced invariant (a decider registered before / `prepend`ed would win instead). This is not a composable authorization chain — layered permission/audit/sandbox interception belongs on `tools/execute`.
|
||||
|
||||
## No method coupling
|
||||
|
||||
Because the plugin influences the world only through events, removing it does not break `@deepseek-ai/dsh-tool-fs` at a service-injection boundary: the tool falls through to the bare `ctx.fs` provider (unconditional write/edit, no observed-state). Loading it back layers the policy on. That graceful add/remove is the whole point of the event gate over a mandatory method service.
|
||||
33
packages/fs/fs-policy/package.json
Normal file
33
packages/fs/fs-policy/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-fs-policy",
|
||||
"description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service surface)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
160
packages/fs/fs-policy/src/index.ts
Normal file
160
packages/fs/fs-policy/src/index.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* The fs-policy PLUGIN: observed-state, read-before-edit, and
|
||||
* "write/edit must be based on the version you read" — added on top of the
|
||||
* `ctx.fs` provider seam through the `fs/*` event gate, NOT through a method
|
||||
* service. This plugin registers NO `ctx.fsPolicy` service and exposes no
|
||||
* `read`/`write`/`edit`/`resolve` methods; it influences the world only by
|
||||
* deciding the `fs/write-intent`/`fs/edit-intent` waterfalls and
|
||||
* recording on `fs/observed`. That is what keeps `@deepseek-ai/dsh-tool-fs`
|
||||
* (the executor) free of any method coupling to the policy layer — removing
|
||||
* this plugin gracefully loses the policy and leaves the unconstrained bare
|
||||
* provider, rather than breaking the tool at a service-injection boundary.
|
||||
*
|
||||
* ## Observed state IS the prior-observation record
|
||||
*
|
||||
* State lives here as `WeakMap<owner, Map<targetKey, { version }>>`. An entry
|
||||
* exists iff the owner has read, written, OR edited that target (every success
|
||||
* emits `fs/observed`), so its presence means "this owner has observed this
|
||||
* target at this version". This is what lets a create-then-edit or
|
||||
* edit-then-edit sequence work without an intervening re-read: the mutation
|
||||
* refreshes the recorded version to its own result. The owner is derived
|
||||
* structurally from `{ agent?: { session? } }` and held weakly, so a collected
|
||||
* session frees its state; disposal drops everything (HMR safety).
|
||||
*
|
||||
* ## Freshness via provider CAS, not stat
|
||||
*
|
||||
* This plugin does NO filesystem I/O. "Have you observed this file?" is a
|
||||
* `WeakMap` lookup (no record ⇒ `FS_NOT_OBSERVED`). "Is the version you read
|
||||
* still current?" is decided INSIDE `ctx.fs.editText`/`writeText`, in the same
|
||||
* atomic lock that performs the mutation — this plugin only supplies the
|
||||
* observed version as the CAS basis. Stat-ing and comparing here would open a
|
||||
* TOCTOU gap the provider lock has to back up anyway, so it is deliberately
|
||||
* avoided.
|
||||
*
|
||||
* ## Single-slot, first-wins
|
||||
*
|
||||
* The `fs/write-intent`/`fs/edit-intent` listeners do NOT call
|
||||
* `next()`: each fully decides its single slot. The slot is first-wins by
|
||||
* registration order — this plugin owning it is the default-deployment
|
||||
* convention, not an event-enforced invariant (a decider registered before /
|
||||
* `prepend`ed would win instead). This is not a composable authorization chain;
|
||||
* layered permission/audit/sandbox interception belongs on `tools/execute`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs-policy
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsPolicyExec } from './types.ts'
|
||||
|
||||
export type { FsPolicyExec } from './types.ts'
|
||||
|
||||
/**
|
||||
* Per-context observed-file state and the three `fs/*` decisions over it. One
|
||||
* instance is created per `apply()` so disposal can drop all state for HMR.
|
||||
*/
|
||||
class ObservedStateGate {
|
||||
/**
|
||||
* Observed-file state, keyed first by the owner object (weakly held, so a
|
||||
* collected session frees its state), then by {@link FsTarget.targetKey}. An
|
||||
* entry's PRESENCE is the prior-observation record.
|
||||
*/
|
||||
private observed = new WeakMap<object, Map<string, FsVersion>>()
|
||||
|
||||
/**
|
||||
* Derive the observed-state owner from the opaque event actor — normally the
|
||||
* active agent session. `undefined` when no owner can be derived (e.g. a
|
||||
* direct tool call with no agent); such calls read freely but cannot satisfy
|
||||
* the write/edit prior-observation policy.
|
||||
*/
|
||||
private owner(actor: object | undefined): object | undefined {
|
||||
return (actor as FsPolicyExec | undefined)?.agent?.session
|
||||
}
|
||||
|
||||
private get(owner: object, targetKey: string): FsVersion | undefined {
|
||||
return this.observed.get(owner)?.get(targetKey)
|
||||
}
|
||||
|
||||
private set(owner: object, targetKey: string, version: FsVersion): void {
|
||||
let byTarget = this.observed.get(owner)
|
||||
if (!byTarget) {
|
||||
byTarget = new Map()
|
||||
this.observed.set(owner, byTarget)
|
||||
}
|
||||
byTarget.set(targetKey, version)
|
||||
}
|
||||
|
||||
/** Drop all recorded state (HMR safety / disposal). */
|
||||
clear(): void {
|
||||
this.observed = new WeakMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the write intent: no prior observation ⇒ `createIfAbsent` (only
|
||||
* new files can be created blindly); a prior observation ⇒ `replaceIfVersion`
|
||||
* at the observed version (existing files replaced only if unchanged).
|
||||
*/
|
||||
writeIntent(target: FsTarget, actor: object | undefined): FsWriteIntent {
|
||||
const owner = this.owner(actor)
|
||||
const prior = owner ? this.get(owner, target.targetKey) : undefined
|
||||
return prior ? { kind: 'replaceIfVersion', version: prior } : { kind: 'createIfAbsent' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the edit version guard: requires a prior observation by this owner
|
||||
* (else `FS_NOT_OBSERVED`); returns the observed version as the CAS basis.
|
||||
*/
|
||||
editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } {
|
||||
const owner = this.owner(actor)
|
||||
const prior = owner ? this.get(owner, target.targetKey) : undefined
|
||||
if (!owner || !prior) {
|
||||
throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED')
|
||||
}
|
||||
return { version: prior }
|
||||
}
|
||||
|
||||
/** Record a successful read/write/edit: this owner observed this target at this version. */
|
||||
observe(target: FsTarget, version: FsVersion, actor: object | undefined): void {
|
||||
const owner = this.owner(actor)
|
||||
if (owner) this.set(owner, target.targetKey, version)
|
||||
}
|
||||
}
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'fs-policy'
|
||||
|
||||
/**
|
||||
* Register the three `fs/*` listeners. No `inject` — this plugin reads no
|
||||
* services; it operates only on its own `WeakMap`. The waterfalls are unbound
|
||||
* (the tool dispatches them with no `this`), so the listeners take the raw
|
||||
* `(target, actor, next)` arguments.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const gate = new ObservedStateGate()
|
||||
|
||||
ctx.effect(() => () => {
|
||||
// Drop all recorded state on disposal so a reloaded plugin starts clean
|
||||
// (HMR safety). The WeakMap itself would be GC'd, but replacing it makes the
|
||||
// release observable and immediate for tests.
|
||||
gate.clear()
|
||||
}, 'fs-policy observed-state teardown')
|
||||
|
||||
// fs/write-intent: occupy the single decision slot — do NOT call next().
|
||||
// Deferred through Promise.resolve().then so the declared Promise return type
|
||||
// holds (a throw rejects, never escapes synchronously through the waterfall).
|
||||
ctx.on('fs/write-intent', (target, actor) => Promise.resolve().then(() => gate.writeIntent(target, actor)))
|
||||
|
||||
// fs/edit-intent: occupy the single decision slot — do NOT call next().
|
||||
// Deferred the same way so an FS_NOT_OBSERVED throw becomes a rejected promise
|
||||
// the edit tool's `await ctx.waterfall(...)` surfaces as its isError result.
|
||||
ctx.on('fs/edit-intent', (target, actor) => Promise.resolve().then(() => gate.editIntent(target, actor)))
|
||||
|
||||
// fs/observed: synchronous, side-effect-only WeakMap write. The tool emits
|
||||
// this with a plain (unguarded) ctx.emit, so this listener MUST NOT throw —
|
||||
// a throw would surface as the tool's isError result for a mutation that
|
||||
// already succeeded. A WeakMap.set honors that contract.
|
||||
ctx.on('fs/observed', (target, version, actor) => {
|
||||
gate.observe(target, version, actor)
|
||||
})
|
||||
}
|
||||
29
packages/fs/fs-policy/src/types.ts
Normal file
29
packages/fs/fs-policy/src/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Vocabulary for the fs-policy plugin: the minimal execution-context
|
||||
* shape used to derive an observed-state owner by narrowing the opaque `object`
|
||||
* actor the `fs/*` events carry.
|
||||
*
|
||||
* The provider vocabulary (`FsTarget`, `FsVersion`, write/edit shapes) is
|
||||
* re-used from `@deepseek-ai/dsh-fs`; this package owns only the observed-state
|
||||
* owner structure on top of it.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs-policy/types
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimal structural view of a tool execution the policy plugin needs to derive
|
||||
* an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies
|
||||
* this shape, so the tool passes its `exec` straight through as the opaque
|
||||
* `object` actor on the `fs/*` events; this plugin narrows that actor to this
|
||||
* shape without importing `dsh-tools`, `dsh-agent`, or `dsh-session`.
|
||||
*
|
||||
* The owner is `agent.session` when present. It is treated as an opaque object
|
||||
* identity (a `WeakMap` key); this package never reads any of its fields.
|
||||
*/
|
||||
export interface FsPolicyExec {
|
||||
/** The agent on whose behalf the call runs, when there is one. */
|
||||
agent?: {
|
||||
/** The session that owns observed-file state, used as an opaque key. */
|
||||
session?: object
|
||||
}
|
||||
}
|
||||
215
packages/fs/fs-policy/tests/policy.spec.ts
Normal file
215
packages/fs/fs-policy/tests/policy.spec.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Tests for the fs-policy PLUGIN: it registers no service, only the
|
||||
* three `fs/*` listeners. We dispatch those events directly (the unbound
|
||||
* waterfalls the tool would dispatch, and the `fs/observed` emit) and assert the
|
||||
* decisions: createIfAbsent vs replaceIfVersion, FS_NOT_OBSERVED for an unread
|
||||
* edit, observed-state-as-prior-observation (read/write/edit all record),
|
||||
* multi-owner isolation, single-slot first-wins, and disposal/HMR release.
|
||||
*
|
||||
* No `ctx.fs` provider is needed — the plugin does no filesystem I/O; it only
|
||||
* decides intents and records versions on its own WeakMap.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import type { FsPolicyExec } from '@deepseek-ai/dsh-fs-policy'
|
||||
|
||||
function target(path: string): FsTarget {
|
||||
return { targetKey: FsTargetKey(path), displayPath: path }
|
||||
}
|
||||
const ownerExec = (session: object): FsPolicyExec => ({ agent: { session } })
|
||||
|
||||
/** Dispatch the write-intent waterfall with the bare default thunk. */
|
||||
function writeIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise<FsWriteIntent | undefined> {
|
||||
return ctx.waterfall('fs/write-intent', t, actor, () => undefined)
|
||||
}
|
||||
/** Dispatch the edit-intent waterfall with the bare default thunk. */
|
||||
function editIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise<{ version: FsVersion } | undefined> {
|
||||
return ctx.waterfall('fs/edit-intent', t, actor, () => undefined)
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(FsPolicy)
|
||||
return { ctx, fiber }
|
||||
}
|
||||
|
||||
describe('registration / disposal', () => {
|
||||
it('registers no service surface (it is a plugin, not ctx.fsPolicy)', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect((ctx as Context & { fsPolicy?: unknown }).fsPolicy).toBeUndefined()
|
||||
})
|
||||
|
||||
it('mounts with no inject (reads no services)', async () => {
|
||||
// It mounts immediately even with nothing else in the context.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FsPolicy)
|
||||
// The listener is live: an unobserved write decides createIfAbsent.
|
||||
expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('write-intent decision', () => {
|
||||
it('an unobserved target decides createIfAbsent', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toEqual({ kind: 'createIfAbsent' })
|
||||
})
|
||||
|
||||
it('a no-owner actor decides createIfAbsent', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' })
|
||||
expect(await writeIntent(ctx, target('a.txt'), {})).toEqual({ kind: 'createIfAbsent' })
|
||||
})
|
||||
|
||||
it('an actor with an agent but no session has no owner (createIfAbsent)', async () => {
|
||||
// The middle optional-chain rung: agent present, session undefined ⇒ owner
|
||||
// undefined ⇒ unobservable, so a write can only be a blind create.
|
||||
const { ctx } = await setup()
|
||||
expect(await writeIntent(ctx, target('a.txt'), { agent: {} })).toEqual({ kind: 'createIfAbsent' })
|
||||
})
|
||||
|
||||
it('an observed target decides replaceIfVersion at the observed version', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v7'), exec)
|
||||
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit-intent decision', () => {
|
||||
it('rejects an unread edit with FS_NOT_OBSERVED', async () => {
|
||||
const { ctx } = await setup()
|
||||
await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('rejects an edit with no owner (cannot prove prior observation)', async () => {
|
||||
const { ctx } = await setup()
|
||||
await expect(editIntent(ctx, target('a.txt'), undefined)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('rejects an edit whose actor has an agent but no session (no owner)', async () => {
|
||||
const { ctx } = await setup()
|
||||
await expect(editIntent(ctx, target('a.txt'), { agent: {} })).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('returns the observed version as the CAS basis after an observation', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v3'), exec)
|
||||
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('observed-state is the prior-observation record', () => {
|
||||
it('a read observation authorizes an in-place write at that version', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) // a read
|
||||
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
|
||||
})
|
||||
|
||||
it('a write/edit observation refreshes the basis, so the next edit needs no re-read', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
// A create records v1; the follow-up edit guards against v1 with no read.
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v1'), exec)
|
||||
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v1' })
|
||||
// The edit records v2; a second edit guards against v2.
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v2'), exec)
|
||||
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v2' })
|
||||
})
|
||||
|
||||
it('a no-owner observation records nothing', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), undefined)
|
||||
// Still unobserved for any owner.
|
||||
await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi-owner isolation', () => {
|
||||
it('owner A observing does not grant owner B edit authority', async () => {
|
||||
const { ctx } = await setup()
|
||||
const a = ownerExec({})
|
||||
const b = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a)
|
||||
await expect(editIntent(ctx, target('a.txt'), b)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(await editIntent(ctx, target('a.txt'), a)).toEqual({ version: 'v0' })
|
||||
})
|
||||
|
||||
it('each owner records its own observed version independently', async () => {
|
||||
const { ctx } = await setup()
|
||||
const a = ownerExec({})
|
||||
const b = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) // A observed v0
|
||||
// B never observed → createIfAbsent; A still holds v0 → replaceIfVersion.
|
||||
expect(await writeIntent(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' })
|
||||
expect(await writeIntent(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('single-slot, first-wins', () => {
|
||||
it('fully decides the slot without calling next() (the bare default is unreached)', async () => {
|
||||
const { ctx } = await setup()
|
||||
let defaultRan = false
|
||||
const intent = await ctx.waterfall('fs/write-intent', target('a.txt'), ownerExec({}), () => {
|
||||
defaultRan = true
|
||||
return undefined
|
||||
})
|
||||
expect(intent).toEqual({ kind: 'createIfAbsent' })
|
||||
expect(defaultRan).toBe(false)
|
||||
})
|
||||
|
||||
it('a SECOND decider registered AFTER fs-policy is not reached (first-wins short-circuit)', async () => {
|
||||
const { ctx } = await setup()
|
||||
let secondRan = false
|
||||
// Registered after fs-policy, so it dispatches second; fs-policy does
|
||||
// not call next(), so this never runs. (A decider registered BEFORE — or with
|
||||
// prepend — would instead win: first-wins is by convention, not enforced.)
|
||||
ctx.on('fs/edit-intent', () => {
|
||||
secondRan = true
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
|
||||
await editIntent(ctx, target('a.txt'), exec)
|
||||
expect(secondRan).toBe(false)
|
||||
})
|
||||
|
||||
it('a SECOND write-intent decider registered AFTER fs-policy is not reached', async () => {
|
||||
const { ctx } = await setup()
|
||||
let secondRan = false
|
||||
ctx.on('fs/write-intent', () => {
|
||||
secondRan = true
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
await writeIntent(ctx, target('a.txt'), ownerExec({}))
|
||||
expect(secondRan).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('disposal releases recorded state (HMR safety)', () => {
|
||||
it('a fresh plugin after disposal starts with no inherited state', async () => {
|
||||
const ctx = new Context()
|
||||
const exec = ownerExec({})
|
||||
const fiber = await ctx.plugin(FsPolicy)
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
|
||||
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' })
|
||||
await fiber.dispose()
|
||||
|
||||
await ctx.plugin(FsPolicy)
|
||||
// Same owner object, but state was released on disposal.
|
||||
await expect(editIntent(ctx, target('a.txt'), exec)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('no listeners remain after disposal (the gate no longer decides)', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(FsPolicy)
|
||||
await fiber.dispose()
|
||||
// With no listener, the waterfall falls through to the bare default.
|
||||
expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
14
packages/fs/fs-policy/tsconfig.json
Normal file
14
packages/fs/fs-policy/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../fs" }
|
||||
]
|
||||
}
|
||||
44
packages/fs/fs/README.md
Normal file
44
packages/fs/fs/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# @deepseek-ai/dsh-fs
|
||||
|
||||
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives a backend provides — resolve a path, stat metadata, read/stream text, list directories, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
|
||||
|
||||
This package is the provider-seam layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam RFC](../../../docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate RFC](../../../docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
|
||||
|
||||
| Layer | Package | Role |
|
||||
|---|---|---|
|
||||
| tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing `read`/`write`/`edit` schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events |
|
||||
| policy | `@deepseek-ai/dsh-fs-policy` | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) |
|
||||
| provider seam | `@deepseek-ai/dsh-fs` (this) | `ctx.fs`: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary |
|
||||
| provider | `@deepseek-ai/dsh-fs-local` | the host-filesystem implementation |
|
||||
|
||||
A future sandboxed, virtual, or remote backend implements this interface and the policy/tool layers don't change.
|
||||
|
||||
## Service API (`ctx.fs`)
|
||||
|
||||
A backend subclasses `FileSystem` and implements seven primitives.
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. |
|
||||
| `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. |
|
||||
| `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). |
|
||||
| `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). |
|
||||
| `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. Broken/disappeared children may be returned as `other` without metadata; child permission/IO failures fail the whole listing with the same structured codes. |
|
||||
| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. |
|
||||
| `editText(target, edit, expected?, signal?)` | Literal edit. `expected` is OPTIONAL: omit ⇒ unconditional edit of the current content; supply `{ version }` to guard (verified BEFORE matching). A missing target reports `FS_STALE_VERSION` either way. Applies and writes atomically — one mutation critical section. |
|
||||
|
||||
The mutation runs inside the backend's per-target lock either way, so an unconditional write/edit is still atomic — "unconditional" drops the *version* precondition, not the atomicity.
|
||||
|
||||
## The `fs/*` policy events
|
||||
|
||||
This package declares three events (see the generated [events catalog](../../../docs/cordis-catalog/events.md)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-intent` and `fs/edit-intent` are single-slot decision waterfalls (the listener fully decides, never calling `next()`); `fs/observed` is a fire-and-forget recording event. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure.
|
||||
|
||||
## A provider seam, not the policy layer
|
||||
|
||||
`ctx.fs` is deliberately close to fsspec-style storage primitives — half a level above byte-level `cat`/`open`, because it decodes text and rejects binaries so the policy layer never touches raw bytes. It owns UTF-8 decoding, binary rejection, atomic writes, and the literal-edit critical section. It does **not** own line windows, numbered lines, rendered footers, or observed-state. Observed-state, read-before-edit, and version-guarded write/edit are policy a plugin (`@deepseek-ai/dsh-fs-policy`) ADDS by supplying the optional guard — not provider behavior — so a sandboxed/remote backend inherits no model-facing observation policy.
|
||||
|
||||
`editText` stays on this seam (not composed in the policy layer from a read plus a write) because version guard + literal match + atomic rewrite must stay inside one critical section for correct error attribution and one-wins/one-stale concurrency, and a remote backend may implement it as a native compare-and-edit.
|
||||
|
||||
## Vocabulary
|
||||
|
||||
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
|
||||
34
packages/fs/fs/package.json
Normal file
34
packages/fs/fs/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-fs",
|
||||
"description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service (text IO + optional version-guarded atomic mutations), and the fs/* policy event vocabulary",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
260
packages/fs/fs/src/index.ts
Normal file
260
packages/fs/fs/src/index.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* The filesystem provider seam (`ctx.fs`): an abstract service defining the
|
||||
* text-storage primitives a backend provides — resolve a path into a stable
|
||||
* target, stat its metadata, read/stream its text, write it atomically with an
|
||||
* explicit intent, and apply a guarded literal edit — without saying HOW.
|
||||
* Implementations subclass {@link FileSystem} and register themselves as the
|
||||
* `fs` service; `@deepseek-ai/dsh-fs-local` (the host filesystem) is the first.
|
||||
* Future implementations swap in sandboxed, remote, virtual, or project-scoped
|
||||
* backends without touching the model-facing tool schemas
|
||||
* (`@deepseek-ai/dsh-tool-fs`).
|
||||
*
|
||||
* The split mirrors the bash seam (`BashExecutor`/`LocalBashExecutor`). See the
|
||||
* capability-seam RFC for why a swappable capability is three (here four)
|
||||
* packages.
|
||||
*
|
||||
* ## This is a provider seam, not the policy layer
|
||||
*
|
||||
* `ctx.fs` is deliberately close to fsspec-style storage primitives. It owns
|
||||
* UTF-8 decoding, binary/NUL rejection, atomic full-file writes, and the
|
||||
* literal-edit critical section — but NOT line windows, numbered lines,
|
||||
* rendered footers, or observed-state. Read windowing lives in the model-facing
|
||||
* tool (`@deepseek-ai/dsh-tool-fs`); observed-state and read-before-write/edit
|
||||
* are policy a plugin (`@deepseek-ai/dsh-fs-policy`) adds through the `fs/*`
|
||||
* event gate. So a sandboxed/remote backend inherits no model-facing observation
|
||||
* policy it has no business carrying.
|
||||
*
|
||||
* `editText` stays on this seam (not composed in the policy layer from a read
|
||||
* plus a write) because version guard + literal match + atomic rewrite must
|
||||
* stay inside one mutation critical section for correct error attribution and
|
||||
* one-wins/one-stale concurrency, and a remote backend may implement it as a
|
||||
* native compare-and-edit.
|
||||
*
|
||||
* ## The version guard is OPTIONAL — additive policy, not subtractive
|
||||
*
|
||||
* `ctx.fs` on its own is a complete, unconstrained text-storage seam: `read`
|
||||
* reads, `write` unconditionally creates-or-overwrites, `edit` unconditionally
|
||||
* replaces literal text in the current content. Both mutations take their
|
||||
* version guard as an OPTIONAL argument — omit it for the unconstrained
|
||||
* bare-provider behavior, supply it to guard against a concurrent change. The
|
||||
* mutation runs inside the backend's per-target lock either way, so an
|
||||
* unconditional write/edit is still atomic; "unconditional" drops the *version*
|
||||
* precondition, not the atomicity. Observed-state, read-before-edit, and
|
||||
* version-guarded write/edit are NOT provider behavior — they are policy a
|
||||
* plugin (`@deepseek-ai/dsh-fs-policy`) adds on top by supplying the guard.
|
||||
*
|
||||
* ## The fs policy events live here, not in the policy plugin
|
||||
*
|
||||
* This package owns the `fs/write-intent`, `fs/edit-intent`, and
|
||||
* `fs/observed` event vocabulary (see {@link Events}). The emitter is
|
||||
* `@deepseek-ai/dsh-tool-fs` and the default listener is
|
||||
* `@deepseek-ai/dsh-fs-policy`; the events live in the one package both
|
||||
* already depend on, so the emitter shares a vocabulary with the policy listener
|
||||
* without depending on the policy plugin. The events carry only `dsh-fs`
|
||||
* vocabulary plus an opaque `object` actor — no model-facing concepts (line
|
||||
* windows, numbered lines) and no agent/session owner structure leak down.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type {
|
||||
FsDirEntry,
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsTarget,
|
||||
FsVersion,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
} from './types.ts'
|
||||
|
||||
export {
|
||||
FsError,
|
||||
FsTargetKey,
|
||||
FsVersion,
|
||||
} from './types.ts'
|
||||
export type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsDirEntry,
|
||||
FsErrorCode,
|
||||
FsInfo,
|
||||
FsTarget,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
} from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
fs: FileSystem
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* Single-slot decision: produce the write intent for the next
|
||||
* {@link FileSystem.writeText}. The tool dispatches this as an unbound
|
||||
* waterfall (no `this`) and supplies a default thunk returning `undefined`
|
||||
* (unconditional create-or-overwrite — the bare provider). The
|
||||
* `@deepseek-ai/dsh-fs-policy` policy listener returns `createIfAbsent`
|
||||
* (unobserved actor) or `{ kind: 'replaceIfVersion', version: vObserved }`
|
||||
* (observed) and does NOT call `next()` — one decision, not a composable
|
||||
* 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>
|
||||
/**
|
||||
* Single-slot decision: produce the optional version guard for the next
|
||||
* {@link FileSystem.editText}. The tool dispatches this as an unbound
|
||||
* waterfall and supplies a default thunk returning `undefined` (unconditional
|
||||
* edit of the current content — the bare provider; no `stat`). The
|
||||
* `@deepseek-ai/dsh-fs-policy` policy listener returns
|
||||
* `{ 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>
|
||||
/**
|
||||
* Record that an actor observed a target at a version, after a successful
|
||||
* read/write/edit. Fire-and-forget (plain `emit`). A listener MUST be a
|
||||
* synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s
|
||||
* is a `WeakMap.set`): the tool does not guard the emit, so a listener that
|
||||
* throws surfaces as the tool's `isError` result, and cordis `emit` does not
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract filesystem provider service. Subclass, implement the seven storage
|
||||
* primitives, and load the subclass as a plugin — it registers as `ctx.fs` (one
|
||||
* implementation per context; loading a second throws, cordis' standard
|
||||
* duplicate-service behavior).
|
||||
*
|
||||
* Semantics every backend must honor:
|
||||
* - {@link resolve} returns a stable {@link FsTarget}; the same underlying file
|
||||
* reached by different input paths must yield the same `targetKey` so stale
|
||||
* guards and target lookup agree across paths (e.g. through symlinks).
|
||||
* - {@link stat} returns {@link FsInfo} metadata (never content) or `undefined`
|
||||
* when the target is absent.
|
||||
* - {@link readText}/{@link streamText} read the whole regular text file (the
|
||||
* stream for large files); both own regular-file checks, UTF-8 decoding,
|
||||
* binary/NUL rejection, and `FS_NOT_TEXT`.
|
||||
* - {@link listDir} returns direct children of a directory in stable name order
|
||||
* with resolved child targets and cheap metadata only. It never reads file
|
||||
* contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw
|
||||
* `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and
|
||||
* other backend I/O failures throw `FS_IO_ERROR`.
|
||||
* - {@link writeText} is atomic temp-file + rename. `expected` is OPTIONAL:
|
||||
* omit it for an unconditional create-or-overwrite (the bare-provider default),
|
||||
* or supply a {@link FsWriteIntent} to guard the write.
|
||||
* - {@link editText} verifies `expected.version` BEFORE literal matching (so a
|
||||
* stale edit reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND`/
|
||||
* `FS_AMBIGUOUS_EDIT` against newer content), then applies literal replacement
|
||||
* and writes atomically — all inside one mutation critical section. `expected`
|
||||
* is OPTIONAL: omit it for an unconditional edit of the current content (a
|
||||
* missing target still reports `FS_STALE_VERSION`).
|
||||
*/
|
||||
export abstract class FileSystem extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'fs')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May
|
||||
* perform I/O (a remote/sandboxed backend may need a round-trip to map a path
|
||||
* to a stable identity), hence async even though the local backend only
|
||||
* normalizes + realpaths.
|
||||
*
|
||||
* `opts.cwd` is the base directory a RELATIVE `path` resolves against; an
|
||||
* absolute `path` ignores it. Omitted ⇒ the backend's own default base (the
|
||||
* local backend uses its configured `cwd`). The CALLER supplies this — the
|
||||
* seam does not read a session or agent — so a tool can resolve against the
|
||||
* 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.
|
||||
* @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.
|
||||
* @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>
|
||||
|
||||
/**
|
||||
* Stream the whole regular text file as decoded text chunks (same text
|
||||
* 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[]>
|
||||
|
||||
/**
|
||||
* Create or fully replace a UTF-8 text file atomically. `expected` is the
|
||||
* 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>
|
||||
|
||||
/**
|
||||
* Apply a literal edit to an existing UTF-8 text file. When `expected` is
|
||||
* supplied, verifies `expected.version` as the stale guard BEFORE literal
|
||||
* 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>
|
||||
}
|
||||
|
||||
export default FileSystem
|
||||
186
packages/fs/fs/src/types.ts
Normal file
186
packages/fs/fs/src/types.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Vocabulary for the filesystem provider seam (`ctx.fs`): the opaque
|
||||
* target/version identities, the metadata `stat` returns, the write-intent
|
||||
* and outcome shapes, the literal-edit request/outcome, and the typed error
|
||||
* taxonomy.
|
||||
*
|
||||
* These types are shared by every backend (`@deepseek-ai/dsh-fs-local` and
|
||||
* future sandboxed/remote backends) and by the policy layer
|
||||
* (`@deepseek-ai/dsh-fs-policy`). They are deliberately a *text-storage*
|
||||
* vocabulary half a level above byte-level fsspec: `readText`/`streamText` hand
|
||||
* back decoded text, never raw bytes. Host-path assumptions stay out — `targetKey`
|
||||
* and `version` are opaque branded tokens, and `displayPath` is the only field a
|
||||
* consumer may show.
|
||||
*
|
||||
* Model-facing concepts (line windows, numbered lines, observed-state) do NOT
|
||||
* live here; they belong to the consumer tool and the policy plugin
|
||||
* (`@deepseek-ai/dsh-tool-fs` / `@deepseek-ai/dsh-fs-policy`).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs/types
|
||||
*/
|
||||
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/**
|
||||
* Opaque key for stale guards and target lookup. The local backend uses a
|
||||
* realpath-like string; a remote backend might use a workspace URI or file id.
|
||||
* Consumers MUST NOT parse it or assume it is a local absolute path.
|
||||
*/
|
||||
export type FsTargetKey = Branded<'FsTargetKey'>
|
||||
|
||||
/** Brand a string as an {@link FsTargetKey}. */
|
||||
export function FsTargetKey(key: string): FsTargetKey {
|
||||
return key as FsTargetKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Opaque file-version token — the freshness token a write/edit guards against.
|
||||
* The local backend derives it from mtime+size; a remote backend might use a
|
||||
* revision id. The policy layer records it for stale checks; consumers may
|
||||
* display related metadata but MUST NOT interpret this token.
|
||||
*/
|
||||
export type FsVersion = Branded<'FsVersion'>
|
||||
|
||||
/** Brand a string as an {@link FsVersion}. */
|
||||
export function FsVersion(v: string): FsVersion {
|
||||
return v as FsVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* A path resolved by a backend into a stable identity. `resolve()` produces
|
||||
* this; every other operation takes it.
|
||||
*/
|
||||
export interface FsTarget {
|
||||
/** Opaque key for stale guards and target lookup. */
|
||||
targetKey: FsTargetKey
|
||||
/**
|
||||
* Path for model/UI-facing output. May be a local absolute path,
|
||||
* workspace-relative path, or remote URI depending on the backend.
|
||||
*/
|
||||
displayPath: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata about a target — what {@link FileSystem.stat} returns. Lets the
|
||||
* policy layer reject directories/special files before reading and choose
|
||||
* `readText` vs `streamText` from `size` without probing by failure. `version`
|
||||
* is the freshness token. `undefined` from `stat` means the target is absent.
|
||||
*/
|
||||
export interface FsInfo {
|
||||
/** Opaque freshness token of the target right now. */
|
||||
version: FsVersion
|
||||
/** Whether the target is a regular file, a directory, or something else. */
|
||||
type: 'file' | 'directory' | 'other'
|
||||
/** Byte size of a regular file, when the backend can report it. */
|
||||
size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One direct child returned by {@link FileSystem.listDir}. Listing returns
|
||||
* metadata and resolved targets only; it must not read file contents.
|
||||
*/
|
||||
export interface FsDirEntry {
|
||||
/** Basename of the child inside the listed directory. */
|
||||
name: string
|
||||
/** Whether the child is a regular file, a directory, or something else. */
|
||||
type: 'file' | 'directory' | 'other'
|
||||
/** Resolved child target for follow-up operations. */
|
||||
target: FsTarget
|
||||
/** Opaque freshness token when the backend can report metadata cheaply. */
|
||||
version?: FsVersion
|
||||
/** Byte size of a regular file, when the backend can report it. */
|
||||
size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The explicit intent of a guarded {@link FileSystem.writeText} call.
|
||||
* `createIfAbsent` creates a missing target and rejects an existing one with
|
||||
* `FS_NOT_OBSERVED` (the path the policy plugin uses when the owner has no prior
|
||||
* read). `replaceIfVersion` replaces only when the target exists at the observed
|
||||
* version; a missing target or a version mismatch throws `FS_STALE_VERSION`.
|
||||
*
|
||||
* `writeText` takes this OPTIONALLY: omitting `expected` is the third,
|
||||
* unconstrained state — an unconditional create-or-overwrite (the bare
|
||||
* provider). The union itself carries only the two GUARDED intents; "no guard"
|
||||
* is expressed by omission, so the write and edit mutations share one symmetric
|
||||
* shape (`expected?`: omit = unconditional, present = guarded).
|
||||
*/
|
||||
export type FsWriteIntent =
|
||||
| { kind: 'createIfAbsent' }
|
||||
| { kind: 'replaceIfVersion'; version: FsVersion }
|
||||
|
||||
/** Outcome of a full-file write. */
|
||||
export interface FsWriteOutcome {
|
||||
/** Whether the write created a new file or replaced an existing one. */
|
||||
operation: 'create' | 'update'
|
||||
/** Opaque version of the file after the write. */
|
||||
version: FsVersion
|
||||
/**
|
||||
* The file's content BEFORE the write, or `null` when the file did not exist
|
||||
* (a create) or was undiffable (binary/non-UTF-8). LF-normalized storage text
|
||||
* (the diff basis), never a diff — a consumer computes the result-time
|
||||
* contextual diff from `before`/`after` when `before` is present, else falls
|
||||
* back to a whole-file diff.
|
||||
*/
|
||||
before: string | null
|
||||
/** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */
|
||||
after: string
|
||||
}
|
||||
|
||||
/** A literal-replacement edit request. */
|
||||
export interface FsEditRequest {
|
||||
/** Literal non-empty text to replace. Must match exactly (after line-ending normalization). */
|
||||
oldString: string
|
||||
/** Literal replacement text. An empty string deletes the matched text. */
|
||||
newString: string
|
||||
/** Replace every match instead of requiring exactly one. */
|
||||
replaceAll: boolean
|
||||
}
|
||||
|
||||
/** Outcome of a literal edit. */
|
||||
export interface FsEditOutcome {
|
||||
/** Opaque version of the file after the edit. */
|
||||
version: FsVersion
|
||||
/**
|
||||
* The file's content BEFORE the edit. Raw storage text (LF-normalized by the
|
||||
* backend), never a diff — a consumer computes the result-time contextual diff
|
||||
* (the applied hunk with context) from `before`/`after`.
|
||||
*/
|
||||
before: string
|
||||
/** The file's content AFTER the edit. */
|
||||
after: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable, machine-routable codes for filesystem failures. Carried on
|
||||
* {@link FsError}; the tool registry surfaces `{ name, code }` on `isError`
|
||||
* results so retry/permission/UI layers can branch without parsing messages.
|
||||
*/
|
||||
export type FsErrorCode =
|
||||
| 'FS_NOT_FOUND'
|
||||
| 'FS_NOT_DIRECTORY'
|
||||
| 'FS_NOT_TEXT'
|
||||
| 'FS_NOT_REGULAR_FILE'
|
||||
| 'FS_PERMISSION_DENIED'
|
||||
| 'FS_IO_ERROR'
|
||||
| 'FS_STALE_VERSION'
|
||||
| 'FS_NOT_OBSERVED'
|
||||
| 'FS_AMBIGUOUS_EDIT'
|
||||
| 'FS_EDIT_NOT_FOUND'
|
||||
| 'FS_ABORTED'
|
||||
|
||||
/**
|
||||
* Typed filesystem error. Extends {@link HarnessError} so it carries a stable
|
||||
* {@link FsErrorCode} and chains `cause`. `dsh-fs` owns this vocabulary so
|
||||
* backends and the policy layer raise the same codes instead of each inventing
|
||||
* message strings.
|
||||
*/
|
||||
export class FsError extends HarnessError {
|
||||
override readonly code: FsErrorCode
|
||||
|
||||
constructor(message: string, code: FsErrorCode, options?: ErrorOptions) {
|
||||
super(message, code, options)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
146
packages/fs/fs/tests/service.spec.ts
Normal file
146
packages/fs/fs/tests/service.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Tests for the filesystem provider seam itself: registration, duplicate-service
|
||||
* behavior, disposal, and the branded id factories. The provider primitives and
|
||||
* policy live in `dsh-fs-local` and `dsh-fs-policy`; this seam owns only the
|
||||
* abstract service contract, so a minimal fake backend exercises it.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsDirEntry,
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsTarget,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
} from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** A minimal in-memory fake implementing the seven provider primitives. */
|
||||
class FakeFileSystem extends FileSystem {
|
||||
files = new Map<string, string>()
|
||||
|
||||
override async resolve(path: string): Promise<FsTarget> {
|
||||
return { targetKey: FsTargetKey(path), displayPath: path }
|
||||
}
|
||||
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
|
||||
const content = this.files.get(target.targetKey)
|
||||
if (content === undefined) return undefined
|
||||
return { version: FsVersion('v1'), type: 'file', size: content.length }
|
||||
}
|
||||
override async readText(target: FsTarget): Promise<string> {
|
||||
const content = this.files.get(target.targetKey)
|
||||
if (content === undefined) throw new FsError(`not found: ${target.displayPath}`, 'FS_NOT_FOUND')
|
||||
return content
|
||||
}
|
||||
override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
|
||||
const content = await this.readText(target)
|
||||
return (async function* () { yield content })()
|
||||
}
|
||||
override async listDir(target: FsTarget): Promise<FsDirEntry[]> {
|
||||
if (target.targetKey !== 'skills') throw new FsError(`not a directory: ${target.displayPath}`, 'FS_NOT_DIRECTORY')
|
||||
return [
|
||||
{
|
||||
name: 'alpha.md',
|
||||
type: 'file',
|
||||
target: { targetKey: FsTargetKey('skills/alpha.md'), displayPath: 'skills/alpha.md' },
|
||||
size: 2,
|
||||
version: FsVersion('v1'),
|
||||
},
|
||||
]
|
||||
}
|
||||
override async writeText(target: FsTarget, content: string, _expected?: FsWriteIntent): Promise<FsWriteOutcome> {
|
||||
const before = this.files.get(target.targetKey) ?? null
|
||||
this.files.set(target.targetKey, content)
|
||||
return { operation: before !== null ? 'update' : 'create', version: FsVersion('v2'), before, after: content }
|
||||
}
|
||||
override async editText(target: FsTarget, edit: FsEditRequest): Promise<FsEditOutcome> {
|
||||
const content = this.files.get(target.targetKey) ?? ''
|
||||
const after = content.split(edit.oldString).join(edit.newString)
|
||||
this.files.set(target.targetKey, after)
|
||||
return { version: FsVersion('v3'), before: content, after }
|
||||
}
|
||||
}
|
||||
|
||||
describe('FileSystem provider seam', () => {
|
||||
it('registers as ctx.fs and serves the primitives', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeFileSystem)
|
||||
const fs = ctx.fs as FakeFileSystem
|
||||
fs.files.set('a.txt', 'hi')
|
||||
const target = await fs.resolve('a.txt')
|
||||
expect((await fs.stat(target))?.type).toBe('file')
|
||||
expect(await fs.readText(target)).toBe('hi')
|
||||
})
|
||||
|
||||
it('throws when a second implementation is loaded (duplicate service)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeFileSystem)
|
||||
await expect(ctx.plugin(FakeFileSystem)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('removes the service when the providing fiber is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(FakeFileSystem)
|
||||
expect(ctx.fs).toBeDefined()
|
||||
await fiber.dispose()
|
||||
expect(ctx.fs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('streamText yields the same text readText returns', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeFileSystem)
|
||||
const fs = ctx.fs as FakeFileSystem
|
||||
fs.files.set('a.txt', 'one\ntwo')
|
||||
const target = await fs.resolve('a.txt')
|
||||
let streamed = ''
|
||||
for await (const chunk of await fs.streamText(target)) streamed += chunk
|
||||
expect(streamed).toBe(await fs.readText(target))
|
||||
})
|
||||
|
||||
it('listDir returns child entry targets without reading file content', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeFileSystem)
|
||||
const fs = ctx.fs as FakeFileSystem
|
||||
const entries = await fs.listDir(await fs.resolve('skills'))
|
||||
expect(entries).toEqual([{
|
||||
name: 'alpha.md',
|
||||
type: 'file',
|
||||
target: { targetKey: 'skills/alpha.md', displayPath: 'skills/alpha.md' },
|
||||
size: 2,
|
||||
version: 'v1',
|
||||
}])
|
||||
})
|
||||
|
||||
it('stat returns undefined for an absent target', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeFileSystem)
|
||||
const fs = ctx.fs as FakeFileSystem
|
||||
expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('branded id factories', () => {
|
||||
it('FsTargetKey and FsVersion brand a string at compile time (identity at runtime)', () => {
|
||||
expect(FsTargetKey('k')).toBe('k')
|
||||
expect(FsVersion('v')).toBe('v')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FsError', () => {
|
||||
it('carries a stable code and HarnessError name', () => {
|
||||
const error = new FsError('nope', 'FS_NOT_FOUND')
|
||||
expect(error.code).toBe('FS_NOT_FOUND')
|
||||
expect(error.name).toBe('FsError')
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
it('chains an underlying cause through ErrorOptions', () => {
|
||||
const root = new Error('EACCES')
|
||||
const error = new FsError('cannot read', 'FS_ABORTED', { cause: root })
|
||||
expect(error.cause).toBe(root)
|
||||
expect(error.code).toBe('FS_ABORTED')
|
||||
})
|
||||
})
|
||||
14
packages/fs/fs/tsconfig.json
Normal file
14
packages/fs/fs/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../util/brand" },
|
||||
{ "path": "../../llm/llm" }
|
||||
]
|
||||
}
|
||||
49
packages/fs/tool-fs/README.md
Normal file
49
packages/fs/tool-fs/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# @deepseek-ai/dsh-tool-fs
|
||||
|
||||
The **model-facing filesystem tools** — `read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) **directly** — it injects `fs` (plus `tools`/`systemPrompt`), **not** a policy service. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it.
|
||||
|
||||
```ts ignore-check
|
||||
// Default deployment: a ctx.fs provider, the policy plugin, then the tools.
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local
|
||||
await ctx.plugin(FsPolicy) // @deepseek-ai/dsh-fs-policy (policy gate)
|
||||
await ctx.plugin(ToolFs) // this package — registers read/write/edit
|
||||
```
|
||||
|
||||
`@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 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. |
|
||||
|
||||
Field names are snake_case to match Claude Code and existing harness tool schemas.
|
||||
|
||||
## The tool is the executor; policy is an event gate
|
||||
|
||||
The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash` (see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then:
|
||||
|
||||
- **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits `fs/observed` with a plain `ctx.emit`. (1 stat.)
|
||||
- **write** — `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, intent)`, then `fs/observed`. (0 stat.)
|
||||
- **edit** — `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, intent)`, then `fs/observed`. (0 stat.)
|
||||
|
||||
The tool passes `exec` (the tool-execution context) as the opaque `actor` on every dispatch. The default thunks return `undefined` (the unconstrained bare provider). When `@deepseek-ai/dsh-fs-policy` is loaded it occupies the single decision slot — returning `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED` — and records on `fs/observed`. Backend errors (`FsError`) and a thrown `FS_NOT_OBSERVED` flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached.
|
||||
|
||||
## `fs/observed` is fire-and-forget
|
||||
|
||||
`fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event.
|
||||
|
||||
The read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them.
|
||||
49
packages/fs/tool-fs/package.json
Normal file
49
packages/fs/tool-fs/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-fs",
|
||||
"description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"diff": "^9.0.0",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
92
packages/fs/tool-fs/src/diff.ts
Normal file
92
packages/fs/tool-fs/src/diff.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Result-time contextual-diff computation for the `write`/`edit` tools. Turns a
|
||||
* before/after pair of file texts into one {@link FileDiff} per applied hunk —
|
||||
* each hunk's `oldText`/`newText` reconstructed from the unified-diff lines with
|
||||
* ±{@link DIFF_CONTEXT} surrounding context lines, matching how claude-agent-acp
|
||||
* renders an editor inline diff.
|
||||
*
|
||||
* This is display-only presentation vocabulary (a UI concern), so it lives in
|
||||
* the model-facing tool, NOT the `dsh-fs` storage seam — the backend returns
|
||||
* only the raw before/after text (storage facts) and the tool computes the diff.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/src/diff
|
||||
*/
|
||||
|
||||
import { structuredPatch } from 'diff'
|
||||
import type { FileDiff } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** Context lines shown on each side of an applied hunk (matches claude-agent-acp). */
|
||||
export const DIFF_CONTEXT = 3
|
||||
|
||||
/**
|
||||
* The `write`/`edit` tools' private `tool/result` `meta` payload: the applied
|
||||
* contextual-diff hunks. Attached opaquely (as `unknown`) on the tool result and
|
||||
* persisted with the session log — it must be JSON-serializable (the session
|
||||
* validates this at `append`), so `presentResult` reproduces the diff card on
|
||||
* replay. The producing tool owns this shape; the bridge only sees the opaque
|
||||
* `meta` and the tool narrows it back via {@link diffsFromMeta}.
|
||||
*/
|
||||
export type FsDiffMeta = { diffs: FileDiff[] }
|
||||
|
||||
/**
|
||||
* Compute one {@link FileDiff} per hunk between `before` and `after`, each
|
||||
* carrying the applied change plus {@link DIFF_CONTEXT} context lines. Returns an
|
||||
* empty array when the texts are identical (no hunks). For a scattered
|
||||
* `replace_all` edit the patch yields multiple hunks, so multiple `FileDiff`s
|
||||
* come back — matching the editor rendering one diff block per site.
|
||||
*
|
||||
* Each hunk's `oldText` is its `-` (removed) and context lines joined by `\n`;
|
||||
* `newText` is its `+` (added) and context lines. A hunk with no old lines
|
||||
* (a pure insertion) reports `oldText: null` (nothing to diff against), mirroring
|
||||
* the call-time card's new-file convention. The unified-diff "\ No newline at end
|
||||
* of file" markers are dropped — they annotate the patch, not file content.
|
||||
*/
|
||||
export function computeHunkDiffs(path: string, before: string, after: string): FileDiff[] {
|
||||
const patch = structuredPatch('', '', before, after, undefined, undefined, { context: DIFF_CONTEXT })
|
||||
const diffs: FileDiff[] = []
|
||||
for (const hunk of patch.hunks) {
|
||||
const oldLines: string[] = []
|
||||
const newLines: string[] = []
|
||||
for (const line of hunk.lines) {
|
||||
// The unified-diff marker for a missing trailing newline annotates the
|
||||
// patch, not the content — skip it so it never leaks into a diff block.
|
||||
if (line.startsWith('\\')) continue
|
||||
const text = line.slice(1)
|
||||
if (line.startsWith('-')) {
|
||||
oldLines.push(text)
|
||||
} else if (line.startsWith('+')) {
|
||||
newLines.push(text)
|
||||
} else {
|
||||
// A context (unchanged) line appears on both sides.
|
||||
oldLines.push(text)
|
||||
newLines.push(text)
|
||||
}
|
||||
}
|
||||
diffs.push({ path, oldText: oldLines.length > 0 ? oldLines.join('\n') : null, newText: newLines.join('\n') })
|
||||
}
|
||||
return diffs
|
||||
}
|
||||
|
||||
/** Whether `value` is a valid {@link FileDiff} (defensive narrowing from opaque `meta`). */
|
||||
function isFileDiff(value: unknown): value is FileDiff {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const { path, oldText, newText } = value as Record<string, unknown>
|
||||
return typeof path === 'string'
|
||||
&& (oldText === null || typeof oldText === 'string')
|
||||
&& typeof newText === 'string'
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an opaque `tool/result` `meta` back to this tool's {@link FileDiff}
|
||||
* hunks, or `undefined` when it is absent/malformed. `presentResult` runs on
|
||||
* arbitrary logged `meta` (possibly from an older shape or a hand-edited log), so
|
||||
* it validates defensively rather than trusting the payload — a bad `meta` yields
|
||||
* `undefined`, and the caller decides the fallback (edit → the generic result
|
||||
* rendering; write → an args-derived whole-file diff), never a thrown presenter.
|
||||
*/
|
||||
export function diffsFromMeta(meta: unknown): FileDiff[] | undefined {
|
||||
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
|
||||
const diffs = (meta as Record<string, unknown>).diffs
|
||||
if (!Array.isArray(diffs) || diffs.length === 0 || !diffs.every(isFileDiff)) return undefined
|
||||
return diffs
|
||||
}
|
||||
120
packages/fs/tool-fs/src/edit.ts
Normal file
120
packages/fs/tool-fs/src/edit.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* The model-facing `edit` tool: update an existing UTF-8 text file by replacing
|
||||
* literal text, requiring a unique match by default. The tool is the executor:
|
||||
* it dispatches the `fs/edit-intent` waterfall to obtain the optional
|
||||
* version guard, calls `ctx.fs.editText` directly, and emits `fs/observed`. The
|
||||
* default thunk returns `undefined` (unconditional edit of the current content
|
||||
* — the bare provider); a policy plugin (`@deepseek-ai/dsh-fs-policy`)
|
||||
* occupies the single decision slot, returning `{ version: vObserved }` or
|
||||
* throwing `FS_NOT_OBSERVED` for an unread file. The tool stats ZERO times
|
||||
* either way; a missing target is reported by the provider as `FS_STALE_VERSION`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/src/edit
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
|
||||
import { sessionCwd } from './session-cwd.ts'
|
||||
|
||||
/** Validated `edit` arguments after defaulting. */
|
||||
interface EditInput {
|
||||
filePath: string
|
||||
oldString: string
|
||||
newString: string
|
||||
replaceAll: boolean
|
||||
}
|
||||
|
||||
/** Validate value constraints the schema DSL can't express. */
|
||||
export function parseEditArgs(args: { file_path: string; old_string: string; new_string: string; replace_all?: boolean }): EditInput {
|
||||
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
|
||||
if (args.old_string.length === 0) throw new Error('old_string must be a non-empty string')
|
||||
if (args.old_string === args.new_string) throw new Error('old_string and new_string must differ')
|
||||
return {
|
||||
filePath: args.file_path,
|
||||
oldString: args.old_string,
|
||||
newString: args.new_string,
|
||||
replaceAll: args.replace_all ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Format an edit success (single-match or replace-all) as a Claude-style model-facing message. */
|
||||
export function formatEditOutput(displayPath: string, replaceAll: boolean): string {
|
||||
return replaceAll
|
||||
? `The file ${displayPath} has been updated. All occurrences were successfully replaced.`
|
||||
: `The file ${displayPath} has been updated successfully.`
|
||||
}
|
||||
|
||||
/** Register the `edit` tool and its system-prompt guidance. */
|
||||
export function applyEditTool(ctx: Context): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:edit',
|
||||
order: 102,
|
||||
text: 'Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'edit',
|
||||
description: 'Edit an existing UTF-8 text file by replacing literal text.',
|
||||
parameters: {
|
||||
file_path: { type: 'string', required: true, description: 'Path to edit, resolved by the filesystem backend.' },
|
||||
old_string: { type: 'string', required: true, description: 'Literal text to replace. Must match exactly.' },
|
||||
new_string: { type: 'string', required: true, description: 'Literal replacement text. Use an empty string to delete the match.' },
|
||||
replace_all: { type: 'boolean', description: 'Replace all matches. Defaults to false; when false, old_string must appear exactly once.' },
|
||||
},
|
||||
async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
|
||||
const input = parseEditArgs(args)
|
||||
const cwd = sessionCwd(exec)
|
||||
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
|
||||
// Single-slot decision: the policy plugin returns { version: vObserved } or
|
||||
// throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit).
|
||||
// No stat — the bare default never manufactures a version basis.
|
||||
const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
|
||||
const outcome = await ctx.fs.editText(
|
||||
target,
|
||||
{ oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll },
|
||||
intent,
|
||||
exec.signal,
|
||||
)
|
||||
// Record the observed version (a no-op when no policy plugin listens).
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
// The result-time applied-hunk diff (before→after with context lines). An
|
||||
// edit always changes content (parseEditArgs requires old_string to differ
|
||||
// and editText matches at least once), so there is always at least one hunk.
|
||||
// The bridge renders these as an inline diff that supersedes the call-time
|
||||
// snippet; the display path is the model-facing `file_path` (the bridge
|
||||
// relativizes it).
|
||||
const diffs = computeHunkDiffs(input.filePath, outcome.before, outcome.after)
|
||||
return {
|
||||
content: [{ type: 'text', text: formatEditOutput(target.displayPath, input.replaceAll) }],
|
||||
meta: { diffs },
|
||||
}
|
||||
},
|
||||
// Pure display: a diff card of the literal replacement (old_string →
|
||||
// new_string), derived from the call args. `oldText: old_string || null`
|
||||
// matches claude-agent-acp's Edit arm; new_string is a required arg here, so
|
||||
// it maps straight to newText. A follow-along location points at the file.
|
||||
presentCall(args): DiffCallView {
|
||||
return {
|
||||
card: 'diff',
|
||||
title: `Edit ${args.file_path}`,
|
||||
diffs: [{ path: args.file_path, oldText: args.old_string || null, newText: args.new_string }],
|
||||
locations: [{ path: args.file_path }],
|
||||
}
|
||||
},
|
||||
// Result-time display: the applied contextual-diff hunks carried on `meta`.
|
||||
// On success with diffs, a `diff` result card supersedes the call-time
|
||||
// snippet; on error (nothing applied) or malformed meta, fall through to the
|
||||
// generic "updated successfully" rendering.
|
||||
presentResult(args, result: ToolResult): DiffResultView | undefined {
|
||||
if (result.isError) return undefined
|
||||
const diffs = diffsFromMeta(result.meta)
|
||||
if (diffs === undefined) return undefined
|
||||
return { card: 'diff', title: `Edit ${args.file_path}`, diffs }
|
||||
},
|
||||
}))
|
||||
}
|
||||
92
packages/fs/tool-fs/src/index.ts
Normal file
92
packages/fs/tool-fs/src/index.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* The model-facing filesystem tool suite (`read`, `write`, `edit`) over the
|
||||
* `ctx.fs` provider seam. This single plugin registers all three tools.
|
||||
*
|
||||
* ## The tool is the executor; policy is an event gate
|
||||
*
|
||||
* The tool reads/writes/edits through `ctx.fs` DIRECTLY and owns model-facing
|
||||
* concerns only — tool names, JSON schemas, argument validation, prompt
|
||||
* sections, read windowing, result formatting. It does NOT inject a policy
|
||||
* service. Instead, on each write/edit it dispatches a single-slot waterfall
|
||||
* (`fs/write-intent`/`fs/edit-intent`) to obtain the OPTIONAL version guard, and
|
||||
* after every read/write/edit it emits `fs/observed` with a plain (unguarded)
|
||||
* `ctx.emit`. A policy plugin (`@deepseek-ai/dsh-fs-policy`) occupies the
|
||||
* decision slot and listens for `fs/observed` to add observed-state +
|
||||
* read-before-edit + version-guarded write/edit; a deployment that loads these
|
||||
* tools is expected to also load it. With no policy plugin the waterfalls fall
|
||||
* through to their `undefined` default (the unconstrained bare provider) and
|
||||
* `fs/observed` is unheard — the tool still functions. This package never
|
||||
* imports `node:fs`, `node:path`, or an `@deepseek-ai/dsh-fs-local`
|
||||
* implementation.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
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'
|
||||
export type { FileReadOutcome, FileTextLine, ReadWindow, WindowResult } from './read-render.ts'
|
||||
export { DIFF_CONTEXT, computeHunkDiffs, diffsFromMeta } from './diff.ts'
|
||||
export type { FsDiffMeta } from './diff.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
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, 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)
|
||||
}
|
||||
178
packages/fs/tool-fs/src/read-render.ts
Normal file
178
packages/fs/tool-fs/src/read-render.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Cordis-free read rendering for `@deepseek-ai/dsh-tool-fs`: turn a file's
|
||||
* decoded text into a bounded, line-numbered window (offset/limit, byte cap,
|
||||
* per-line truncation) and format it as the model-facing text block. This is
|
||||
* the `read` tool's RENDERING detail — not a storage primitive, not freshness
|
||||
* policy — so it lives apart from the tool's I/O and event wiring as a pure,
|
||||
* independently-testable module (no cordis, no filesystem).
|
||||
*
|
||||
* The provider (`ctx.fs.readText`/`streamText`) hands back already-decoded text
|
||||
* (UTF-8 validated, binary rejected); {@link buildWindow} only scans that text
|
||||
* for newlines and builds the requested window. A capped line buffer means a
|
||||
* newline-free giant line can never balloon memory even when streamed.
|
||||
* {@link formatReadOutput} turns the resulting {@link FileReadOutcome} into the
|
||||
* `<path>/<content>` envelope the model sees.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/read-render
|
||||
*/
|
||||
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** Default maximum characters returned for a single line (the `readMaxLineLength` config). */
|
||||
export const READ_MAX_LINE_LENGTH = 2000
|
||||
|
||||
/** Default maximum bytes returned for selected file lines (the `readMaxBytes` config). */
|
||||
export const READ_MAX_BYTES = 50 * 1024
|
||||
|
||||
/** 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. */
|
||||
export interface FileTextLine {
|
||||
/** 1-based line number in the file. */
|
||||
number: number
|
||||
/** Line text without its trailing newline. */
|
||||
text: string
|
||||
}
|
||||
|
||||
/** The windowed result {@link buildWindow} produces from a file's decoded text. */
|
||||
export interface WindowResult {
|
||||
/** Returned lines, already numbered. */
|
||||
lines: FileTextLine[]
|
||||
/** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
|
||||
totalLines: number
|
||||
/** Whether selected output hit the byte cap before EOF or the requested limit. */
|
||||
truncatedByBytes: boolean
|
||||
}
|
||||
|
||||
/** Outcome of a bounded text read — what {@link formatReadOutput} renders. */
|
||||
export interface FileReadOutcome {
|
||||
/** 1-based first line requested. */
|
||||
offset: number
|
||||
/** Returned lines, already numbered. */
|
||||
lines: FileTextLine[]
|
||||
/** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
|
||||
totalLines: number
|
||||
/** Whether selected output hit the byte cap before EOF or the requested limit. */
|
||||
truncatedByBytes?: true
|
||||
}
|
||||
|
||||
interface WindowAccumulator {
|
||||
lines: FileTextLine[]
|
||||
totalLines: number
|
||||
outputBytes: number
|
||||
truncatedByBytes: boolean
|
||||
done: boolean
|
||||
}
|
||||
|
||||
function newAccumulator(): WindowAccumulator {
|
||||
return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false }
|
||||
}
|
||||
|
||||
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 {
|
||||
return Buffer.byteLength(line, 'utf8') + (currentLineCount > 0 ? 1 : 0)
|
||||
}
|
||||
|
||||
function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindow): void {
|
||||
acc.totalLines += 1
|
||||
if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return
|
||||
|
||||
const text = truncateLine(rawLine, request.maxLineLength)
|
||||
const bytes = lineByteSize(text, acc.lines.length)
|
||||
if (acc.outputBytes + bytes > request.maxBytes) {
|
||||
acc.truncatedByBytes = true
|
||||
acc.done = true
|
||||
return
|
||||
}
|
||||
acc.outputBytes += bytes
|
||||
acc.lines.push({ number: acc.totalLines, text })
|
||||
}
|
||||
|
||||
function stripCarriageReturn(line: string): string {
|
||||
return line.endsWith('\r') ? line.slice(0, -1) : line
|
||||
}
|
||||
|
||||
function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string): WindowResult {
|
||||
if (!acc.truncatedByBytes && request.offset > acc.totalLines && !(acc.totalLines === 0 && request.offset === 1)) {
|
||||
throw new FsError(`offset ${request.offset} is out of range for "${displayPath}" (${acc.totalLines} lines)`, 'FS_NOT_FOUND')
|
||||
}
|
||||
return { lines: acc.lines, totalLines: acc.totalLines, truncatedByBytes: acc.truncatedByBytes }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a bounded, line-numbered window from a file's decoded text chunks.
|
||||
* 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 `request.maxLineLength`),
|
||||
* enforces the byte cap, and throws `FS_NOT_FOUND` for an offset past EOF.
|
||||
*/
|
||||
export async function buildWindow(
|
||||
chunks: AsyncIterable<string> | Iterable<string>,
|
||||
request: ReadWindow,
|
||||
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 >= lineBufferCap) return
|
||||
lineBuffer += segment
|
||||
if (lineBuffer.length > lineBufferCap) lineBuffer = lineBuffer.slice(0, lineBufferCap)
|
||||
}
|
||||
|
||||
function flushLine(): void {
|
||||
consumeLine(acc, stripCarriageReturn(lineBuffer), request)
|
||||
lineBuffer = ''
|
||||
}
|
||||
|
||||
for await (const chunk of chunks) {
|
||||
let startPos = 0
|
||||
let newlinePos: number
|
||||
while ((newlinePos = chunk.indexOf('\n', startPos)) !== -1) {
|
||||
appendToLineBuffer(chunk.slice(startPos, newlinePos))
|
||||
flushLine()
|
||||
startPos = newlinePos + 1
|
||||
if (acc.done) return finish(acc, request, displayPath)
|
||||
}
|
||||
appendToLineBuffer(chunk.slice(startPos))
|
||||
}
|
||||
if (lineBuffer.length > 0) flushLine()
|
||||
return finish(acc, request, displayPath)
|
||||
}
|
||||
|
||||
/** Format a read outcome as one OpenCode-style line-numbered text block body. */
|
||||
export function formatReadOutput(displayPath: string, outcome: FileReadOutcome): string {
|
||||
const endLine = outcome.lines.at(-1)?.number ?? Math.max(0, outcome.offset - 1)
|
||||
let footer: string
|
||||
if (outcome.truncatedByBytes) {
|
||||
footer = `(Output capped. Showing lines ${outcome.offset}-${endLine}. Use offset=${endLine + 1} to continue.)`
|
||||
} else if (endLine < outcome.totalLines) {
|
||||
footer = `(Showing lines ${outcome.offset}-${endLine} of ${outcome.totalLines}. Use offset=${endLine + 1} to continue.)`
|
||||
} else {
|
||||
footer = `(End of file - total ${outcome.totalLines} lines)`
|
||||
}
|
||||
const body = outcome.lines.length > 0
|
||||
? `${outcome.lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n\n${footer}`
|
||||
: footer
|
||||
return `<path>${displayPath}</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
${body}
|
||||
</content>`
|
||||
}
|
||||
141
packages/fs/tool-fs/src/read.ts
Normal file
141
packages/fs/tool-fs/src/read.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* The model-facing `read` tool: inspect a UTF-8 text file and return
|
||||
* line-numbered content with pagination guidance. The tool is the executor — it
|
||||
* stats and reads through `ctx.fs` directly, builds the line window
|
||||
* ({@link module:@deepseek-ai/dsh-tool-fs/read-render}), and emits `fs/observed`
|
||||
* so a policy plugin (`@deepseek-ai/dsh-fs-policy`) can record the read. With
|
||||
* no policy plugin the emit is simply unheard. This module owns the
|
||||
* model-facing schema, argument validation, and the read I/O; the rendering
|
||||
* (windowing + formatting) lives in `read-render.ts` and the
|
||||
* freshness/observation policy is not its concern.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/src/read
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
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 (the `readLimit` config). */
|
||||
export const READ_LIMIT = 2000
|
||||
|
||||
/**
|
||||
* 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
|
||||
offset: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: number, name: string): number {
|
||||
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`${name} must be a positive integer`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** 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 ? 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, caps: ReadToolCaps): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:read',
|
||||
order: 100,
|
||||
text: 'Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'read',
|
||||
description: 'Read a UTF-8 text file and return line-numbered content.',
|
||||
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 ${caps.limit}.` },
|
||||
},
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseReadArgs(args, caps.limit)
|
||||
const cwd = sessionCwd(exec)
|
||||
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
|
||||
|
||||
// One stat: type check + size routing + the version recorded as observed.
|
||||
// A writer racing between this stat and the read can at worst make a LATER
|
||||
// guarded edit spuriously FS_STALE_VERSION (fail-closed: re-read; editText
|
||||
// re-checks the version in its lock).
|
||||
const info = await ctx.fs.stat(target, exec.signal)
|
||||
if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
||||
if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
|
||||
// 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 >= 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, maxLineLength: caps.maxLineLength, maxBytes: caps.maxBytes },
|
||||
target.displayPath,
|
||||
)
|
||||
|
||||
const outcome: FileReadOutcome = {
|
||||
offset: input.offset,
|
||||
lines: window.lines,
|
||||
totalLines: window.totalLines,
|
||||
...window.truncatedByBytes ? { truncatedByBytes: true } : {},
|
||||
}
|
||||
// Record the observed version (a no-op when no policy plugin listens). The
|
||||
// read already succeeded; an fs/observed listener is contractually a
|
||||
// synchronous, side-effect-only recorder.
|
||||
ctx.emit('fs/observed', target, info.version, exec)
|
||||
return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }]
|
||||
},
|
||||
// Pure display: a generic card titled by the file with the read window
|
||||
// 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/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
|
||||
? ` (${offset ?? 1} - ${(offset ?? 1) + limit - 1})`
|
||||
: offset !== undefined ? ` (from line ${offset})` : ''
|
||||
return {
|
||||
card: 'generic',
|
||||
title: `Read ${args.file_path}${window}`,
|
||||
kind: 'read',
|
||||
locations: [{ path: args.file_path, line: offset ?? 1 }],
|
||||
}
|
||||
},
|
||||
}))
|
||||
}
|
||||
24
packages/fs/tool-fs/src/session-cwd.ts
Normal file
24
packages/fs/tool-fs/src/session-cwd.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Derive the working directory a filesystem tool resolves relative paths
|
||||
* against: the calling agent's per-session workspace
|
||||
* (`exec.agent.session.header.cwd`), so each ACP session's `read`/`write`/`edit`
|
||||
* act on ITS workspace, not the server's launch dir — mirroring how
|
||||
* `dsh-tool-bash` defaults a bash `workdir` to the session cwd.
|
||||
*
|
||||
* The `agent` is optional-chained — a non-agent caller yields `undefined`, and
|
||||
* the tool then calls `ctx.fs.resolve(path)` with no base so the backend applies
|
||||
* its own configured default (preserving the non-ACP / no-session behavior).
|
||||
* `session`/`header` are non-optional on a real `Agent`, so only `agent` needs
|
||||
* the guard (mirroring `dsh-tool-bash`'s `resolveWorkdir`). Returning `undefined`
|
||||
* rather than reading `process.cwd()` here keeps the default in ONE place (the
|
||||
* provider), per the "explicit > implicit at seams" convention.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/session-cwd
|
||||
*/
|
||||
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** The session workspace cwd for this call, or `undefined` when none applies. */
|
||||
export function sessionCwd(exec: ToolExecution): string | undefined {
|
||||
return exec.agent?.session.header.cwd
|
||||
}
|
||||
102
packages/fs/tool-fs/src/write.ts
Normal file
102
packages/fs/tool-fs/src/write.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* The model-facing `write` tool: create or fully replace a UTF-8 text file. The
|
||||
* tool is the executor: it dispatches the `fs/write-intent` waterfall to
|
||||
* obtain the optional version guard, calls `ctx.fs.writeText` directly, and
|
||||
* emits `fs/observed`. The default thunk returns `undefined` (unconditional
|
||||
* create-or-overwrite — the bare provider); a policy plugin
|
||||
* (`@deepseek-ai/dsh-fs-policy`) occupies the single decision slot and
|
||||
* returns `createIfAbsent`/`replaceIfVersion` instead. The tool stats ZERO
|
||||
* times either way.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/src/write
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
|
||||
import { sessionCwd } from './session-cwd.ts'
|
||||
|
||||
/** Validate value constraints the schema DSL can't express. */
|
||||
export function parseWriteArgs(args: { file_path: string; content: string }): { filePath: string; content: string } {
|
||||
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
|
||||
return { filePath: args.file_path, content: args.content }
|
||||
}
|
||||
|
||||
/** Format a write outcome as one model-facing text block body. */
|
||||
export function formatWriteOutput(displayPath: string, outcome: FsWriteOutcome): string {
|
||||
const verb = outcome.operation === 'create' ? 'Created' : 'Updated'
|
||||
return `<path>${displayPath}</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
${verb} file
|
||||
</content>`
|
||||
}
|
||||
|
||||
/** Register the `write` tool and its system-prompt guidance. */
|
||||
export function applyWriteTool(ctx: Context): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:write',
|
||||
order: 101,
|
||||
text: 'Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'write',
|
||||
description: 'Create or fully replace a UTF-8 text file.',
|
||||
parameters: {
|
||||
file_path: { type: 'string', required: true, description: 'Path to write, resolved by the filesystem backend.' },
|
||||
content: { type: 'string', required: true, description: 'Full UTF-8 text content to write.' },
|
||||
},
|
||||
async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
|
||||
const input = parseWriteArgs(args)
|
||||
const cwd = sessionCwd(exec)
|
||||
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
|
||||
// Single-slot decision: the policy plugin produces createIfAbsent/
|
||||
// replaceIfVersion; the bare default is undefined (unconditional). No stat.
|
||||
const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)
|
||||
const outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal)
|
||||
// Record the observed version (a no-op when no policy plugin listens).
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
// Attach a contextual hunk as `meta` ONLY for an overwrite (a before-version
|
||||
// exists). A create has no "before" — `outcome.before` is null — so it
|
||||
// carries no `meta`; `presentResult` then renders a whole-file diff from the
|
||||
// args, so the completed card is still a diff (never the result text).
|
||||
const diffs = outcome.before !== null ? computeHunkDiffs(input.filePath, outcome.before, outcome.after) : []
|
||||
return {
|
||||
content: [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }],
|
||||
...diffs.length > 0 ? { meta: { diffs } } : {},
|
||||
}
|
||||
},
|
||||
// Pure display: a diff card (an editor renders write as a new-file / full-
|
||||
// replace diff). `oldText: null` — a call-time presenter has no access to the
|
||||
// file's prior content, so even an overwrite renders new-file style, matching
|
||||
// claude-agent-acp. A follow-along location points at the written file.
|
||||
presentCall(args): DiffCallView {
|
||||
return {
|
||||
card: 'diff',
|
||||
title: `Write ${args.file_path}`,
|
||||
diffs: [{ path: args.file_path, oldText: null, newText: args.content }],
|
||||
locations: [{ path: args.file_path }],
|
||||
}
|
||||
},
|
||||
// Result-time display: a `diff` card so the completed `tool_call_update`
|
||||
// re-installs the diff rather than the model-facing result text (an ACP
|
||||
// `tool_call_update.content` REPLACES the call's content, so a text result
|
||||
// would clobber the pending diff card). An OVERWRITE uses the applied
|
||||
// contextual hunks on `meta`; a CREATE has no `meta` (no prior content), so
|
||||
// its whole-file new-file diff is derived from `args.content` (replay-safe,
|
||||
// matching the call-time card). An error falls through to generic rendering
|
||||
// so its message shows.
|
||||
presentResult(args, result: ToolResult): DiffResultView | undefined {
|
||||
if (result.isError) return undefined
|
||||
const diffs = diffsFromMeta(result.meta)
|
||||
?? [{ path: args.file_path, oldText: null, newText: args.content }]
|
||||
return { card: 'diff', title: `Write ${args.file_path}`, diffs }
|
||||
},
|
||||
}))
|
||||
}
|
||||
113
packages/fs/tool-fs/tests/diff.spec.ts
Normal file
113
packages/fs/tool-fs/tests/diff.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Unit tests for the result-time contextual-diff computation (`src/diff.ts`):
|
||||
* the pure before/after → {@link FileDiff}[] hunk builder and the defensive
|
||||
* `meta` narrowing. These pin the exact hunk reconstruction (context lines,
|
||||
* multi-hunk replaceAll, pure insertion/deletion, no-op) the ACP bridge renders.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeHunkDiffs, diffsFromMeta, DIFF_CONTEXT } from '@deepseek-ai/dsh-tool-fs'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const lines = (n: number): string => Array.from({ length: n }, (_, i) => `line${i + 1}`).join('\n') + '\n'
|
||||
|
||||
describe('computeHunkDiffs', () => {
|
||||
it('a single-line change yields one hunk with ±context lines on both sides', () => {
|
||||
const before = lines(8)
|
||||
const after = before.replace('line4', 'CHANGED')
|
||||
const diffs = computeHunkDiffs('f.txt', before, after)
|
||||
expect(diffs).toEqual([{
|
||||
path: 'f.txt',
|
||||
oldText: 'line1\nline2\nline3\nline4\nline5\nline6\nline7',
|
||||
newText: 'line1\nline2\nline3\nCHANGED\nline5\nline6\nline7',
|
||||
}])
|
||||
})
|
||||
|
||||
it('a scattered replace_all yields one FileDiff PER hunk (matching per-site editor blocks)', () => {
|
||||
const before = lines(20)
|
||||
const after = before.replace('line3', 'A').replace('line16', 'B')
|
||||
const diffs = computeHunkDiffs('f.txt', before, after)
|
||||
expect(diffs).toHaveLength(2)
|
||||
expect(diffs[0]?.path).toBe('f.txt')
|
||||
expect(diffs[0]?.oldText).toContain('line3')
|
||||
expect(diffs[0]?.newText).toContain('A')
|
||||
expect(diffs[1]?.oldText).toContain('line16')
|
||||
expect(diffs[1]?.newText).toContain('B')
|
||||
// The two hunks are distinct sites, not one merged block.
|
||||
expect(diffs[0]?.newText).not.toContain('B')
|
||||
expect(diffs[1]?.newText).not.toContain('A')
|
||||
})
|
||||
|
||||
it('identical before/after (a no-op) yields no hunks', () => {
|
||||
expect(computeHunkDiffs('f.txt', 'same\n', 'same\n')).toEqual([])
|
||||
})
|
||||
|
||||
it('a pure insertion into empty content reports oldText null (nothing to diff against)', () => {
|
||||
const diffs = computeHunkDiffs('f.txt', '', 'brand new\n')
|
||||
expect(diffs).toEqual([{ path: 'f.txt', oldText: null, newText: 'brand new' }])
|
||||
})
|
||||
|
||||
it('a pure deletion of the whole file reports newText empty', () => {
|
||||
const diffs = computeHunkDiffs('f.txt', 'gone\n', '')
|
||||
expect(diffs).toEqual([{ path: 'f.txt', oldText: 'gone', newText: '' }])
|
||||
})
|
||||
|
||||
it('drops the "\\ No newline at end of file" marker from a no-trailing-newline change', () => {
|
||||
const diffs = computeHunkDiffs('f.txt', 'x', 'y')
|
||||
// The marker line (starting with "\\") must never leak into a diff block.
|
||||
expect(diffs).toEqual([{ path: 'f.txt', oldText: 'x', newText: 'y' }])
|
||||
expect(diffs[0]?.oldText).not.toContain('\\')
|
||||
expect(diffs[0]?.newText).not.toContain('\\')
|
||||
})
|
||||
|
||||
it('uses DIFF_CONTEXT (3) surrounding lines', () => {
|
||||
expect(DIFF_CONTEXT).toBe(3)
|
||||
const before = lines(20)
|
||||
const after = before.replace('line10', 'CHANGED')
|
||||
const [diff] = computeHunkDiffs('f.txt', before, after)
|
||||
// 3 context above (7,8,9) + the change + 3 below (11,12,13) = 7 lines a side.
|
||||
expect(diff?.oldText?.split('\n')).toHaveLength(7)
|
||||
expect(diff?.newText.split('\n')).toHaveLength(7)
|
||||
expect(diff?.oldText?.split('\n')[0]).toBe('line7')
|
||||
})
|
||||
})
|
||||
|
||||
describe('diffsFromMeta (defensive narrowing)', () => {
|
||||
// The narrowing accepts an opaque JsonValue; a malformed payload is not a
|
||||
// statically-valid JsonValue, so route every case through one cast helper that
|
||||
// mirrors how a hand-edited/older session log delivers arbitrary shapes.
|
||||
const m = (value: unknown): JsonValue | undefined => value as JsonValue | undefined
|
||||
const good = { diffs: [{ path: 'f.txt', oldText: 'a', newText: 'b' }] }
|
||||
|
||||
it('narrows a well-formed { diffs } payload', () => {
|
||||
expect(diffsFromMeta(m(good))).toEqual(good.diffs)
|
||||
})
|
||||
|
||||
it('accepts a diff whose oldText is null (a create-style hunk)', () => {
|
||||
const meta = { diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] }
|
||||
expect(diffsFromMeta(m(meta))).toEqual(meta.diffs)
|
||||
})
|
||||
|
||||
it('rejects undefined / non-object / array meta', () => {
|
||||
expect(diffsFromMeta(undefined)).toBeUndefined()
|
||||
expect(diffsFromMeta(null)).toBeUndefined()
|
||||
expect(diffsFromMeta(m('nope'))).toBeUndefined()
|
||||
expect(diffsFromMeta(m([]))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a missing / empty / non-array diffs field', () => {
|
||||
expect(diffsFromMeta(m({}))).toBeUndefined()
|
||||
expect(diffsFromMeta(m({ diffs: [] }))).toBeUndefined()
|
||||
expect(diffsFromMeta(m({ diffs: 'x' }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a diffs array containing a malformed entry', () => {
|
||||
expect(diffsFromMeta(m({ diffs: [{ path: 'f.txt', oldText: 'a' }] }))).toBeUndefined()
|
||||
expect(diffsFromMeta(m({ diffs: [{ path: 1, oldText: 'a', newText: 'b' }] }))).toBeUndefined()
|
||||
expect(diffsFromMeta(m({ diffs: [{ path: 'f', oldText: 5, newText: 'b' }] }))).toBeUndefined()
|
||||
expect(diffsFromMeta(m({ diffs: [{ path: 'f', oldText: 'a', newText: 7 }] }))).toBeUndefined()
|
||||
expect(diffsFromMeta(m({ diffs: [null] }))).toBeUndefined()
|
||||
expect(diffsFromMeta(m({ diffs: ['x'] }))).toBeUndefined()
|
||||
expect(diffsFromMeta(m({ diffs: [[]] }))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
84
packages/fs/tool-fs/tests/fs-tools.e2e.ts
Normal file
84
packages/fs/tool-fs/tests/fs-tools.e2e.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { fsHarness, waitForIdle } from './harness.ts'
|
||||
|
||||
/**
|
||||
* With-key smoke for the filesystem tools: a REAL model drives the REAL
|
||||
* read/write/edit tools (over the real local backend + policy gate), and we
|
||||
* verify the WORLD — the file on disk — not the agent's self-report. This is the
|
||||
* "green units, broken product" guard: mocks prove the plumbing, only a real
|
||||
* model proves the tools actually work end-to-end. Key-gated (self-skips without
|
||||
* DEEPSEEK_API_KEY).
|
||||
*/
|
||||
|
||||
let ctx: Context | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
workdir = undefined
|
||||
})
|
||||
|
||||
const SYSTEM = 'You are a coding assistant. Use the write tool to create files, the read tool to inspect '
|
||||
+ 'them, and the edit tool for literal replacements. Read a file before editing it. Keep replies terse.'
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => {
|
||||
it('creates, reads, then edits a file — verified on disk', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-fs-e2e-'))
|
||||
ctx = await fsHarness(workdir, SYSTEM)
|
||||
// agentLoop.create prepares a session with no cwd, so the provider default
|
||||
// (config.cwd = workdir) is the workspace.
|
||||
const agent = ctx.agentLoop.create(AgentId('fs-e2e'), { model: 'deepseek-v4-flash' })
|
||||
|
||||
agent.send([{ type: 'text', text:
|
||||
'Create a file named note.txt containing exactly the line: status: draft. '
|
||||
+ 'Then read it back, then edit it to replace the literal word draft with final. '
|
||||
+ 'Tell me when done.' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Verify the WORLD: the edit landed on disk.
|
||||
const content = await readFile(join(workdir, 'note.txt'), 'utf8')
|
||||
expect(content).toContain('status: final')
|
||||
expect(content).not.toContain('draft')
|
||||
|
||||
// The log records real read/write/edit tool calls (not bash).
|
||||
const calls = [...agent.session.events].filter(e => e.type === 'tool/call').map(e => e.data.name)
|
||||
expect(calls).toContain('write')
|
||||
expect(calls).toContain('read')
|
||||
expect(calls).toContain('edit')
|
||||
}, 180_000)
|
||||
|
||||
it('resolves a relative path against the per-session cwd (factory meta.cwd)', async () => {
|
||||
// config.cwd is the harness workdir, but the agent's SESSION cwd is a
|
||||
// different dir; the write must land in the SESSION dir, proving the tool
|
||||
// passes the per-session cwd (not the backend default).
|
||||
const configDir = await mkdtemp(join(tmpdir(), 'dsh-fs-e2e-cfg-'))
|
||||
workdir = configDir
|
||||
const sessionDir = await mkdtemp(join(tmpdir(), 'dsh-fs-e2e-session-'))
|
||||
try {
|
||||
ctx = await fsHarness(configDir, SYSTEM)
|
||||
const handle = ctx.agents.create({
|
||||
agentId: AgentId('fs-e2e-cwd'),
|
||||
sessionId: SessionId(`fs-e2e-cwd-${Date.now()}`),
|
||||
meta: { cwd: sessionDir },
|
||||
agentOptions: { model: 'deepseek-v4-flash' },
|
||||
})
|
||||
handle.agent.send([{ type: 'text', text:
|
||||
'Use the write tool to create a file named where.txt containing exactly the line: here. Tell me when done.' }])
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
|
||||
// The file is in the SESSION dir, not the config dir.
|
||||
expect(await readFile(join(sessionDir, 'where.txt'), 'utf8')).toContain('here')
|
||||
await expect(readFile(join(configDir, 'where.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
} finally {
|
||||
await rm(sessionDir, { recursive: true, force: true })
|
||||
}
|
||||
}, 180_000)
|
||||
})
|
||||
48
packages/fs/tool-fs/tests/harness.ts
Normal file
48
packages/fs/tool-fs/tests/harness.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
|
||||
/**
|
||||
* Shared harness for the fs-tools with-key e2e: a minimal real agent stack (the
|
||||
* DeepSeek adapter + the real fs provider + the read-before-write/edit policy +
|
||||
* the model-facing read/write/edit tools). Lives outside the *.e2e.ts pattern so
|
||||
* importing it never re-registers another file's tests.
|
||||
*
|
||||
* `fsCwd` is the local backend's default base; a per-session cwd (set via a
|
||||
* session header) overrides it, but this harness creates agents without a
|
||||
* session cwd, so the provider default IS the workspace. `persona` is the
|
||||
* deployment persona (the system-prompt plugin's per-context config).
|
||||
*/
|
||||
export async function fsHarness(fsCwd: string, persona = ''): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
|
||||
await ctx.plugin(LocalFileSystem, { cwd: fsCwd })
|
||||
await ctx.plugin(FsPolicy)
|
||||
await ctx.plugin(ToolFs)
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
410
packages/fs/tool-fs/tests/integration.spec.ts
Normal file
410
packages/fs/tool-fs/tests/integration.spec.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* Integration tests: the real local backend (`dsh-fs-local`) plus the model
|
||||
* tools (`dsh-tool-fs`) as the executor, exercised through `ctx.tools.execute()`
|
||||
* so nothing bypasses the tool registry. Two deployments:
|
||||
*
|
||||
* - DEFAULT — with the real `dsh-fs-policy` policy gate plugin: read-before-
|
||||
* write/edit, version-guarded mutation, FS_NOT_OBSERVED for unread edits.
|
||||
* - BARE — WITHOUT the policy plugin: every `fs/*` waterfall falls through to
|
||||
* its undefined default, so write/edit are unconditional. This proves the
|
||||
* tool carries no dependency on the policy plugin.
|
||||
*
|
||||
* These verify the WORLD — files are read back from disk and asserted
|
||||
* byte-for-byte — not the tool's self-report.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
|
||||
let dir: string
|
||||
let ctx: Context
|
||||
let fiber: Awaited<ReturnType<Context['plugin']>>
|
||||
// A stable session object stands in for an agent session (the file-state
|
||||
// owner). It carries a `header` (no `cwd`) so `sessionCwd(exec)` resolves to
|
||||
// `undefined` and the backend falls back to its configured cwd (= `dir`).
|
||||
const session = { header: {} }
|
||||
|
||||
let callCounter = 0
|
||||
function call(name: string, args: unknown) {
|
||||
return ctx.tools.execute({
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
agent: { session } as never,
|
||||
})
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await fiber.dispose()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// DEFAULT deployment: the policy gate plugin is loaded.
|
||||
// --------------------------------------------------------------------------
|
||||
describe('default deployment (with dsh-fs-policy)', () => {
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-'))
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: dir })
|
||||
await ctx.plugin(FsPolicy)
|
||||
fiber = await ctx.plugin(ToolFs)
|
||||
})
|
||||
|
||||
describe('write → disk', () => {
|
||||
it('creates a file with exactly the requested bytes', async () => {
|
||||
const result = await call('write', { file_path: 'new.txt', content: 'line one\nline two\n' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('line one\nline two\n')
|
||||
})
|
||||
|
||||
it('rejects overwriting an existing file without reading it first', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'original')
|
||||
const result = await call('write', { file_path: 'a.txt', content: 'clobber' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original')
|
||||
})
|
||||
|
||||
it('allows overwriting after a read', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'original')
|
||||
expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false)
|
||||
const result = await call('write', { file_path: 'a.txt', content: 'replaced' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('replaced')
|
||||
})
|
||||
|
||||
it('rejects a full overwrite when the file changed since the read (stale)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'original')
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
await writeFile(join(dir, 'a.txt'), 'changed-externally') // out-of-band change
|
||||
const result = await call('write', { file_path: 'a.txt', content: 'replaced' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('read', () => {
|
||||
it('returns line-numbered content', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'alpha\nbeta')
|
||||
const result = await call('read', { file_path: 'a.txt' })
|
||||
expect(text(result)).toContain('1: alpha')
|
||||
expect(text(result)).toContain('2: beta')
|
||||
expect(text(result)).toContain('(End of file - total 2 lines)')
|
||||
})
|
||||
|
||||
it('reports a binary file as an error', async () => {
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01, 0x02]))
|
||||
const result = await call('read', { file_path: 'bin' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('paginates a multi-line file with offset/limit', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree\nfour')
|
||||
const result = await call('read', { file_path: 'a.txt', offset: 2, limit: 2 })
|
||||
expect(text(result)).toContain('2: two')
|
||||
expect(text(result)).toContain('3: three')
|
||||
expect(text(result)).toContain('(Showing lines 2-3 of 4. Use offset=4 to continue.)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit → disk', () => {
|
||||
it('applies a unique literal replacement after a read', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
|
||||
})
|
||||
|
||||
it('rejects an edit before any read, leaving the file untouched', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world')
|
||||
})
|
||||
|
||||
it('lets a WINDOWED read authorize an edit when the file is unchanged (freshness, not full-view)', async () => {
|
||||
// A file with more lines than the read window; read only the first line.
|
||||
const lines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`)
|
||||
await writeFile(join(dir, 'a.txt'), lines.join('\n'))
|
||||
const read = await call('read', { file_path: 'a.txt', offset: 1, limit: 1 })
|
||||
expect(read.isError).toBe(false)
|
||||
expect(text(read)).toContain('(Showing lines 1-1 of 20')
|
||||
|
||||
// Editing a line OUTSIDE the window is authorized because the file is unchanged.
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'line 12', new_string: 'LINE 12' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe(lines.map(l => l === 'line 12' ? 'LINE 12' : l).join('\n'))
|
||||
})
|
||||
|
||||
it('rejects an edit when the file changed since the windowed read (stale before matching)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
await call('read', { file_path: 'a.txt', offset: 1, limit: 1 })
|
||||
await writeFile(join(dir, 'a.txt'), 'goodbye') // out-of-band change removes 'world'
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('rejects an ambiguous match without replace_all', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'a a a')
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a a a')
|
||||
})
|
||||
|
||||
it('replaces all matches with replace_all', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'a a a')
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b', replace_all: true })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b')
|
||||
})
|
||||
|
||||
it('supports a full write→edit cycle without an intervening read', async () => {
|
||||
await call('write', { file_path: 'a.txt', content: 'one two' })
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'two', new_string: 'three' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('one three')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the gate records only through the events (no method coupling)', () => {
|
||||
it('a direct ctx.fs.readText records no observed-state, so a later edit rejects', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
// Reach AROUND the tool — an explicit escape hatch for non-tool consumers.
|
||||
await ctx.fs.readText(await ctx.fs.resolve('a.txt'))
|
||||
// The model-facing edit still rejects: the read did not emit fs/observed.
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('stat budget', () => {
|
||||
it('read stats once; write and edit never stat in the tool (the gate stats zero too)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const statSpy = vi.spyOn(ctx.fs, 'stat')
|
||||
|
||||
// read: exactly one stat (type + size routing + observed version).
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
expect(statSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
// edit (guarded, after the read): the gate supplies vObserved; the tool
|
||||
// does not stat to manufacture a basis. CAS happens in editText's lock.
|
||||
statSpy.mockClear()
|
||||
const edited = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(edited.isError).toBe(false)
|
||||
expect(statSpy).not.toHaveBeenCalled()
|
||||
|
||||
// write (guarded replace, after the edit refreshed observed state): zero stat.
|
||||
statSpy.mockClear()
|
||||
const written = await call('write', { file_path: 'a.txt', content: 'fresh' })
|
||||
expect(written.isError).toBe(false)
|
||||
expect(statSpy).not.toHaveBeenCalled()
|
||||
statSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// BARE deployment: the tool suite WITHOUT the policy gate.
|
||||
// --------------------------------------------------------------------------
|
||||
describe('bare provider (no dsh-fs-policy)', () => {
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-bare-'))
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: dir })
|
||||
fiber = await ctx.plugin(ToolFs)
|
||||
})
|
||||
|
||||
it('read works (it never needed policy)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'alpha\nbeta')
|
||||
const result = await call('read', { file_path: 'a.txt' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('1: alpha')
|
||||
})
|
||||
|
||||
it('write unconditionally creates a new file', async () => {
|
||||
const result = await call('write', { file_path: 'new.txt', content: 'fresh' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
|
||||
})
|
||||
|
||||
it('write unconditionally OVERWRITES an existing unread file', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'original')
|
||||
const result = await call('write', { file_path: 'a.txt', content: 'clobbered' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('clobbered')
|
||||
})
|
||||
|
||||
it('edit unconditionally edits an UNREAD existing file', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
|
||||
})
|
||||
|
||||
it('edit of a MISSING target reports FS_STALE_VERSION even on the unguarded path', async () => {
|
||||
const result = await call('edit', { file_path: 'missing.txt', old_string: 'a', new_string: 'b' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('edit still enforces literal-match codes (FS_EDIT_NOT_FOUND), unrelated to freshness', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'absent', new_string: 'x' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('neither write nor edit stats in the tool on the bare path', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const statSpy = vi.spyOn(ctx.fs, 'stat')
|
||||
expect((await call('write', { file_path: 'a.txt', content: 'x y' })).isError).toBe(false)
|
||||
expect((await call('edit', { file_path: 'a.txt', old_string: 'y', new_string: 'z' })).isError).toBe(false)
|
||||
expect(statSpy).not.toHaveBeenCalled()
|
||||
statSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Per-session cwd: a relative file_path resolves against the CALLING session's
|
||||
// workspace (`exec.agent.session.header.cwd`), NOT the backend's config.cwd —
|
||||
// so an ACP editor's per-session dir wins, matching dsh-tool-bash. The regression
|
||||
// this guards: before the seam fix the tool passed no cwd, so a relative write
|
||||
// landed in config.cwd instead of the session dir.
|
||||
// --------------------------------------------------------------------------
|
||||
describe('per-session cwd', () => {
|
||||
let sessionDir: string
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-cfg-'))
|
||||
sessionDir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-session-'))
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: dir }) // config.cwd = dir, NOT sessionDir
|
||||
await ctx.plugin(FsPolicy)
|
||||
fiber = await ctx.plugin(ToolFs)
|
||||
})
|
||||
afterEach(async () => { await rm(sessionDir, { recursive: true, force: true }) })
|
||||
|
||||
const callIn = (sessionObj: object, name: string, args: unknown) =>
|
||||
ctx.tools.execute({
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
agent: { session: sessionObj } as never,
|
||||
})
|
||||
|
||||
it('writes a relative path into the SESSION cwd, not config.cwd', async () => {
|
||||
const result = await callIn({ header: { cwd: sessionDir } }, 'write', { file_path: 'note.txt', content: 'hi' })
|
||||
expect(result.isError).toBe(false)
|
||||
// Verify the WORLD: the file is in the session dir, and NOT in config.cwd.
|
||||
expect(await readFile(join(sessionDir, 'note.txt'), 'utf8')).toBe('hi')
|
||||
await expect(readFile(join(dir, 'note.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('read + edit both resolve against the session cwd (end-to-end)', async () => {
|
||||
// ONE session object across both calls — observed-state keys by owner
|
||||
// identity, so read must record under the same owner the edit reads.
|
||||
const session = { header: { cwd: sessionDir } }
|
||||
await writeFile(join(sessionDir, 'code.txt'), 'alpha')
|
||||
expect((await callIn(session, 'read', { file_path: 'code.txt' })).isError).toBe(false)
|
||||
const edited = await callIn(session, 'edit', { file_path: 'code.txt', old_string: 'alpha', new_string: 'beta' })
|
||||
expect(edited.isError).toBe(false)
|
||||
expect(await readFile(join(sessionDir, 'code.txt'), 'utf8')).toBe('beta')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Abort-through-the-tool, tool-tier concurrency, and the fs/observed contract —
|
||||
// all through ctx.tools.execute() against the REAL backend + policy.
|
||||
// --------------------------------------------------------------------------
|
||||
describe('signal, concurrency, and the fs/observed contract', () => {
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-'))
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: dir })
|
||||
await ctx.plugin(FsPolicy)
|
||||
fiber = await ctx.plugin(ToolFs)
|
||||
})
|
||||
|
||||
const session = { header: {} }
|
||||
const callSig = (signal: AbortSignal, name: string, args: unknown) =>
|
||||
ctx.tools.execute({ callId: CallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never, signal })
|
||||
const callOwned = (name: string, args: unknown) =>
|
||||
ctx.tools.execute({ callId: CallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never })
|
||||
|
||||
it('a pre-aborted signal makes read/write/edit return isError FS_ABORTED', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello')
|
||||
const read = await callSig(AbortSignal.abort(), 'read', { file_path: 'a.txt' })
|
||||
expect(read.isError).toBe(true)
|
||||
expect(read.error).toMatchObject({ code: 'FS_ABORTED' })
|
||||
|
||||
const write = await callSig(AbortSignal.abort(), 'write', { file_path: 'new.txt', content: 'x' })
|
||||
expect(write.isError).toBe(true)
|
||||
expect(write.error).toMatchObject({ code: 'FS_ABORTED' })
|
||||
await expect(readFile(join(dir, 'new.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
|
||||
// Read first (un-aborted, SAME session owner) so the edit clears the
|
||||
// observation gate; then the aborted edit fails on the signal, not on
|
||||
// FS_NOT_OBSERVED.
|
||||
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
|
||||
const edit = await callSig(AbortSignal.abort(), 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' })
|
||||
expect(edit.isError).toBe(true)
|
||||
expect(edit.error).toMatchObject({ code: 'FS_ABORTED' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello') // unchanged
|
||||
})
|
||||
|
||||
it('two concurrent edits of the same file, same session: one wins, one FS_STALE_VERSION', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'base value here')
|
||||
// One read establishes the observed version both edits guard against; then
|
||||
// race two edits so both carry the SAME observed version (the barrier).
|
||||
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
|
||||
const [one, two] = await Promise.all([
|
||||
callOwned('edit', { file_path: 'a.txt', old_string: 'base', new_string: 'ONE', replaceAll: false }),
|
||||
callOwned('edit', { file_path: 'a.txt', old_string: 'value', new_string: 'TWO', replaceAll: false }),
|
||||
])
|
||||
const errors = [one, two].filter(r => r.isError)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]?.error).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
// The world is consistent: exactly one edit landed.
|
||||
const onDisk = await readFile(join(dir, 'a.txt'), 'utf8')
|
||||
expect(onDisk === 'ONE value here' || onDisk === 'base TWO here').toBe(true)
|
||||
})
|
||||
|
||||
it('a throwing fs/observed listener surfaces as isError, but the mutation already hit disk', async () => {
|
||||
// fs/observed is a plain ctx.emit AFTER the write succeeded; a throwing
|
||||
// listener cannot roll the write back — it only turns the tool result into
|
||||
// isError. The file must still carry the written bytes.
|
||||
ctx.on('fs/observed', () => { throw new Error('recording bug') })
|
||||
const result = await callOwned('write', { file_path: 'w.txt', content: 'durable' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(await readFile(join(dir, 'w.txt'), 'utf8')).toBe('durable')
|
||||
})
|
||||
})
|
||||
116
packages/fs/tool-fs/tests/read-render.spec.ts
Normal file
116
packages/fs/tool-fs/tests/read-render.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Cordis-free tests for the line-windowing module: offset/limit windows, byte
|
||||
* caps, per-line truncation, CRLF stripping, offset-past-EOF rejection, and the
|
||||
* capped line buffer for newline-free giant lines — all over an async-iterable
|
||||
* of decoded text chunks (so one code path serves whole-file and streamed reads).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
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 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> {
|
||||
yield text
|
||||
}
|
||||
|
||||
/** Yield `text` split into fixed-size chunks (streamed read shape). */
|
||||
async function* chunked(text: string, size: number): AsyncIterable<string> {
|
||||
for (let i = 0; i < text.length; i += size) yield text.slice(i, i + size)
|
||||
}
|
||||
|
||||
describe('buildWindow', () => {
|
||||
it('numbers lines and reports total for a whole-file read', async () => {
|
||||
const result = await buildWindow(whole('one\ntwo\nthree'), READ_ALL, 'f')
|
||||
expect(result.lines).toEqual([
|
||||
{ number: 1, text: 'one' },
|
||||
{ number: 2, text: 'two' },
|
||||
{ number: 3, text: 'three' },
|
||||
])
|
||||
expect(result.totalLines).toBe(3)
|
||||
expect(result.truncatedByBytes).toBe(false)
|
||||
})
|
||||
|
||||
it('applies offset/limit', async () => {
|
||||
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)
|
||||
})
|
||||
|
||||
it('strips CRLF', async () => {
|
||||
const result = await buildWindow(whole('one\r\ntwo\r\n'), READ_ALL, 'f')
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
})
|
||||
|
||||
it('truncates an over-long line', async () => {
|
||||
const result = await buildWindow(whole('x'.repeat(3000)), READ_ALL, 'f')
|
||||
expect(result.lines[0]?.text).toContain(`... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`)
|
||||
})
|
||||
|
||||
it('caps output bytes and reports truncatedByBytes', async () => {
|
||||
const big = Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')
|
||||
const result = await buildWindow(whole(big), READ_ALL, 'f')
|
||||
expect(result.truncatedByBytes).toBe(true)
|
||||
})
|
||||
|
||||
it('reads an empty file at offset 1 as zero lines', async () => {
|
||||
const result = await buildWindow(whole(''), READ_ALL, 'f')
|
||||
expect(result.lines).toEqual([])
|
||||
expect(result.totalLines).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects an offset past EOF', async () => {
|
||||
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 () => {
|
||||
const result = await buildWindow(whole('one\ntwo'), READ_ALL, 'f')
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
})
|
||||
|
||||
it('handles a trailing newline (no dangling empty line)', async () => {
|
||||
const result = await buildWindow(whole('one\ntwo\n'), READ_ALL, 'f')
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
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, ...DEFAULT_CAPS }, 'f')
|
||||
expect(result.lines).toEqual([{ number: 2, text: 'two' }])
|
||||
expect(result.totalLines).toBe(3)
|
||||
})
|
||||
|
||||
it('caps a newline-free giant line split across chunks without unbounded buffering', async () => {
|
||||
const result = await buildWindow(chunked('z'.repeat(5000), 256), READ_ALL, 'f')
|
||||
expect(result.lines[0]?.text).toContain(`... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`)
|
||||
})
|
||||
|
||||
it('caps output bytes mid-stream', async () => {
|
||||
const big = Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')
|
||||
const result = await buildWindow(chunked(big, 512), READ_ALL, 'f')
|
||||
expect(result.truncatedByBytes).toBe(true)
|
||||
})
|
||||
|
||||
it('flushes a final newline-terminated line across a chunk boundary', async () => {
|
||||
const result = await buildWindow(chunked('one\ntwo\n', 3), READ_ALL, 'f')
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
})
|
||||
})
|
||||
})
|
||||
566
packages/fs/tool-fs/tests/tools.spec.ts
Normal file
566
packages/fs/tool-fs/tests/tools.spec.ts
Normal file
@@ -0,0 +1,566 @@
|
||||
/**
|
||||
* Consumer-surface tests for the filesystem tools as the EXECUTOR. They run the
|
||||
* REAL `@deepseek-ai/dsh-fs-policy` gate plugin (the genuine policy
|
||||
* collaborator, per the prefer-the-real-implementation rule) over a fake
|
||||
* `ctx.fs` provider, so they verify schemas, argument validation, result
|
||||
* formatting, FsError→isError propagation, and that each tool dispatches the
|
||||
* `fs/*` waterfalls + records observed-state through the gate (read authorizes a
|
||||
* later edit) — not just that it moved bytes.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsDirEntry,
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsTarget,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
} from '@deepseek-ai/dsh-fs'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import { formatReadOutput, STREAM_MIN_SIZE } from '@deepseek-ai/dsh-tool-fs'
|
||||
import type { FileReadOutcome } from '@deepseek-ai/dsh-tool-fs'
|
||||
|
||||
/** An in-memory fake provider; a test can arm a rejection on any primitive. */
|
||||
class FakeFs extends FileSystem {
|
||||
files = new Map<string, string>()
|
||||
rejectWith?: FsError
|
||||
writeIntents: (FsWriteIntent | undefined)[] = []
|
||||
editIntents: ({ version: FsVersion } | undefined)[] = []
|
||||
|
||||
private throwIfArmed(): void {
|
||||
if (this.rejectWith) throw this.rejectWith
|
||||
}
|
||||
|
||||
override async resolve(path: string): Promise<FsTarget> {
|
||||
return { targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` }
|
||||
}
|
||||
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
|
||||
this.throwIfArmed()
|
||||
const content = this.files.get(target.targetKey)
|
||||
if (content === undefined) return undefined
|
||||
return { version: FsVersion('v1'), type: 'file', size: content.length }
|
||||
}
|
||||
override async readText(target: FsTarget): Promise<string> {
|
||||
return this.files.get(target.targetKey) ?? ''
|
||||
}
|
||||
override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
|
||||
const content = this.files.get(target.targetKey) ?? ''
|
||||
return (async function* () { yield content })()
|
||||
}
|
||||
override async listDir(_target: FsTarget): Promise<FsDirEntry[]> {
|
||||
return []
|
||||
}
|
||||
override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise<FsWriteOutcome> {
|
||||
this.throwIfArmed()
|
||||
this.writeIntents.push(expected)
|
||||
const before = this.files.get(target.targetKey) ?? null
|
||||
this.files.set(target.targetKey, content)
|
||||
return { operation: before !== null ? 'update' : 'create', version: FsVersion('v2'), before, after: content }
|
||||
}
|
||||
override async editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }): Promise<FsEditOutcome> {
|
||||
this.throwIfArmed()
|
||||
this.editIntents.push(expected)
|
||||
const content = this.files.get(target.targetKey) ?? ''
|
||||
const after = content.split(edit.oldString).join(edit.newString)
|
||||
this.files.set(target.targetKey, after)
|
||||
return { version: FsVersion('v3'), before: content, after }
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeFs)
|
||||
await ctx.plugin(FsPolicy)
|
||||
await ctx.plugin(ToolFs)
|
||||
const fs = ctx.fs as FakeFs
|
||||
return { ctx, fs }
|
||||
}
|
||||
|
||||
let callCounter = 0
|
||||
function call(ctx: Context, name: string, args: unknown, agent?: object) {
|
||||
return ctx.tools.execute({
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
...agent ? { agent: agent as never } : {},
|
||||
})
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers read, write, and edit', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['edit', 'read', 'write'])
|
||||
})
|
||||
|
||||
it('registers prompt sections for each tool', async () => {
|
||||
const { ctx } = await setup()
|
||||
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
|
||||
expect(prompt).toContain('Use the read tool')
|
||||
expect(prompt).toContain('Use the write tool')
|
||||
expect(prompt).toContain('Use the edit tool')
|
||||
})
|
||||
|
||||
it('stays pending until ctx.fs exists (inject)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(ToolFs) // no fs provider
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('unregisters everything on fiber disposal (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeFs)
|
||||
await ctx.plugin(FsPolicy)
|
||||
const fiber = await ctx.plugin(ToolFs)
|
||||
// Each tool contributes BOTH a schema and a prompt section; disposal must
|
||||
// withdraw both, not just the schemas.
|
||||
expect(ctx.tools.schemas()).toHaveLength(3)
|
||||
const sectionNames = (a: { sections: { name: string }[] }) => a.sections.map(s => s.name).sort()
|
||||
expect(sectionNames(await ctx.systemPrompt.assemble())).toEqual(['deployment:persona', 'harness:identity', 'tool:edit', 'tool:read', 'tool:write'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
// Only the system-prompt plugin's own built-in sections remain.
|
||||
expect(sectionNames(await ctx.systemPrompt.assemble())).toEqual(['deployment:persona', 'harness:identity'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('read tool', () => {
|
||||
it('formats line-numbered content with a footer', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.files.set('key:a.txt', 'hello\nworld')
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe(`<path>/abs/a.txt</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
1: hello
|
||||
2: world
|
||||
|
||||
(End of file - total 2 lines)
|
||||
</content>`)
|
||||
})
|
||||
|
||||
it('rejects a non-positive offset via arg validation', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt', offset: 0 })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('offset must be a positive integer')
|
||||
})
|
||||
|
||||
it('rejects a fractional or NaN offset, and a zero/negative limit', async () => {
|
||||
const { ctx } = await setup()
|
||||
for (const args of [
|
||||
{ file_path: 'a.txt', offset: 1.5 },
|
||||
{ file_path: 'a.txt', offset: Number.NaN },
|
||||
{ file_path: 'a.txt', limit: 0 },
|
||||
{ file_path: 'a.txt', limit: -3 },
|
||||
]) {
|
||||
const result = await call(ctx, 'read', args)
|
||||
expect(result.isError, JSON.stringify(args)).toBe(true)
|
||||
expect(text(result)).toMatch(/must be a positive integer/)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a limit above the cap', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt', limit: 99999 })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('less than or equal to 2000')
|
||||
})
|
||||
|
||||
it('rejects a blank file_path', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'read', { file_path: ' ' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('file_path must be a non-empty string')
|
||||
})
|
||||
|
||||
it('records observed state so a follow-up edit by the same session is authorized', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const session = { header: {} }
|
||||
fs.files.set('key:a.txt', 'hello')
|
||||
expect((await call(ctx, 'read', { file_path: 'a.txt' }, { session })).isError).toBe(false)
|
||||
const edited = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' }, { session })
|
||||
expect(edited.isError).toBe(false)
|
||||
expect(fs.editIntents).toEqual([{ version: 'v1' }])
|
||||
})
|
||||
|
||||
it('propagates FS_NOT_FOUND for an absent file', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'read', { file_path: 'missing.txt' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('rejects a non-regular target', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.files.set('key:d', '')
|
||||
fs.stat = async () => ({ version: FsVersion('v1'), type: 'directory' })
|
||||
const result = await call(ctx, 'read', { file_path: 'd' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('streams a large file (size at/above the cap) instead of reading whole', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.files.set('key:big.txt', 'alpha\nbeta')
|
||||
const readSpy = vi.spyOn(fs, 'readText')
|
||||
const streamSpy = vi.spyOn(fs, 'streamText')
|
||||
fs.stat = async () => ({ version: FsVersion('v1'), type: 'file', size: STREAM_MIN_SIZE })
|
||||
const result = await call(ctx, 'read', { file_path: 'big.txt' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('1: alpha')
|
||||
expect(streamSpy).toHaveBeenCalled()
|
||||
expect(readSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('streams when the backend reports no size (never buffers a size-less file)', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.files.set('key:a.txt', 'alpha')
|
||||
const streamSpy = vi.spyOn(fs, 'streamText')
|
||||
fs.stat = async () => ({ version: FsVersion('v1'), type: 'file' }) // no size
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(streamSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a byte-capped read as a truncated footer', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
// Many long lines so the window hits the byte cap before EOF.
|
||||
fs.files.set('key:big.txt', Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n'))
|
||||
const result = await call(ctx, 'read', { file_path: 'big.txt' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('Output capped.')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('formatReadOutput footer variants', () => {
|
||||
const base: FileReadOutcome = { offset: 1, lines: [{ number: 1, text: 'x' }], totalLines: 1 }
|
||||
|
||||
it('reports a byte-capped read', () => {
|
||||
const out = formatReadOutput('/f', { ...base, totalLines: 99, truncatedByBytes: true })
|
||||
expect(out).toContain('(Output capped. Showing lines 1-1. Use offset=2 to continue.)')
|
||||
})
|
||||
|
||||
it('reports a more-remaining page', () => {
|
||||
const out = formatReadOutput('/f', { ...base, totalLines: 99 })
|
||||
expect(out).toContain('(Showing lines 1-1 of 99. Use offset=2 to continue.)')
|
||||
})
|
||||
|
||||
it('reports end-of-file', () => {
|
||||
expect(formatReadOutput('/f', base)).toContain('(End of file - total 1 lines)')
|
||||
})
|
||||
|
||||
it('renders an empty file as just the footer', () => {
|
||||
const out = formatReadOutput('/f', { ...base, lines: [], totalLines: 0 })
|
||||
expect(out).toContain('(End of file - total 0 lines)')
|
||||
expect(out).not.toContain(': ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('write tool', () => {
|
||||
it('formats a create result and uses createIfAbsent (unobserved, with the gate)', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }, { session: { header: {} } })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('Created file')
|
||||
expect(fs.writeIntents).toEqual([{ kind: 'createIfAbsent' }])
|
||||
})
|
||||
|
||||
it('rejects a blank file_path', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'write', { file_path: ' ', content: 'hi' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('file_path must be a non-empty string')
|
||||
})
|
||||
|
||||
it('propagates a backend FsError as an isError result carrying its code', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.rejectWith = new FsError('blocked', 'FS_STALE_VERSION')
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ name: 'FsError', code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit tool', () => {
|
||||
it('formats a single-replacement success after a read', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const session = { header: {} }
|
||||
fs.files.set('key:a.txt', 'a')
|
||||
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session })
|
||||
expect(text(result)).toBe('The file /abs/a.txt has been updated successfully.')
|
||||
})
|
||||
|
||||
it('formats the replace_all success message distinctly', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const session = { header: {} }
|
||||
fs.files.set('key:a.txt', 'a a a')
|
||||
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b', replace_all: true }, { session })
|
||||
expect(text(result)).toBe('The file /abs/a.txt has been updated. All occurrences were successfully replaced.')
|
||||
})
|
||||
|
||||
it('rejects identical old/new strings', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'x', new_string: 'x' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('must differ')
|
||||
})
|
||||
|
||||
it('rejects an empty old_string', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: '', new_string: 'x' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('old_string must be a non-empty string')
|
||||
})
|
||||
|
||||
it('rejects a blank file_path', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'edit', { file_path: ' ', old_string: 'a', new_string: 'b' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('file_path must be a non-empty string')
|
||||
})
|
||||
|
||||
it('propagates FS_NOT_OBSERVED when the file was never read (the gate decides)', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.files.set('key:a.txt', 'hello')
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session: { header: {} } })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-owned presentation (pure presentCall)', () => {
|
||||
// presentCall is a pure display function of args (no I/O); it drives the ACP
|
||||
// card's title/kind and the `locations` an editor follows along to.
|
||||
const presentCall = async (name: string, args: unknown) => {
|
||||
const { ctx } = await setup()
|
||||
return ctx.tools.get(name)?.presentCall?.(args)
|
||||
}
|
||||
|
||||
it('read: generic card titled by file with the read window, read kind, location with the offset line', async () => {
|
||||
expect(await presentCall('read', { file_path: 'src/a.ts', offset: 12, limit: 40 })).toEqual({
|
||||
card: 'generic', title: 'Read src/a.ts (12 - 51)', kind: 'read',
|
||||
locations: [{ path: 'src/a.ts', line: 12 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('read: bare title and line-1 location when offset/limit are unset', async () => {
|
||||
expect(await presentCall('read', { file_path: 'a.txt' })).toEqual({
|
||||
card: 'generic', title: 'Read a.txt', kind: 'read', locations: [{ path: 'a.txt', line: 1 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('read: "from line N" window when only offset is set', async () => {
|
||||
expect(await presentCall('read', { file_path: 'a.txt', offset: 5 })).toEqual({
|
||||
card: 'generic', title: 'Read a.txt (from line 5)', kind: 'read', locations: [{ path: 'a.txt', line: 5 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('write: diff card (new-file style, oldText null), location', async () => {
|
||||
expect(await presentCall('write', { file_path: 'out.txt', content: 'hello' })).toEqual({
|
||||
card: 'diff', title: 'Write out.txt',
|
||||
diffs: [{ path: 'out.txt', oldText: null, newText: 'hello' }],
|
||||
locations: [{ path: 'out.txt' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('read: a limit with no offset windows from line 1', async () => {
|
||||
expect(await presentCall('read', { file_path: 'a.txt', limit: 10 })).toEqual({
|
||||
card: 'generic', title: 'Read a.txt (1 - 10)', kind: 'read', locations: [{ path: 'a.txt', line: 1 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('edit: an empty old_string maps to oldText null (a whole-file replace diff)', async () => {
|
||||
// presentCall runs on replay of raw logged args, which parseEditArgs does not
|
||||
// gate — an empty old_string must still produce a valid diff (oldText null).
|
||||
expect(await presentCall('edit', { file_path: 'a.txt', old_string: '', new_string: 'seed' })).toEqual({
|
||||
card: 'diff', title: 'Edit a.txt',
|
||||
diffs: [{ path: 'a.txt', oldText: null, newText: 'seed' }],
|
||||
locations: [{ path: 'a.txt' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('result-time contextual diff (meta + presentResult)', () => {
|
||||
// An edit records the applied contextual hunk on `tool/result` meta, and the
|
||||
// tool's presentResult narrows it back into a `diff` result card the bridge
|
||||
// renders. Drive execute end-to-end so the meta is the REAL computed hunk.
|
||||
const withContext = 'a\nb\nc\nOLD\nd\ne\nf\n'
|
||||
|
||||
it('edit: execute attaches the applied hunk as meta { diffs }', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const session = { header: {} }
|
||||
fs.files.set('key:a.txt', withContext)
|
||||
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'OLD', new_string: 'NEW' }, { session })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.meta).toEqual({
|
||||
diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('edit: presentResult turns the meta into a diff result card', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const session = { header: {} }
|
||||
fs.files.set('key:a.txt', withContext)
|
||||
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'OLD', new_string: 'NEW' }, { session })
|
||||
const view = ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'OLD', new_string: 'NEW' }, result)
|
||||
expect(view).toEqual({
|
||||
card: 'diff', title: 'Edit a.txt',
|
||||
diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('write OVERWRITE: execute attaches a contextual hunk; presentResult renders a diff card', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const session = { header: {} }
|
||||
fs.files.set('key:a.txt', withContext)
|
||||
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'a\nb\nc\nNEW\nd\ne\nf\n' }, { session })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.meta).toEqual({ diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] })
|
||||
const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'x' }, result)
|
||||
expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] })
|
||||
})
|
||||
|
||||
it('write CREATE: no before-version → no meta, but presentResult still renders a whole-file diff card', async () => {
|
||||
// A create has no prior content (no `meta`), yet the completed card must be a
|
||||
// `diff` — an ACP tool_call_update.content REPLACES the call's content, so a
|
||||
// non-diff result would clobber the pending new-file diff. The whole-file diff
|
||||
// is derived from the args (oldText:null), replay-safe.
|
||||
const { ctx } = await setup()
|
||||
const session = { header: {} }
|
||||
const result = await call(ctx, 'write', { file_path: 'new.txt', content: 'fresh\n' }, { session })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.meta).toBeUndefined()
|
||||
const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'new.txt', content: 'fresh\n' }, result)
|
||||
expect(view).toEqual({ card: 'diff', title: 'Write new.txt', diffs: [{ path: 'new.txt', oldText: null, newText: 'fresh\n' }] })
|
||||
})
|
||||
|
||||
it('write OVERWRITE with identical content: a before exists but yields no hunk → no meta, presentResult falls back to a whole-file diff', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const session = { header: {} }
|
||||
fs.files.set('key:a.txt', 'same\n')
|
||||
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'same\n' }, { session })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.meta).toBeUndefined()
|
||||
const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'same\n' }, result)
|
||||
expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: null, newText: 'same\n' }] })
|
||||
})
|
||||
|
||||
it('presentResult returns undefined on an error result (nothing applied)', async () => {
|
||||
const { ctx } = await setup()
|
||||
const errorResult = { content: [{ type: 'text' as const, text: 'Error: boom' }], isError: true }
|
||||
expect(ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'x', new_string: 'y' }, errorResult)).toBeUndefined()
|
||||
expect(ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'y' }, errorResult)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('edit presentResult returns undefined on malformed meta (defensive narrowing)', async () => {
|
||||
// edit has no whole-file fallback (only a literal replacement), so a malformed
|
||||
// meta yields the generic "updated successfully" rendering.
|
||||
const { ctx } = await setup()
|
||||
const badMeta = { content: [{ type: 'text' as const, text: 'ok' }], isError: false, meta: { diffs: 'nope' } }
|
||||
expect(ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'x', new_string: 'y' }, badMeta)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('write presentResult falls back to a whole-file diff on malformed meta (never leaks the result text)', async () => {
|
||||
// write always renders a diff card so the completed update can't clobber the
|
||||
// pending diff with the model-facing text; a malformed meta falls back to the
|
||||
// args-derived whole-file diff, same as a create.
|
||||
const { ctx } = await setup()
|
||||
const badMeta = { content: [{ type: 'text' as const, text: 'ok' }], isError: false, meta: { diffs: 'nope' } }
|
||||
const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'y' }, badMeta)
|
||||
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)
|
||||
})
|
||||
})
|
||||
18
packages/fs/tool-fs/tsconfig.json
Normal file
18
packages/fs/tool-fs/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/tools" },
|
||||
{ "path": "../../core/system-prompt" },
|
||||
{ "path": "../fs" },
|
||||
{ "path": "../fs-policy" }
|
||||
]
|
||||
}
|
||||
11
packages/hooks/README.md
Normal file
11
packages/hooks/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# hooks/ — hook bridges + shared protocol
|
||||
|
||||
The hooks subsystem lets users extend the agent at lifecycle points the way Claude Code and Codex do — by pointing a bridge plugin at an existing `hooks.json` (or settings) so those external shell hooks run faithfully. The canonical extension surface itself is the harness's typed interception seams ([the interception-seams RFC](../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)); a "native hook" is just an ordinary cordis plugin on those seams. These packages are the **bridges** that translate the external shell-hook protocol onto that same surface, plus the shared wire-protocol library they build on.
|
||||
|
||||
| Package | Role | Shape |
|
||||
|---|---|---|
|
||||
| `hook-protocol/` | Shared wire-protocol core: matcher primitive, exit-code/stdout codec, `runHook` (via `ctx.bash`), most-restrictive merge, `hook/*` session events | library (no plugin) |
|
||||
| `hooks-claude/` | Bridge for a Claude Code `hooks.json` / settings | plugin |
|
||||
| `hooks-codex/` | Bridge for a Codex `hooks.json` | plugin |
|
||||
|
||||
Codex deliberately reimplements a *subset* of the Claude Code protocol (same `hooks.json` shape, 5 events vs CC's many, command-only, regex-only matcher, no env/substitution), so `hook-protocol` owns the genuinely-identical primitives and each bridge owns only what differs (its per-event stdin payload, env, and the mapping of a hook's neutral outcome onto the harness's typed Decisions). See [hook-protocol/README.md](hook-protocol/README.md).
|
||||
32
packages/hooks/hook-protocol/README.md
Normal file
32
packages/hooks/hook-protocol/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# @deepseek-ai/dsh-hook-protocol
|
||||
|
||||
The **shared core** of the Claude Code / Codex hook wire protocol. NOT a cordis plugin — it registers nothing and injects nothing. It is a **library** of dialect-neutral primitives the two bridge plugins (`@deepseek-ai/dsh-hooks-claude`, `@deepseek-ai/dsh-hooks-codex`) import so neither re-implements the identical halves of the protocol.
|
||||
|
||||
Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claude Code hook protocol — the same `hooks.json` matcher-group shape, the same exit-code/stdout output contract, the same command-hook execution model. The genuinely-shared parts live here; each bridge owns only what differs.
|
||||
|
||||
## What's shared (here) vs. per-dialect (the bridges)
|
||||
|
||||
| Concern | Here (`dsh-hook-protocol`) | The bridge (`dsh-hooks-claude` / `-codex`) |
|
||||
|---|---|---|
|
||||
| Matcher test | `matchesMatcher(pattern, query, mode)` — literal-or-regex by `mode` | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) |
|
||||
| Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** |
|
||||
| Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto a seam-specific typed Decision |
|
||||
| Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — |
|
||||
| Durable record | `appendHookInvoked` / `appendHookResult` (`hook/*` session events; the result's `decision`/`stderrSummary` derive from the `HookOutput` here) | calls them around each invocation |
|
||||
|
||||
## Primitives
|
||||
|
||||
- **`matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. An invalid regex matches nothing (never throws).
|
||||
- **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
|
||||
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** — the exit-code + structured-stdout codec. Exit `0` → parse JSON stdout (lenient: non-JSON is left for the bridge); exit `2` → blocking error, `stderr` is the block reason (surfaced as `decision: 'block'`); other → non-blocking error. `hookSpecificOutput.permissionDecision` (allow/deny/ask) overrides a legacy top-level `decision`; `additionalContext`/`updatedInput`/`systemMessage`/`continue`/`stopReason` are parsed too. The schemas key the `hookSpecificOutput` block by `hookEventName`, so passing `expectedEventName` (the firing event) DISCARDS a block whose `hookEventName` names a different event — or omits it entirely — its event-scoped fields don't take effect (a `PreToolUse` block on a `Stop` hook is malformed, and so is a discriminator-less block that would otherwise apply to any event), while the event-agnostic top-level fields still apply. Pure and total.
|
||||
- **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order.
|
||||
|
||||
## `hook/*` session events
|
||||
|
||||
Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`, with `appendHookResult` owning the decision rule). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog/log-events.md); `stderrSummary` is truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty).
|
||||
|
||||
Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `context/message` is the durable evidence) — see the hooks RFC.
|
||||
|
||||
## Input rewrite is parsed but not honored
|
||||
|
||||
`HookOutput.updatedInput` carries a hook's requested tool-input rewrite (CC `updatedInput`), but the harness does not honor it yet — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)). A bridge logs + warns when a hook sets it. See `src/types.ts` for the full contracts.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user