From 5e01564afbbfa0bcc634e78d24e58f97a7337c86 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 22 Jun 2026 10:48:41 +0800 Subject: [PATCH 01/46] Add filesystem capability seam and tools --- docs/architecture.md | 6 + docs/cordis-catalog/events-and-services.md | 28 +- docs/module-graph.md | 9 + docs/rfc/README.md | 2 + .../2026-06-17-filesystem-capability-seam.md | 182 +++++++ .../2026-06-17-filesystem-tool-schemas.md | 113 +++++ packages/README.md | 7 + packages/fs/README.md | 11 + packages/fs/fs-local/README.md | 23 + packages/fs/fs-local/package.json | 34 ++ packages/fs/fs-local/src/fsio.ts | 470 ++++++++++++++++++ packages/fs/fs-local/src/index.ts | 197 ++++++++ packages/fs/fs-local/tests/filesystem.spec.ts | 268 ++++++++++ packages/fs/fs-local/tests/fsio.spec.ts | 364 ++++++++++++++ packages/fs/fs-local/tsconfig.json | 15 + packages/fs/fs/README.md | 38 ++ packages/fs/fs/package.json | 30 ++ packages/fs/fs/src/index.ts | 256 ++++++++++ packages/fs/fs/src/types.ts | 194 ++++++++ packages/fs/fs/tests/service.spec.ts | 313 ++++++++++++ packages/fs/fs/tsconfig.json | 13 + packages/fs/tool-fs/README.md | 33 ++ packages/fs/tool-fs/package.json | 51 ++ packages/fs/tool-fs/src/edit.ts | 82 +++ packages/fs/tool-fs/src/index.ts | 35 ++ packages/fs/tool-fs/src/read.ts | 95 ++++ packages/fs/tool-fs/src/write.ts | 63 +++ packages/fs/tool-fs/tests/integration.spec.ts | 143 ++++++ packages/fs/tool-fs/tests/subpaths.spec.ts | 74 +++ packages/fs/tool-fs/tests/tools.spec.ts | 270 ++++++++++ packages/fs/tool-fs/tsconfig.json | 16 + packages/fs/tool-fs/tsdown.config.ts | 18 + pnpm-lock.yaml | 52 ++ tsconfig.base.json | 4 + tsconfig.build.json | 3 + tsconfig.typecheck.json | 4 + 36 files changed, 3515 insertions(+), 1 deletion(-) create mode 100644 docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md create mode 100644 docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md create mode 100644 packages/fs/README.md create mode 100644 packages/fs/fs-local/README.md create mode 100644 packages/fs/fs-local/package.json create mode 100644 packages/fs/fs-local/src/fsio.ts create mode 100644 packages/fs/fs-local/src/index.ts create mode 100644 packages/fs/fs-local/tests/filesystem.spec.ts create mode 100644 packages/fs/fs-local/tests/fsio.spec.ts create mode 100644 packages/fs/fs-local/tsconfig.json create mode 100644 packages/fs/fs/README.md create mode 100644 packages/fs/fs/package.json create mode 100644 packages/fs/fs/src/index.ts create mode 100644 packages/fs/fs/src/types.ts create mode 100644 packages/fs/fs/tests/service.spec.ts create mode 100644 packages/fs/fs/tsconfig.json create mode 100644 packages/fs/tool-fs/README.md create mode 100644 packages/fs/tool-fs/package.json create mode 100644 packages/fs/tool-fs/src/edit.ts create mode 100644 packages/fs/tool-fs/src/index.ts create mode 100644 packages/fs/tool-fs/src/read.ts create mode 100644 packages/fs/tool-fs/src/write.ts create mode 100644 packages/fs/tool-fs/tests/integration.spec.ts create mode 100644 packages/fs/tool-fs/tests/subpaths.spec.ts create mode 100644 packages/fs/tool-fs/tests/tools.spec.ts create mode 100644 packages/fs/tool-fs/tsconfig.json create mode 100644 packages/fs/tool-fs/tsdown.config.ts diff --git a/docs/architecture.md b/docs/architecture.md index 2b05e613b1..8f78a0b086 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,6 +24,8 @@ For a catalog of the **data structures** this architecture moves around — the │ @deepseek-ai/dsh-agent-loop (the ONE concrete plugin) │ │ @deepseek-ai/dsh-bash-local (bash impl) │ │ @deepseek-ai/dsh-tool-bash (bash tool schemas) │ +│ @deepseek-ai/dsh-fs-local (filesystem impl) │ +│ @deepseek-ai/dsh-tool-fs (filesystem tool schemas) │ │ @deepseek-ai/dsh-session-persistence-jsonl (persistence impl)│ ├─────────────────────────────────────────────────────────────┤ │ @deepseek-ai/dsh-agent (vocabulary + registry) │ @@ -33,6 +35,7 @@ For a catalog of the **data structures** this architecture moves around — the │ @deepseek-ai/dsh-session-persistence (persistence seam) │ │ @deepseek-ai/dsh-llm (abstract model service) │ │ @deepseek-ai/dsh-bash (abstract bash executor) │ +│ @deepseek-ai/dsh-fs (abstract filesystem) │ ├─────────────────────────────────────────────────────────────┤ │ vendor/: cordis, loader, include, group, timer, hmr, │ │ logger-console, cosmokit, schemastery │ @@ -53,6 +56,7 @@ Dependency rule: **extension** plugins depend on interface packages, never on `d | `ctx.agents` | `AgentRegistry` | dsh-agent | live `Agent` handles + the create/resume factory seam (returns an `AgentHandle` = `{ agent, dispose() }` for owned per-agent teardown) | | `ctx.agentLoop` | `AgentLoop` | dsh-agent-loop | creates `ReactLoopAgent`s and drives their loops | | `ctx.bash` | `BashExecutor` (abstract) | dsh-bash | bash execution seam: foreground runs + background tasks | +| `ctx.fs` | `FileSystem` (abstract) | dsh-fs | filesystem seam: path resolution, text reads, writes, edits, and observed-file policy | All registrations (`registerAdapter`, `section`, `tools`, `register`, …) go through `ctx.effect()` and return disposers, so plugin hot-reload (vendored HMR) and fiber disposal clean up automatically. @@ -68,6 +72,8 @@ Swappable capabilities are split into **three packages** so each part evolves in The LLM seam has the same topology folded differently: `dsh-llm` carries the interface (`LlmAdapter`) AND the consumer surface (`ctx.llm.stream()`), with adapters as implementation packages — there the consumer is the loop itself, not a swappable schema surface. Use the full three-package split when the consumer is independently replaceable; keep interface + consumer together when they are one concern. Don't split preemptively: a capability with one conceivable implementation and one consumer stays one package until proven otherwise. +The filesystem capability follows the bash topology: `dsh-fs` owns the abstract `ctx.fs` service and observed-file policy, `dsh-fs-local` provides the local backend, and `dsh-tool-fs` exposes the model-facing `read`/`write`/`edit` schemas over the interface. + > **"Capability" — two unrelated meanings.** (1) The *seam pattern* above ("one plugin provides a capability, another needs it") is realized by plain Cordis **services + `inject`**: a provider registers a service (`ctx.bash`, declared in `interface Context`); a consumer declares `inject: ['bash']` and its fiber stays pending until the service exists, tearing down via HMR if it later vanishes. No extra library is needed. (2) `@cordisjs/plugin-capability` is a different axis entirely — a **permission/capability-security** service (named permissions with inheritance/dependency, tested against a session via `ctx.capability.test`). It is a candidate for the deferred permissions/sandbox work (the `tools/execute` veto seam), NOT a mechanism for swapping implementations. ## The vocabulary (dsh-llm) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 083d3295f6..a64e962098 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -279,7 +279,7 @@ Source: [`packages/core/tools/src/index.ts:43`](../../packages/core/tools/src/in ## Services -The 8 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. +The 9 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. ### `ctx.agentLoop` — `AgentLoop` @@ -339,6 +339,32 @@ Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../c Source: [`packages/bash/bash/src/index.ts:59`](../../packages/bash/bash/src/index.ts) +### `ctx.fs` — `FileSystem` (abstract seam) + +Abstract filesystem service. Subclass, implement the four backend primitives (resolve, readPage, createOrReplace, applyEdit), 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). + +Consumers call the concrete public API (read/write/ edit), which derives the file-state owner, enforces the read-before-write/edit policy, and refreshes recorded state — then delegates the actual I/O to the backend primitives. + +Semantics every backend must honor: + +- resolve returns a stable FsTarget; the same underlying file reached by different input paths must yield the same `targetKey` so stale guards and file-state lookup agree across paths (e.g. through symlinks). +- readPage returns line-numbered UTF-8 content with a `version` and a `view` (`full` only when the page covered the whole file). +- createOrReplace honors the FsExpectation: `observed` rejects with `FS_STALE_VERSION` if the file changed since `version`; `partial` rejects existing targets because the owner saw only a non-editable view; `unobserved` creates iff the target is absent and otherwise rejects. +- applyEdit verifies the expected version (stale guard) and is atomic (read-modify-write must not interleave with a concurrent edit). + +```ts cordis-catalog +abstract resolve(path: string): Promise +abstract readPage(target: FsTarget, request: FsReadRequest, signal?: AbortSignal): Promise +abstract createOrReplace(target: FsTarget, content: string, expected: FsExpectation, signal?: AbortSignal): Promise +abstract applyEdit(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise +owner(exec?: FsExecContext): object | undefined +async read(target: FsTarget, request: FsReadRequest, exec?: FsExecContext, signal?: AbortSignal): Promise +async write(target: FsTarget, content: string, exec?: FsExecContext, signal?: AbortSignal): Promise +async edit(target: FsTarget, edit: FsEditRequest, exec?: FsExecContext, signal?: AbortSignal): Promise +``` + +Source: [`packages/fs/fs/src/index.ts:94`](../../packages/fs/fs/src/index.ts) + ### `ctx.llm` — `LlmService` The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall. diff --git a/docs/module-graph.md b/docs/module-graph.md index 9efe6e9669..5a69585ef8 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -10,6 +10,7 @@ graph TD bash --> brand llm --> brand bash-local --> bash + fs --> llm llm-deepseek --> llm llm-pi-ai --> llm session --> brand @@ -18,6 +19,7 @@ graph TD agent --> brand agent --> llm agent --> session + fs-local --> fs llm-replay --> llm llm-replay --> session session-persistence --> session @@ -49,6 +51,10 @@ graph TD tool-bash --> bash tool-bash --> llm tool-bash --> tools + tool-fs --> fs + tool-fs --> llm + tool-fs --> system-prompt + tool-fs --> tools agent-core --> agent agent-core --> agent-loop agent-core --> invariants @@ -73,11 +79,13 @@ graph TD | `bash` | `brand` | | `llm` | `brand` | | `bash-local` | `bash` | +| `fs` | `llm` | | `llm-deepseek` | `llm` | | `llm-pi-ai` | `llm` | | `session` | `brand`, `llm` | | `system-prompt` | `llm` | | `agent` | `brand`, `llm`, `session` | +| `fs-local` | `fs` | | `llm-replay` | `llm`, `session` | | `session-persistence` | `session` | | `invariants` | `agent`, `llm`, `session` | @@ -88,6 +96,7 @@ graph TD | `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` | | `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` | | `tool-bash` | `agent`, `bash`, `llm`, `tools` | +| `tool-fs` | `fs`, `llm`, `system-prompt`, `tools` | | `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` | | `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` | | `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `ui-stdio` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 4eb7900276..7f56757be0 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -81,6 +81,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | Title | First proposed | |---|---| +| [Filesystem tool schemas — model-facing read/write/edit shapes](implemented/feature/2026-06-17-filesystem-tool-schemas.md) | 2026-06-17 | | [Rich ACP bash rendering — the terminal card (`_meta`) and command classification](implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 | ### Simplification @@ -110,6 +111,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Two LLM adapters as a design-verification twin](implemented/architecture/2026-06-13-twin-llm-adapters.md) | 2026-06-13 | | [Session persistence as an abstract service over `SessionEvent`](implemented/architecture/2026-06-14-session-persistence.md) | 2026-06-14 | | [Every session event is enclosed in a turn](implemented/architecture/2026-06-15-turn-enclosure-invariant.md) | 2026-06-15 | +| [Filesystem capability seam — ctx.fs, local backend, and model-facing filesystem tools](implemented/architecture/2026-06-17-filesystem-capability-seam.md) | 2026-06-17 | | [Shared persistence write coordinator](implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 | | [Agent lifecycle and ownership seams](implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 | | [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md new file mode 100644 index 0000000000..55f5efe14b --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -0,0 +1,182 @@ +# RFC: Filesystem capability seam — ctx.fs, local backend, and model-facing filesystem tools + +Status: implemented + +## Problem + +The harness has a concrete `bash` capability seam (`dsh-bash` / `dsh-bash-local` / `dsh-tool-bash`), but filesystem operations are about to be added as model-facing tools without an equivalent seam. If `read`, `write`, and `edit` directly use `node:fs`, the model-facing tool package will own filesystem execution policy, local path resolution, atomic write behavior, text decoding, symlink behavior, and edit semantics all at once. + +That couples three concerns that change independently: + +1. The filesystem contract: what operations plugins can ask for. +2. The backend: local disk now, sandboxed/remote/project-scoped filesystem later. +3. The consumer surface: model-facing `read` / `write` / `edit` schemas and result formatting. + +Without a `ctx.fs` interface, swapping local filesystem access for a sandboxed or remote backend would churn the tool schemas, demos, and prompt guidance even when the model-facing contract should stay stable. It also makes permission/sandbox boundaries harder to reason about: a `cwd` option can look like a sandbox even though it is only a base path unless an explicit backend or `tools/execute` policy enforces containment. + +We need the filesystem tools to land in the same capability-seam shape as bash before they become a public package surface. + +## Proposal + +Introduce filesystem access as a first-class capability seam following [the capability-seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md): + +1. `@deepseek-ai/dsh-fs` (`packages/fs/fs`) owns the abstract `ctx.fs` service, filesystem vocabulary types, and file-state tracking contract. +2. `@deepseek-ai/dsh-fs-local` (`packages/fs/fs-local`) provides the first implementation, backed by the local filesystem. +3. `@deepseek-ai/dsh-tool-fs` (`packages/fs/tool-fs`) provides the model-facing `read`, `write`, and `edit` tools over `ctx.fs`. + +The consumer package depends only on the interface package, never on `dsh-fs-local`. A deployment that wants a different backend loads a different provider for `ctx.fs` without changing the tool schemas or model-facing prompt guidance. + +The first backend is deliberately local-only: `dsh-fs-local` implements `ctx.fs` against the host filesystem. Future sibling backends can provide sandboxed, remote, virtual, or project-scoped filesystems behind the same interface. + +The first consumer is deliberately text-file-only: `dsh-tool-fs` exposes model-facing `read`, `write`, and `edit` tools for UTF-8 text files. Future consumers can add directory listing, search/glob, binary-safe operations, file watching, or higher-level project operations without changing the local backend package, as long as the needed capability exists on `ctx.fs`. + +Filesystem permissions and sandboxing are not implied by this split. The local backend resolves relative paths from its configured base directory, but containment policy is a separate decision: either a stricter `ctx.fs` implementation enforces it, or a permission/sandbox plugin wraps `tools/execute` and vetoes calls before they reach the consumer. + +Read-before-write/edit is part of the filesystem seam, not a separate service. `ctx.fs` records which file states the current execution context has seen and validates write-like operations against that state. The first `tool-fs` consumer passes the current tool execution context, or a structural projection of it, through to `ctx.fs`; `ctx.fs` derives the file-state owner from that context, normally `exec.agent.session`. `tool-fs` does not know the cache shape, the owner key, or the `read` tool name/schema. + +## Package topology + +The filesystem seam uses the same dependency direction as the bash trio: + +```text +@deepseek-ai/dsh-tool-fs --depends on--> @deepseek-ai/dsh-fs <--depends on-- @deepseek-ai/dsh-fs-local + consumer interface implementation +``` + +`@deepseek-ai/dsh-fs` depends only on `cordis` plus the repo-wide `HarnessError` base from `@deepseek-ai/dsh-llm`. It declares the `ctx.fs` key, the abstract `FileSystem` service, the vocabulary types shared by backends and consumers, the filesystem error vocabulary, and the file-state contract. The interface defines a minimal structural execution context shape rather than importing `dsh-tools`, `dsh-agent`, or `dsh-session`; the implementation derives a file-state owner from that shape when one is available. The owner object is opaque to `dsh-fs`: `tool-fs` may pass the `ToolExecution` it already receives, or a projected object containing only the owner-bearing fields, without making `dsh-fs` depend on the tool or agent packages. + +`@deepseek-ai/dsh-fs-local` depends on `@deepseek-ai/dsh-fs` and `cordis`. It subclasses `FileSystem`, registers itself as `ctx.fs`, owns local-backend configuration such as the base directory, contains all direct `node:fs` / `node:path` access, and provides the in-memory file-state store for the local backend. + +`@deepseek-ai/dsh-tool-fs` depends on `@deepseek-ai/dsh-fs`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and `cordis`. It registers model-facing tools and prompt sections. It must not import `node:fs`, `node:path`, or `@deepseek-ai/dsh-fs-local`; filesystem execution always goes through `ctx.fs`. If the implementation needs concrete agent or session helper types, those dependencies belong in `tool-fs`; they must not leak back into `dsh-fs`. + +The root `tool-fs` plugin registers the full filesystem tool suite by composing the per-tool registration helpers (`read`, `write`, and `edit`). The same helpers are exposed as subpath plugins such as `@deepseek-ai/dsh-tool-fs/read`, `@deepseek-ai/dsh-tool-fs/write`, and `@deepseek-ai/dsh-tool-fs/edit` for focused deployments. Root and subpath plugins follow the same rule: they inject `fs` and never import an implementation package. + +## `ctx.fs` contract + +`@deepseek-ai/dsh-fs` owns a semantic filesystem service. It is higher-level than `readFile` / `writeFile` so `tool-fs` does not reimplement path resolution, versioning, text decoding, binary rejection, pagination, atomic replacement, symlink behavior, or literal edit semantics. + +The exact TypeScript signatures are implementation details for the PR, but the interface must cover four semantic operations: + +- Resolve a model/plugin-supplied path into a backend-defined target. +- Read a bounded UTF-8 text page from a target. +- Create or replace a UTF-8 text file. +- Edit an existing UTF-8 text file by literal replacement. + +The interface must also cover file state: + +- Derive a file-state owner from the current execution context, normally the active agent session. +- Record that the owner saw a target at a backend-defined version. +- Determine whether that owner has a full editable view of a target. +- Use the recorded version as the stale guard for write/edit operations that require prior observation. +- Refresh the recorded state after a successful write/edit so follow-up modifications can proceed without forcing another read. + +The in-memory shape is conceptually a weakly-owned cache: file state is keyed first by the derived owner object, then by the backend `targetKey`. The owner is usually `exec.agent.session`, but `dsh-fs` treats it as opaque and does not import `dsh-session`. Each cached `FileState` records the `targetKey`, `displayPath`, backend `version`, current view (`full` or `partial`), update time, and source (`read`, `write`, `edit`, or a future seed path). Only a `full` view authorizes write/edit. A `partial` view records useful context (paged read, truncated read, injected context) but does not grant edit authority. + +Path resolution should be explicit and allowed to be async. Local resolution may only normalize a path, but sandboxed/remote/project-scoped backends may need I/O to resolve a user-supplied path into a stable target identity. + +Resolved targets must expose at least three concepts: + +- The original input path, for diagnostics. +- An opaque `targetKey`, used for stale guards and file-state lookup. The local backend might use a realpath-like key; a remote backend might use a workspace URI or file id. Consumers must not parse or assume this is a local absolute path. +- A `displayPath`, used for model/UI-facing output. It may be a local absolute path, workspace-relative path, or remote URI depending on the backend. + +Read and mutation results must include an opaque file `version`. A local backend can use mtime/size or a hash-like token; a remote backend can use a revision id. `ctx.fs` records versions in its file-state store for stale checks; consumers may display related metadata but must not interpret the version token. + +Text reads return structured UTF-8 line records or ranges with pagination metadata. `tool-fs` owns line-numbered model text rendering; the backend owns bounded line length, bounded output bytes, binary-file rejection, total-line accounting, and whether the returned content is a partial view of the file. + +When a read has a file-state owner, `ctx.fs` records the target, version, display path, view metadata, timestamp, and source. Partial views are useful context but do not authorize write/edit unless a future operation can prove the model saw the raw editable content. + +Full-file writes create or replace UTF-8 text files. Backends may create parent directories when that behavior is supported and documented. Existing non-regular targets are rejected. For updates to existing files, `ctx.fs` should require a full prior file state for the current owner and reject absent or partial state. The backend then compares the current file version to the recorded version and rejects stale writes. If the recorded target no longer exists, the write is stale rather than a create. A create is expressed as a write to a target with no existing file and does not require prior state or a file-state owner. + +Literal edit is part of `ctx.fs`, not composed in `tool-fs` from a read plus write. Literal matching, duplicate-match rejection, CRLF preservation, binary rejection, prior-file-state checking, stale-version checking, and atomic read-modify-write are filesystem/backend semantics. A remote backend may implement edit as a native compare-and-edit operation; the consumer should not force local-style composition. + +Direct tool executions without a derivable file-state owner can still exercise lower-level helpers in tests. Production `write`/`edit` tool calls should reject without an owner when they update an existing target, because those operations require prior state. Owner-less `write` may still create a new file when the backend confirms that the target does not already exist. + +Filesystem contract failures are thrown as `FsError extends HarnessError` in the first implementation, and the tool registry converts them into `isError` tool results with structured `{ name, code }` metadata. `dsh-fs` owns this vocabulary rather than each tool inventing messages. Initial codes should include `FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_PARTIAL_OBSERVATION`, `FS_NOT_REGULAR_FILE`, `FS_AMBIGUOUS_EDIT`, and `FS_EDIT_NOT_FOUND`. + +## Tool consumer behavior + +`@deepseek-ai/dsh-tool-fs` is the model-facing consumer. It owns tool names, JSON schemas, argument validation at the model boundary, prompt sections, and result formatting. It does not own filesystem execution. + +The first tool suite contains: + +- `read`: inspect a UTF-8 text file and return line-numbered content with pagination guidance. +- `write`: create or fully replace a UTF-8 text file. +- `edit`: update an existing UTF-8 text file by replacing literal text, requiring a unique match by default and allowing an explicit replace-all mode. + +Each tool follows the same execution shape: + +1. Validate and normalize model arguments. +2. Call the appropriate `ctx.fs` operation. +3. Format the result as `ContentBlock[]` for the model. +4. Let thrown backend/tool errors flow through `ToolRegistry.execute()`, which converts them into `isError` tool results. + +The package registers prompt guidance through `ctx.systemPrompt.section(...)` and registers schemas through `ctx.tools.register(...)`. Tool schemas still flow into the normal prompt assembly path via `SystemPrompt.assemble()` and `ToolRegistry.schemas()`; no agent-loop changes are required. + +The tool package must keep model-facing contracts stable when backends change. A local backend and a remote backend may resolve paths differently internally, but the `read` / `write` / `edit` schemas should not change solely because the backend changes. + +The first implementation requires a prior full `read` before updating an existing file with `write` or `edit`. `tool-fs` does not implement this by checking whether a tool named `read` ran or by reading the file-state cache. It passes the current execution context to `ctx.fs`, and `ctx.fs` derives the file-state owner and enforces file-state/stale-version policy. Creating a new file with `write` does not require prior state or an owner. + +The root plugin registers the full suite by composing the per-tool registration helpers. The subpath plugins register one tool each for focused deployments and tests. Both forms inject `fs`, `tools`, and `systemPrompt`. + +## Migration plan + +This RFC starts from `origin/master`, where no filesystem tool package exists yet. The final implementation should add the new three-package topology directly: + +1. Add `packages/fs/fs` with the `ctx.fs` abstract service and vocabulary types. +2. Add `packages/fs/fs-local` with the local backend implementation and backend-level tests. +3. Add `packages/fs/tool-fs` with the model-facing `read`, `write`, and `edit` tools over `ctx.fs`. +4. Wire examples by loading a `ctx.fs` provider first (`dsh-fs-local`), then the consumer (`dsh-tool-fs` or one of its subpath plugins). +5. Update `docs/architecture.md`, `packages/README.md`, package READMEs, build/typecheck config, and aggregate maintenance scripts such as `scripts/publint-all.ts`. + +This first pass does not add a separate `@deepseek-ai/dsh-file-context` package. The file-state store lives behind `ctx.fs` so root and subpath `tool-fs` plugins share the same read-before-write/edit policy automatically. + +If this work is split into multiple PRs, they should follow the seam order: + +1. Interface PR: `dsh-fs` only, with service registration and contract tests. +2. Implementation PR: `dsh-fs-local`, with real filesystem behavior tests. +3. Consumer PR: `dsh-tool-fs`, examples, docs, and integration tests. + +The earlier combined package name `@deepseek-ai/dsh-fs-tools` should not become part of the new public surface. + +## Tests + +Tests should follow the package boundary, not only the user-visible tools. + +`dsh-fs` tests cover the service seam itself: a provider registers `ctx.fs`, duplicate providers follow Cordis service behavior, disposal removes the service, and any shared contract helpers or type-level utilities behave as documented. + +`dsh-fs-local` tests cover real filesystem behavior through the `ctx.fs` interface, not through model tools. They should include path resolution, absolute paths, `..` segments, symlinks inside and outside the configured base directory, reading small and large text files, pagination, output caps, binary-file rejection, abort handling, full-file create/update writes, owner-less creates, owner-less update rejection, parent-directory creation, non-regular target rejection, literal edit success/failure, unique-match enforcement, replace-all behavior, line-ending preservation, file-state recording after reads, session/owner isolation, read-before-update rejection, stale-version rejection, partial-view rejection, structured `FsError` codes, and file-state refresh after successful writes/edits. + +Beyond the happy/sad paths above, `dsh-fs-local` tests must cover the defensive-pattern classes this repo has been bitten by: + +- **Atomic-write temp-file safety**, not just cleanup. The atomic replace must write its temp file into a private (`0700`) directory, with a random name and an exclusive owner-only (`'wx'`, `0o600`) open, mirroring the bash spill-file rules — predictable world-readable temp paths invite symlink races and disclosure. Assert the temp file's permissions and that a pre-existing temp path does not get clobbered, alongside the existing cleanup-on-failure path. +- **Implementation requirement:** `dsh-fs-local` write/edit use the same private-temp primitive: a random `0700` staging directory next to the target, an exclusive `0o600` temp file, cleanup on failure, and a final atomic rename. Do not move this RFC to `implemented/` if that primitive regresses or is deliberately revised. +- **`targetKey` identity through symlinks.** Two different input paths that resolve to the same realpath must share one file-state entry: a `read` via path A must satisfy the read-before-edit guard for an `edit` via symlink path B, and a stale write through one path must be detected through the other. This is the contract that makes the stale guard correct, so test it directly. +- **Concurrency / stale races.** The RFC names edit as race-prone (see Risks). Test that two concurrent write/edit operations against the same target settle deterministically: one succeeds and the other is rejected with `FS_STALE_VERSION` rather than silently overwriting, and that a successful edit refreshes recorded state so an immediately-following edit by the same owner proceeds. +- **HMR safety and disposal.** `dsh-fs-local` registers `ctx.fs` and owns the in-memory file-state store, so it needs its own HMR-safety test (register the backend on a fiber, dispose it, assert the `ctx.fs` provider is withdrawn and the file-state store is released — a later provider starts with no inherited state). + +`dsh-tool-fs` tests cover the consumer surface with a fake `ctx.fs` implementation. They should verify tool schemas, argument validation, prompt-section registration, formatting of successful results, propagation of backend `FsError` codes into `isError` tool results through `ctx.tools.execute()`, that read/write/edit pass the current execution context or structural projection through to `ctx.fs`, root-plugin suite registration, subpath plugin registration, and HMR cleanup. + +Integration tests should load `dsh-fs-local` plus `dsh-tool-fs` and execute `read`, `write`, and `edit` through `ctx.tools.execute()` to prove the three packages work together without bypassing the tool registry. They must verify the world, not the tool's self-report: after a `write`/`edit`, read the file back from disk and assert byte-identical content (and that untouched files are unchanged), rather than trusting the returned `ContentBlock[]`. Each integration/e2e test owns its resources — create the harness in the test, run against a per-test temporary directory, and dispose the harness and remove the directory in `afterEach` even on failure or timeout. + +Repo gates for the implementation include the focused vitest suites, `yarn typecheck`, `yarn test:coverage` for runtime code, and build/publint coverage after adding package entrypoints. + +## Risks + +**`cwd` can be mistaken for a sandbox.** The local backend's base directory is a resolution default, not automatically a containment boundary. If containment is required, it must be enforced by the backend contract or by a permission/sandbox plugin on `tools/execute`. + +**The interface can become too local.** Returning fields such as `absolutePath` from `ctx.fs` would make remote, sandboxed, or virtual backends awkward. The contract should expose display metadata without requiring consumers to understand host paths. + +**The interface can become too thin.** If `ctx.fs` only mirrors `node:fs` primitives, `tool-fs` will reimplement binary detection, pagination, atomic writes, and edit semantics. That recreates the coupling this RFC is trying to avoid. + +**Edit semantics are race-prone.** Literal edit is a read-modify-write operation. Without a stale-content guard or backend-level atomic edit primitive, concurrent edits can overwrite each other. The first implementation should document its guarantees clearly; stronger compare-and-swap semantics can be added later if needed. + +**File state inside `ctx.fs` can blur concerns.** Recording what an execution context has seen is workflow state, not raw filesystem I/O. This RFC still keeps it inside the filesystem seam because write/edit safety depends on backend-defined target identity and version tokens, and because putting it in `tool-fs` would couple write/edit to the read tool implementation. The boundary is narrow: `ctx.fs` derives the file-state owner, records file state, and checks stale versions, while `tool-fs` owns only model-facing schemas and formatting. + +**The `resolve`-then-operate shape costs an extra round-trip per call.** Each tool may resolve a path to an `FsTarget` and then issue the read/write/edit as a separate `ctx.fs` call. For the local backend this is negligible (resolution is in-memory path normalization), but a remote/sandboxed backend may turn each step into its own request, so a single `read` can become two network round-trips. Backends where the round-trip matters can cache or fold resolution internally while preserving the observable contract. + +**File-state persistence is deferred.** The first implementation can keep file state in memory. Resumed sessions should conservatively require files to be read again before write/edit tools accept updates until a future session-event or persistence mechanism makes file state replayable. + +**Error codes become part of the seam.** `FsError` codes make stale-version and observation failures machine-routable through the existing structured error taxonomy. The cost is that `dsh-fs` imports the shared `HarnessError` base from `dsh-llm`; that dependency is intentional and should stay limited to the error vocabulary. + +**Package churn is front-loaded.** The three-package split adds boilerplate before there is more than one backend. This is intentional: filesystem access is a likely sandbox/remote boundary, and changing the package surface after shipping model-facing tools would be more expensive. diff --git a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md new file mode 100644 index 0000000000..45928e9871 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md @@ -0,0 +1,113 @@ +# RFC: Filesystem tool schemas — model-facing read/write/edit shapes + +Status: implemented + +## Problem + +[The filesystem capability-seam RFC](../architecture/2026-06-17-filesystem-capability-seam.md) defines the filesystem capability seam (`ctx.fs`), the three-package split (`dsh-fs`, `dsh-fs-local`, `dsh-tool-fs`), and the observed-file/stale-version policy for read-before-write/edit checks. The remaining decision for the first filesystem tool delivery is the model-facing schema surface: what arguments the model sees for `read`, `write`, and `edit`. + +The schema should be small enough to implement in the first `dsh-tool-fs` pass, but stable enough that future local/remote/sandboxed filesystem backends do not require model-facing churn. It should also avoid importing every option from reference systems. Claude Code and OpenCode expose similar core file tools but differ in naming style and extra flags; this RFC chooses the minimal shared surface for the prototype. + +## Proposal + +`@deepseek-ai/dsh-tool-fs` exposes these three model-facing tools in the first filesystem suite: + +| Tool | Our schema | Claude Code | OpenCode | Notes | Part of prototype | +|---|---|---|---|---|---| +| `read` | `read(file_path, offset?, limit?)` | `Read(file_path, offset?, limit?, pages?)` | `read(filePath, offset?, limit?)` | Files only; 1-indexed `offset`; no image/PDF/multimodal support in the first pass. | YES | +| `write` | `write(file_path, content)` | `Write(file_path, content)` | `write(content, filePath)` | Creates or overwrites UTF-8 text. Updates to existing files require prior observation through `ctx.fs`; new-file creates do not. | YES | +| `edit` | `edit(file_path, old_string, new_string, replace_all?)` | `Edit(file_path, old_string, new_string, replace_all?)` | `edit(filePath, oldString, newString, replaceAll?)` | Literal string replacement; unique match required by default; requires prior full observation through `ctx.fs`. | YES | + +The schema uses snake_case field names (`file_path`, `old_string`, `new_string`, `replace_all`) to align with Claude Code and with existing DeepSeek Harness tool-schema examples. The consumer package translates these model-facing names into internal `ctx.fs` requests. + +## Tool schemas + +### `read` + +`read` inspects a UTF-8 text file and returns line-numbered content. + +Arguments: + +- `file_path: string` — required. Path to read, resolved by `ctx.fs`. +- `offset?: number` — optional. 1-based first line to return. Defaults to the first line. +- `limit?: number` — optional. Maximum number of lines to return. Defaults and caps are implementation details of `dsh-tool-fs` / `ctx.fs`. + +Non-goals for the first pass: + +- No PDF `pages` argument. +- No image or multimodal file reads. +- No directory listing through `read`; if needed, listing becomes a separate future tool. + +### `write` + +`write` creates or fully replaces a UTF-8 text file. + +Arguments: + +- `file_path: string` — required. Path to write, resolved by `ctx.fs`. +- `content: string` — required. Full UTF-8 text content to write. + +For existing files, `write` requires prior full file state derived from a previous read in the same execution context. `ctx.fs` derives the file-state owner and uses the recorded version as the stale guard. Creating a new file does not require prior state or an owner. + +The schema does not expose `expected_hash`, `expected_version`, or `create_only` as model-facing parameters. Stale-version checks are driven by `ctx.fs` file state and backend-produced versions, not by asking the model to copy version tokens through the schema. + +### `edit` + +`edit` updates an existing UTF-8 text file by replacing literal text. + +Arguments: + +- `file_path: string` — required. Path to edit, resolved by `ctx.fs`. +- `old_string: string` — required. Literal text to replace. Empty strings are invalid in the first pass. +- `new_string: string` — required. Literal replacement text; an empty string deletes the match. +- `replace_all?: boolean` — optional. Defaults to false. When false, `old_string` must identify exactly one match. + +`edit` requires prior full file state derived from a previous read in the same execution context. `ctx.fs` derives the file-state owner and uses the recorded version as the stale guard. + +The first pass rejects Codex-style patch grammars and multi-mode edit APIs. It uses one strict literal replacement mode so the model-facing contract stays simple and the backend can own exact-match, duplicate-match, line-ending, and stale-version semantics. + +## Result shape + +The first implementation returns `ContentBlock[]` through the existing `ToolDefinition.execute()` contract. `ctx.fs` returns structured filesystem results and owns file-state recording/refreshing; `tool-fs` formats those results into the model projection. + +Default native projections: + +| Tool | Structured `ctx.fs` outcome consumed by `tool-fs` | Default model projection | +|---|---|---| +| `read` | returned lines, returned line count, total line count, target display path, file version, partial-view flag | line-numbered text plus pagination footer | +| `write` | create/update operation, target display path, new file version | concise create/update success text | +| `edit` | replacement count, replace-all flag, target display path, new file version | concise edit success text | + +The structured outcome should not restate model arguments such as `file_path`, `old_string`, or `content` unless the backend has resolved them into new information such as `displayPath`, `targetKey`, or a new version. Token-conscious truncation is part of the model projection, not the backend's canonical result. + +## Deferred + +The following are deliberately out of scope for the first filesystem schema pass: + +- Model-facing `expected_hash`, `expected_version`, or `create_only` parameters. +- Directory listing, glob, grep, and search tools. +- Binary-safe read/write operations. +- PDF/image/multimodal `read`. +- Code Mode projection values for filesystem tools. +- A canonical edit diff format. + +## Tests + +`dsh-tool-fs` schema tests should assert: + +- `read` requires `file_path` and accepts optional positive integer `offset` / `limit`. +- `write` requires `file_path` and `content`. +- `edit` requires `file_path`, `old_string`, and `new_string`, accepts optional boolean `replace_all`, rejects empty `old_string`, and defaults `replace_all` to false. +- The registered JSON schemas use the snake_case field names in this RFC. +- The tool descriptions accurately describe that existing-file `write` and `edit` require a prior full read in the same execution context, while new-file `write` does not. +- The root plugin and subpath plugins register the same schemas. + +Integration tests should execute `read`, `write`, and `edit` through `ctx.tools.execute()` with a fake or local `ctx.fs` provider and verify that model arguments are translated into the expected `ctx.fs` calls. + +## Risks + +**The first schema is intentionally smaller than Claude Code's.** Dropping PDF pages, multimodal read, rich grep/list flags, and expected hash fields keeps the first implementation focused, but users may ask for those quickly. They should be added as separate RFCs or focused follow-ups rather than overloaded into the initial schema. + +**No explicit model-facing stale guard in v1.** The schema does not ask the model to provide an expected hash/version. That is intentional: stale checks come from backend-produced versions and `ctx.fs` observed-file state, not from fragile model-copied tokens. Filesystem safety failures surface through structured `FsError` codes owned by `dsh-fs`, not through model-supplied version fields. + +**Naming becomes public surface.** Once shipped, changing `file_path` to `filePath` or `old_string` to `oldString` would churn prompts, examples, and downstream clients. This RFC chooses snake_case up front and treats it as the stable model-facing contract. diff --git a/packages/README.md b/packages/README.md index f39d7b70a1..dd418f97ca 100644 --- a/packages/README.md +++ b/packages/README.md @@ -11,6 +11,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`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 | | [`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 | @@ -30,6 +31,9 @@ dsh-agent ← dsh-llm, dsh-session, dsh-brand dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent dsh-bash-local ← dsh-bash (BashExecutor impl) dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas) +dsh-fs ← dsh-llm (abstract filesystem seam) +dsh-fs-local ← dsh-fs (FileSystem impl) +dsh-tool-fs ← dsh-fs, dsh-tools (file 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-system-prompt, dsh-tools, dsh-agent @@ -58,6 +62,9 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop | `bash/` | `bash` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` | | `bash-local/` | `bash` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) | | `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) | +| `fs/` | `fs` | Abstract filesystem seam (interface + vocabulary + observed-file policy) | `ctx.fs` | +| `fs-local/` | `fs` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | +| `tool-fs/` | `fs` | Model-facing `read`/`write`/`edit` tool schemas | (registers on `ctx.tools`) | | `llm-deepseek/` | `llm` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | | `llm-pi-ai/` | `llm` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) | | `session-persistence/` | `session-persistence` | Persistence seam + write coordinator | `ctx.sessionPersistence` | diff --git a/packages/fs/README.md b/packages/fs/README.md new file mode 100644 index 0000000000..15757698b2 --- /dev/null +++ b/packages/fs/README.md @@ -0,0 +1,11 @@ +# fs/ - filesystem capability family + +The filesystem capability seam: an abstract filesystem interface, a local implementation, and the model-facing file tools. All **product** packages. + +| Package | Role | ctx key | +|---|---|---| +| `fs/` | Abstract filesystem seam (interface + vocabulary + observed-file policy) | `ctx.fs` | +| `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | +| `tool-fs/` | Model-facing `read`/`write`/`edit` tool schemas | (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 interface or model-facing tool schemas. diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md new file mode 100644 index 0000000000..52dfa2e38b --- /dev/null +++ b/packages/fs/fs-local/README.md @@ -0,0 +1,23 @@ +# @deepseek-ai/dsh-fs-local + +The **local-filesystem implementation** of the `ctx.fs` seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the four `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-tool-fs to expose read/write/edit to the model. +``` + +## Behavior + +- **`resolve(path)`** — relative paths resolve from `config.cwd` (default `process.cwd()`). 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 keeps its absolute path as the key so creates still get a stable identity. `displayPath` is the absolute (un-resolved) path. +- **`readPage`** — UTF-8 only. A fast path (`readFile`) handles files under `FAST_PATH_MAX_SIZE` (10 MB); larger files stream with a capped line buffer so a newline-free giant file can't exhaust memory. NUL-byte samples are rejected (`FS_NOT_TEXT`). Output is bounded to `READ_LIMIT` (2000) lines, `READ_MAX_BYTES` (50 KB), and `READ_MAX_LINE_LENGTH` (2000) chars per line. The `version` is `mtimeMs:size`. +- **`createOrReplace`** — 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`. Honors the `FsExpectation`: an `observed` write must match the recorded version (else `FS_STALE_VERSION`); a `partial` write onto an existing file is rejected (`FS_PARTIAL_OBSERVATION`); an `unobserved` write onto an existing file is rejected (`FS_NOT_OBSERVED`). +- **`applyEdit`** — atomic literal read-modify-write over the same primitive. Verifies the expected version, 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 Risks section](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md#risks). + +The raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring. diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json new file mode 100644 index 0000000000..8c5591398e --- /dev/null +++ b/packages/fs/fs-local/package.json @@ -0,0 +1,34 @@ +{ + "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/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "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" + } +} diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts new file mode 100644 index 0000000000..8c94abee24 --- /dev/null +++ b/packages/fs/fs-local/src/fsio.ts @@ -0,0 +1,470 @@ +/** + * 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 read/write/edit mechanics can be unit-tested without a Context. + * + * The reader uses two code paths so a single huge line can never balloon + * memory: a **fast path** (`readFile` + in-memory split) for files under + * {@link FAST_PATH_MAX_SIZE}, and a **streaming path** (manual newline scan + * with a capped line buffer) for larger files. Both reject NUL-byte binary + * samples and keep only the requested page in memory. + * + * 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, rename, rm, stat } from 'node:fs/promises' +import type { Stats } from 'node:fs' +import { basename, dirname, join, resolve } from 'node:path' +import { FsError } from '@deepseek-ai/dsh-fs' +import type { FsReadRequest, FsTextLine, FsView } from '@deepseek-ai/dsh-fs' + +/** Default and maximum number of lines returned by one read. */ +export const READ_LIMIT = 2000 + +/** Maximum characters returned for a single line. */ +export const READ_MAX_LINE_LENGTH = 2000 + +/** Maximum bytes returned for selected file lines. */ +export const READ_MAX_BYTES = 50 * 1024 + +/** Files smaller than this use the in-memory fast path; larger files stream. */ +export const FAST_PATH_MAX_SIZE = 10 * 1024 * 1024 + +const READ_MAX_BYTES_LABEL = `${READ_MAX_BYTES / 1024} KB` +const READ_MAX_LINE_SUFFIX = `... (line truncated to ${READ_MAX_LINE_LENGTH} chars)` +const BINARY_SAMPLE_BYTES = 8192 +const NUL_CHAR = String.fromCharCode(0) +const LINE_BUFFER_CAP = READ_MAX_LINE_LENGTH + 1 + +/** + * Test seam: lets specs force the streaming path (via a small + * `fastPathMaxSize`) and pin the temp-file name (to prove exclusive-open + * behavior) without a 10 MB fixture or a name race. + */ +export interface FsIoInternals { + /** Override {@link FAST_PATH_MAX_SIZE} for routing. */ + fastPathMaxSize?: number + /** Override the generated private staging-dir name (relative to the target dir). */ + tempDirName?: (writePath: string) => string + /** Override the generated temp-file name (relative to the private staging dir). */ + 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 +} + +/** 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: string +} + +/** Result of probing a path: null when it does not exist. */ +export interface PathInfo { + version: string + mode: number + isFile: boolean +} + +function isENOENT(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +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 throwIfAborted(signal: AbortSignal | undefined, verb: string): void { + if (signal?.aborted) throw new FsError(`${verb} aborted`, 'FS_ABORTED') +} + +/** Opaque version token from a stat: mtime (ns precision) + size. */ +function versionOf(info: Stats): string { + return `${info.mtimeMs}:${info.size}` +} + +/** + * Resolve a path to its absolute display path and realpath identity. Relative + * paths are based on `cwd`. The `targetKey` realpaths the parent directory and + * re-appends the basename, so a not-yet-created file gets the same stable key + * it will have after creation (the directory exists even when the file does + * not). Two input paths reaching the same file via symlinks share one key. + * Falls back to the absolute path when even the parent cannot be resolved. + */ +export async function resolveLocalTarget(cwd: string, path: string): Promise { + 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: await realpath(displayPath) } + } catch (error: unknown) { + /* v8 ignore next -- non-ENOENT realpath failure needs a permission/IO fault; ENOENT falls through to parent-dir resolution. */ + if (!isENOENT(error)) throw error + } + try { + // File absent: realpath the parent dir + basename so creates get a stable key. + return { displayPath, targetKey: join(await realpath(dirname(displayPath)), basename(displayPath)) } + } catch (error: unknown) { + /* v8 ignore next -- parent-dir realpath failing needs the dir itself to be missing/unreadable; fall back to the absolute path. */ + if (!isENOENT(error)) throw error + return { displayPath, targetKey: displayPath } + } +} + +/** Probe a path for its version, mode, and regular-file status. Null if absent. */ +export async function probe(absolutePath: string): Promise { + try { + const info = await stat(absolutePath) + return { version: versionOf(info), mode: info.mode & 0o777, isFile: info.isFile() } + } catch (error: unknown) { + /* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; surface it. */ + if (!isENOENT(error)) throw error + return null + } +} + +// --- Reading --- + +interface PageAccumulator { + lines: FsTextLine[] + totalLines: number + outputBytes: number + truncatedByBytes: boolean + done: boolean +} + +function newAccumulator(): PageAccumulator { + return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false } +} + +function truncateReadLine(line: string): string { + return line.length > READ_MAX_LINE_LENGTH + ? `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}` + : line +} + +function lineByteSize(line: string, currentLineCount: number): number { + return Buffer.byteLength(line, 'utf8') + (currentLineCount > 0 ? 1 : 0) +} + +function consumeLine(acc: PageAccumulator, rawLine: string, request: FsReadRequest): void { + acc.totalLines += 1 + if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return + + const text = truncateReadLine(rawLine) + const bytes = lineByteSize(text, acc.lines.length) + if (acc.outputBytes + bytes > READ_MAX_BYTES) { + 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 +} + +/** The outcome shape `readTextPage` returns (minus the offset/limit echo, which the caller adds). */ +export interface ReadPageResult { + lines: FsTextLine[] + totalLines: number + truncatedByBytes: boolean + view: FsView + version: string +} + +function buildResult(acc: PageAccumulator, request: FsReadRequest, version: string, displayPath: string): ReadPageResult { + 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') + } + const endLine = acc.lines.at(-1)?.number ?? Math.max(0, request.offset - 1) + const view: FsView = request.offset === 1 && !acc.truncatedByBytes && endLine >= acc.totalLines ? 'full' : 'partial' + return { lines: acc.lines, totalLines: acc.totalLines, truncatedByBytes: acc.truncatedByBytes, view, version } +} + +/** + * Read a bounded UTF-8 text-file page. Rejects non-regular files and NUL-byte + * binary samples; dispatches to the fast or streaming path by file size. + */ +export async function readTextPage( + target: LocalTarget, + request: FsReadRequest, + signal?: AbortSignal, + internals: FsIoInternals = {}, +): Promise { + throwIfAborted(signal, 'read') + const absolutePath = target.targetKey + let info: Stats + try { + info = await stat(absolutePath) + } 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 read "${target.displayPath}": not found`, 'FS_NOT_FOUND') + } + if (!info.isFile()) throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + + const version = versionOf(info) + const fastPathMax = internals.fastPathMaxSize ?? FAST_PATH_MAX_SIZE + return info.size < fastPathMax + ? readTextPageFast(target, request, version, signal) + : readTextPageStreaming(target, request, version, signal) +} + +async function readTextPageFast( + target: LocalTarget, + request: FsReadRequest, + version: string, + signal?: AbortSignal, +): Promise { + const raw = await readFile(target.targetKey, signal ? { 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') + } + + const text = raw.toString('utf8') + const acc = newAccumulator() + let startPos = 0 + let newlinePos: number + while ((newlinePos = text.indexOf('\n', startPos)) !== -1) { + consumeLine(acc, stripCarriageReturn(text.slice(startPos, newlinePos)), request) + if (acc.done) break + startPos = newlinePos + 1 + } + if (!acc.done && startPos < text.length) { + consumeLine(acc, stripCarriageReturn(text.slice(startPos)), request) + } + return buildResult(acc, request, version, target.displayPath) +} + +async function readTextPageStreaming( + target: LocalTarget, + request: FsReadRequest, + version: string, + signal?: AbortSignal, +): Promise { + const stream = createReadStream(target.targetKey, { encoding: 'utf8', ...signal ? { signal } : {} }) + const acc = newAccumulator() + let lineBuffer = '' + let firstChunk = true + + function appendToLineBuffer(segment: string): void { + if (lineBuffer.length >= LINE_BUFFER_CAP) return + lineBuffer += segment + if (lineBuffer.length > LINE_BUFFER_CAP) lineBuffer = lineBuffer.slice(0, LINE_BUFFER_CAP) + } + + function flushLine(): void { + consumeLine(acc, stripCarriageReturn(lineBuffer), request) + lineBuffer = '' + } + + try { + for await (const chunk of stream as AsyncIterable) { + if (firstChunk) { + firstChunk = false + if (chunk.slice(0, BINARY_SAMPLE_BYTES).includes(NUL_CHAR)) { + throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT') + } + } + 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 buildResult(acc, request, version, target.displayPath) + } + appendToLineBuffer(chunk.slice(startPos)) + } + } 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 + } + + if (lineBuffer.length > 0) flushLine() + return buildResult(acc, request, version, target.displayPath) +} + +/** Format the line-numbered body + pagination footer for a read page. */ +export function formatReadBody(result: ReadPageResult, offset: number): string { + const endLine = result.lines.at(-1)?.number ?? Math.max(0, offset - 1) + let footer: string + if (result.truncatedByBytes) { + footer = `(Output capped at ${READ_MAX_BYTES_LABEL}. Showing lines ${offset}-${endLine}. Use offset=${endLine + 1} to continue.)` + } else if (endLine < result.totalLines) { + footer = `(Showing lines ${offset}-${endLine} of ${result.totalLines}. Use offset=${endLine + 1} to continue.)` + } else { + footer = `(End of file - total ${result.totalLines} lines)` + } + return result.lines.length > 0 + ? `${result.lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n\n${footer}` + : footer +} + +// --- Writing --- + +async function removeStagingDirOrThrow(stagingDir: string, originalError: unknown): Promise { + 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 { + 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> | 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 readFile(absolutePath, signal ? { signal } : {}) + throwIfAborted(signal, 'edit') + if (buffer.includes(0)) throw new FsError(`cannot edit "${displayPath}": binary file`, 'FS_NOT_TEXT') + const raw = buffer.toString('utf8') + return { content: normalizeLineEndings(raw), lineEndings: detectLineEndings(raw) } +} + +/** + * 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 { restoreLineEndings } diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts new file mode 100644 index 0000000000..0184a8a323 --- /dev/null +++ b/packages/fs/fs-local/src/index.ts @@ -0,0 +1,197 @@ +/** + * Local-filesystem implementation of the `ctx.fs` seam. {@link LocalFileSystem} + * subclasses {@link FileSystem} and backs the four 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 } from '@deepseek-ai/dsh-fs' +import type { + FsEditOutcome, + FsEditRequest, + FsExpectation, + FsReadOutcome, + FsReadRequest, + FsTarget, + FsVersion, + FsWriteOutcome, +} from '@deepseek-ai/dsh-fs' +import { + applyLiteralEdit, + probe, + readForEdit, + readTextPage, + resolveLocalTarget, + restoreLineEndings, + writeFileAtomic, +} from './fsio.ts' +import type { FsIoInternals } from './fsio.ts' + +export { + FAST_PATH_MAX_SIZE, + READ_LIMIT, + READ_MAX_BYTES, + READ_MAX_LINE_LENGTH, + applyLiteralEdit, + formatReadBody, + probe, + readForEdit, + readTextPage, + resolveLocalTarget, + restoreLineEndings, + writeFileAtomic, +} from './fsio.ts' +export type { FsIoInternals, LineEndings, LocalTarget, PathInfo, ReadPageResult } 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 + +/** + * 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 = 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>() + + 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(targetKey: string, op: () => Promise): Promise { + 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): Promise { + const local = await resolveLocalTarget(this.config.cwd, path) + return { inputPath: path, targetKey: local.targetKey, displayPath: local.displayPath } + } + + override async readPage(target: FsTarget, request: FsReadRequest, signal?: AbortSignal): Promise { + const result = await readTextPage( + { displayPath: target.displayPath, targetKey: target.targetKey }, + request, + signal, + this.internals, + ) + return { + offset: request.offset, + limit: request.limit, + lines: result.lines, + totalLines: result.totalLines, + version: result.version, + view: result.view, + ...result.truncatedByBytes ? { truncatedByBytes: true } : {}, + } + } + + override async createOrReplace( + target: FsTarget, + content: string, + expected: FsExpectation, + signal?: AbortSignal, + ): Promise { + return this.withLock(target.targetKey, async () => { + const existing = await probe(target.targetKey) + if (existing && !existing.isFile) { + throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + } + + if (expected.kind === 'observed') { + // Stale guard: the file must still be 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 === 'partial') { + if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, 'FS_STALE_VERSION') + throw new FsError(`cannot overwrite existing "${target.displayPath}" after only a partial read`, 'FS_PARTIAL_OBSERVATION') + } else if (existing) { + // Unobserved write 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') + } + + 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), + } + }) + } + + override async applyEdit( + target: FsTarget, + edit: FsEditRequest, + expected: { version: FsVersion }, + signal?: AbortSignal, + ): Promise { + return this.withLock(target.targetKey, async () => { + const existing = await probe(target.targetKey) + if (!existing) throw new FsError(`cannot edit "${target.displayPath}": not found`, 'FS_NOT_FOUND') + if (!existing.isFile) throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + if (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 { + replacements: edited.replacements, + replaceAll: edit.replaceAll, + version: this.versionAfterWrite(after, target), + } + }) + } + + /* 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: string } | null, target: FsTarget): string { + if (after) return after.version + return `missing:${target.targetKey}` + } +} + +export default LocalFileSystem diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts new file mode 100644 index 0000000000..ae1605892a --- /dev/null +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -0,0 +1,268 @@ +/** + * Tests for the local backend through the `ctx.fs` service: the full + * read→write→edit lifecycle with the read-before-write policy, stale-version + * guards, concurrency races, symlink identity, and HMR/disposal. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, readFile, 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 type { FsExecContext } from '@deepseek-ai/dsh-fs' + +let dir: string +let ctx: Context +let fs: LocalFileSystem +let fiber: Awaited> + +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 }) +}) + +const READ_ALL = { offset: 1, limit: 2000 } +const exec = (): FsExecContext => ({ agent: { session: {} } }) +function lockCount(localFs: LocalFileSystem): number { + return (localFs as unknown as { locks: Map> }).locks.size +} + +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('read → write → edit lifecycle', () => { + it('creates a new file without a prior read', async () => { + const target = await fs.resolve('new.txt') + const outcome = await fs.write(target, 'fresh', exec()) + expect(outcome.operation).toBe('create') + expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh') + }) + + it('updates an existing file after reading it', async () => { + await writeFile(join(dir, 'a.txt'), 'old') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, owner) + const outcome = await fs.write(target, 'new', owner) + expect(outcome.operation).toBe('update') + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('new') + }) + + it('edits an existing file after reading it', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, owner) + const outcome = await fs.edit(target, { oldString: 'world', newString: 'there', replaceAll: false }, owner) + expect(outcome.replacements).toBe(1) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there') + }) + + it('rejects an empty edit oldString through ctx.fs without hanging or changing the file', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, owner) + + await expect(fs.edit(target, { oldString: '', newString: 'boom', replaceAll: false }, owner)) + .rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' }) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world') + }) + + it('propagates truncatedByBytes from a byte-capped read', async () => { + await writeFile(join(dir, 'big.txt'), Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')) + const outcome = await fs.read(await fs.resolve('big.txt'), READ_ALL, exec()) + expect(outcome.truncatedByBytes).toBe(true) + expect(outcome.view).toBe('partial') + }) + + it('allows a follow-up edit without re-reading (write/edit refresh state)', async () => { + await writeFile(join(dir, 'a.txt'), 'a b') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, owner) + await fs.edit(target, { oldString: 'a', newString: 'X', replaceAll: false }, owner) + await fs.edit(target, { oldString: 'b', newString: 'Y', replaceAll: false }, owner) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('X Y') + }) + + it('releases per-target mutation locks after success and failure', async () => { + const target = await fs.resolve('a.txt') + await fs.write(target, 'created', exec()) + expect(lockCount(fs)).toBe(0) + + await expect(fs.write(target, 'blind overwrite', exec())).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(lockCount(fs)).toBe(0) + }) +}) + +describe('read-before-write policy', () => { + it('rejects a blind overwrite of an existing file (no prior read)', async () => { + await writeFile(join(dir, 'a.txt'), 'old') + const target = await fs.resolve('a.txt') + await expect(fs.write(target, 'new', exec())).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + + it('rejects a write after only a partial read', async () => { + await writeFile(join(dir, 'a.txt'), 'one\ntwo') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, { offset: 1, limit: 1 }, owner) + await expect(fs.write(target, 'new', owner)).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) + }) + + it('rejects a write after a partial read when the file was deleted, without recreating it', async () => { + const path = join(dir, 'a.txt') + await writeFile(path, 'one\ntwo') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, { offset: 1, limit: 1 }, owner) + await unlink(path) + + await expect(fs.write(target, 'new', owner)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) + await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('rejects an edit with no prior read (FS_NOT_OBSERVED)', async () => { + await writeFile(join(dir, 'a.txt'), 'old') + const target = await fs.resolve('a.txt') + await expect(fs.edit(target, { oldString: 'old', newString: 'new', replaceAll: false }, exec())) + .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) +}) + +describe('stale-version guard + concurrency (defensive class B)', () => { + it('rejects a write when the file changed since it was read', async () => { + await writeFile(join(dir, 'a.txt'), 'v1') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, owner) + // An out-of-band change after the read. + await writeFile(join(dir, 'a.txt'), 'changed-externally') + await expect(fs.write(target, 'v2', owner)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) + }) + + it('rejects an observed write when the file was deleted after the read', async () => { + await writeFile(join(dir, 'a.txt'), 'v1') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, owner) + await unlink(join(dir, 'a.txt')) // file vanishes; observed write must fail (not silently create) + await expect(fs.write(target, 'v2', owner)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) + }) + + it('two concurrent edits: one wins, the other is rejected as stale', async () => { + await writeFile(join(dir, 'a.txt'), 'base') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, owner) + // Both edits captured the same recorded version; only one rename can match it. + const results = await Promise.allSettled([ + fs.edit(target, { oldString: 'base', newString: 'one', replaceAll: false }, owner), + fs.edit(target, { oldString: 'base', newString: 'two', replaceAll: false }, owner), + ]) + const fulfilled = results.filter(r => r.status === 'fulfilled') + const rejected = results.filter(r => r.status === 'rejected') + expect(fulfilled).toHaveLength(1) + expect(rejected).toHaveLength(1) + expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(lockCount(fs)).toBe(0) + }) +}) + +describe('symlink targetKey identity (defensive class F)', () => { + it('a read via the real path authorizes an edit via the symlink path', async () => { + await writeFile(join(dir, 'real.txt'), 'hello') + await symlink(join(dir, 'real.txt'), join(dir, 'link.txt')) + const owner = exec() + await fs.read(await fs.resolve('real.txt'), READ_ALL, owner) + // Edit through the link: same realpath → same targetKey → prior read counts. + const linkTarget = await fs.resolve('link.txt') + const outcome = await fs.edit(linkTarget, { oldString: 'hello', newString: 'bye', replaceAll: false }, owner) + expect(outcome.replacements).toBe(1) + expect(await readFile(join(dir, 'real.txt'), 'utf8')).toBe('bye') // link preserved, target written + }) + + it('write through a symlink preserves the link and writes the real target', async () => { + await writeFile(join(dir, 'real.txt'), 'hello') + await symlink(join(dir, 'real.txt'), join(dir, 'link.txt')) + const owner = exec() + const linkTarget = await fs.resolve('link.txt') + await fs.read(linkTarget, READ_ALL, owner) + await fs.write(linkTarget, 'replaced', owner) + expect(await readFile(join(dir, 'real.txt'), 'utf8')).toBe('replaced') + }) + + 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 owner = exec() + await fs.read(await fs.resolve('real.txt'), READ_ALL, owner) + await writeFile(join(dir, 'real.txt'), 'changed') // out-of-band via real path + const linkTarget = await fs.resolve('link.txt') + await expect(fs.edit(linkTarget, { oldString: 'hello', newString: 'bye', replaceAll: false }, owner)) + .rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) + }) +}) + +describe('non-regular targets', () => { + it('rejects writing onto a directory', async () => { + const target = await fs.resolve('.') // the cwd dir + await expect(fs.write(target, 'x', exec())).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + }) + + it('applyEdit rejects a target that vanished after the read', async () => { + await writeFile(join(dir, 'a.txt'), 'hello') + const owner = exec() + const target = await fs.resolve('a.txt') + const version = (await fs.read(target, READ_ALL, owner)).version + await unlink(join(dir, 'a.txt')) + await expect(fs.applyEdit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version })) + .rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + }) + + it('applyEdit rejects a non-regular target', async () => { + const target = await fs.resolve('.') + await expect(fs.applyEdit(target, { oldString: 'a', newString: 'b', replaceAll: false }, { version: 'v' })) + .rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + }) +}) + +describe('HMR / disposal (defensive class D)', () => { + it('disposing the fiber withdraws ctx.fs', async () => { + const local = new Context() + const fiber = await local.plugin(LocalFileSystem, { cwd: dir }) + expect(local.fs).toBeDefined() + await fiber.dispose() + expect(local.fs).toBeUndefined() + }) + + it('a fresh provider does not inherit recorded file state', async () => { + await writeFile(join(dir, 'a.txt'), 'hello') + const local = new Context() + const owner = exec() + const fiber = await local.plugin(LocalFileSystem, { cwd: dir }) + await (local.fs as LocalFileSystem).read(await local.fs.resolve('a.txt'), READ_ALL, owner) + await fiber.dispose() + + await local.plugin(LocalFileSystem, { cwd: dir }) + const fs2 = local.fs as LocalFileSystem + const target = await fs2.resolve('a.txt') + // Same owner object, but state was released on disposal. + await expect(fs2.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, owner)) + .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) +}) diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts new file mode 100644 index 0000000000..77b8d8ccba --- /dev/null +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -0,0 +1,364 @@ +/** + * Cordis-free tests for the raw local-filesystem I/O: path resolution, + * fast/streaming reads, pagination/caps, binary rejection, atomic-write temp + * safety, literal edit matching, and line-ending handling. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, readFile, rm, stat, symlink, writeFile, mkdir, readdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + applyLiteralEdit, + formatReadBody, + probe, + readForEdit, + readTextPage, + resolveLocalTarget, + restoreLineEndings, + writeFileAtomic, +} from '@deepseek-ai/dsh-fs-local' +import type { LocalTarget } from '@deepseek-ai/dsh-fs-local' + +let dir: string +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dsh-fsio-')) +}) +afterEach(async () => { + await rm(dir, { recursive: true, force: true }) +}) + +const READ_ALL = { offset: 1, limit: 2000 } +const localTarget = (path: string): LocalTarget => ({ displayPath: path, targetKey: path }) + +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 (await import('node:fs/promises')).realpath(file)) + }) + + it('uses the realpathed parent + basename when the file does not exist (stable across create)', async () => { + const { realpath } = await import('node:fs/promises') + 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('falls back to the absolute path when even the parent dir is absent', async () => { + const target = await resolveLocalTarget(dir, 'no-such-dir/child.txt') + expect(target.targetKey).toBe(join(dir, 'no-such-dir', 'child.txt')) + }) + + it('rejects a blank path', async () => { + await expect(resolveLocalTarget(dir, ' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + }) +}) + +describe('readTextPage', () => { + it('reads a small file with line numbers and full view', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo\nthree') + const result = await readTextPage(localTarget(file), READ_ALL) + expect(result.lines).toEqual([ + { number: 1, text: 'one' }, + { number: 2, text: 'two' }, + { number: 3, text: 'three' }, + ]) + expect(result.totalLines).toBe(3) + expect(result.view).toBe('full') + }) + + it('paginates with offset/limit and reports a partial view', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo\nthree\nfour') + const result = await readTextPage(localTarget(file), { offset: 2, limit: 2 }) + expect(result.lines.map(l => l.number)).toEqual([2, 3]) + expect(result.view).toBe('partial') + expect(formatReadBody(result, 2)).toContain('(Showing lines 2-3 of 4. Use offset=4 to continue.)') + }) + + it('a whole-file read from offset 1 is a full view; offset>1 is partial', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo') + expect((await readTextPage(localTarget(file), { offset: 1, limit: 10 })).view).toBe('full') + expect((await readTextPage(localTarget(file), { offset: 2, limit: 10 })).view).toBe('partial') + }) + + it('truncates an over-long line', async () => { + const file = join(dir, 'long.txt') + await writeFile(file, 'x'.repeat(3000)) + const result = await readTextPage(localTarget(file), READ_ALL) + expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)') + }) + + it('caps output bytes and reports truncatedByBytes', async () => { + const file = join(dir, 'big.txt') + const lines = Array.from({ length: 2000 }, () => 'y'.repeat(100)) + await writeFile(file, lines.join('\n')) + const result = await readTextPage(localTarget(file), READ_ALL) + expect(result.truncatedByBytes).toBe(true) + expect(formatReadBody(result, 1)).toContain('Output capped at 50 KB') + }) + + it('strips CRLF so a Windows file reads like LF', async () => { + const file = join(dir, 'crlf.txt') + await writeFile(file, 'one\r\ntwo\r\n') + const result = await readTextPage(localTarget(file), READ_ALL) + expect(result.lines.map(l => l.text)).toEqual(['one', 'two']) + }) + + it('reads an empty file at offset 1', async () => { + const file = join(dir, 'empty.txt') + await writeFile(file, '') + const result = await readTextPage(localTarget(file), READ_ALL) + expect(result.lines).toEqual([]) + expect(result.totalLines).toBe(0) + expect(formatReadBody(result, 1)).toBe('(End of file - total 0 lines)') + }) + + it('rejects an offset past EOF', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo') + await expect(readTextPage(localTarget(file), { offset: 9, limit: 1 })).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + }) + + it('rejects a binary file (fast path)', async () => { + const file = join(dir, 'bin') + await writeFile(file, Buffer.from([0x68, 0x00, 0x69])) + await expect(readTextPage(localTarget(file), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + }) + + it('rejects a missing file and a directory', async () => { + await expect(readTextPage(localTarget(join(dir, 'nope')), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + await expect(readTextPage(localTarget(dir), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + }) + + it('honors a pre-aborted signal', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one') + await expect(readTextPage(localTarget(file), READ_ALL, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) + + it('passes a live (non-aborted) signal through the fast path', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo') + const result = await readTextPage(localTarget(file), READ_ALL, new AbortController().signal) + expect(result.totalLines).toBe(2) + }) + + describe('streaming path (forced via a tiny fastPathMaxSize)', () => { + const stream = { fastPathMaxSize: 1 } + + it('reads and paginates large files the same way', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo\nthree') + const result = await readTextPage(localTarget(file), { offset: 2, limit: 1 }, undefined, stream) + expect(result.lines).toEqual([{ number: 2, text: 'two' }]) + expect(result.totalLines).toBe(3) + }) + + it('rejects a binary file on the streaming path', async () => { + const file = join(dir, 'bin') + await writeFile(file, Buffer.from([0x68, 0x00, 0x69])) + await expect(readTextPage(localTarget(file), READ_ALL, undefined, stream)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + }) + + it('caps a newline-free giant line without unbounded buffering', async () => { + const file = join(dir, 'one-line.txt') + await writeFile(file, 'z'.repeat(5000)) + const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) + expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)') + }) + + it('honors abort on the streaming path', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo') + await expect(readTextPage(localTarget(file), READ_ALL, AbortSignal.abort(), stream)).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) + + it('caps output bytes mid-stream', async () => { + const file = join(dir, 'big.txt') + await writeFile(file, Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')) + const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) + expect(result.truncatedByBytes).toBe(true) + }) + + it('flushes a final line with no trailing newline', async () => { + const file = join(dir, 'no-nl.txt') + await writeFile(file, 'one\ntwo') // no trailing \n + const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) + expect(result.lines.map(l => l.text)).toEqual(['one', 'two']) + }) + + it('handles a trailing newline (no dangling buffer at EOF)', async () => { + const file = join(dir, 'nl.txt') + await writeFile(file, 'one\ntwo\n') // trailing \n → empty buffer at end + const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) + expect(result.lines.map(l => l.text)).toEqual(['one', 'two']) + expect(result.totalLines).toBe(2) + }) + + it('passes a live (non-aborted) signal through to the stream', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo') + const result = await readTextPage(localTarget(file), READ_ALL, new AbortController().signal, stream) + expect(result.totalLines).toBe(2) + }) + + it('scans across multiple stream chunks', async () => { + // A file well past the default 64 KB stream highWaterMark yields multiple chunks, + // exercising the non-first-chunk branch and the line-buffer cap across appends. + const file = join(dir, 'multi.txt') + const lines = Array.from({ length: 50 }, (_, i) => `line ${i}: ${'x'.repeat(3000)}`) + await writeFile(file, lines.join('\n')) + const result = await readTextPage(localTarget(file), { offset: 1, limit: 3 }, undefined, stream) + expect(result.lines[0]?.text.startsWith('line 0:')).toBe(true) + expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)') + expect(result.totalLines).toBeGreaterThanOrEqual(3) + }) + }) +}) + +describe('writeFileAtomic — temp-file safety (defensive class A)', () => { + 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') + const info = await stat(file) + expect(info.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' }) + // The pre-existing staging dir is intact and the target was not created. + 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) // rename(temp, sub) fails because sub is a non-empty/dir target + await expect(writeFileAtomic(sub, 'hi', undefined, undefined)).rejects.toBeInstanceOf(Error) + // No leftover staging dirs in the directory. + 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', async () => { + const file = join(dir, 'bin') + await writeFile(file, Buffer.from([0x00, 0x01])) + await expect(readForEdit(file, file)).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') + }) +}) + +describe('probe', () => { + it('returns null for a missing path and info 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?.isFile).toBe(true) + expect(typeof info?.version).toBe('string') + }) + + it('marks a directory as not a regular file', async () => { + const sub = join(dir, 'sub') + await mkdir(sub) + expect((await probe(sub))?.isFile).toBe(false) + }) +}) diff --git a/packages/fs/fs-local/tsconfig.json b/packages/fs/fs-local/tsconfig.json new file mode 100644 index 0000000000..895a46ef55 --- /dev/null +++ b/packages/fs/fs-local/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../llm/llm" }, + { "path": "../fs" } + ] +} diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md new file mode 100644 index 0000000000..856ec95076 --- /dev/null +++ b/packages/fs/fs/README.md @@ -0,0 +1,38 @@ +# @deepseek-ai/dsh-fs + +The **filesystem seam**: an abstract `FileSystem` service (`ctx.fs`) defining WHAT a filesystem backend does — resolve paths, read bounded text pages, create/replace files, apply literal edits — without saying HOW. + +This package is one third of the filesystem capability, 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) and [the filesystem capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md)): + +| Package | Role | +|---|---| +| `@deepseek-ai/dsh-fs` (this) | the interface: abstract service + vocabulary types + read-before-write/edit policy | +| `@deepseek-ai/dsh-fs-local` | an implementation: the host filesystem | +| `@deepseek-ai/dsh-tool-fs` | the model-facing `read`/`write`/`edit` tool schemas over `ctx.fs` | + +A future sandboxed, virtual, or remote backend implements this interface and the tool schemas don't change. + +## Service API (`ctx.fs`) + +Consumers call the concrete public API; backends implement the four primitives. + +| Member | Kind | Semantics | +|---|---|---| +| `resolve(path)` | primitive | Resolve a path into a stable `FsTarget` (`inputPath`, opaque `targetKey`, `displayPath`). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. | +| `readPage(target, request, signal?)` | primitive | Read a bounded UTF-8 text page. Returns line-numbered content, `totalLines`, an opaque `version`, and a `view` (`full` only when the page covered the whole file). | +| `createOrReplace(target, content, expected, signal?)` | primitive | Create/replace a file honoring the `FsExpectation` stale guard. | +| `applyEdit(target, edit, expected, signal?)` | primitive | Atomic literal read-modify-write, verifying the expected version. `oldString` must be non-empty. | +| `read(target, request, exec?, signal?)` | public | Calls `readPage`, then records observed state for the derived owner. | +| `write(target, content, exec?, signal?)` | public | Builds the `FsExpectation` from recorded state, calls `createOrReplace`, refreshes state to `full`. Updating an existing file needs a prior `full` read; a create does not. | +| `edit(target, edit, exec?, signal?)` | public | Requires a prior `full` read by this owner (else `FS_NOT_OBSERVED` / `FS_PARTIAL_OBSERVATION`), rejects empty `oldString`, calls `applyEdit`, refreshes state. | +| `owner(exec?)` | helper | Derives the file-state owner (`exec.agent.session`) — `undefined` when there is none. | + +## Read-before-write/edit lives in the seam + +Write/edit safety depends on backend-defined target identity and version tokens, so `ctx.fs` — not the tool layer — records what each owner has observed (keyed by an opaque owner object, normally the agent session, then by `targetKey`) and enforces the policy. The base class owns owner derivation, the file-state store, and *which* `FsExpectation` to hand the backend; the backend owns version comparison and I/O. Only a `full` view authorizes write/edit; a `partial` view (paged/truncated read) records context but does not. + +State is held in a `WeakMap` keyed by the owner object and dropped on disposal (HMR safety). Persistence across sessions is deferred — a resumed session must read files again before write/edit. + +## Vocabulary + +`FsTarget` / `FsVersion` are opaque — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. 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_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_PARTIAL_OBSERVATION`, `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. diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json new file mode 100644 index 0000000000..a0bce4940a --- /dev/null +++ b/packages/fs/fs/package.json @@ -0,0 +1,30 @@ +{ + "name": "@deepseek-ai/dsh-fs", + "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service, and the read-before-write/edit file-state contract", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-llm": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-llm": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts new file mode 100644 index 0000000000..b23d7dd90a --- /dev/null +++ b/packages/fs/fs/src/index.ts @@ -0,0 +1,256 @@ +/** + * The filesystem seam (`ctx.fs`): an abstract service defining WHAT a + * filesystem backend does — resolve paths into stable targets, read bounded + * text pages, create/replace files, and apply literal edits — 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 tool schemas that consume them + * (`@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 packages. + * + * ## Read-before-write/edit lives here, not in the tools + * + * Write/edit safety depends on backend-defined target identity and version + * tokens, so the seam — not the consumer — records what each owner has observed + * and enforces the policy. The base class owns owner derivation, the file-state + * store, and the decision of *which* {@link FsExpectation} to hand a backend; + * the backend owns version comparison and the actual I/O. A consumer passes its + * execution context through {@link read}/{@link write}/{@link edit} and never + * touches the cache, owner key, or version tokens. + * + * @module @deepseek-ai/dsh-fs + */ + +import { Context, Service } from 'cordis' +import { FsError } from './types.ts' +import type { + FsEditOutcome, + FsEditRequest, + FsExecContext, + FsExpectation, + FsReadOutcome, + FsReadRequest, + FsTarget, + FsVersion, + FsWriteOutcome, + FileState, +} from './types.ts' + +export { + FsError, +} from './types.ts' +export type { + FsEditOutcome, + FsEditRequest, + FsErrorCode, + FsExecContext, + FsExpectation, + FsReadOutcome, + FsReadRequest, + FsStateSource, + FsTarget, + FsTextLine, + FsVersion, + FsView, + FsWriteOutcome, + FileState, +} from './types.ts' + +declare module 'cordis' { + interface Context { + fs: FileSystem + } +} + +/** + * Abstract filesystem service. Subclass, implement the four backend primitives + * ({@link resolve}, {@link readPage}, {@link createOrReplace}, + * {@link applyEdit}), 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). + * + * Consumers call the concrete public API ({@link read}/{@link write}/ + * {@link edit}), which derives the file-state owner, enforces the + * read-before-write/edit policy, and refreshes recorded state — then delegates + * the actual I/O to the backend primitives. + * + * 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 file-state lookup agree across paths (e.g. through symlinks). + * - {@link readPage} returns line-numbered UTF-8 content with a `version` and a + * `view` (`full` only when the page covered the whole file). + * - {@link createOrReplace} honors the {@link FsExpectation}: `observed` + * rejects with `FS_STALE_VERSION` if the file changed since `version`; + * `partial` rejects existing targets because the owner saw only a + * non-editable view; `unobserved` creates iff the target is absent and + * otherwise rejects. + * - {@link applyEdit} verifies the expected version (stale guard) and is atomic + * (read-modify-write must not interleave with a concurrent edit). + */ +export abstract class FileSystem extends Service { + /** + * Observed-file state, keyed first by the owner object (weakly held, so a + * collected session frees its state), then by {@link FsTarget.targetKey}. + */ + private fileStates = new WeakMap>() + + constructor(ctx: Context) { + super(ctx, 'fs') + ctx.effect(() => () => { + // Drop all recorded state on disposal so a reloaded backend starts clean + // (HMR safety). The WeakMap itself would be GC'd, but replacing it makes + // the release observable and immediate for tests. + this.fileStates = new WeakMap() + }, 'fs file-state teardown') + } + + // --- Backend primitives (subclass implements; all backend I/O lives here) --- + + /** + * 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. + */ + abstract resolve(path: string): Promise + + /** Read a bounded UTF-8 text page from a target. */ + abstract readPage(target: FsTarget, request: FsReadRequest, signal?: AbortSignal): Promise + + /** + * Create or fully replace a UTF-8 text file, honoring `expected` as the + * stale guard / create-vs-update decision. + */ + abstract createOrReplace(target: FsTarget, content: string, expected: FsExpectation, signal?: AbortSignal): Promise + + /** + * Apply a literal edit to an existing UTF-8 text file, verifying + * `expected.version` as the stale guard. Atomic read-modify-write. + */ + abstract applyEdit(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise + + // --- Owner + file-state machinery (shared by all backends) --- + + /** + * Derive the file-state owner from an execution context — normally the active + * agent session. Returns `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. + */ + owner(exec?: FsExecContext): object | undefined { + return exec?.agent?.session + } + + /** Look up recorded state for an owner+target, if any. */ + protected getState(owner: object, targetKey: string): FileState | undefined { + return this.fileStates.get(owner)?.get(targetKey) + } + + /** Record (or replace) one owner's observed state for a target. */ + protected recordState(owner: object, state: FileState): void { + let byTarget = this.fileStates.get(owner) + if (!byTarget) { + byTarget = new Map() + this.fileStates.set(owner, byTarget) + } + byTarget.set(state.targetKey, state) + } + + // --- Concrete public API (orchestration; consumers call these) --- + + /** + * Read a bounded text page and, when an owner is derivable, record the + * observed state (a `full` view authorizes later write/edit; a `partial` view + * does not). + */ + async read(target: FsTarget, request: FsReadRequest, exec?: FsExecContext, signal?: AbortSignal): Promise { + const outcome = await this.readPage(target, request, signal) + const owner = this.owner(exec) + if (owner) { + this.recordState(owner, { + targetKey: target.targetKey, + displayPath: target.displayPath, + version: outcome.version, + view: outcome.view, + updatedAt: this.now(), + source: 'read', + }) + } + return outcome + } + + /** + * Create or fully replace a file. Updating an existing file requires a `full` + * prior observation by this owner; a create (no prior state, target absent) + * does not. After a successful write the recorded state refreshes to `full` + * at the new version so a follow-up modification needs no re-read. + */ + async write(target: FsTarget, content: string, exec?: FsExecContext, signal?: AbortSignal): Promise { + const owner = this.owner(exec) + const prior = owner ? this.getState(owner, target.targetKey) : undefined + const expected: FsExpectation = prior + ? prior.view === 'full' + ? { kind: 'observed', version: prior.version } + : { kind: 'partial', version: prior.version } + : { kind: 'unobserved' } + + const outcome = await this.createOrReplace(target, content, expected, signal) + if (owner) { + this.recordState(owner, { + targetKey: target.targetKey, + displayPath: target.displayPath, + version: outcome.version, + view: 'full', + updatedAt: this.now(), + source: 'write', + }) + } + return outcome + } + + /** + * Apply a literal edit. Always requires a `full` prior observation by this + * owner. No owner or absent state rejects with `FS_NOT_OBSERVED`; a partial + * view rejects with `FS_PARTIAL_OBSERVATION`; an empty `oldString` rejects + * before backend I/O. There is no "create via edit". Refreshes recorded + * state to `full` at the new version on success. + */ + async edit(target: FsTarget, edit: FsEditRequest, exec?: FsExecContext, signal?: AbortSignal): Promise { + if (edit.oldString.length === 0) { + throw new FsError('old_string must be a non-empty string', 'FS_EDIT_NOT_FOUND') + } + const owner = this.owner(exec) + const prior = owner ? this.getState(owner, target.targetKey) : undefined + if (!owner || !prior) { + throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED') + } + if (prior.view !== 'full') { + throw new FsError(`edit requires a full read of "${target.displayPath}" first`, 'FS_PARTIAL_OBSERVATION') + } + + const outcome = await this.applyEdit(target, edit, { version: prior.version }, signal) + this.recordState(owner, { + targetKey: target.targetKey, + displayPath: target.displayPath, + version: outcome.version, + view: 'full', + updatedAt: this.now(), + source: 'edit', + }) + return outcome + } + + /** + * Wall-clock now (ms). A protected seam so tests can use deterministic + * timestamps; production uses `Date.now()`. + */ + protected now(): number { + return Date.now() + } +} + +export default FileSystem diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts new file mode 100644 index 0000000000..f08723731e --- /dev/null +++ b/packages/fs/fs/src/types.ts @@ -0,0 +1,194 @@ +/** + * Vocabulary for the filesystem capability seam (`ctx.fs`): the request/outcome + * shapes backends produce and consumers format, the opaque target/version + * identities, the per-owner file-state record, 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 model-facing consumer + * (`@deepseek-ai/dsh-tool-fs`). They deliberately avoid host-path assumptions: + * `targetKey` and `version` are opaque tokens, and `displayPath` is the only + * field a consumer may show. + * + * @module @deepseek-ai/dsh-fs/types + */ + +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** + * Minimal structural view of a tool execution the filesystem seam needs to + * derive a file-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` + * satisfies this shape, so the consumer passes its `exec` straight through + * without `dsh-fs` 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); `dsh-fs` never reads any of its fields. + */ +export interface FsExecContext { + /** 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 + } +} + +/** + * A path resolved by a backend into a stable identity. `resolve()` produces + * this; every other operation takes it. + */ +export interface FsTarget { + /** The original model/plugin-supplied path, for diagnostics only. */ + inputPath: string + /** + * Opaque key for stale guards and file-state 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. + */ + targetKey: string + /** + * Path for model/UI-facing output. May be a local absolute path, + * workspace-relative path, or remote URI depending on the backend. + */ + displayPath: string +} + +/** + * Opaque file-version token. The local backend derives it from mtime+size; a + * remote backend might use a revision id. `ctx.fs` records it for stale checks; + * consumers may display related metadata but MUST NOT interpret this token. + */ +export type FsVersion = string + +/** Resolved read window. The consumer applies its defaults/caps before calling. */ +export interface FsReadRequest { + /** 1-based first line to return. */ + offset: number + /** Maximum number of lines to return. */ + limit: number +} + +/** One line returned from a text file. */ +export interface FsTextLine { + /** 1-based line number in the file. */ + number: number + /** Line text without its trailing newline. */ + text: string +} + +/** Whether a recorded/returned view covers the whole file or only part of it. */ +export type FsView = 'full' | 'partial' + +/** Outcome of a bounded text read. */ +export interface FsReadOutcome { + /** 1-based first line requested. */ + offset: number + /** Maximum number of lines requested. */ + limit: number + /** Returned lines, already numbered. */ + lines: FsTextLine[] + /** 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 + /** Opaque version of the file at read time. */ + version: FsVersion + /** + * Whether this read saw the whole file (`full`) or only part of it + * (`partial`). Only a `full` view authorizes a later write/edit. + */ + view: FsView +} + +/** + * The read-before-write decision the base service hands to a backend for a + * full-file write. `observed` means the owner has a `full` view recorded at + * `version` (the backend rejects if the file has since changed); `partial` + * means the owner saw only a non-editable view of that target; `unobserved` + * means there is no prior view (the backend may create iff the target is + * absent, else rejects as not observed). + */ +export type FsExpectation = + | { kind: 'observed'; version: FsVersion } + | { kind: 'partial'; version: FsVersion } + | { kind: 'unobserved' } + +/** 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 +} + +/** 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 { + /** Number of literal replacements applied. */ + replacements: number + /** Whether every match was replaced. */ + replaceAll: boolean + /** Opaque version of the file after the edit. */ + version: FsVersion +} + +/** Source that last touched a recorded {@link FileState}. */ +export type FsStateSource = 'read' | 'write' | 'edit' + +/** + * What an owner has observed about one target. Keyed (inside the service) first + * by the owner object, then by {@link FsTarget.targetKey}. Only a `full` view + * authorizes write/edit. + */ +export interface FileState { + /** Backend target identity this state describes. */ + targetKey: string + /** Display path captured when the state was recorded. */ + displayPath: string + /** Opaque version the owner last saw. */ + version: FsVersion + /** Whether the owner saw the whole file or only part of it. */ + view: FsView + /** Wall-clock time the state was last updated (ms since epoch). */ + updatedAt: number + /** Operation that produced this state. */ + source: FsStateSource +} + +/** + * 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_TEXT' + | 'FS_NOT_REGULAR_FILE' + | 'FS_STALE_VERSION' + | 'FS_NOT_OBSERVED' + | 'FS_PARTIAL_OBSERVATION' + | '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 + } +} diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts new file mode 100644 index 0000000000..84f84cbe0b --- /dev/null +++ b/packages/fs/fs/tests/service.spec.ts @@ -0,0 +1,313 @@ +/** + * Tests for the filesystem service seam itself: registration/disposal, owner + * derivation, and the read-before-write/edit policy the base class enforces + * (which `FsExpectation` it hands the backend, multi-owner isolation, and + * state refresh) — all exercised through a fake in-memory backend that records + * the expectations it received. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { FileSystem, FsError } from '@deepseek-ai/dsh-fs' +import type { + FsEditOutcome, + FsEditRequest, + FsExpectation, + FsReadOutcome, + FsReadRequest, + FsTarget, + FsView, + FsWriteOutcome, +} from '@deepseek-ai/dsh-fs' + +/** A fake backend: an in-memory file table, recording every expectation it is handed. */ +class FakeFileSystem extends FileSystem { + files = new Map() + versions = new Map() + /** View the next `readPage` should report (tests flip this for partial reads). */ + nextReadView: FsView = 'full' + /** Expectations handed to `createOrReplace`, in call order. */ + writeExpectations: FsExpectation[] = [] + /** Versions handed to `applyEdit`, in call order. */ + editExpectedVersions: string[] = [] + + private bump(key: string): string { + const next = (this.versions.get(key) ?? 0) + 1 + this.versions.set(key, next) + return `v${next}` + } + + override async resolve(path: string): Promise { + return { inputPath: path, targetKey: path, displayPath: path } + } + + override async readPage(target: FsTarget, request: FsReadRequest): Promise { + const content = this.files.get(target.targetKey) + if (content === undefined) throw new FsError(`not found: ${target.displayPath}`, 'FS_NOT_FOUND') + const allLines = content.split('\n') + const lines = allLines + .slice(request.offset - 1, request.offset - 1 + request.limit) + .map((text, i) => ({ number: request.offset + i, text })) + return { + offset: request.offset, + limit: request.limit, + lines, + totalLines: allLines.length, + version: `v${this.versions.get(target.targetKey) ?? 0}`, + view: this.nextReadView, + } + } + + override async createOrReplace(target: FsTarget, content: string, expected: FsExpectation): Promise { + this.writeExpectations.push(expected) + const existed = this.files.has(target.targetKey) + this.files.set(target.targetKey, content) + return { operation: existed ? 'update' : 'create', version: this.bump(target.targetKey) } + } + + override async applyEdit(target: FsTarget, edit: FsEditRequest, expected: { version: string }): Promise { + this.editExpectedVersions.push(expected.version) + const content = this.files.get(target.targetKey) ?? '' + this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString)) + return { replacements: 1, replaceAll: edit.replaceAll, version: this.bump(target.targetKey) } + } +} + +async function setup() { + const ctx = new Context() + await ctx.plugin(FakeFileSystem) + const fs = ctx.fs as FakeFileSystem + return { ctx, fs } +} + +const READ_ALL: FsReadRequest = { offset: 1, limit: 2000 } +const ownerExec = (session: object) => ({ agent: { session } }) + +describe('FileSystem service seam', () => { + it('registers as ctx.fs and serves the API', async () => { + const { fs } = await setup() + fs.files.set('a.txt', 'hi') + const outcome = await fs.read(await fs.resolve('a.txt'), READ_ALL) + expect(outcome.lines).toEqual([{ number: 1, text: 'hi' }]) + }) + + it('throws when a second implementation is loaded (duplicate service)', async () => { + const { ctx } = await setup() + 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() + }) +}) + +describe('owner derivation', () => { + it('derives the owner from exec.agent.session', async () => { + const { fs } = await setup() + const session = {} + expect(fs.owner(ownerExec(session))).toBe(session) + }) + + it('returns undefined with no exec, no agent, or no session', async () => { + const { fs } = await setup() + expect(fs.owner()).toBeUndefined() + expect(fs.owner({})).toBeUndefined() + expect(fs.owner({ agent: {} })).toBeUndefined() + }) +}) + +describe('read records observed state', () => { + it('a full read authorizes a later in-place write (observed expectation)', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await fs.read(target, READ_ALL, exec) + await fs.write(target, 'goodbye', exec) + + expect(fs.writeExpectations).toEqual([{ kind: 'observed', version: 'v0' }]) + }) + + it('a partial read does NOT authorize a write (passes a partial expectation)', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + fs.nextReadView = 'partial' + const target = await fs.resolve('a.txt') + + await fs.read(target, { offset: 1, limit: 1 }, exec) + await fs.write(target, 'goodbye', exec) + + expect(fs.writeExpectations).toEqual([{ kind: 'partial', version: 'v0' }]) + }) + + it('skips recording when there is no owner', async () => { + const { fs } = await setup() + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await fs.read(target, READ_ALL) // no exec + await fs.write(target, 'goodbye') // no exec → cannot be observed + + expect(fs.writeExpectations).toEqual([{ kind: 'unobserved' }]) + }) +}) + +describe('write policy', () => { + it('a create (no prior state) is unobserved', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + const target = await fs.resolve('new.txt') + + const outcome = await fs.write(target, 'fresh', exec) + + expect(outcome.operation).toBe('create') + expect(fs.writeExpectations).toEqual([{ kind: 'unobserved' }]) + }) + + it('refreshes state to full after a write, so a follow-up edit needs no re-read', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + const target = await fs.resolve('a.txt') + + await fs.write(target, 'one', exec) // create → state now full at v1 + await fs.edit(target, { oldString: 'one', newString: 'two', replaceAll: false }, exec) + + expect(fs.editExpectedVersions).toEqual(['v1']) + }) +}) + +describe('edit policy', () => { + it('rejects with FS_NOT_OBSERVED when the file was never read', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await expect( + fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec), + ).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + + it('rejects with FS_PARTIAL_OBSERVATION when only a partial view was recorded', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + fs.nextReadView = 'partial' + const target = await fs.resolve('a.txt') + await fs.read(target, { offset: 1, limit: 1 }, exec) + + await expect( + fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec), + ).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) + }) + + it('rejects an empty oldString before calling the backend primitive', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, exec) + + await expect( + fs.edit(target, { oldString: '', newString: 'bye', replaceAll: false }, exec), + ).rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' }) + expect(fs.editExpectedVersions).toEqual([]) + }) + + it('rejects when there is no owner (cannot prove prior observation)', async () => { + const { fs } = await setup() + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await expect( + fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }), + ).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + + it('proceeds after a full read, passing the recorded version as the stale guard', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + fs.versions.set('a.txt', 7) // distinguishable version + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, exec) + + await fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec) + + expect(fs.editExpectedVersions).toEqual(['v7']) + }) +}) + +describe('multi-owner isolation', () => { + it('owner A reading does not grant owner B edit authority', async () => { + const { fs } = await setup() + const a = ownerExec({}) + const b = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await fs.read(target, READ_ALL, a) + + // B never read it → B's edit must be rejected. + await expect( + fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, b), + ).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + // A still may edit. + await expect( + fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, a), + ).resolves.toMatchObject({ replacements: 1 }) + }) + + it('each owner records its own observed version independently', async () => { + const { fs } = await setup() + const a = ownerExec({}) + const b = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await fs.read(target, READ_ALL, a) // A sees v0 + await fs.write(target, 'mid', b) // B writes unobserved → file now v1 + await fs.write(target, 'late', a) // A still holds its v0 observation + + expect(fs.writeExpectations).toEqual([ + { kind: 'unobserved' }, + { kind: 'observed', version: 'v0' }, + ]) + }) +}) + +describe('disposal releases recorded state', () => { + it('a fresh provider after disposal starts with no inherited state', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(FakeFileSystem) + const fs1 = ctx.fs as FakeFileSystem + const exec = ownerExec({}) + fs1.files.set('a.txt', 'hello') + await fs1.read(await fs1.resolve('a.txt'), READ_ALL, exec) + await fiber.dispose() + + await ctx.plugin(FakeFileSystem) + const fs2 = ctx.fs as FakeFileSystem + fs2.files.set('a.txt', 'hello') + const target = await fs2.resolve('a.txt') + // Reusing the same exec/owner object: state must NOT carry over. + await expect( + fs2.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec), + ).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) +}) + +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) + }) +}) diff --git a/packages/fs/fs/tsconfig.json b/packages/fs/fs/tsconfig.json new file mode 100644 index 0000000000..7b250a29c4 --- /dev/null +++ b/packages/fs/fs/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../llm/llm" } + ] +} diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md new file mode 100644 index 0000000000..2beb45be9c --- /dev/null +++ b/packages/fs/tool-fs/README.md @@ -0,0 +1,33 @@ +# @deepseek-ai/dsh-tool-fs + +The **model-facing filesystem tools** — `read`, `write`, `edit` — over the `ctx.fs` seam ([`@deepseek-ai/dsh-fs`](../fs)). This is the consumer third of the filesystem capability; it owns tool names, JSON schemas, argument validation, prompt sections, and result formatting, and **never** touches filesystem I/O (no `node:fs`/`node:path`, no implementation import). + +```ts ignore-check +// Load a ctx.fs provider first, then the tools. +await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local +await ctx.plugin(ToolFs) // this package — registers read/write/edit +``` + +Each tool also ships as a subpath plugin for focused deployments: + +```ts ignore-check +import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read' +import * as writePlugin from '@deepseek-ai/dsh-tool-fs/write' +import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' +``` + +## Tools (schemas per [the filesystem tool schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md)) + +| Tool | Arguments | Behavior | +|---|---|---| +| `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at 2000 lines. | +| `write` | `file_path`, `content` | Create or fully replace a file. Overwriting an existing file requires a prior `read` (the backend enforces it); creating a new file does not. | +| `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. Requires a prior `read`. | + +Field names are snake_case to match Claude Code and existing harness tool schemas. + +## How the read-before-write policy is enforced + +The tools do **not** check whether a `read` ran or inspect any cache. Each tool resolves the path via `ctx.fs.resolve()`, then calls `ctx.fs.read/write/edit(target, …, exec)` — passing the current tool execution context straight through. `ctx.fs` derives the file-state owner (normally the agent session) from that context and owns the prior-observation and stale-version policy. Backend errors (`FsError`) flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached. + +Tool schemas reach the system prompt automatically via the tool registry; this package additionally registers short prose guidance through `ctx.systemPrompt.section(...)`. diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json new file mode 100644 index 0000000000..744a41736d --- /dev/null +++ b/packages/fs/tool-fs/package.json @@ -0,0 +1,51 @@ +{ + "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/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./read": { + "types": "./lib/read.d.ts", + "default": "./lib/read.js" + }, + "./write": { + "types": "./lib/write.d.ts", + "default": "./lib/write.js" + }, + "./edit": { + "types": "./lib/edit.d.ts", + "default": "./lib/edit.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-fs": "^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" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts new file mode 100644 index 0000000000..3f65f5660d --- /dev/null +++ b/packages/fs/tool-fs/src/edit.ts @@ -0,0 +1,82 @@ +/** + * The model-facing `edit` tool: update an existing UTF-8 text file by replacing + * literal text, requiring a unique match by default. Execution goes through + * `ctx.fs`, which enforces prior observation and the stale-version guard and + * owns the literal-match semantics. + * + * @module @deepseek-ai/dsh-tool-fs/edit + */ + +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { FsEditOutcome } from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-system-prompt' + +/** 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 outcome as a Claude-style model-facing success message. */ +export function formatEditOutput(displayPath: string, outcome: FsEditOutcome): string { + return outcome.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 apply(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.', + }) + + 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 { + const input = parseEditArgs(args) + const target = await ctx.fs.resolve(input.filePath) + const outcome = await ctx.fs.edit( + target, + { oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll }, + exec, + exec.signal, + ) + return [{ type: 'text', text: formatEditOutput(target.displayPath, outcome) }] + }, + })) +} + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'fs-edit' + +/** Services required by the `edit` tool plugin. */ +export const inject = ['tools', 'fs', 'systemPrompt'] + +/** Named helper for direct registration in the root plugin and tests. */ +export const applyEditTool = apply diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts new file mode 100644 index 0000000000..437c16b5dd --- /dev/null +++ b/packages/fs/tool-fs/src/index.ts @@ -0,0 +1,35 @@ +/** + * The model-facing filesystem tool suite (`read`, `write`, `edit`) over the + * `ctx.fs` seam. This root plugin registers all three tools by composing the + * per-tool registration helpers; each tool is also exposed as a subpath plugin + * (`@deepseek-ai/dsh-tool-fs/read`, `/write`, `/edit`) for focused deployments. + * + * The package owns model-facing concerns only — tool names, JSON schemas, + * argument validation, prompt sections, result formatting. All filesystem + * execution goes through `ctx.fs`; 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 { applyReadTool } from './read.ts' +import { applyWriteTool } from './write.ts' +import { applyEditTool } from './edit.ts' + +export { READ_LIMIT, applyReadTool, formatReadOutput, parseReadArgs } from './read.ts' +export { applyWriteTool, formatWriteOutput, parseWriteArgs } from './write.ts' +export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.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'] + +/** Register the full `read`/`write`/`edit` filesystem tool suite. */ +export function apply(ctx: Context): void { + applyReadTool(ctx) + applyWriteTool(ctx) + applyEditTool(ctx) +} diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts new file mode 100644 index 0000000000..bfa67a588f --- /dev/null +++ b/packages/fs/tool-fs/src/read.ts @@ -0,0 +1,95 @@ +/** + * The model-facing `read` tool: inspect a UTF-8 text file and return + * line-numbered content with pagination guidance. Execution goes through + * `ctx.fs` — this module owns only the model-facing schema, argument + * validation, and result formatting, never filesystem I/O. + * + * @module @deepseek-ai/dsh-tool-fs/read + */ + +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { FsReadOutcome } from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-system-prompt' + +/** Default and maximum number of lines returned by one `read` call. */ +export const READ_LIMIT = 2000 + +/** 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. */ +export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }): ReadInput { + if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') + const offset = args.offset === undefined ? 1 : parsePositiveInteger(args.offset, 'offset') + const limit = args.limit === undefined ? READ_LIMIT : parsePositiveInteger(args.limit, 'limit') + if (limit > READ_LIMIT) throw new Error(`limit must be less than or equal to ${READ_LIMIT}`) + return { filePath: args.file_path, offset, limit } +} + +/** Format a read outcome as one OpenCode-style line-numbered text block body. */ +export function formatReadOutput(displayPath: string, outcome: FsReadOutcome): 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 `${displayPath} +file + +${body} +` +} + +/** Register the `read` tool and its system-prompt guidance. */ +export function apply(ctx: Context): void { + ctx.systemPrompt.section({ + name: 'tool:read', + order: 100, + text: 'Use the read tool 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 ${READ_LIMIT}.` }, + }, + async execute(args, exec): Promise { + const input = parseReadArgs(args) + const target = await ctx.fs.resolve(input.filePath) + const outcome = await ctx.fs.read(target, { offset: input.offset, limit: input.limit }, exec, exec.signal) + return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }] + }, + })) +} + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'fs-read' + +/** Services required by the `read` tool plugin. */ +export const inject = ['tools', 'fs', 'systemPrompt'] + +/** Named helper for direct registration in the root plugin and tests. */ +export const applyReadTool = apply diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts new file mode 100644 index 0000000000..ff66d10127 --- /dev/null +++ b/packages/fs/tool-fs/src/write.ts @@ -0,0 +1,63 @@ +/** + * The model-facing `write` tool: create or fully replace a UTF-8 text file. + * Execution goes through `ctx.fs`, which enforces the read-before-overwrite + * policy (updating an existing file requires a prior read in the same + * execution context; creating a new file does not). + * + * @module @deepseek-ai/dsh-tool-fs/write + */ + +import type { Context } from 'cordis' +import { defineTool } 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-system-prompt' + +/** 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 `${displayPath} +file + +${verb} file +` +} + +/** Register the `write` tool and its system-prompt guidance. */ +export function apply(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 backend 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 { + const input = parseWriteArgs(args) + const target = await ctx.fs.resolve(input.filePath) + const outcome = await ctx.fs.write(target, input.content, exec, exec.signal) + return [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }] + }, + })) +} + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'fs-write' + +/** Services required by the `write` tool plugin. */ +export const inject = ['tools', 'fs', 'systemPrompt'] + +/** Named helper for direct registration in the root plugin and tests. */ +export const applyWriteTool = apply diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts new file mode 100644 index 0000000000..6f81763241 --- /dev/null +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -0,0 +1,143 @@ +/** + * Integration tests: the real local backend (`dsh-fs-local`) plus the model + * tools (`dsh-tool-fs`), exercised through `ctx.tools.execute()` so nothing + * bypasses the tool registry. 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 } 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 ToolFs from '@deepseek-ai/dsh-tool-fs' + +let dir: string +let ctx: Context +let fiber: Awaited> +// A stable session object stands in for an agent session (the file-state owner). +const session = {} + +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 }) + fiber = await ctx.plugin(ToolFs) +}) +afterEach(async () => { + await fiber.dispose() + await rm(dir, { recursive: true, force: true }) +}) + +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('') +} + +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' }) + // The world is unchanged. + 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') + }) +}) + +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' }) + }) +}) + +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('rejects an edit after only a partial read, leaving the file untouched', async () => { + await writeFile(join(dir, 'a.txt'), 'hello\nworld') + await call('read', { file_path: 'a.txt', offset: 1, limit: 1 }) + 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_PARTIAL_OBSERVATION' }) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello\nworld') + }) + + 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') + }) +}) diff --git a/packages/fs/tool-fs/tests/subpaths.spec.ts b/packages/fs/tool-fs/tests/subpaths.spec.ts new file mode 100644 index 0000000000..ac35babe04 --- /dev/null +++ b/packages/fs/tool-fs/tests/subpaths.spec.ts @@ -0,0 +1,74 @@ +/** + * Tests for the per-tool subpath plugins (`@deepseek-ai/dsh-tool-fs/read`, + * `/write`, `/edit`): each registers exactly one tool, injects the same + * services, and cleans up on disposal. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import { FileSystem } from '@deepseek-ai/dsh-fs' +import type { + FsEditOutcome, + FsReadOutcome, + FsTarget, + FsWriteOutcome, +} from '@deepseek-ai/dsh-fs' +import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read' +import * as writePlugin from '@deepseek-ai/dsh-tool-fs/write' +import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' + +class StubFs extends FileSystem { + override async resolve(path: string): Promise { + return { inputPath: path, targetKey: path, displayPath: path } + } + override async readPage(): Promise { + return { offset: 1, limit: 1, lines: [], totalLines: 0, version: 'v', view: 'full' } + } + override async createOrReplace(): Promise { + return { operation: 'create', version: 'v' } + } + override async applyEdit(): Promise { + return { replacements: 1, replaceAll: false, version: 'v' } + } +} + +async function base() { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(StubFs) + return ctx +} + +describe('subpath plugins', () => { + it('each registers exactly its one tool', async () => { + const cases: Array<[unknown, string]> = [ + [readPlugin, 'read'], + [writePlugin, 'write'], + [editPlugin, 'edit'], + ] + for (const [plugin, toolName] of cases) { + const ctx = await base() + await ctx.plugin(plugin as Parameters[0]) + expect(ctx.tools.schemas().map(s => s.name)).toEqual([toolName]) + } + }) + + it('cleans up on disposal (HMR safety)', async () => { + const ctx = await base() + const fiber = await ctx.plugin(readPlugin as Parameters[0]) + expect(ctx.tools.schemas()).toHaveLength(1) + await fiber.dispose() + expect(ctx.tools.schemas()).toHaveLength(0) + }) + + it('stays pending without a ctx.fs provider', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(writePlugin as Parameters[0]) + expect(ctx.tools.schemas()).toHaveLength(0) + }) +}) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts new file mode 100644 index 0000000000..594a07dbbd --- /dev/null +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -0,0 +1,270 @@ +/** + * Consumer-surface tests for the filesystem tools using a fake `ctx.fs` that + * records the execution context it received and returns canned outcomes. These + * verify schemas, argument validation, result formatting, FsError→isError + * propagation, and that each tool passes `exec` straight through to `ctx.fs`. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import { FileSystem, FsError } from '@deepseek-ai/dsh-fs' +import type { + FsEditOutcome, + FsEditRequest, + FsExecContext, + FsReadOutcome, + FsReadRequest, + FsTarget, + FsWriteOutcome, +} from '@deepseek-ai/dsh-fs' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import { formatReadOutput } from '@deepseek-ai/dsh-tool-fs' + +/** + * Records the public-API calls (and the exec each received) and returns canned + * outcomes; lets a test arm a rejection. Overrides the public methods directly + * (not the primitives) so we observe exactly what the tool passed. + */ +class FakeFs extends FileSystem { + calls: Array<{ op: string; exec: FsExecContext | undefined; target: FsTarget }> = [] + rejectWith?: FsError + + override async resolve(path: string): Promise { + return { inputPath: path, targetKey: `key:${path}`, displayPath: `/abs/${path}` } + } + + override async readPage(): Promise { + throw new Error('not used: tool tests override read()') + } + override async createOrReplace(): Promise { + throw new Error('not used') + } + override async applyEdit(): Promise { + throw new Error('not used') + } + + override async read(target: FsTarget, _request: FsReadRequest, exec?: FsExecContext): Promise { + this.calls.push({ op: 'read', exec, target }) + if (this.rejectWith) throw this.rejectWith + return { + offset: 1, + limit: 2000, + lines: [{ number: 1, text: 'hello' }, { number: 2, text: 'world' }], + totalLines: 2, + version: 'v1', + view: 'full', + } + } + + override async write(target: FsTarget, _content: string, exec?: FsExecContext): Promise { + this.calls.push({ op: 'write', exec, target }) + if (this.rejectWith) throw this.rejectWith + return { operation: 'create', version: 'v1' } + } + + override async edit(target: FsTarget, _edit: FsEditRequest, exec?: FsExecContext): Promise { + this.calls.push({ op: 'edit', exec, target }) + if (this.rejectWith) throw this.rejectWith + return { replacements: 1, replaceAll: false, version: 'v1' } + } +} + +async function setup() { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FakeFs) + 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) + const fiber = await ctx.plugin(ToolFs) + expect(ctx.tools.schemas()).toHaveLength(3) + await fiber.dispose() + expect(ctx.tools.schemas()).toHaveLength(0) + }) +}) + +describe('read tool', () => { + it('formats line-numbered content with a footer', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'read', { file_path: 'a.txt' }) + expect(result.isError).toBe(false) + expect(text(result)).toBe(`/abs/a.txt +file + +1: hello +2: world + +(End of file - total 2 lines) +`) + }) + + 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 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('passes the execution context through to ctx.fs', async () => { + const { ctx, fs } = await setup() + const session = {} + await call(ctx, 'read', { file_path: 'a.txt' }, { session }) + expect(fs.calls).toHaveLength(1) + expect(fs.calls[0]?.op).toBe('read') + expect(fs.calls[0]?.exec?.agent?.session).toBe(session) + }) +}) + +describe('formatReadOutput footer variants', () => { + const base = { offset: 1, limit: 2000, lines: [{ number: 1, text: 'x' }], totalLines: 1, version: 'v', view: 'full' as const } + + 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', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }) + expect(result.isError).toBe(false) + expect(text(result)).toContain('Created file') + }) + + 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', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) + expect(text(result)).toBe('The file /abs/a.txt has been updated successfully.') + }) + + 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 from the backend', async () => { + const { ctx, fs } = await setup() + fs.rejectWith = new FsError('read first', 'FS_NOT_OBSERVED') + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + + it('propagates FS_PARTIAL_OBSERVATION from the backend', async () => { + const { ctx, fs } = await setup() + fs.rejectWith = new FsError('read fully first', 'FS_PARTIAL_OBSERVATION') + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) + }) +}) diff --git a/packages/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json new file mode 100644 index 0000000000..ee5a853c91 --- /dev/null +++ b/packages/fs/tool-fs/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../llm/llm" }, + { "path": "../../core/tools" }, + { "path": "../../core/system-prompt" }, + { "path": "../fs" } + ] +} diff --git a/packages/fs/tool-fs/tsdown.config.ts b/packages/fs/tool-fs/tsdown.config.ts new file mode 100644 index 0000000000..131735d482 --- /dev/null +++ b/packages/fs/tool-fs/tsdown.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsdown' + +/** + * tool-fs exposes one package root plus one entry per tool plugin, so each tool + * can be loaded or replaced independently as a subpath plugin + * (`@deepseek-ai/dsh-tool-fs/read`, `/write`, `/edit`). The root tsdown config + * only auto-discovers `src/index.ts`, so the subpath entries are declared here. + */ +export default defineConfig({ + entry: ['src/index.ts', 'src/read.ts', 'src/write.ts', 'src/edit.ts'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bca330308..cae3a42202 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,6 +236,58 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/fs/fs: + devDependencies: + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/fs/fs-local: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../fs + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/fs/tool-fs: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../fs-local + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/llm/llm: devDependencies: '@deepseek-ai/dsh-brand': diff --git a/tsconfig.base.json b/tsconfig.base.json index 8e2070fe28..ad08d09468 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -34,6 +34,9 @@ "@cordisjs/plugin-timer": ["./vendor/timer/src"], "@cordisjs/plugin-hmr": ["./vendor/hmr/src"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], + "@deepseek-ai/dsh-tool-fs/read": ["./packages/fs/tool-fs/src/read.ts"], + "@deepseek-ai/dsh-tool-fs/write": ["./packages/fs/tool-fs/src/write.ts"], + "@deepseek-ai/dsh-tool-fs/edit": ["./packages/fs/tool-fs/src/edit.ts"], // One wildcard maps every @deepseek-ai/dsh- to its source. Package // dir names are unique across groups, so first-on-disk-wins resolution is // unambiguous; adding a package under an existing group needs no edit @@ -43,6 +46,7 @@ "./packages/core/*/src", "./packages/llm/*/src", "./packages/bash/*/src", + "./packages/fs/*/src", "./packages/session-persistence/*/src", "./packages/ui/*/src", "./packages/util/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index 27a17a3f17..ea3882b873 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -26,6 +26,9 @@ { "path": "./packages/llm/llm-pi-ai" }, { "path": "./packages/bash/bash-local" }, { "path": "./packages/bash/tool-bash" }, + { "path": "./packages/fs/fs" }, + { "path": "./packages/fs/fs-local" }, + { "path": "./packages/fs/tool-fs" }, { "path": "./packages/support/invariants" }, { "path": "./packages/ui/acp" }, { "path": "./packages/ui/acp-agent" }, diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index 54769bdbc0..0522b9649c 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -16,10 +16,14 @@ "@cordisjs/plugin-timer": ["./vendor/timer/lib"], "@cordisjs/plugin-hmr": ["./vendor/hmr/lib"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/lib/shared"], + "@deepseek-ai/dsh-tool-fs/read": ["./packages/fs/tool-fs/src/read.ts"], + "@deepseek-ai/dsh-tool-fs/write": ["./packages/fs/tool-fs/src/write.ts"], + "@deepseek-ai/dsh-tool-fs/edit": ["./packages/fs/tool-fs/src/edit.ts"], "@deepseek-ai/dsh-*": [ "./packages/core/*/src", "./packages/llm/*/src", "./packages/bash/*/src", + "./packages/fs/*/src", "./packages/session-persistence/*/src", "./packages/ui/*/src", "./packages/util/*/src", From ac28e1a1d3952c47942951fb5c6f99889dced282 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 22 Jun 2026 14:53:36 +0800 Subject: [PATCH 02/46] docs: add filesystem data structures catalog --- docs/cordis-catalog/events-and-services.md | 2 + docs/core-data-structures/core.md | 1 + docs/core-data-structures/filesystem.md | 145 +++++++++++++++++++++ scripts/gen-cordis-catalog.ts | 9 ++ scripts/type-equiv.manifest.json | 17 ++- 5 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 docs/core-data-structures/filesystem.md diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index a64e962098..0d0cd7b90b 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -363,6 +363,8 @@ async write(target: FsTarget, content: string, exec?: FsExecContext, signal?: Ab async edit(target: FsTarget, edit: FsEditRequest, exec?: FsExecContext, signal?: AbortSignal): Promise ``` +Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsExecContext](../core-data-structures/filesystem.md) · [FsExpectation](../core-data-structures/filesystem.md) · [FsReadOutcome](../core-data-structures/filesystem.md) · [FsReadRequest](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) + Source: [`packages/fs/fs/src/index.ts:94`](../../packages/fs/fs/src/index.ts) ### `ctx.llm` — `LlmService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index b50c3483e4..e6aa7f177f 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -20,6 +20,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | | [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/execute` waterfall | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s | +| [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` | > Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts. diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md new file mode 100644 index 0000000000..fc80891ef6 --- /dev/null +++ b/docs/core-data-structures/filesystem.md @@ -0,0 +1,145 @@ +# Filesystem + +The filesystem execution seam is split across three packages: interface ([dsh-fs](../../packages/fs/fs), `ctx.fs`), implementation ([dsh-fs-local](../../packages/fs/fs-local), local disk), and consumer ([dsh-tool-fs](../../packages/fs/tool-fs), the model-facing `read`/`write`/`edit` tools). Filesystem access is an optional capability, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). A sandboxed, remote, virtual, or project-scoped backend can implement the same `FileSystem` service without changing the tool schemas. + +Source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) and [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts) + +## Execution context and target identity + +The filesystem seam needs just enough execution context to derive the observed-file owner. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through without making `dsh-fs` import the tool, agent, or session packages. + +```ts type-equiv +interface FsExecContext { + agent?: { + session?: object + } +} +``` + +Every operation resolves a user-supplied path to an opaque backend target first. Consumers may display `displayPath`, but must not parse `targetKey` or assume it is a local absolute path. + +```ts type-equiv +interface FsTarget { + inputPath: string + targetKey: string + displayPath: string +} +``` + +The backend also owns file-version tokens. `ctx.fs` stores them for stale checks; consumers do not interpret them. + +```ts type-equiv +type FsVersion = string +``` + +## Reads and editable views + +A text read is bounded by line window, byte cap, and backend limits. The returned view records whether the owner saw the whole file or only a partial page; only a `full` view authorizes later write/edit. + +```ts type-equiv +interface FsReadRequest { + offset: number + limit: number +} +``` + +```ts type-equiv +interface FsTextLine { + number: number + text: string +} +``` + +```ts type-equiv +type FsView = 'full' | 'partial' +``` + +```ts type-equiv +interface FsReadOutcome { + offset: number + limit: number + lines: FsTextLine[] + totalLines: number + truncatedByBytes?: true + version: FsVersion + view: FsView +} +``` + +## Write and edit guards + +The base `FileSystem` service converts recorded state into an `FsExpectation` before calling the backend. `observed` carries the stale guard, `partial` means the owner saw a non-editable view, and `unobserved` allows create-if-absent but rejects blind overwrite. + +```ts type-equiv +type FsExpectation = + | { kind: 'observed'; version: FsVersion } + | { kind: 'partial'; version: FsVersion } + | { kind: 'unobserved' } +``` + +```ts type-equiv +interface FsWriteOutcome { + operation: 'create' | 'update' + version: FsVersion +} +``` + +Literal edit is a backend operation, not a `read` plus `write` composed in the tool wrapper. That keeps matching, line-ending handling, stale checks, and atomic replacement inside the filesystem seam. + +```ts type-equiv +interface FsEditRequest { + oldString: string + newString: string + replaceAll: boolean +} +``` + +```ts type-equiv +interface FsEditOutcome { + replacements: number + replaceAll: boolean + version: FsVersion +} +``` + +## Observed-file state + +Observed state is keyed inside the service by owner object and `FsTarget.targetKey`. The owner is normally `exec.agent.session`, but `dsh-fs` treats it as opaque and never reads its fields. A successful read/write/edit refreshes this state for that owner. + +```ts type-equiv +type FsStateSource = 'read' | 'write' | 'edit' +``` + +```ts type-equiv +interface FileState { + targetKey: string + displayPath: string + version: FsVersion + view: FsView + updatedAt: number + source: FsStateSource +} +``` + +## Error taxonomy + +Filesystem failures use stable `FsErrorCode` strings carried by `FsError` (`HarnessError`). The tool registry preserves `{ name, code }` on error results, so retry, permission, and UI layers can branch without parsing text. + +```ts type-equiv +type FsErrorCode = + | 'FS_NOT_FOUND' + | 'FS_NOT_TEXT' + | 'FS_NOT_REGULAR_FILE' + | 'FS_STALE_VERSION' + | 'FS_NOT_OBSERVED' + | 'FS_PARTIAL_OBSERVATION' + | 'FS_AMBIGUOUS_EDIT' + | 'FS_EDIT_NOT_FOUND' + | 'FS_ABORTED' +``` + +`FS_NOT_OBSERVED` means no usable prior observation exists. `FS_PARTIAL_OBSERVATION` means the owner saw only a partial read. `FS_STALE_VERSION` means there was a prior full observation, but the backend version no longer matches. + +## The service + +`FileSystem` (`ctx.fs`, abstract) owns the shared orchestration: `resolve`, `readPage`, `createOrReplace`, and `applyEdit` are backend primitives; public `read`, `write`, and `edit` derive/record owner state and enforce the read-before-write/edit policy before delegating to the backend. The generated wiring catalog shows the exact service signatures on [events-and-services.md](../cordis-catalog/events-and-services.md#ctxfs--filesystem-abstract-seam). diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 7479f5306c..41f8830335 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -75,6 +75,15 @@ const LINK_MAP: Record = { BashRunResult: 'bash.md', BashTask: 'bash.md', BashTaskRead: 'bash.md', + FsEditOutcome: 'filesystem.md', + FsEditRequest: 'filesystem.md', + FsExecContext: 'filesystem.md', + FsExpectation: 'filesystem.md', + FsReadOutcome: 'filesystem.md', + FsReadRequest: 'filesystem.md', + FsTarget: 'filesystem.md', + FsVersion: 'filesystem.md', + FsWriteOutcome: 'filesystem.md', } /** One harness event, extracted from an `interface Events` block. */ diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index f46c4fca6b..d9df0302ab 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -35,6 +35,21 @@ { "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" }, { "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/bash/src/types.ts" }, { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" } + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" }, + + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsExecContext", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTarget", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsReadRequest", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTextLine", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsView", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsReadOutcome", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsExpectation", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteOutcome", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditRequest", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditOutcome", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsStateSource", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileState", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsErrorCode", "source": "packages/fs/fs/src/types.ts" } ] } From c7a197fb5f19edd71a2e3b59c1aa1d3010cd4f0d Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 22 Jun 2026 18:51:30 +0800 Subject: [PATCH 03/46] fix(fs-local): reject unsafe text observations --- .../2026-06-17-filesystem-capability-seam.md | 11 +- packages/fs/fs-local/README.md | 4 +- packages/fs/fs-local/src/fsio.ts | 107 ++++++++++++------ packages/fs/fs-local/tests/filesystem.spec.ts | 31 ++++- packages/fs/fs-local/tests/fsio.spec.ts | 20 ++++ 5 files changed, 133 insertions(+), 40 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md index 55f5efe14b..4faae84d83 100644 --- a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -121,21 +121,22 @@ The root plugin registers the full suite by composing the per-tool registration ## Migration plan -This RFC starts from `origin/master`, where no filesystem tool package exists yet. The final implementation should add the new three-package topology directly: +This RFC starts from `origin/master`, where no filesystem tool package exists yet. The landed implementation adds the new three-package topology directly: 1. Add `packages/fs/fs` with the `ctx.fs` abstract service and vocabulary types. 2. Add `packages/fs/fs-local` with the local backend implementation and backend-level tests. 3. Add `packages/fs/tool-fs` with the model-facing `read`, `write`, and `edit` tools over `ctx.fs`. -4. Wire examples by loading a `ctx.fs` provider first (`dsh-fs-local`), then the consumer (`dsh-tool-fs` or one of its subpath plugins). -5. Update `docs/architecture.md`, `packages/README.md`, package READMEs, build/typecheck config, and aggregate maintenance scripts such as `scripts/publint-all.ts`. +4. Update `docs/architecture.md`, `packages/README.md`, package READMEs, build/typecheck config, and aggregate maintenance scripts such as `scripts/publint-all.ts`. This first pass does not add a separate `@deepseek-ai/dsh-file-context` package. The file-state store lives behind `ctx.fs` so root and subpath `tool-fs` plugins share the same read-before-write/edit policy automatically. +Example leaf configs stay bash-only in this landing. Wiring `examples/coding-agent` or `examples/acp-agent` to `dsh-fs-local` + `dsh-tool-fs` changes the model prompt, visible tool schemas, and ACP snapshot transcript, so it should land as a follow-up UX/example change with prompt and snapshot updates in the same PR. + If this work is split into multiple PRs, they should follow the seam order: 1. Interface PR: `dsh-fs` only, with service registration and contract tests. 2. Implementation PR: `dsh-fs-local`, with real filesystem behavior tests. -3. Consumer PR: `dsh-tool-fs`, examples, docs, and integration tests. +3. Consumer PR: `dsh-tool-fs`, docs, and integration tests; example wiring follows in a separate prompt/snapshot PR. The earlier combined package name `@deepseek-ai/dsh-fs-tools` should not become part of the new public surface. @@ -159,7 +160,7 @@ Beyond the happy/sad paths above, `dsh-fs-local` tests must cover the defensive- Integration tests should load `dsh-fs-local` plus `dsh-tool-fs` and execute `read`, `write`, and `edit` through `ctx.tools.execute()` to prove the three packages work together without bypassing the tool registry. They must verify the world, not the tool's self-report: after a `write`/`edit`, read the file back from disk and assert byte-identical content (and that untouched files are unchanged), rather than trusting the returned `ContentBlock[]`. Each integration/e2e test owns its resources — create the harness in the test, run against a per-test temporary directory, and dispose the harness and remove the directory in `afterEach` even on failure or timeout. -Repo gates for the implementation include the focused vitest suites, `yarn typecheck`, `yarn test:coverage` for runtime code, and build/publint coverage after adding package entrypoints. +Repo gates for the implementation include the focused vitest suites, `pnpm run typecheck`, `pnpm run test:coverage` for runtime code, and build/publint coverage after adding package entrypoints. ## Risks diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 52dfa2e38b..794239ed2d 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -11,8 +11,8 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) ## Behavior -- **`resolve(path)`** — relative paths resolve from `config.cwd` (default `process.cwd()`). 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 keeps its absolute path as the key so creates still get a stable identity. `displayPath` is the absolute (un-resolved) path. -- **`readPage`** — UTF-8 only. A fast path (`readFile`) handles files under `FAST_PATH_MAX_SIZE` (10 MB); larger files stream with a capped line buffer so a newline-free giant file can't exhaust memory. NUL-byte samples are rejected (`FS_NOT_TEXT`). Output is bounded to `READ_LIMIT` (2000) lines, `READ_MAX_BYTES` (50 KB), and `READ_MAX_LINE_LENGTH` (2000) chars per line. The `version` is `mtimeMs:size`. +- **`resolve(path)`** — relative paths resolve from `config.cwd` (default `process.cwd()`). 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. +- **`readPage`** — UTF-8 only. A fast path (`readFile`) handles files under `FAST_PATH_MAX_SIZE` (10 MB); larger files stream with a capped line buffer so a newline-free giant file can't exhaust memory. Invalid UTF-8 and NUL-byte samples are rejected (`FS_NOT_TEXT`). Output is bounded to `READ_LIMIT` (2000) lines, `READ_MAX_BYTES` (50 KB), and `READ_MAX_LINE_LENGTH` (2000) chars per line; hitting any bound records a `partial` view. The `version` is `mtimeMs:size`. - **`createOrReplace`** — 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`. Honors the `FsExpectation`: an `observed` write must match the recorded version (else `FS_STALE_VERSION`); a `partial` write onto an existing file is rejected (`FS_PARTIAL_OBSERVATION`); an `unobserved` write onto an existing file is rejected (`FS_NOT_OBSERVED`). - **`applyEdit`** — atomic literal read-modify-write over the same primitive. Verifies the expected version, 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`). diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 8c94abee24..a7e124db15 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -6,8 +6,8 @@ * The reader uses two code paths so a single huge line can never balloon * memory: a **fast path** (`readFile` + in-memory split) for files under * {@link FAST_PATH_MAX_SIZE}, and a **streaming path** (manual newline scan - * with a capped line buffer) for larger files. Both reject NUL-byte binary - * samples and keep only the requested page in memory. + * with a capped line buffer) for larger files. Both reject invalid UTF-8 and + * NUL-byte binary samples, and keep only the requested page in memory. * * 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 @@ -23,6 +23,7 @@ import { createReadStream } from 'node:fs' import { chmod, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises' import type { Stats } from 'node:fs' import { basename, dirname, join, resolve } from 'node:path' +import { TextDecoder } from 'node:util' import { FsError } from '@deepseek-ai/dsh-fs' import type { FsReadRequest, FsTextLine, FsView } from '@deepseek-ai/dsh-fs' @@ -41,7 +42,6 @@ export const FAST_PATH_MAX_SIZE = 10 * 1024 * 1024 const READ_MAX_BYTES_LABEL = `${READ_MAX_BYTES / 1024} KB` const READ_MAX_LINE_SUFFIX = `... (line truncated to ${READ_MAX_LINE_LENGTH} chars)` const BINARY_SAMPLE_BYTES = 8192 -const NUL_CHAR = String.fromCharCode(0) const LINE_BUFFER_CAP = READ_MAX_LINE_LENGTH + 1 /** @@ -145,17 +145,18 @@ interface PageAccumulator { totalLines: number outputBytes: number truncatedByBytes: boolean + truncatedByLine: boolean done: boolean } function newAccumulator(): PageAccumulator { - return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false } + return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, truncatedByLine: false, done: false } } -function truncateReadLine(line: string): string { +function truncateReadLine(line: string): { text: string; truncated: boolean } { return line.length > READ_MAX_LINE_LENGTH - ? `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}` - : line + ? { text: `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}`, truncated: true } + : { text: line, truncated: false } } function lineByteSize(line: string, currentLineCount: number): number { @@ -166,7 +167,8 @@ function consumeLine(acc: PageAccumulator, rawLine: string, request: FsReadReque acc.totalLines += 1 if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return - const text = truncateReadLine(rawLine) + const { text, truncated } = truncateReadLine(rawLine) + if (truncated) acc.truncatedByLine = true const bytes = lineByteSize(text, acc.lines.length) if (acc.outputBytes + bytes > READ_MAX_BYTES) { acc.truncatedByBytes = true @@ -195,13 +197,41 @@ function buildResult(acc: PageAccumulator, request: FsReadRequest, version: stri throw new FsError(`offset ${request.offset} is out of range for "${displayPath}" (${acc.totalLines} lines)`, 'FS_NOT_FOUND') } const endLine = acc.lines.at(-1)?.number ?? Math.max(0, request.offset - 1) - const view: FsView = request.offset === 1 && !acc.truncatedByBytes && endLine >= acc.totalLines ? 'full' : 'partial' + const view: FsView = request.offset === 1 && !acc.truncatedByBytes && !acc.truncatedByLine && endLine >= acc.totalLines ? 'full' : 'partial' return { lines: acc.lines, totalLines: acc.totalLines, truncatedByBytes: acc.truncatedByBytes, view, version } } +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) { + if (error instanceof TypeError) throw notTextError(verb, displayPath) + throw error + } +} + +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) { + if (error instanceof TypeError) throw notTextError(verb, displayPath) + throw error + } +} + /** - * Read a bounded UTF-8 text-file page. Rejects non-regular files and NUL-byte - * binary samples; dispatches to the fast or streaming path by file size. + * Read a bounded UTF-8 text-file page. Rejects non-regular files, invalid + * UTF-8, and NUL-byte binary samples; dispatches to the fast or streaming path + * by file size. */ export async function readTextPage( target: LocalTarget, @@ -240,7 +270,7 @@ async function readTextPageFast( throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT') } - const text = raw.toString('utf8') + const text = decodeUtf8(raw, 'read', target.displayPath) const acc = newAccumulator() let startPos = 0 let newlinePos: number @@ -261,10 +291,11 @@ async function readTextPageStreaming( version: string, signal?: AbortSignal, ): Promise { - const stream = createReadStream(target.targetKey, { encoding: 'utf8', ...signal ? { signal } : {} }) + const stream = createReadStream(target.targetKey, signal ? { signal } : {}) const acc = newAccumulator() let lineBuffer = '' - let firstChunk = true + let sampledBytes = 0 + const decoder = new TextDecoder('utf-8', { fatal: true }) function appendToLineBuffer(segment: string): void { if (lineBuffer.length >= LINE_BUFFER_CAP) return @@ -277,24 +308,36 @@ async function readTextPageStreaming( lineBuffer = '' } - try { - for await (const chunk of stream as AsyncIterable) { - if (firstChunk) { - firstChunk = false - if (chunk.slice(0, BINARY_SAMPLE_BYTES).includes(NUL_CHAR)) { - throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT') - } - } - 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 buildResult(acc, request, version, target.displayPath) - } - appendToLineBuffer(chunk.slice(startPos)) + 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 + } + + function consumeChunk(chunk: string): ReadPageResult | undefined { + 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 buildResult(acc, request, version, target.displayPath) + } + appendToLineBuffer(chunk.slice(startPos)) + return undefined + } + + try { + for await (const chunk of stream as AsyncIterable) { + scanBinarySample(chunk) + const result = consumeChunk(decodeUtf8Stream(decoder, chunk, 'read', target.displayPath)) + if (result) return result + } + const finalResult = consumeChunk(decodeUtf8Stream(decoder, undefined, 'read', target.displayPath)) + if (finalResult) return finalResult } 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') @@ -435,7 +478,7 @@ export async function readForEdit( const buffer = await readFile(absolutePath, signal ? { signal } : {}) throwIfAborted(signal, 'edit') if (buffer.includes(0)) throw new FsError(`cannot edit "${displayPath}": binary file`, 'FS_NOT_TEXT') - const raw = buffer.toString('utf8') + const raw = decodeUtf8(buffer, 'edit', displayPath) return { content: normalizeLineEndings(raw), lineEndings: detectLineEndings(raw) } } diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index ae1605892a..eb675472af 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -9,7 +9,7 @@ import { mkdtemp, readFile, rm, stat, symlink, writeFile, unlink } from 'node:fs import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' -import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' +import { LocalFileSystem, probe } from '@deepseek-ai/dsh-fs-local' import type { FsExecContext } from '@deepseek-ai/dsh-fs' let dir: string @@ -89,6 +89,19 @@ describe('read → write → edit lifecycle', () => { expect(outcome.view).toBe('partial') }) + it('records an over-long-line read as partial, so write/edit stay blocked', async () => { + await writeFile(join(dir, 'long.txt'), 'x'.repeat(3000)) + const owner = exec() + const target = await fs.resolve('long.txt') + const outcome = await fs.read(target, READ_ALL, owner) + + expect(outcome.view).toBe('partial') + await expect(fs.write(target, 'new', owner)).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) + await expect( + fs.edit(target, { oldString: 'x', newString: 'y', replaceAll: false }, owner), + ).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) + }) + it('allows a follow-up edit without re-reading (write/edit refresh state)', async () => { await writeFile(join(dir, 'a.txt'), 'a b') const owner = exec() @@ -142,6 +155,22 @@ describe('read-before-write policy', () => { await expect(fs.edit(target, { oldString: 'old', newString: 'new', replaceAll: false }, exec())) .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) }) + + it('rejects invalid UTF-8 reads and edits without rewriting the file', async () => { + const path = join(dir, 'invalid-utf8.txt') + const bytes = Buffer.from([0x68, 0xff, 0x69]) + await writeFile(path, bytes) + const owner = exec() + const target = await fs.resolve('invalid-utf8.txt') + + await expect(fs.read(target, READ_ALL, owner)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + const existing = await probe(target.targetKey) + if (!existing) throw new Error('expected invalid UTF-8 fixture to exist') + await expect( + fs.applyEdit(target, { oldString: 'h', newString: 'H', replaceAll: false }, { version: existing.version }), + ).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + expect(await readFile(path)).toEqual(bytes) + }) }) describe('stale-version guard + concurrency (defensive class B)', () => { diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 77b8d8ccba..f25f9e9a0b 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -102,6 +102,7 @@ describe('readTextPage', () => { await writeFile(file, 'x'.repeat(3000)) const result = await readTextPage(localTarget(file), READ_ALL) expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)') + expect(result.view).toBe('partial') }) it('caps output bytes and reports truncatedByBytes', async () => { @@ -141,6 +142,12 @@ describe('readTextPage', () => { await expect(readTextPage(localTarget(file), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) }) + it('rejects invalid UTF-8 bytes (fast path)', async () => { + const file = join(dir, 'invalid-utf8.txt') + await writeFile(file, Buffer.from([0x68, 0xff, 0x69])) + await expect(readTextPage(localTarget(file), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + }) + it('rejects a missing file and a directory', async () => { await expect(readTextPage(localTarget(join(dir, 'nope')), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) await expect(readTextPage(localTarget(dir), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) @@ -181,6 +188,13 @@ describe('readTextPage', () => { await writeFile(file, 'z'.repeat(5000)) const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)') + expect(result.view).toBe('partial') + }) + + it('rejects invalid UTF-8 bytes on the streaming path', async () => { + const file = join(dir, 'invalid-utf8.txt') + await writeFile(file, Buffer.from([0x68, 0xff, 0x69])) + await expect(readTextPage(localTarget(file), READ_ALL, undefined, stream)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) }) it('honors abort on the streaming path', async () => { @@ -338,6 +352,12 @@ describe('readForEdit + restoreLineEndings', () => { await expect(readForEdit(file, file)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) }) + it('rejects invalid UTF-8 bytes', async () => { + const file = join(dir, 'invalid-utf8.txt') + await writeFile(file, Buffer.from([0x68, 0xff, 0x69])) + await expect(readForEdit(file, file)).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') From aa9afcefc7dc85f7dc53b7f79430952ab94a9527 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 25 Jun 2026 17:30:42 +0800 Subject: [PATCH 04/46] feat(compact-basic): baseline compaction backend (squashed from compact-basic) Collapses the per-round review churn of the prior compact-basic branch into a single clean baseline on top of compact-interface, so the upcoming retention refactor lands as fresh, well-scoped commits rather than stacking on a history of fixes that are being superseded. --- docs/architecture.md | 4 +- docs/cordis-catalog/events-and-services.md | 8 +- docs/core-data-structures/compaction.md | 15 +- docs/module-graph.md | 5 + packages/README.md | 6 +- packages/compact/README.md | 6 +- packages/compact/compact-basic/README.md | 45 + packages/compact/compact-basic/package.json | 39 + packages/compact/compact-basic/src/index.ts | 753 +++++++++ packages/compact/compact-basic/src/types.ts | 44 + .../compact-basic/tests/compact-basic.spec.ts | 1361 +++++++++++++++++ packages/compact/compact-basic/tsconfig.json | 16 + packages/compact/compact/README.md | 2 +- packages/compact/compact/src/index.ts | 18 +- packages/compact/compact/src/types.ts | 11 +- packages/core/session/src/index.ts | 1 + packages/core/session/src/step-boundary.ts | 97 ++ .../core/session/tests/step-boundary.spec.ts | 172 +++ packages/support/invariants/src/index.ts | 3 - .../invariants/tests/invariants.spec.ts | 26 +- pnpm-lock.yaml | 21 + tsconfig.build.json | 1 + tsconfig.json | 1 + 23 files changed, 2628 insertions(+), 27 deletions(-) create mode 100644 packages/compact/compact-basic/README.md create mode 100644 packages/compact/compact-basic/package.json create mode 100644 packages/compact/compact-basic/src/index.ts create mode 100644 packages/compact/compact-basic/src/types.ts create mode 100644 packages/compact/compact-basic/tests/compact-basic.spec.ts create mode 100644 packages/compact/compact-basic/tsconfig.json create mode 100644 packages/core/session/src/step-boundary.ts create mode 100644 packages/core/session/tests/step-boundary.spec.ts diff --git a/docs/architecture.md b/docs/architecture.md index c76d8f7ba4..bff7b03091 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -192,7 +192,7 @@ Every MVP feature (including the TODO-marked ones), with the mechanism that impl | `/loop` | on `agent/turn-end`, `send()` the next iteration; or force-continue | | Dynamic workflow | orchestrator plugin on `agent/turn-end` / `agent/step-end` driving `send`/`steer` (+ sub-agents later) | | Queued + steering messages | core `Agent.send()` / `Agent.steer()` | -| Context compaction (auto + manual) | the `ctx.compact` seam ([dsh-compact](../packages/compact/compact)): a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure at turn boundaries, manual = a `/compact` tool. See the [compaction capability-seam RFC](rfc/proposed/feature/2026-06-18-compaction-capability-seam.md) | +| Context compaction (auto + manual) | the `dsh-compact` seam (`ctx.compact`) + a backend (`dsh-compact-basic`) wrapping `agent/request`: a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure before each model call, manual = a (deferred) `/compact` tool invoking the same `ctx.compact` routine. See the [compaction capability-seam RFC](rfc/proposed/feature/2026-06-18-compaction-capability-seam.md) | | System prompt configurability | `ctx.systemPrompt.section()` with ordering | | AGENTS.md (root) | a section provider reading the file | | AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener | @@ -220,6 +220,6 @@ Code skeletons for the three plugin shapes (tool, hook/permission-gate, UI) and Tracked here deliberately — each is designed-for but not implemented: - **Sub-agent spawn/fork semantics** (seam: `AgentLoop.create()`); inter-agent channels beyond `send`/`steer`/events. -- **Compaction implementation** (auto thresholds, summarization prompts) on the `agent/request` seam, with its session-event types added by declaration merging. +- **Compaction** — the `dsh-compact` seam (`ctx.compact`) and the `dsh-compact-basic` backend exist (auto thresholds, summarization on the `agent/request` seam, `compact/*` session events via declaration merging). The model-facing `/compact` consumer tool is still deferred. See [the compaction capability-seam RFC](rfc/proposed/feature/2026-06-18-compaction-capability-seam.md). - **Parallel tool execution** (concurrency-safety hints on ToolDefinition). - **Session branching/tree** (pi-style entry tree) if needed beyond seed-based forking. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 84ee2a893f..b8758bef00 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -207,7 +207,7 @@ A session was created in the store. 'session/created'(session: Session): void ``` -Source: [`packages/core/session/src/index.ts:33`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:34`](../../packages/core/session/src/index.ts) #### `session/event` — emit @@ -219,7 +219,7 @@ An event was appended to a session log (sync, fire-and-forget). This is the per- Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:39`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:40`](../../packages/core/session/src/index.ts) #### `session/flush` — parallel @@ -229,7 +229,7 @@ Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flus 'session/flush'(session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:48`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:49`](../../packages/core/session/src/index.ts) ### `subagent/*` @@ -430,7 +430,7 @@ get(id: SessionId): Session | undefined list(): Session[] ``` -Source: [`packages/core/session/src/index.ts:321`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:322`](../../packages/core/session/src/index.ts) ### `ctx.subagents` — `SubagentService` diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 9bdb987f81..ef22d79c94 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -1,6 +1,6 @@ # Compaction -The compaction seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) split like bash: interface ([dsh-compact](../../packages/compact/compact), `ctx.compact`), implementation (a backend such as `dsh-compact-basic`, deferred), and consumer (a `/compact` tool, deferred). Compaction is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A tokenizer- or template-based backend is a sibling package implementing the same interface. Unlike bash, the interface necessarily depends on `dsh-session` and `dsh-llm`: its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary (see the [compaction capability-seam RFC](../rfc/proposed/feature/2026-06-18-compaction-capability-seam.md)). +The compaction seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) split like bash: interface ([dsh-compact](../../packages/compact/compact), `ctx.compact`), implementation (a backend such as [dsh-compact-basic](../../packages/compact/compact-basic)), and consumer (a `/compact` tool, deferred). Compaction is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A tokenizer- or template-based backend is a sibling package implementing the same interface. Unlike bash, the interface necessarily depends on `dsh-session` and `dsh-llm`: its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary (see the [compaction capability-seam RFC](../rfc/proposed/feature/2026-06-18-compaction-capability-seam.md)). Source: [`packages/compact/compact/src/types.ts`](../../packages/compact/compact/src/types.ts) @@ -11,7 +11,7 @@ Compaction extends [`SessionEventMap`](session.md) with three event types via de | Event | Payload | Role | |---|---|---| | `compact/start` | `{ turn }` | acquires the log-recorded lock | -| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount }` | provenance: the summary blocks, the shadowed seq range, and the estimated token count | +| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount }` | provenance: the summary blocks, the shadowed surface-boundary pair (`start`/`end` seqs — a position span, not a numeric interval), the shadowed seqs in surface order, and the estimated token count | | `compact/end` | `{ turn, error? }` | releases the lock (`error` set when summarization threw) | The lock brackets the **whole** operation: `compact/start` is appended first, then summarization, the `compact/summary` provenance record, and the `user/message` replacement all land, and only then `compact/end`. Releasing the lock last turns a crash mid-operation into a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished. @@ -32,9 +32,16 @@ interface CompactionResult { endSeq: number /** The summary content blocks produced by the backend. */ summary: ContentBlock[] - /** The seq range that was shadowed [start, end] inclusive. */ + /** + * The surface-boundary pair that was shadowed: the seqs of the first + * (`start`) and last (`end`) surface nodes of the replaced range. A + * surface-POSITION span, not a numeric seq interval — after a prior replace + * lands a fresh high-seq summary node at an older range's position, `start` + * can be GREATER than `end`. {@link CompactionResult.shadowedSeqs} is the + * authoritative set of shadowed nodes, in surface order. + */ shadowedRange: { start: number; end: number } - /** The seq numbers of all shadowed surface nodes. */ + /** The seqs of all shadowed surface nodes, in surface order. */ shadowedSeqs: number[] /** Estimated token count of the shadowed content. */ shadowedTokenCount: number diff --git a/docs/module-graph.md b/docs/module-graph.md index 346ef157fe..137538a0f9 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -23,6 +23,10 @@ graph TD llm-replay --> llm llm-replay --> session session-persistence --> session + compact-basic --> agent + compact-basic --> compact + compact-basic --> llm + compact-basic --> session invariants --> agent invariants --> llm invariants --> session @@ -106,6 +110,7 @@ graph TD | `compact` | `llm`, `session` | | `llm-replay` | `llm`, `session` | | `session-persistence` | `session` | +| `compact-basic` | `agent`, `compact`, `llm`, `session` | | `invariants` | `agent`, `llm`, `session` | | `session-persistence-jsonl` | `session`, `session-persistence` | | `session-persistence-sqlite` | `session`, `session-persistence` | diff --git a/packages/README.md b/packages/README.md index 11cace9017..b828fd5407 100644 --- a/packages/README.md +++ b/packages/README.md @@ -11,7 +11,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`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 | -| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam (backend + tool deferred) | 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 | | [`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 | @@ -29,7 +29,8 @@ dsh-bash ← dsh-brand (abstract executor seam; b 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; backend + tool deferred) +dsh-compact ← dsh-session, dsh-llm (abstract compaction seam; tool deferred) +dsh-compact-basic ← dsh-compact, dsh-session, dsh-llm, dsh-agent (char/4 + token-budget retention backend) dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent dsh-bash-local ← dsh-bash (BashExecutor impl) dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas) @@ -68,6 +69,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop | `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` | +| `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` | diff --git a/packages/compact/README.md b/packages/compact/README.md index 0d63b3b8cd..384fe98ffe 100644 --- a/packages/compact/README.md +++ b/packages/compact/README.md @@ -1,11 +1,11 @@ # compact/ — compaction capability family -A three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract compaction interface, a backend that summarizes, and the model-facing tool that consumes it. Only the interface tier exists today; the backend and consumer are deferred. All **product** packages. +A three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract compaction interface, a backend that summarizes, and the model-facing tool that consumes it. The interface and a first backend (`compact-basic/`) exist; the consumer tool is deferred. All **product** packages. | Package | Role | ctx key | |---|---|---| | `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` | -| `compact-basic/` (deferred) | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | +| `compact-basic/` | A backend: char/4 estimation + 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/`. 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/proposed/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. +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/proposed/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. diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md new file mode 100644 index 0000000000..8aa64a1111 --- /dev/null +++ b/packages/compact/compact-basic/README.md @@ -0,0 +1,45 @@ +# @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 `ctx.llm.stream()` summarization. + +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/proposed/feature/2026-06-18-compaction-capability-seam.md) for the design. + +## What it owns + +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). +- **Retention policy** — `compactIfNeeded()` ALWAYS retains the in-flight turn's surface nodes verbatim (its initiating request and any mid-turn tool results — the exact input/observation the model is acting on, even if they exceed the budget), then walks the OLDER (closed-turn) nodes tail→head, summing per-node token estimates, and compacts everything older than the first node that overflows the `retainTokens` budget. The cutoff is snapped to a step boundary so the compacted region never splits a step's `assistant/message` tool-calls from their `tool/result`s (the budget is a soft target): it prefers snapping FORWARD to the next clean boundary, and falls back to snapping BACKWARD when the forward snap would reach the protected in-flight turn. If no step-aligned cutoff exists in the older range (e.g. its only content is an open tail step), it declines (returns `null`) and retries once an older step closes. `compactRegion()` enforces step-alignment strictly, throwing on a boundary that would split a step. Token-based (not turn-count) retention keeps more short turns and compacts tool-heavy turns sooner. +- **Summarization** — `summarize()`: a `ctx.llm.stream()` call assembled via `BlockAssembler` (the single model-call surface) 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 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. +- **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 `` 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/request` waterfall listener delegates to `compactIfNeeded()` before every model call (every step, not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts) and re-derives messages after compacting; the listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`). + +`estimateContentTokens()` and `summarize()` are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the retention walk and surface plumbing. + +## Config (`BasicCompactConfig`) + +| Key | Default | Meaning | +|---|---|---| +| `contextWindow` | `128000` | Context window size in tokens. | +| `thresholdRatio` | `0.8` | Compact when estimated usage exceeds this fraction of the window. | +| `retainTokens` | `20480` | Tokens of recent context to keep intact. | +| `summarizationModel` | `''` | Model for summarization (empty → use the agent's model). | +| `summarizationMaxTokens` | `2048` | Max tokens for the summary response. | +| `auto` | `true` | Register the `agent/request` auto-compaction listener. Set `false` for manual-only. | + +## Usage + +```ts +import type { Context } from 'cordis' +import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' + +export const name = 'compact-basic' +export const inject = ['llm'] + +export function apply(ctx: Context): void { + ctx.plugin(BasicCompactService, { contextWindow: 128000, retainTokens: 20480 }) +} +``` + +Loading the plugin registers `ctx.compact`. With `auto: true` (the default) it compacts automatically under token pressure; a consumer (a future `/compact` tool) can also call `ctx.compact.compactIfNeeded(...)` or `ctx.compact.compactRegion(...)` directly. diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json new file mode 100644 index 0000000000..c745fda233 --- /dev/null +++ b/packages/compact/compact-basic/package.json @@ -0,0 +1,39 @@ +{ + "name": "@deepseek-ai/dsh-compact-basic", + "description": "Basic compaction backend (char/4 token estimation + token-budget retention + llm.generate() summarization) for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-compact": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts new file mode 100644 index 0000000000..28a0040848 --- /dev/null +++ b/packages/compact/compact-basic/src/index.ts @@ -0,0 +1,753 @@ +/** + * `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. + * - **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 step boundary so a compacted region never splits a step's + * tool-call/result pair (an open tail step is never crossed — compaction + * declines and retries once it closes). + * - **Summarization** — `ctx.llm.stream()` assembled via `BlockAssembler` + * (the single model-call surface; same path the loop uses) with a fixed + * condense-the-history system prompt. + * - **Surface mutation** — a single `user/message` replace node carries the + * summary; `compact/*` events are log-only lock + provenance records. + * - **Auto-compaction** — an `agent/request` waterfall listener delegates to + * {@link BasicCompactService.compactIfNeeded} before EVERY model call (every + * step, so a tool-heavy turn that grows the surface mid-turn still compacts); + * it owns the sole token-pressure check. + * + * A different backend (real tokenizer, template summarizer, turn-count + * retention) either subclasses this and overrides the {@link + * BasicCompactService.estimateContentTokens} / {@link + * BasicCompactService.summarize} hooks, or implements the abstract + * {@link CompactService} from scratch. + * + * @module @deepseek-ai/dsh-compact-basic + */ + +import { Context } from 'cordis' +import { CompactService } from '@deepseek-ai/dsh-compact' +import type { CompactionResult } from '@deepseek-ai/dsh-compact' +import { BlockAssembler } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-session' +import { isStepAlignedStart, isStepAlignedEnd } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { BasicCompactConfig, ResolvedConfig } from './types.ts' +import { resolveConfig } from './types.ts' + +export type { BasicCompactConfig, ResolvedConfig } from './types.ts' +export { DEFAULTS, 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 + +/** Tags wrapping the structured summary inside the landed checkpoint node. */ +const SUMMARY_OPEN_TAG = '' +const SUMMARY_CLOSE_TAG = '' + +/** + * The summarization system prompt: instructs the model to condense the + * conversation into a fixed, fully-populated structure rather than freeform + * bullets. The fixed structure guarantees coverage of the things a resuming + * model needs (original intent, pending work, the next step, critical context) + * and is stable across compaction cycles, so a prior checkpoint can be merged + * in place. The final rule keys off {@link SUMMARY_OPEN_TAG}: when the + * transcript already contains a prior checkpoint, the model consolidates rather + * than re-summarizing it verbatim (a cheap incremental-merge that needs no + * extra log/event machinery — the tag travels on the summary surface node). + */ +const SUMMARIZE_SYSTEM_PROMPT = [ + 'You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.', + '', + 'Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.', + '', + '## Primary Request and Intent', + "- [the user's original and evolving goals; quote verbatim where the exact wording matters]", + '', + '## Key Technical Concepts', + '- [technologies, frameworks, patterns, and conventions in play]', + '', + '## Files and Code', + '- [exact path: why it matters, key changes or snippets]', + '', + '## Errors and Fixes', + '- [error: how it was resolved, plus any related user feedback]', + '', + '## Pending Tasks', + '- [explicitly requested work not yet completed]', + '', + '## Current Work', + '- [precisely what was in progress at this checkpoint]', + '', + '## Next Step', + '- [the single next action, directly in line with the most recent request, or "(none)"]', + '', + '## Critical Context', + '- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]', + '', + 'Rules:', + '- Preserve exact file paths, commands, error strings, identifiers, and function signatures.', + '- Capture user feedback and explicit instructions faithfully, especially corrections.', + '- Do NOT mention this summarization process or that the context was compacted.', + `- If the transcript already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`, +].join('\n') + +/** + * Framing prepended to the landed summary so a resuming model reads it as a + * checkpoint rather than a fresh user request, and continues the task from it. + * It summarizes an earlier span of the conversation; the messages that follow + * are the continuation. Because region compaction can be invoked manually, a + * surface may hold several checkpoints, so the framing does NOT claim that + * everything after it is recent or verbatim — only that the captured context + * should be built on, not restated. + */ +const CHECKPOINT_PREAMBLE = + 'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.' + +/** + * Map a terminal `FinishReason` to the error a SUMMARIZATION must throw, or + * `undefined` for an acceptable finish. `FinishReason` is merge-extensible. + * + * Compaction fails CLOSED on a truncated summary: `error`, `aborted`, AND + * `max-tokens` all raise. Unlike an ordinary agent turn — where `max-tokens` is + * a normal "the model hit its budget" outcome the loop keeps — a summary cut off + * at the token cap is an INCOMPLETE checkpoint, and committing it would shadow + * (discard) the real history it summarizes. Raising here keeps the original + * surface intact (the caller appends `compact/end` with the error and the auto + * path proceeds with full history). `stop`/future kinds are accepted. + */ +function finishError(finish: FinishReason): Error | undefined { + switch (finish.kind) { + case 'error': { + const error = new Error(finish.message) as Error & { code?: string } + if (finish.code !== undefined) error.code = finish.code + return error + } + case 'aborted': { + const error = new Error('summarization stream aborted') as Error & { code?: string } + error.code = 'ABORTED' + return error + } + case 'max-tokens': { + const error = new Error('summarization truncated at the token cap (incomplete checkpoint)') as Error & { code?: string } + error.code = 'MAX_TOKENS' + return error + } + default: + return undefined + } +} + +/** + * Basic, dependency-light compaction backend. Defaults target a 128K context + * window, compacting at 80% utilization and retaining ~20K tokens of recent + * context. + */ +export class BasicCompactService extends CompactService { + /** + * `summarize()` reads `ctx.llm.stream()`. Declaring `llm` here lets the cordis + * context proxy resolve it when this service loads as a sibling of LlmService: + * without the inject, `this.ctx.llm` cannot be resolved from this fiber and + * compaction throws at runtime (see postmortem 0001). + */ + static inject = ['llm'] + + /** Resolved configuration (defaults applied). */ + readonly config: ResolvedConfig + + constructor(ctx: Context, config: BasicCompactConfig = {}) { + super(ctx) + this.config = resolveConfig(config) + + if (this.config.auto) { + // Auto-compaction: delegate to compactIfNeeded before EVERY model call — + // every step, not just the first. A tool-heavy ReAct turn appends an + // assistant/message and a tool/result per step, so the surface (and the + // derived token count) grows within a turn; gating to step 1 would let a + // runaway turn overflow the window before the next turn's check. The + // listener stays agnostic — it owns NO threshold logic; compactIfNeeded is + // the single place that decides whether to compact, and its in-progress + // lock serializes concurrent attempts. + ctx.on('agent/request', async (agent: Agent, _turn, _step, request, next) => { + const before = this.estimateTokens(request.messages, request.system) + try { + const result = await this.compactIfNeeded(agent.session, request.system, request.model, request.signal) + if (result) { + // The surface has been mutated — re-derive messages for the call. + const rederived = agent.session.deriveMessages() + const afterTokens = this.estimateTokens(rederived, request.system) + + ctx.logger.info( + `compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` + + `(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` + + `~${result.shadowedTokenCount} tokens) ` + + `→ ${afterTokens} estimated tokens after compaction ` + + `(pressure was ~${before})`, + ) + + request.messages = rederived + } + } catch (error: unknown) { + // A failed compaction must not prevent the model call — proceed + // with the original messages. + const msg = error instanceof Error ? error.message : String(error) + ctx.logger.warn(`compaction failed: ${msg}; proceeding with full history`) + } + + return next() + }) + } + } + + // ---- Token estimation (overridable hooks) ---- + + /** + * Estimate the token count of content blocks — char/4 with per-block + * overhead. Override in a subclass to plug in a real tokenizer. + */ + estimateContentTokens(blocks: readonly ContentBlock[]): number { + let tokens = 0 + for (const block of blocks) { + switch (block.type) { + case 'text': + case 'reasoning': + tokens += Math.ceil(block.text.length / 4) + BLOCK_OVERHEAD + break + case 'tool-call': + tokens += Math.ceil(block.name.length / 4) + + Math.ceil(block.arguments.length / 4) + + 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) + } + } + return tokens + } + + /** + * Estimate token count for a single session event. Returns 0 for non-message + * event types (boundaries, chunks, usage, errors, compact markers). + */ + estimateEventTokens(event: SessionEvent): number { + switch (event.type) { + case 'user/message': + case 'assistant/message': + case 'context/message': + case 'steering/message': + case 'tool/result': + return this.estimateContentTokens(event.data.content) + default: + return 0 + } + } + + /** Estimate total tokens across a list of messages plus optional system prompt. */ + estimateTokens(messages: readonly Message[], systemPrompt?: string): number { + let total = 0 + for (const msg of messages) { + total += this.estimateContentTokens(msg.content) + total += ROLE_OVERHEAD + } + if (systemPrompt) total += Math.ceil(systemPrompt.length / 4) + return total + } + + /** + * Summarize conversation text into content blocks via `ctx.llm.stream()` + * assembled through a `BlockAssembler` (the single model-call surface). + * Override in a subclass for a template or remote summarizer. + * + * Honors the adapter failure contract: an adapter may report a model failure + * by throwing from `stream()` (propagated here) OR by ending the stream with + * a `finish {kind:'error'|'aborted'}` chunk — the latter is re-thrown so a + * provider error never yields an empty summary. + * + * Forwards `signal` into `GenerateOptions.signal` so an abort/dispose tears + * down the in-flight summarization rather than orphaning the model call. + */ + async summarize(text: string, model: string, signal?: AbortSignal): Promise { + if (!model) throw new Error('no model available for summarization') + + const assembler = new BlockAssembler() + const options: GenerateOptions = { + model, + messages: [{ + role: 'user', + content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }], + }], + system: SUMMARIZE_SYSTEM_PROMPT, + maxTokens: this.config.summarizationMaxTokens, + } + // exactOptionalPropertyTypes: only set `signal` when present — assigning + // `undefined` to an optional `signal?: AbortSignal` is a type error. + if (signal) options.signal = signal + for await (const chunk of this.ctx.llm.stream(options)) { + assembler.push(chunk) + } + + const error = finishError(assembler.finish) + if (error) throw error + + return assembler.message().content + } + + // ---- Core API (implements the abstract contract) ---- + + /** + * The sole token-pressure gate: estimate the current history, and if it + * exceeds the threshold (`contextWindow * thresholdRatio`), compact the oldest + * surface nodes outside the `retainTokens` budget. The auto-compaction listener + * delegates here rather than pre-checking, so this is the only place the + * decision lives. + */ + override async compactIfNeeded( + session: Session, + systemPrompt?: string, + model?: string, + signal?: AbortSignal, + ): Promise { + const messages = session.deriveMessages() + const totalTokens = this.estimateTokens(messages, systemPrompt) + + const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio) + if (totalTokens < threshold) return null + + // Walk surface nodes tail→head, accumulating token estimates. + const nodes = session.surface.nodes + if (nodes.length === 0) return null + + const retainBudget = this.config.retainTokens + // ALWAYS retain the IN-FLIGHT turn's surface nodes verbatim — its initiating + // user request and any mid-turn tool results are the exact input/observation + // the model is acting on right now, even if they exceed the soft retain + // budget. Compacting them would hand the model a lossy summary of its own + // current task. Only nodes in PRIOR (closed) turns are eligible to compact; + // `protectedIdx` is the first surface node of the open turn (or `nodes.length` + // when the open turn has no surface nodes yet, e.g. before step 1). + const protectedIdx = this._openTurnFirstSurfaceIdx(session, nodes) + if (protectedIdx === 0) return null + + let accumulated = 0 + let cutoffIdx = -1 + // Seed the accumulator with the protected suffix so the retain budget is + // measured against what actually stays, then look for a cutoff only among + // the older (compactable) nodes. + for (let i = nodes.length - 1; i >= protectedIdx; i--) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const event = session.events[nodes[i]!.seq] + if (event) accumulated += this.estimateEventTokens(event) + } + + for (let i = protectedIdx - 1; i >= 0; i--) { + // nodes[i] bounded by i >= 0 and i < nodes.length — never undefined. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const node = nodes[i]! + const event = session.events[node.seq] + /* v8 ignore next -- node.seq is a surface-node seq, always a valid log index by construction */ + if (!event) continue + accumulated += this.estimateEventTokens(event) + if (accumulated > retainBudget) { + cutoffIdx = i + break + } + } + + // If we walked the entire compactable range without exceeding the budget, + // everything outside the protected in-flight turn fits — no compaction + // needed. + if (cutoffIdx === -1) return null + + // Snap the cutoff to a step-aligned end so the compacted region never splits + // a step (which would orphan a tool-call or its tool/result). The token + // budget is a soft target. PREFER snapping FORWARD (compact slightly more + // recent context to reach a clean boundary), but never into the protected + // in-flight turn: if the forward snap would reach `protectedIdx`, fall back + // to snapping BACKWARD to the previous step-aligned end (compact slightly + // less), and decline only if no step-aligned end exists in the compactable + // range at all. + const events = session.events + cutoffIdx = this._snapCutoff(events, nodes, cutoffIdx, protectedIdx) + if (cutoffIdx === -1) return null + + // nodes is non-empty (checked above) and cutoffIdx is a valid index. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const firstSeq = nodes[0]!.seq + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const cutoffSeq = nodes[cutoffIdx]!.seq + const resolvedModel = model ?? '' + + return this.compactRegion(session, firstSeq, cutoffSeq, resolvedModel, signal) + } + + override async compactRegion( + session: Session, + start: number, + end: number, + model: string, + signal?: AbortSignal, + ): Promise { + // Resolve the range by surface POSITION, not numeric seq interval. A prior + // replace lands a fresh high-seq summary node AT the shadowed range's + // position, so the surface order (head→tail) no longer tracks seq order — + // `[newSummarySeq, olderRetainedSeq, …]` is normal. Indexing into the + // ordered node list and slicing it is the only correct way to read a range; + // a `node.seq >= start && node.seq <= end` interval test would mis-collect + // nodes (and `start > end` would falsely reject) once that happens. + const nodes = session.surface.nodes + const startIdx = nodes.findIndex(n => n.seq === start) + const endIdx = nodes.findIndex(n => n.seq === end) + if (startIdx === -1) throw new Error(`compactRegion: start seq ${start} not found in surface`) + if (endIdx === -1) throw new Error(`compactRegion: end seq ${end} not found in surface`) + if (startIdx > endIdx) { + throw new Error(`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`) + } + + // The region must contain whole steps, never split a step's + // assistant-message tool-calls from their tool/results (which would orphan + // one side and produce a transcript every provider rejects). A boundary is + // valid when it sits on a step edge or on a node that belongs to no step + // (pre-step user message, inter-step steering, injection context); an `end` + // inside an open (unclosed) tail step is also rejected — its tool-calls have + // no results yet. See dsh-session's step-boundary predicates. + const events = session.events + if (!isStepAlignedStart(events, start)) { + throw new Error(`compactRegion: start seq ${start} is not on a step boundary (would split a step's tool-call/result pair)`) + } + if (!isStepAlignedEnd(events, end)) { + throw new Error(`compactRegion: end seq ${end} is not on a step boundary (would split a step, or the step is still open)`) + } + + if (this._isCompactionInProgress(session)) { + throw new Error('compaction already in progress') + } + + // Compaction's events (compact/* and the replacement user/message) must be + // turn-enclosed: the session-log contract rejects any plugin event appended + // outside an open turn. Auto-compaction satisfies this — it runs inside the + // `agent/request` waterfall, strictly between a turn's start and end. A + // manual call on a fully-closed session has no turn to enclose the events, + // so reject rather than emit an un-enclosed run. + const turn = this._openTurn(session) + if (turn === null) { + throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn') + } + // Slice the ordered surface nodes [startIdx, endIdx] inclusive — the + // shadowed range is positional, so this is the set the replace op covers. + const shadowedSeqs = nodes.slice(startIdx, endIdx + 1).map(n => n.seq) + + // --- Acquire lock --- + const startEvent = session.append('compact/start', { turn }) + + try { + // --- Extract text and summarize --- + const text = this._extractText(session, shadowedSeqs) + const summaryModel = this.config.summarizationModel || model + const summary = await this.summarize(text, summaryModel, signal) + + // Estimate token count of the shadowed content for provenance. + let shadowedTokenCount = 0 + for (const seq of shadowedSeqs) { + // seq comes from a surface node — always a valid log index by construction. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + shadowedTokenCount += this.estimateEventTokens(session.events[seq]!) + } + + // --- Provenance record (log-only) --- + const summaryEvent = session.append('compact/summary', { + summary, + shadowedRange: { start, end }, + shadowedSeqs, + shadowedTokenCount, + }) + + // --- Surface replacement --- + // The user/message directly shadows all compacted surface nodes with a + // single replace op. It is the ONLY surface event in the compaction + // sequence — compact/start, compact/summary, and compact/end are log-only + // (surfaceOp is rejected by the compiler for non-SurfaceEventType). + // The landed content is FRAMED (checkpoint preamble + tag-wrapped summary); + // the compact/summary provenance event above holds the raw model output. + session.append('user/message', { + content: this._frameSummary(summary), + source: { kind: 'plugin', plugin: 'compact' }, + }, { + surfaceOp: { op: 'replace', start, end }, + sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs], + }) + + // --- Release lock (log-only) --- + // Appended LAST so the lock brackets the WHOLE operation: a crash between + // compact/start and here leaves a detectable orphaned lock (a compact/start + // with no matching compact/end) rather than a compact/end that falsely + // claims compaction finished before the surface replacement landed. + const endEvent = session.append('compact/end', { turn }) + + return { + startSeq: startEvent.seq, + summarySeq: summaryEvent.seq, + endSeq: endEvent.seq, + summary, + shadowedRange: { start, end }, + shadowedSeqs, + shadowedTokenCount, + } + } catch (error: unknown) { + // Always release the lock — append compact/end with the error so a + // wedged lock is impossible. + const msg = error instanceof Error ? error.message : String(error) + session.append('compact/end', { turn, error: msg }) + throw error + } + } + + // ---- Internal helpers ---- + + /** + * The index of the first surface node that belongs to the currently-open turn + * — the boundary of the protected, never-compacted suffix. Returns + * `nodes.length` when the open turn has contributed no verbatim surface node + * yet (e.g. before step 1 appends anything), so the whole surface is + * compaction-eligible up to the tail. + * + * The in-flight turn's verbatim nodes (its request, mid-turn assistant + * messages, tool results — all `append` ops) form a CONTIGUOUS run at the TAIL + * of the surface. A compaction replacement node, though also appended during + * the open turn (seq > `turn/start`), lands at the position of the older range + * it shadowed — earlier in the surface, NOT in the tail run — so it is itself + * compaction-eligible (a later cycle can merge it). The protected suffix is + * therefore the contiguous tail run of nodes whose seq exceeds the open turn's + * `turn/start`, found by walking from the tail. With no open turn (a closed + * session — only manual `compactRegion`, never the auto path), nothing is + * protected and this returns `nodes.length`. + */ + private _openTurnFirstSurfaceIdx(session: Session, nodes: readonly SurfaceNode[]): number { + const openTurn = this._openTurn(session) + if (openTurn === null) return nodes.length + // Find the open turn's turn/start seq (scanning back from the tail). + let turnStartSeq = -1 + for (let i = session.events.length - 1; i >= 0; i--) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const e = session.events[i]! + if (e.type === 'turn/start' && e.data.turn === openTurn) { turnStartSeq = e.seq; break } + } + /* v8 ignore next -- _openTurn returned non-null, so its turn/start exists */ + if (turnStartSeq === -1) return nodes.length + // Walk from the tail while nodes belong to the open turn (seq > turn/start), + // taking only the CONTIGUOUS run — a compaction summary node appended this + // turn but sitting earlier in the surface stops the run and stays eligible. + let idx = nodes.length + while (idx > 0 && nodes[idx - 1]!.seq > turnStartSeq) idx -= 1 // eslint-disable-line @typescript-eslint/no-non-null-assertion + return idx + } + + /** + * Snap a raw token-budget cutoff index to a step-aligned end among the nodes + * BELOW the protected suffix (`protectedIdx`, the first node of the in-flight + * turn). Returns the snapped index, or `-1` if no step-aligned end exists in + * the compactable range (e.g. it is empty, or its only content is an open tail + * step). + * + * Prefers snapping FORWARD to the next step-aligned end (compact slightly more + * recent context for a clean boundary); if the forward scan reaches + * `protectedIdx` without finding one, falls back to scanning BACKWARD from the + * raw cutoff (compact slightly less). The protected suffix is never returned — + * it stays verbatim so the model sees its current task, not a summary. + */ + private _snapCutoff( + events: readonly SessionEvent[], + nodes: readonly SurfaceNode[], + rawCutoffIdx: number, + protectedIdx: number, + ): number { + // Forward: the next step-aligned end strictly below the protected suffix. + for (let i = rawCutoffIdx; i < protectedIdx; i++) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (isStepAlignedEnd(events, nodes[i]!.seq)) return i + } + // Backward: the nearest step-aligned end at or below the raw cutoff. + for (let i = rawCutoffIdx - 1; i >= 0; i--) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (isStepAlignedEnd(events, nodes[i]!.seq)) return i + } + return -1 + } + + /** + * Frame the raw summary blocks into the content that lands on the surface: + * a checkpoint preamble (so a resuming model reads it as a checkpoint, not a + * fresh user request) followed by the summary wrapped in + * {@link SUMMARY_OPEN_TAG}/{@link SUMMARY_CLOSE_TAG}. The tags make a prior + * checkpoint detectable in the transcript on the next compaction cycle, which + * triggers the merge rule in the summarization prompt. The raw, unframed + * `summary` is preserved separately on the `compact/summary` provenance event. + */ + private _frameSummary(summary: readonly ContentBlock[]): ContentBlock[] { + return [ + { type: 'text', text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` }, + ...summary, + { type: 'text', text: SUMMARY_CLOSE_TAG }, + ] + } + + /** + * Whether a compaction is currently in progress for `session` — an unmatched + * `compact/start` (no later `compact/end`) WITHIN the current turn. + * + * The scan is scoped to the current turn: walking back from the tail it stops + * at the first `turn/end` (the boundary closing the prior turn). A + * `compact/start` left orphaned by a crash mid-compaction lives in a turn that + * persistence repair then closes with a synthetic `turn/end`; scoping here so + * that a stale orphan from a PAST turn cannot wedge compaction forever (it sits + * before the nearest `turn/end`, so the scan never reaches it). An in-progress + * compaction's `compact/start` is always in the still-open current turn, + * before any `turn/end`, so it is still detected. + */ + private _isCompactionInProgress(session: Session): boolean { + const events = session.events + for (let i = events.length - 1; i >= 0; i--) { + // Index bounded by i >= 0 and i < events.length — never undefined. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const e = events[i]! + if (e.type === 'compact/start') return true + if (e.type === 'compact/end') break + // A turn/end bounds the scan: anything before it belongs to a prior + // (closed) turn and cannot be an in-progress compaction of THIS turn. + if (e.type === 'turn/end') break + } + return false + } + + /** + * The turn number of the currently OPEN turn — a `turn/start` not yet + * followed by its `turn/end` — or `null` if the session has no open turn. + * + * Compaction's events must be enclosed in a turn, so scanning back from the + * tail: a `turn/start` means that turn is open (return it); a `turn/end` means + * the most recent turn already closed (return null). The whole compaction + * sequence (compact/start … compact/end) is stamped with this turn. + */ + private _openTurn(session: Session): number | null { + for (let i = session.events.length - 1; i >= 0; i--) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const e = session.events[i]! + if (e.type === 'turn/start') return e.data.turn + if (e.type === 'turn/end') return null + } + return null + } + + /** + * Extract plain-text conversation from a set of surface node seqs, for + * feeding into the summarization model. Walks events in log order so the + * summary captures chronological flow. + */ + private _extractText(session: Session, seqs: number[]): string { + const lines: string[] = [] + + // Walk seqs in the order given (surface order, as compactRegion slices the + // surface-node list) — NOT ascending log-seq order. After a replace the + // summary node carries a fresh high seq while sitting at the head of the + // surface before older retained lower-seq nodes, so a log-order scan would + // feed the transcript out of order and break the checkpoint-merge prompt. + for (const seq of seqs) { + const event = session.events[seq] + /* v8 ignore next -- seq is a surface-node seq, always a valid log index by construction */ + if (!event) continue + + switch (event.type) { + case 'user/message': { + const text = this._blocksToText(event.data.content) + if (text) lines.push(`User: ${text}`) + break + } + case 'assistant/message': { + const text = this._blocksToText(event.data.content) + if (text) lines.push(`Assistant: ${text}`) + break + } + case 'tool/result': { + const text = this._blocksToText(event.data.content) + const label = event.data.isError ? 'Tool error' : 'Tool result' + if (text) lines.push(`${label} (call ${event.data.callId}): ${text}`) + break + } + case 'context/message': { + const text = this._blocksToText(event.data.content) + if (text) lines.push(`[Context: ${text}]`) + break + } + case 'steering/message': { + const text = this._blocksToText(event.data.content) + if (text) lines.push(`[Steering: ${text}]`) + break + } + // SessionEventMap is merge-extensible — unknown types are + // non-message events that carry no extractable text. + /* v8 ignore next 2 -- seqs only name surface nodes, always one of the 5 handled SurfaceEventTypes; unreachable */ + default: + break + } + } + + return lines.join('\n\n') + } + + /** + * 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. + */ + private _blocksToText(blocks: readonly ContentBlock[]): string { + const parts: string[] = [] + for (const block of blocks) { + switch (block.type) { + case 'text': + if (block.text) parts.push(block.text) + break + case 'reasoning': + if (block.text) parts.push(`[reasoning: ${block.text}]`) + break + case 'tool-call': + parts.push(`[tool-call: ${block.name}(${block.arguments})]`) + break + case 'tool-result': { + const inner = this._blocksToText(block.content) + 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. + default: + parts.push(`[${(block as ContentBlock).type}]`) + } + } + return parts.join('\n') + } +} + +export default BasicCompactService diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts new file mode 100644 index 0000000000..120fad0b19 --- /dev/null +++ b/packages/compact/compact-basic/src/types.ts @@ -0,0 +1,44 @@ +/** + * Configuration vocabulary for the basic compaction backend. + * + * Every tunable lives here, in the implementation — the abstract contract + * (`@deepseek-ai/dsh-compact`) carries no config, because thresholds and + * retention policy are HOW decisions a different backend would make + * differently. + * + * @module @deepseek-ai/dsh-compact-basic/types + */ + +/** Backend configuration — all optional with sensible defaults. */ +export interface BasicCompactConfig { + /** Context window size in tokens (default 128000). */ + contextWindow?: number + /** Compact when estimated token usage exceeds this fraction of context window (default 0.8). */ + thresholdRatio?: number + /** Number of tokens of recent context to retain during compaction (default 20480). */ + retainTokens?: number + /** Model to use for summarization (default '' — uses the agent's model). */ + summarizationModel?: string + /** Maximum tokens for the summarization response (default 2048). */ + summarizationMaxTokens?: number + /** Enable automatic compaction on the `agent/request` waterfall (default true). */ + auto?: boolean +} + +/** Resolved config with all defaults applied. */ +export type ResolvedConfig = Required + +/** Default configuration values. */ +export const DEFAULTS: ResolvedConfig = { + contextWindow: 128000, + thresholdRatio: 0.8, + retainTokens: 20480, + summarizationModel: '', + summarizationMaxTokens: 2048, + auto: true, +} + +/** Apply defaults to a partial config. */ +export function resolveConfig(config: BasicCompactConfig): ResolvedConfig { + return { ...DEFAULTS, ...config } +} diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts new file mode 100644 index 0000000000..3852b825f7 --- /dev/null +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -0,0 +1,1361 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' +import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' +import type { ContentBlock, GenerateOptions, Message, StreamChunk } from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter, LlmService } from '@deepseek-ai/dsh-llm' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SurfaceEvent } from '@deepseek-ai/dsh-session' +import * as Invariants from '@deepseek-ai/dsh-invariants' +import type { Agent } from '@deepseek-ai/dsh-agent' + +/** + * A BasicCompactService with summarize() stubbed (no real model call) and a + * predictable token estimate, for deterministic unit tests of the algorithm. + */ +class TestCompactService extends BasicCompactService { + /** Track calls to summarize for test assertions. */ + summarizeCalls: { text: string; model: string }[] = [] + /** The fixed summary to return. */ + mockSummary: ContentBlock[] = [{ type: 'text', text: 'Test summary of compacted content.' }] + /** If set, summarize() throws this error. */ + summarizeError: Error | null = null + + override estimateContentTokens(blocks: readonly ContentBlock[]): number { + // 10 tokens per block — predictable for retention/threshold math. + return blocks.length * 10 + } + + override async summarize(text: string, model: string): Promise { + this.summarizeCalls.push({ text, model }) + if (this.summarizeError) throw this.summarizeError + return this.mockSummary + } +} + +/** Create a test service with a throwaway context (auto disabled — no model). */ +function createTestService(config: BasicCompactConfig = {}): TestCompactService { + return new TestCompactService(new Context(), { auto: false, ...config }) +} + +/** + * A test service where specific surface seqs (in `bigSeqs`) weigh 1000 tokens + * and every other message-producing event weighs 10 — for exercising the + * "newest node alone exceeds retainTokens" retention path. summarize() is + * stubbed (no model call). + */ +class TestCompactServiceVarTokens extends BasicCompactService { + bigSeqs = new Set() + constructor(config: BasicCompactConfig = {}) { + super(new Context(), { auto: false, ...config }) + } + + override estimateEventTokens(event: SessionEvent): number { + if (this.bigSeqs.has(event.seq)) return 1000 + return super.estimateEventTokens(event) + } + + override async summarize(): Promise { + return [{ type: 'text', text: 'summary' }] + } +} + +/** + * Build a multi-turn session with surface markers (simulating real agent-loop + * output). Compaction always runs inside an OPEN turn (the loop fires the + * `agent/request` waterfall between a turn's start and its end), so by default + * the session is left with a trailing open turn: turns `1..turns` close, then + * one more `turn/start` opens with no matching `turn/end`. Pass + * `{ leaveOpen: false }` for a fully-closed session (e.g. to assert that manual + * compaction is rejected when no turn is open). + */ +function multiTurnSession(turns: number, messagesPerTurn: number = 2, opts: { leaveOpen?: boolean } = {}): Session { + const leaveOpen = opts.leaveOpen ?? true + const s = new Session(SessionId('test')) + for (let t = 1; t <= turns; t++) { + s.append('turn/start', { turn: t, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: t, step: 1 }) + for (let m = 0; m < messagesPerTurn; m++) { + s.append('user/message', { + content: [{ type: 'text', text: `turn ${t} user message ${m + 1}` }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + s.append('assistant/message', { + turn: t, step: 1, + content: [{ type: 'text', text: `turn ${t} assistant response ${m + 1}` }], + }, { surfaceOp: 'append' }) + } + s.append('step/end', { turn: t, step: 1 }) + s.append('turn/end', { turn: t, reason: { kind: 'completed' } }) + } + // Open one more turn so compaction's events are turn-enclosed, as they are + // when the loop runs the auto-compaction listener mid-turn. + if (leaveOpen) { + s.append('turn/start', { turn: turns + 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + } + return s +} + +/** Build a session with tool calls for richer extraction tests. */ +function sessionWithTools(): Session { + const s = new Session(SessionId('tools')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('user/message', { + content: [{ type: 'text', text: 'read file x' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + s.append('assistant/message', { + turn: 1, step: 1, + content: [ + { type: 'text', text: 'Let me read that file.' }, + { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{"command":"cat x"}' }, + ], + }, { surfaceOp: 'append' }) + s.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{"command":"cat x"}' }) + s.append('tool/result', { + turn: 1, step: 1, callId: CallId('c1'), + content: [{ type: 'text', text: 'hello world' }], + isError: false, + }, { surfaceOp: 'append' }) + s.append('assistant/message', { + turn: 1, step: 1, + content: [{ type: 'text', text: 'The file contains: hello world' }], + }, { surfaceOp: 'append' }) + s.append('step/end', { turn: 1, step: 1 }) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + // Open a trailing turn so compaction's events are turn-enclosed (as they are + // when the loop runs the auto-compaction listener mid-turn). + s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + return s +} + +/** + * Build a session of `turns` turns, each a SINGLE step containing an + * assistant/message that issues a tool-call plus its tool/result — the real + * multi-node-step shape (a step is two surface nodes: the assistant and the + * result). Each turn is preceded by a user/message. Used to exercise + * step-alignment: a region boundary must not fall between the assistant and its + * result. + */ +function toolTurnSession(turns: number): Session { + const s = new Session(SessionId('tools-multi')) + for (let t = 1; t <= turns; t++) { + s.append('turn/start', { turn: t, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('user/message', { + content: [{ type: 'text', text: `turn ${t} request` }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + s.append('step/start', { turn: t, step: 1 }) + s.append('assistant/message', { + turn: t, step: 1, + content: [ + { type: 'text', text: `turn ${t} calling tool` }, + { type: 'tool-call', id: CallId(`c${t}`), name: 'bash', arguments: '{"command":"ls"}' }, + ], + }, { surfaceOp: 'append' }) + s.append('tool/call', { turn: t, step: 1, callId: CallId(`c${t}`), name: 'bash', arguments: '{"command":"ls"}' }) + s.append('tool/result', { + turn: t, step: 1, callId: CallId(`c${t}`), + content: [{ type: 'text', text: `turn ${t} output` }], + isError: false, + }, { surfaceOp: 'append' }) + s.append('step/end', { turn: t, step: 1 }) + s.append('turn/end', { turn: t, reason: { kind: 'completed' } }) + } + // Open a trailing turn so compaction's events are turn-enclosed. + s.append('turn/start', { turn: turns + 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + return s +} + +/** + * Assert the derived transcript has NO orphaned tool-result: every + * `tool-result` block's `toolCallId` must be matched by a preceding `tool-call` + * block in an earlier (assistant) message. A dangling tool-result is exactly + * what splitting a step at compaction produces, and every provider rejects it. + */ +function expectNoOrphanToolResults(messages: Message[]): void { + const seenCallIds = new Set() + for (const msg of messages) { + for (const block of msg.content) { + if (block.type === 'tool-call') seenCallIds.add(block.id) + if (block.type === 'tool-result') { + expect(seenCallIds.has(block.toolCallId), + `orphaned tool-result for callId ${block.toolCallId} (no preceding tool-call)`).toBe(true) + } + } + } +} + +describe('BasicCompactService step-alignment (never split a tool-call/result pair)', () => { + it('compactIfNeeded snaps the cutoff forward past a mid-step boundary (no orphaned tool-result)', async () => { + // 3 turns, each one step = { assistant(tool-call) , tool/result }. Surface + // (9 nodes): user1, asst1, res1, user2, asst2, res2, user3, asst3, res3 — + // 10/20/10 tokens. With retainTokens=55 the tail→head walk overflows at + // asst2 (idx4), so the RAW cutoff falls BETWEEN asst2 and its result res2 + // (idx5) — splitting turn 2's step. The fix snaps the cutoff forward to res2 + // so the whole step is compacted and no dangling result survives. + const svc = createTestService({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 55 }) + const session = toolTurnSession(3) + + const result = await svc.compactIfNeeded(session) + expect(result).not.toBeNull() + // res2 (idx5) was pulled into the compacted region by the snap, not stranded. + expectNoOrphanToolResults(session.deriveMessages()) + // Turn 3's step is retained intact (summary + user3 + asst3 + res3 = 4 msgs). + expect(session.deriveMessages().length).toBe(4) + }) + + it('compactIfNeeded returns null when the only cutoff would enter an open tail step', async () => { + // A pre-step user/message then an OPEN step (assistant issued a tool-call, no + // tool/result / step/end yet — mid-flight). The token walk wants to compact + // into that open step, but its tool-call has no result yet; compacting it + // would defer the orphan. With no safe step-aligned cutoff, compactIfNeeded + // declines (returns null) rather than summarizing a pending tool-call away. + const s = new Session(SessionId('open-step')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('assistant/message', { + turn: 1, step: 1, + content: [{ type: 'text', text: 'calling' }, { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], + }, { surfaceOp: 'append' }) + // no tool/result, no step/end — the step is open at the tail. + + const svc = createTestService({ contextWindow: 50, thresholdRatio: 0.5, retainTokens: 5 }) + const result = await svc.compactIfNeeded(s) + expect(result).toBeNull() + // The open step's assistant survived — its tool-call is intact for the result. + expect(s.events.some(e => e.type === 'compact/start')).toBe(false) + }) + + it('compactRegion rejects a start that is not a step boundary (splits a step)', async () => { + const svc = createTestService() + const session = toolTurnSession(1) + const nodes = session.surface.nodes // [user, asst(tool-call), result] + const userSeq = nodes[0]!.seq + const resultSeq = nodes[2]!.seq + // start = the tool/result: its issuing assistant precedes it IN THE SAME STEP, + // so starting here would orphan that assistant's tool-call. end is fine (user). + await expect(svc.compactRegion(session, resultSeq, resultSeq, 'm')) + .rejects.toThrow(/start seq .* is not on a step boundary/) + expect(userSeq).toBeLessThan(resultSeq) // sanity: ordering as expected + }) + + it('compactRegion rejects an end that is not a step boundary (splits a step)', async () => { + const svc = createTestService() + const session = toolTurnSession(1) + const nodes = session.surface.nodes + const userSeq = nodes[0]!.seq + const asstSeq = nodes[1]!.seq + // end = the assistant/message: its tool/result follows IN THE SAME STEP, so + // ending here would strand that result. start is fine (the pre-step user). + await expect(svc.compactRegion(session, userSeq, asstSeq, 'm')) + .rejects.toThrow(/end seq .* is not on a step boundary/) + }) + + it('compactRegion rejects an end inside an open tail step', async () => { + const svc = createTestService() + const s = new Session(SessionId('open-tail')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('assistant/message', { + turn: 1, step: 1, + content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], + }, { surfaceOp: 'append' }) + const nodes = s.surface.nodes // [user, asst] + const userSeq = nodes[0]!.seq + const asstSeq = nodes[1]!.seq + await expect(svc.compactRegion(s, userSeq, asstSeq, 'm')) + .rejects.toThrow(/end seq .* is not on a step boundary/) + }) + + it('compactRegion accepts step-aligned boundaries (pre-step user → last result of a closed step)', async () => { + const svc = createTestService() + const session = toolTurnSession(2) + const nodes = session.surface.nodes // [user1, asst1, res1, user2, asst2, res2] + const startSeq = nodes[0]!.seq // pre-step user1 (free boundary) + const endSeq = nodes[2]!.seq // res1 = last node of turn 1's closed step + const result = await svc.compactRegion(session, startSeq, endSeq, 'm') + expect(result.shadowedRange).toEqual({ start: startSeq, end: endSeq }) + expectNoOrphanToolResults(session.deriveMessages()) + }) + + it('compactRegion accepts a single inter-step node (start === end on a pre-step user/message)', async () => { + const svc = createTestService() + const session = toolTurnSession(1) + const nodes = session.surface.nodes + const userSeq = nodes[0]!.seq // pre-step user: free boundary both ways + const result = await svc.compactRegion(session, userSeq, userSeq, 'm') + expect(result.shadowedRange).toEqual({ start: userSeq, end: userSeq }) + }) + + it('compactRegion accepts an injection-turn context node (no step at all)', async () => { + const svc = createTestService() + const s = new Session(SessionId('inject')) + // An idle inject(): turn/start → context/message, NO step. A later turn is + // open so compaction's events are turn-enclosed. + s.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } }) + s.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + const nodes = s.surface.nodes + const ctxSeq = nodes[0]!.seq + const result = await svc.compactRegion(s, ctxSeq, ctxSeq, 'm') + expect(result.shadowedRange).toEqual({ start: ctxSeq, end: ctxSeq }) + }) +}) + +describe('BasicCompactService.estimateEventTokens', () => { + it('returns 0 for non-message events (boundary, chunk, step/end, tool/call)', () => { + const svc = createTestService() + expect(svc.estimateEventTokens({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } })).toBe(0) + expect(svc.estimateEventTokens({ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } })).toBe(0) + expect(svc.estimateEventTokens({ type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } } })).toBe(0) + expect(svc.estimateEventTokens({ type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } })).toBe(0) + expect(svc.estimateEventTokens({ type: 'tool/call', seq: 4, time: 5, data: { turn: 1, step: 1, callId: CallId('c1'), name: 'read', arguments: '{}' } })).toBe(0) + }) + + it('returns estimate for message-producing events', () => { + const svc = createTestService() + const userEvent: SessionEvent = { type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } } } + expect(svc.estimateEventTokens(userEvent)).toBe(10) + + const asstEvent: SessionEvent = { type: 'assistant/message', seq: 1, time: 2, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }] } } + expect(svc.estimateEventTokens(asstEvent)).toBe(20) + + const toolEvent: SessionEvent = { type: 'tool/result', seq: 2, time: 3, data: { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'output' }], isError: false } } + expect(svc.estimateEventTokens(toolEvent)).toBe(10) + }) +}) + +describe('BasicCompactService.estimateTokens', () => { + it('sums token estimates across messages', () => { + const svc = createTestService() + const messages: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'hi' }, { type: 'text', text: 'there' }] }, + ] + // 1 block * 10 + 4 (role) + 2 blocks * 10 + 4 (role) = 10 + 4 + 20 + 4 = 38 + expect(svc.estimateTokens(messages)).toBe(38) + }) + + it('includes system prompt in the estimate', () => { + const svc = createTestService() + const messages: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'hi' }] }, + ] + const systemPrompt = 'You are a helpful assistant.' + // 1 block * 10 + 4 (role) + ceil(28/4) = 10 + 4 + 7 = 21 + expect(svc.estimateTokens(messages, systemPrompt)).toBe(21) + }) +}) + +describe('BasicCompactService.compactRegion', () => { + it('shadows surface nodes and inserts a summary via user/message', async () => { + const svc = createTestService() + const session = multiTurnSession(3, 1) // 3 turns, 2 surface nodes each = 6 nodes + + const nodes = session.surface.nodes + expect(nodes.length).toBe(6) + + const firstSeq = nodes[0]!.seq + const secondSeq = nodes[1]!.seq + const result = await svc.compactRegion(session, firstSeq, secondSeq, 'test-model') + + expect(result.shadowedSeqs).toEqual([firstSeq, secondSeq]) + expect(result.shadowedRange.start).toBe(firstSeq) + expect(result.shadowedRange.end).toBe(secondSeq) + expect(result.summary).toEqual(svc.mockSummary) + + const events = session.events + const startEvent = events.findLast(e => e.type === 'compact/start') + const summaryEvent = events.findLast(e => e.type === 'compact/summary') + const endEvent = events.findLast(e => e.type === 'compact/end') + expect(startEvent).toBeDefined() + expect(summaryEvent).toBeDefined() + expect(endEvent).toBeDefined() + + // compact/* events are log-only — no surfaceOp (type system enforces this). + const startRaw = startEvent as unknown as { surfaceOp?: unknown } + expect(startRaw.surfaceOp).toBeUndefined() + + // The user/message carries the replace surfaceOp. + const userMsg = events.findLast(e => e.type === 'user/message')! + const surfaceUserMsg = userMsg as SurfaceEvent + expect(surfaceUserMsg.surfaceOp).toEqual({ op: 'replace', start: firstSeq, end: secondSeq }) + expect(surfaceUserMsg.sourceEventSeqs).toContain(startEvent!.seq) + expect(surfaceUserMsg.sourceEventSeqs).toContain(summaryEvent!.seq) + expect(surfaceUserMsg.sourceEventSeqs).toContain(firstSeq) + expect(surfaceUserMsg.sourceEventSeqs).toContain(secondSeq) + // compact/end is appended AFTER the replacement (the lock brackets the whole + // op), so the replacement cannot reference it — sourceEventSeqs may only + // reference earlier seqs. + expect(surfaceUserMsg.sourceEventSeqs).not.toContain(endEvent!.seq) + expect(endEvent!.seq).toBeGreaterThan(userMsg.seq) + + // Surface now has: summary user/message + retained 4 nodes = 5 nodes. + const newNodes = session.surface.nodes + expect(newNodes.length).toBe(5) + expect(newNodes[0]!.seq).toBe(userMsg.seq) + + // deriveMessages() produces the framed summary as a user-role message: + // a checkpoint preamble + tag-wrapped summary blocks. + const derived = session.deriveMessages() + expect(derived.length).toBe(5) + expect(derived[0]!.role).toBe('user') + const framed = derived[0]!.content + expect(framed[0]).toMatchObject({ type: 'text' }) + expect((framed[0] as { text: string }).text).toContain('') + expect(framed).toContainEqual(svc.mockSummary[0]) + expect((framed[framed.length - 1] as { text: string }).text).toBe('') + }) + + it('throws when start or end are not surface nodes', async () => { + const svc = createTestService() + const session = multiTurnSession(1, 1) + await expect(svc.compactRegion(session, 999, 1000, 'm')) + .rejects.toThrow(/start seq 999 not found in surface/) + }) + + it('throws when start is positioned after end on the surface', async () => { + const svc = createTestService() + const session = multiTurnSession(2, 1) + const nodes = session.surface.nodes + await expect(svc.compactRegion(session, nodes[1]!.seq, nodes[0]!.seq, 'm')) + .rejects.toThrow(/is after end seq .* on the surface/) + }) + + it('throws when compaction is already in progress', async () => { + const svc = createTestService() + const session = multiTurnSession(2, 1) + const nodes = session.surface.nodes + session.append('compact/start', { turn: 2 }) + await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm')) + .rejects.toThrow(/compaction already in progress/) + }) + + it('appends compact/end with error on summarize failure', async () => { + const svc = createTestService() + svc.summarizeError = new Error('model unavailable') + const session = multiTurnSession(2, 1) + const nodes = session.surface.nodes + + await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm')) + .rejects.toThrow('model unavailable') + + const endEvent = session.events.findLast(e => e.type === 'compact/end') + expect(endEvent).toBeDefined() + // multiTurnSession(2,…) closes turns 1-2 and leaves turn 3 open; compaction + // stamps the open turn. + expect(endEvent!.data).toMatchObject({ turn: 3, error: 'model unavailable' }) + + // No replace-op user/message was appended (summarize failed). + const userMsgsAfter = session.events.filter(e => e.type === 'user/message') + const replaceMsgs = userMsgsAfter.filter((e) => { + const se = e as unknown as { surfaceOp?: unknown } + return se.surfaceOp !== undefined && typeof se.surfaceOp !== 'string' + }) + expect(replaceMsgs.length).toBe(0) + }) + + it('extracts conversation text for summarization', async () => { + const svc = createTestService() + const session = multiTurnSession(1, 2) + const nodes = session.surface.nodes + + await svc.compactRegion(session, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + + expect(svc.summarizeCalls.length).toBe(1) + const { text, model } = svc.summarizeCalls[0]! + expect(model).toBe('m') + expect(text).toContain('User: turn 1 user message 1') + expect(text).toContain('Assistant: turn 1 assistant response 1') + }) + + it('frames the landed summary with a checkpoint preamble and tags, keeping raw provenance', async () => { + const svc = createTestService() + svc.mockSummary = [{ type: 'text', text: 'STRUCTURED SUMMARY' }] + const session = multiTurnSession(3, 1) + const nodes = session.surface.nodes + + const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm') + + // Provenance (compact/summary) carries the RAW, unframed summary. + expect(result.summary).toEqual([{ type: 'text', text: 'STRUCTURED SUMMARY' }]) + const summaryEvent = session.events.findLast(e => e.type === 'compact/summary')! + expect(summaryEvent.data).toMatchObject({ summary: [{ type: 'text', text: 'STRUCTURED SUMMARY' }] }) + + // The landed surface node is framed: preamble + tag-wrapped summary. + const landed = session.deriveMessages()[0]!.content + expect((landed[0] as { text: string }).text).toContain('checkpoint') + expect((landed[0] as { text: string }).text).toContain('') + expect(landed).toContainEqual({ type: 'text', text: 'STRUCTURED SUMMARY' }) + expect((landed[landed.length - 1] as { text: string }).text).toBe('') + }) + + it('extracts tool-call and tool-result context', async () => { + const svc = createTestService() + const session = sessionWithTools() + const nodes = session.surface.nodes + + const firstSeq = nodes[0]!.seq + const lastSeq = nodes[nodes.length - 1]!.seq + await svc.compactRegion(session, firstSeq, lastSeq, 'm') + + expect(svc.summarizeCalls.length).toBe(1) + const { text } = svc.summarizeCalls[0]! + expect(text).toContain('read file x') + expect(text).toContain('bash') + expect(text).toContain('Tool result') + }) +}) + +describe('BasicCompactService.compactIfNeeded', () => { + it('returns null when tokens are under threshold', async () => { + const svc = createTestService({ contextWindow: 128000, thresholdRatio: 0.8 }) + const session = multiTurnSession(1, 1) + expect(await svc.compactIfNeeded(session)).toBeNull() + }) + + it('compacts when tokens exceed threshold', async () => { + const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) + const session = multiTurnSession(3, 1) // 6 surface nodes, 10 tokens each = 60 + + const result = await svc.compactIfNeeded(session) + expect(result).not.toBeNull() + expect(result!.shadowedSeqs.length).toBeGreaterThan(0) + }) + + it('walks tail→head and retains nodes within token budget', async () => { + const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 15 }) + const session = multiTurnSession(5, 1) // 10 surface nodes = ~100 tokens + + const result = await svc.compactIfNeeded(session) + expect(result).not.toBeNull() + const nodes = session.surface.nodes + expect(result!.shadowedSeqs.length).toBeGreaterThan(0) + expect(result!.shadowedSeqs).not.toContain(nodes[nodes.length - 1]!.seq) + }) + + it('returns null when total tokens fit within budget', async () => { + const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 1000 }) + const session = multiTurnSession(2, 1) + expect(await svc.compactIfNeeded(session)).toBeNull() + }) + + it('retains the in-flight turn verbatim even when its newest node exceeds retainTokens', async () => { + // The current turn's first step has CLOSED (so its last node is step-aligned + // and would otherwise be a valid compaction cutoff), and that node — a fresh + // tool result — is larger than the whole retain budget. It must NOT be + // compacted: it is the observation the model needs for the turn's next step. + // Only the older closed turns are eligible. + const svc = new TestCompactServiceVarTokens({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5 }) + const s = new Session(SessionId('big-tail')) + // Two closed turns (compactable older context). + for (const t of [1, 2]) { + s.append('turn/start', { turn: t, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: t, step: 1 }) + s.append('user/message', { content: [{ type: 'text', text: `turn ${t}` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('assistant/message', { turn: t, step: 1, content: [{ type: 'text', text: `reply ${t}` }] }, { surfaceOp: 'append' }) + s.append('step/end', { turn: t, step: 1 }) + s.append('turn/end', { turn: t, reason: { kind: 'completed' } }) + } + // The in-flight turn 3: a user request, then a CLOSED step 1 whose tool + // result is HUGE (1000 tokens). The step is closed (step/end), so the result + // node is step-aligned — without the in-flight-turn protection the retention + // walk would pick it as the cutoff and compact it away. The turn itself is + // still open (no turn/end): the model is mid-turn, about to run step 2. + s.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('user/message', { content: [{ type: 'text', text: 'current request' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('step/start', { turn: 3, step: 1 }) + s.append('assistant/message', { turn: 3, step: 1, content: [{ type: 'text', text: 'calling' }, { type: 'tool-call', id: CallId('huge'), name: 'bash', arguments: '{}' }] }, { surfaceOp: 'append' }) + s.append('tool/call', { turn: 3, step: 1, callId: CallId('huge'), name: 'bash', arguments: '{}' }) + const hugeSeq = s.append('tool/result', { + turn: 3, step: 1, callId: CallId('huge'), + content: [{ type: 'text', text: 'HUGE' }], isError: false, + }, { surfaceOp: 'append' }).seq + s.append('step/end', { turn: 3, step: 1 }) + svc.bigSeqs.add(hugeSeq) // make this node weigh 1000 tokens + + const result = await svc.compactIfNeeded(s) + expect(result).not.toBeNull() + // The in-flight turn's nodes — the request, the assistant, AND the huge + // result — are retained: none shadowed, all survive on the surface verbatim. + expect(result!.shadowedSeqs).not.toContain(hugeSeq) + const survivingSeqs = new Set(s.surface.nodes.map(n => n.seq)) + expect(survivingSeqs.has(hugeSeq)).toBe(true) + const requestSeq = s.events.find(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text === 'current request'))!.seq + expect(survivingSeqs.has(requestSeq)).toBe(true) + // The older closed turns WERE compacted. + expect(result!.shadowedSeqs.length).toBeGreaterThan(0) + }) + + it('returns null for an empty surface', async () => { + const svc = createTestService({ contextWindow: 10, thresholdRatio: 0.1 }) + const session = new Session(SessionId('empty')) + expect(await svc.compactIfNeeded(session)).toBeNull() + }) + + it('compacts again within the same open turn (the prior summary node is still eligible)', async () => { + // After the first compaction lands a replacement summary node, that node is + // appended DURING the open turn (seq > turn/start) but sits earlier in the + // surface (at the shadowed range's position), NOT in the verbatim tail run. + // It must stay compaction-eligible: a second step in the SAME turn, still + // over threshold, must be able to compact older context — protectedIdx must + // not collapse to 0 and silently disable per-step auto-compaction. + // retainTokens=25 leaves a couple of retained closed-turn nodes after the + // first compaction (so the surface is [summary, …retained], not [summary]). + const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 25 }) + const s = multiTurnSession(4, 1) // turns 1-4 closed, turn 5 open (no surface yet) + + const first = await svc.compactIfNeeded(s) + expect(first).not.toBeNull() + // The summary node now heads the surface; the open turn has no verbatim tail + // node yet, so the whole surface (incl. the summary) is eligible — the + // protected suffix is the contiguous tail run of open-turn nodes (none yet). + // The summary node's seq exceeds turn 5's turn/start, yet it sits at the + // head (not the tail), so it must NOT be counted as protected. + const summaryHeadSeq = s.surface.nodes[0]!.seq + const turn5StartSeq = s.events.filter(e => e.type === 'turn/start').at(-1)!.seq + expect(summaryHeadSeq).toBeGreaterThan(turn5StartSeq) + + // Append a verbatim node in the open turn (a step's output), still over + // threshold, then compact again — the older summary + closed turns compact, + // the fresh nodes are retained. + s.append('step/start', { turn: 5, step: 1 }) + s.append('user/message', { content: [{ type: 'text', text: 'turn 5 work' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('assistant/message', { turn: 5, step: 1, content: [{ type: 'text', text: 'reply 5' }] }, { surfaceOp: 'append' }) + s.append('step/end', { turn: 5, step: 1 }) + + const second = await svc.compactIfNeeded(s) + expect(second).not.toBeNull() + expect(second!.shadowedSeqs.length).toBeGreaterThan(0) + // The fresh open-turn nodes were NOT compacted. + const turn5UserSeq = s.events.find(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text === 'turn 5 work'))!.seq + expect(second!.shadowedSeqs).not.toContain(turn5UserSeq) + }) +}) + +describe('BasicCompactService replay equivalence', () => { + it('produces identical deriveMessages() after seeding from compacted log', async () => { + const svc = createTestService() + const session = multiTurnSession(3, 1) + const nodes = session.surface.nodes + + await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm') + const derived = session.deriveMessages() + + const replayed = new Session(SessionId('replay'), [...session.events]) + expect(replayed.deriveMessages()).toEqual(derived) + }) +}) + +describe('BasicCompactService blocking (compaction in progress)', () => { + it('detects in-progress compaction from unmatched compact/start', async () => { + const svc = createTestService() + const session = multiTurnSession(1, 1) + session.append('compact/start', { turn: 1 }) + const nodes = session.surface.nodes + // Whole step (user → assistant) is a step-aligned region, so the call reaches + // the in-progress check rather than being rejected for splitting a step. + await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm')) + .rejects.toThrow(/compaction already in progress/) + }) + + it('allows compaction after compact/end is appended', async () => { + const svc = createTestService() + const session = multiTurnSession(2, 1) + const nodes = session.surface.nodes + session.append('compact/start', { turn: 1 }) + session.append('compact/end', { turn: 1 }) + const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm') + expect(result).toBeDefined() + }) + + it('is not wedged by an orphaned compact/start from a prior (now-closed) turn', async () => { + // A crash mid-compaction left a compact/start with no compact/end; the turn + // it lived in was later closed (persistence repair appends turn/end). A + // whole-log scan would treat that stale start as an active lock forever. The + // scan is scoped to the current turn, so a NEW turn compacts normally. + const svc = createTestService() + const s = new Session(SessionId('stale-lock')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('user/message', { content: [{ type: 'text', text: 'turn 1' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'reply 1' }] }, { surfaceOp: 'append' }) + s.append('compact/start', { turn: 1 }) // ← orphaned: no matching compact/end + s.append('step/end', { turn: 1, step: 1 }) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) // repair closed the turn + // A new open turn. + s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + const nodes = s.surface.nodes + + // The stale start is before the turn/end, so it is NOT seen as in-progress. + const result = await svc.compactRegion(s, nodes[0]!.seq, nodes[1]!.seq, 'm') + expect(result).toBeDefined() + }) +}) + +describe('BasicCompactService token estimation (char/4 heuristic)', () => { + it('estimates text blocks with char/4 + overhead', () => { + const svc = new BasicCompactService(new Context(), { auto: false }) + // 'this is a somewhat longer text block' = 36 → ceil(36/4)+4 = 13; 'short' = 5 → 2+4 = 6 + const blocks: ContentBlock[] = [ + { type: 'text', text: 'this is a somewhat longer text block' }, + { type: 'text', text: 'short' }, + ] + expect(svc.estimateContentTokens(blocks)).toBe(19) + }) + + it('estimates reasoning blocks same as text', () => { + const svc = new BasicCompactService(new Context(), { auto: false }) + // 'thinking about this...' = 22 → ceil(22/4)+4 = 10 + expect(svc.estimateContentTokens([{ type: 'reasoning', text: 'thinking about this...' }])).toBe(10) + }) + + it('estimates tool-call blocks from name + arguments', () => { + const svc = new BasicCompactService(new Context(), { auto: false }) + // 'bash' = 4 → 1; '{"command":"ls"}' = 16 → 4; + 4 overhead = 9 + expect(svc.estimateContentTokens([ + { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' }, + ])).toBe(9) + }) + + it('estimates tool-result blocks recursively', () => { + const svc = new BasicCompactService(new Context(), { auto: false }) + // inner text 5 → 2+4 = 6; outer 6 + 4 overhead = 10 + expect(svc.estimateContentTokens([ + { type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'hello' }], isError: false }, + ])).toBe(10) + }) + + it('estimates image blocks at fixed 85 tokens', () => { + const svc = new BasicCompactService(new Context(), { 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(), { auto: false }) + expect(svc.estimateContentTokens([])).toBe(0) + }) +}) + +describe('BasicCompactService HMR safety', () => { + it('registers as ctx.compact', () => { + const ctx = new Context() + void new BasicCompactService(ctx, { auto: false }) + expect(ctx.compact).toBeDefined() + expect(ctx.compact).toBeInstanceOf(BasicCompactService) + }) +}) + +/** An adapter that emits a fixed summary text, for exercising the real summarize() path. */ +class ScriptedAdapter extends LlmAdapter { + lastOptions: GenerateOptions | null = null + constructor(private summaryText: string) { + super() + } + + async * stream(options: GenerateOptions): AsyncIterable { + this.lastOptions = options + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: this.summaryText } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +/** Wire a real LlmService + scripted adapter into a context. */ +async function ctxWithModel(summaryText: string, model = 'test-model'): Promise<{ ctx: Context; adapter: ScriptedAdapter }> { + const ctx = new Context() + await ctx.plugin(LlmService) + const adapter = new ScriptedAdapter(summaryText) + ctx.llm.registerAdapter([model], adapter) + return { ctx, adapter } +} + +/** An adapter whose stream ends with a finish chunk of the given reason (no content). */ +class FinishOnlyAdapter extends LlmAdapter { + constructor(private reason: StreamChunk & { type: 'finish' }) { + super() + } + + async * stream(): AsyncIterable { + yield this.reason + } +} + +/** Wire a real LlmService + finish-only adapter into a context. */ +async function ctxWithFinish(reason: (StreamChunk & { type: 'finish' })['reason'], model = 'test-model'): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter([model], new FinishOnlyAdapter({ type: 'finish', reason })) + return ctx +} + +/** A minimal Agent stub carrying just session + options (enough for the listeners). */ +function stubAgent(session: Session, model?: string): Agent { + return { session, options: { model } } as unknown as Agent +} + +describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { + it('summarizes via the registered adapter and returns its content', async () => { + const { ctx, adapter } = await ctxWithModel('SUMMARY TEXT') + const svc = new BasicCompactService(ctx, { auto: false, summarizationMaxTokens: 512 }) + + const summary = await svc.summarize('User: hi\n\nAssistant: hello', 'test-model') + expect(summary).toEqual([{ type: 'text', text: 'SUMMARY TEXT' }]) + // The fixed system prompt and maxTokens flow through. + expect(adapter.lastOptions!.system).toContain('compaction engine') + expect(adapter.lastOptions!.system).toContain('## Next Step') + expect(adapter.lastOptions!.maxTokens).toBe(512) + expect(adapter.lastOptions!.messages[0]!.content[0]).toMatchObject({ type: 'text' }) + }) + + it('throws when no model is provided', async () => { + const { ctx } = await ctxWithModel('x') + const svc = new BasicCompactService(ctx, { auto: false }) + await expect(svc.summarize('text', '')).rejects.toThrow(/no model available/) + }) + + it('rethrows when the stream ends with a finish-error chunk', async () => { + const ctx = await ctxWithFinish({ kind: 'error', message: 'provider 401', code: 'UNAUTHORIZED' }) + const svc = new BasicCompactService(ctx, { auto: false }) + await expect(svc.summarize('text', 'test-model')).rejects.toMatchObject({ message: 'provider 401', code: 'UNAUTHORIZED' }) + }) + + it('rethrows a finish-error chunk without a code (code stays undefined)', async () => { + const ctx = await ctxWithFinish({ kind: 'error', message: 'opaque failure' }) + const svc = new BasicCompactService(ctx, { auto: false }) + const error = await svc.summarize('text', 'test-model').then(() => null, (e: unknown) => e as Error & { code?: string }) + expect(error?.message).toBe('opaque failure') + expect(error?.code).toBeUndefined() + }) + + it('rethrows when the stream ends with a finish-aborted chunk', async () => { + const ctx = await ctxWithFinish({ kind: 'aborted' }) + const svc = new BasicCompactService(ctx, { auto: false }) + await expect(svc.summarize('text', 'test-model')).rejects.toMatchObject({ message: 'summarization stream aborted', code: 'ABORTED' }) + }) + + it('fails closed on a max-tokens finish (an incomplete checkpoint must not commit)', async () => { + const ctx = await ctxWithFinish({ kind: 'max-tokens' }) + const svc = new BasicCompactService(ctx, { auto: false }) + await expect(svc.summarize('text', 'test-model')).rejects.toMatchObject({ code: 'MAX_TOKENS' }) + }) + + it('compactRegion leaves the surface intact when summarization hits max-tokens', async () => { + const ctx = await ctxWithFinish({ kind: 'max-tokens' }) + const svc = new BasicCompactService(ctx, { auto: false }) + const session = multiTurnSession(2, 1) + const before = [...session.surface.nodes] + const nodes = session.surface.nodes + + await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model')) + .rejects.toMatchObject({ code: 'MAX_TOKENS' }) + + // No replacement landed — the surface is byte-identical, and the lock was + // released with the error (compact/end carries it). + expect(session.surface.nodes).toEqual(before) + const endEvent = session.events.findLast(e => e.type === 'compact/end')! + const endData = endEvent.data as { error?: string } + expect(endData.error).toContain('truncated') + }) + + it('compactRegion uses the real summarizer end-to-end', async () => { + const { ctx } = await ctxWithModel('CONDENSED') + const svc = new BasicCompactService(ctx, { auto: false }) + const session = multiTurnSession(2, 1) + const nodes = session.surface.nodes + + const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') + expect(result.summary).toEqual([{ type: 'text', text: 'CONDENSED' }]) + // The raw summary is wrapped in the checkpoint framing on the surface. + expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'CONDENSED' }) + }) +}) + +describe('BasicCompactService auto-compaction (agent/request listener)', () => { + /** Fire the agent/request waterfall as the loop does. */ + function fireRequest(ctx: Context, agent: Agent, step: number, options: GenerateOptions): Promise { + return ctx.waterfall('agent/request', agent, 1, step, options, () => Promise.resolve(options)) + } + + it('compacts and rewrites request.messages when over threshold', async () => { + // Tiny window so the (large) session is over threshold; char/4 estimate. + const { ctx } = await ctxWithModel('SUMMARY') + const svc = new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 }) + const session = multiTurnSession(5, 1) // 10 surface nodes + const agent = stubAgent(session, 'test-model') + + const messages = session.deriveMessages() + const before = messages.length + const options: GenerateOptions = { model: 'test-model', messages } + + const out = await fireRequest(ctx, agent, 1, options) + // The surface shrank — request.messages was re-derived to fewer entries. + expect(out.messages.length).toBeLessThan(before) + expect(session.events.some(e => e.type === 'compact/summary')).toBe(true) + // Re-derived first message is the framed summary checkpoint. + expect(out.messages[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) + expect(svc).toBeDefined() + }) + + it('compacts mid-turn on steps after the first (the surface grows within a turn)', async () => { + const { ctx } = await ctxWithModel('SUMMARY') + void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) + const session = multiTurnSession(3, 1) // over the 0.5 threshold + const agent = stubAgent(session, 'test-model') + const options: GenerateOptions = { model: 'test-model', messages: session.deriveMessages() } + + // A step-2 request (a tool-heavy turn's later step) must still compact — the + // surface accumulated assistant/message + tool/result nodes since step 1. + await fireRequest(ctx, agent, 2, options) + expect(session.events.some(e => e.type === 'compact/start')).toBe(true) + }) + + it('passes through unchanged when under threshold', async () => { + const { ctx } = await ctxWithModel('SUMMARY') + void new BasicCompactService(ctx, { contextWindow: 128000, thresholdRatio: 0.8 }) + const session = multiTurnSession(1, 1) + const agent = stubAgent(session, 'test-model') + const msgs = session.deriveMessages() + const options: GenerateOptions = { model: 'test-model', messages: msgs } + + const out = await fireRequest(ctx, agent, 1, options) + expect(out.messages).toBe(msgs) + expect(session.events.some(e => e.type === 'compact/start')).toBe(false) + }) + + it('proceeds with original history when compaction fails', async () => { + // No adapter registered for this model → summarize() rejects → caught, proceeds. + const ctx = new Context() + await ctx.plugin(LlmService) + void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.1, retainTokens: 10 }) + const session = multiTurnSession(3, 1) + const agent = stubAgent(session, 'missing-model') + const msgs = session.deriveMessages() + const options: GenerateOptions = { model: 'missing-model', messages: msgs } + + const out = await fireRequest(ctx, agent, 1, options) + // Listener swallowed the failure and left messages intact. + expect(out.messages).toBe(msgs) + }) + + it('does not register the listener when auto is false', async () => { + const { ctx } = await ctxWithModel('SUMMARY') + void new BasicCompactService(ctx, { auto: false, contextWindow: 10, thresholdRatio: 0.1, retainTokens: 1 }) + const session = multiTurnSession(3, 1) + const agent = stubAgent(session, 'test-model') + const options: GenerateOptions = { model: 'test-model', messages: session.deriveMessages() } + + await fireRequest(ctx, agent, 1, options) + expect(session.events.some(e => e.type === 'compact/start')).toBe(false) + }) +}) + +describe('BasicCompactService._extractText branches', () => { + it('renders reasoning, context, and steering messages', async () => { + const svc = createTestService() + const s = new Session(SessionId('rich')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('context/message', { + content: [{ type: 'text', text: 'project context here' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + s.append('assistant/message', { + turn: 1, step: 1, + content: [{ type: 'reasoning', text: 'thinking hard' }, { type: 'text', text: 'answer' }], + }, { surfaceOp: 'append' }) + s.append('steering/message', { + turn: 1, + content: [{ type: 'text', text: 'steer this way' }], + 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' } } }) + + const nodes = s.surface.nodes + await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + + const { text } = svc.summarizeCalls[0]! + expect(text).toContain('[Context: project context here]') + expect(text).toContain('[reasoning: thinking hard]') + expect(text).toContain('[Steering: steer this way]') + }) + + it('labels tool errors distinctly from tool results', async () => { + const svc = createTestService() + const s = new Session(SessionId('toolerr')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('user/message', { content: [{ type: 'text', text: 'run it' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('tool/call', { turn: 1, step: 1, callId: CallId('c9'), name: 'bash', arguments: '{}' }) + s.append('tool/result', { + turn: 1, step: 1, callId: CallId('c9'), + content: [{ type: 'text', text: 'boom failure' }], + isError: true, + }, { 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' } } }) + + const nodes = s.surface.nodes + await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + expect(svc.summarizeCalls[0]!.text).toContain('Tool error (call c9): boom failure') + }) +}) + +describe('BasicCompactService edge cases', () => { + it('renders bare and nested tool-result placeholders and unknown blocks', async () => { + const svc = createTestService() + const s = new Session(SessionId('toolresult')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + // assistant/message carrying a nested tool-result block and an unknown block. + s.append('assistant/message', { + turn: 1, step: 1, + content: [ + { type: 'tool-result', toolCallId: CallId('n1'), content: [{ type: 'image', url: 'https://x/n.png' }] }, + { type: 'custom-widget', payload: 'x' } as unknown as ContentBlock, + ], + }, { surfaceOp: 'append' }) + // tool/result whose content is itself only non-text → bare '[tool-result]'. + s.append('tool/call', { turn: 1, step: 1, callId: CallId('b1'), name: 'bash', arguments: '{}' }) + s.append('tool/result', { + turn: 1, step: 1, callId: CallId('b1'), + content: [{ type: 'tool-result', toolCallId: CallId('inner'), content: [] }], + isError: false, + }, { 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' } } }) + + const nodes = s.surface.nodes + await svc.compactRegion(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('[custom-widget]') // unknown block placeholder + expect(text).toContain('Tool result (call b1): [tool-result]') // empty nested → bare placeholder + }) + + it('estimates unknown block types via JSON length (default branch)', () => { + const svc = new BasicCompactService(new Context(), { auto: false }) + // A block whose type is none of the known kinds — exercises the default arm. + const unknown = { type: 'custom-widget', payload: 'some data' } as unknown as ContentBlock + expect(svc.estimateContentTokens([unknown])).toBeGreaterThan(0) + }) + + it('compacts and re-derives without re-checking a post-compaction threshold', async () => { + const { ctx } = await ctxWithModel('SUMMARY') + // Even with a window so tiny the post-compaction history still exceeds the + // threshold, the agnostic listener does NOT re-gate or warn — it compacts + // once (the single check lives in compactIfNeeded) and proceeds. + const warnings: string[] = [] + ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn + void new BasicCompactService(ctx, { contextWindow: 10, thresholdRatio: 0.1, retainTokens: 5 }) + const session = multiTurnSession(4, 1) + const agent = stubAgent(session, 'test-model') + const options: GenerateOptions = { model: 'test-model', messages: session.deriveMessages() } + + await ctx.waterfall('agent/request', agent, 1, 1, options, () => Promise.resolve(options)) + expect(session.events.some(e => e.type === 'compact/summary')).toBe(true) + // The surface was re-derived into the request; no cascade warning is emitted. + expect(options.messages[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) + expect(warnings.length).toBe(0) + }) + + it('rejects compaction when no turn is open (compaction events must be turn-enclosed)', async () => { + const svc = createTestService() + // A session with surface nodes but NO open turn — compaction's compact/* and + // replacement events would be appended outside any turn, which the session-log + // contract forbids. + const s = new Session(SessionId('noturn')) + s.append('user/message', { content: [{ type: 'text', text: 'orphan' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const nodes = s.surface.nodes + + await expect(svc.compactRegion(s, nodes[0]!.seq, nodes[0]!.seq, 'm')) + .rejects.toThrow(/no open turn/) + // The lock was never acquired — no compact/start landed. + expect(s.events.some(e => e.type === 'compact/start')).toBe(false) + }) + + it('compactIfNeeded returns null for empty surface even when over threshold', async () => { + const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1 }) + const session = new Session(SessionId('empty-but-pressured')) + // No surface nodes, but a large system prompt pushes the estimate over threshold. + const bigPrompt = 'x'.repeat(400) // ceil(400/4) = 100 tokens >> threshold 10 + expect(await svc.compactIfNeeded(session, bigPrompt)).toBeNull() + }) + + it('compactRegion throws when end is not a surface node (start valid)', async () => { + const svc = createTestService() + const session = multiTurnSession(1, 1) + const nodes = session.surface.nodes + await expect(svc.compactRegion(session, nodes[0]!.seq, 9999, 'm')) + .rejects.toThrow(/end seq 9999 not found in surface/) + }) + + it('compactRegion stringifies a non-Error thrown by summarize', async () => { + const svc = createTestService() + // Throw a non-Error value to exercise the String(error) branch in the catch. + svc.summarizeError = 'plain string failure' as unknown as Error + const session = multiTurnSession(1, 1) + const nodes = session.surface.nodes + + // Whole step (user → assistant): a step-aligned region that reaches summarize. + await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm')).rejects.toBe('plain string failure') + const endEvent = session.events.findLast(e => e.type === 'compact/end')! + expect(endEvent.data).toMatchObject({ error: 'plain string failure' }) + }) + + it('auto-compaction listener stringifies a non-Error and proceeds', async () => { + const { ctx } = await ctxWithModel('SUMMARY') + const warnings: string[] = [] + ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn + const svc = new TestCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.1, retainTokens: 10 }) + svc.summarizeError = 'boom' as unknown as Error + const session = multiTurnSession(3, 1) + const agent = stubAgent(session, 'test-model') + const msgs = session.deriveMessages() + const options: GenerateOptions = { model: 'test-model', messages: msgs } + + const out = await ctx.waterfall('agent/request', agent, 1, 1, options, () => Promise.resolve(options)) + expect(out.messages).toBe(msgs) // proceeded with original history + expect(warnings.some(w => w.includes('compaction failed: boom'))).toBe(true) + }) + + it('auto-compaction listener takes the result-null branch (nothing to compact)', async () => { + const { ctx } = await ctxWithModel('SUMMARY') + // A large system prompt pushes the listener's estimate over threshold, but + // retainTokens is huge so compactIfNeeded walks everything and returns null. + const svc = new TestCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.1, retainTokens: 100000 }) + const session = multiTurnSession(2, 1) + const agent = stubAgent(session, 'test-model') + const bigSystem = 'x'.repeat(400) + const msgs = session.deriveMessages() + const options: GenerateOptions = { model: 'test-model', messages: msgs, system: bigSystem } + + const out = await ctx.waterfall('agent/request', agent, 1, 1, options, () => Promise.resolve(options)) + expect(session.events.some(e => e.type === 'compact/start')).toBe(false) + expect(out.messages).toBe(msgs) + expect(svc.summarizeCalls.length).toBe(0) + }) + + it('skips messages whose extracted text is empty across all kinds', async () => { + const svc = createTestService() + const s = new Session(SessionId('empties')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + // Empty-text text/reasoning blocks contribute nothing → message skipped. + s.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'reasoning', text: '' }] }, { surfaceOp: 'append' }) + // tool/result with empty content → empty extraction → skipped. + s.append('tool/call', { turn: 1, step: 1, callId: CallId('z1'), name: 'bash', arguments: '{}' }) + s.append('tool/result', { turn: 1, step: 1, callId: CallId('z1'), content: [], isError: false }, { surfaceOp: 'append' }) + s.append('context/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('steering/message', { turn: 1, content: [{ type: 'text', text: '' }], 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' } } }) + + const nodes = s.surface.nodes + await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + // Every message extracted to empty text — the conversation is empty. + expect(svc.summarizeCalls[0]!.text).toBe('') + }) + + it('renders non-text blocks as type-tagged placeholders across all message kinds', async () => { + const svc = createTestService() + const s = new Session(SessionId('placeholders')) + 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 only an image block → '[image]' placeholder. + s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'image', url: 'https://x/z.png' }] }, { surfaceOp: 'append' }) + // tool/result with an image block → '[image]' 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('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' } } }) + + const nodes = s.surface.nodes + await svc.compactRegion(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]]') + }) + +}) + +describe('BasicCompactService positional range (surface seqs are not monotonic after a replace)', () => { + it('compacts a second region after the first replace lands a high-seq summary at the head position', async () => { + // A replace inserts the new summary node (a high seq) AT the shadowed + // range's surface position, so the surface becomes + // [highSeqSummary, …olderRetainedLowerSeqs]. A second compaction over a + // range whose start node has a HIGHER seq than its end node must still + // succeed — the range is positional, not a numeric seq interval. + const svc = createTestService({ auto: false }) + const session = multiTurnSession(4, 1) + + // First compaction: shadow the two oldest surface nodes. + const nodes0 = session.surface.nodes + const first = await svc.compactRegion(session, nodes0[0]!.seq, nodes0[1]!.seq, 'm') + + // The summary node now sits at the head with a seq HIGHER than the + // retained older nodes that follow it — the non-monotonic surface. (The + // head is the user/message replace node, appended after the compact/summary + // provenance event, so its seq is at least first.summarySeq.) + const nodes1 = session.surface.nodes + expect(nodes1[0]!.seq).toBeGreaterThanOrEqual(first.summarySeq) + expect(nodes1[0]!.seq).toBeGreaterThan(nodes1[1]!.seq) + + // Second compaction: shadow [summary(head) … turn-2's step end]. The start + // seq (the head summary node) is GREATER than the end seq (an older retained + // node), so the range is a SURFACE-POSITION span, not a numeric seq interval. + // The end must land on a step boundary (turn-2's assistant message closes + // its step). + const startSeq = nodes1[0]!.seq + const endSeq = nodes1[2]!.seq + expect(startSeq).toBeGreaterThan(endSeq) + const second = await svc.compactRegion(session, startSeq, endSeq, 'm') + + // Exactly the three nodes at surface positions [0..2] are shadowed, in + // surface order — the positional slice, regardless of their seq values. + expect(second.shadowedSeqs).toEqual([nodes1[0]!.seq, nodes1[1]!.seq, nodes1[2]!.seq]) + // The surface still derives cleanly: a new head replace node + the rest. + const finalNodes = session.surface.nodes + expect(finalNodes[0]!.seq).toBeGreaterThanOrEqual(second.summarySeq) + expect(session.deriveMessages().length).toBe(finalNodes.length) + }) + + it('extracts the second-compaction transcript in surface order, not log-seq order', async () => { + const svc = createTestService({ auto: false }) + const session = multiTurnSession(3, 1) + + // First compaction shadows the oldest two surface nodes, landing a high-seq + // summary node at the head. + const n0 = session.surface.nodes + await svc.compactRegion(session, n0[0]!.seq, n0[1]!.seq, 'm') + + // Second compaction spans [head summary … turn-2's step end]. The head's seq + // is higher than the older retained nodes' seqs, so a log-seq-order walk + // would emit the older messages BEFORE the checkpoint. + const n1 = session.surface.nodes + svc.summarizeCalls = [] + await svc.compactRegion(session, n1[0]!.seq, n1[2]!.seq, 'm') + + // The extracted transcript follows surface order: the checkpoint (head) + // first, then the older retained messages — matching deriveMessages(). + const { text } = svc.summarizeCalls[0]! + const checkpointIdx = text.indexOf('compacted-summary') + const olderIdx = text.indexOf('turn 2 user') + expect(checkpointIdx).toBeGreaterThanOrEqual(0) + expect(olderIdx).toBeGreaterThan(checkpointIdx) + }) +}) + +describe('BasicCompactService llm inject (real plugin-load path)', () => { + it('declares llm in static inject so a sibling fiber can resolve ctx.llm', () => { + // summarize() reads ctx.llm; the inject lets the cordis ctx proxy resolve a + // sibling LlmService when this service is mounted as its own plugin fiber. + // Asserting the declaration (and exercising the real mount below) guards the + // resolution that root-ctx unit tests cannot, since they share one fiber. + expect(BasicCompactService.inject).toContain('llm') + }) + + it('resolves ctx.llm and summarizes when mounted as a sibling plugin of LlmService', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter('CONDENSED')) + // Mount the service through its real plugin fiber (NOT new …(rootCtx)), so + // the sibling-fiber ctx.llm resolution actually exercises the inject. + const fiber = await ctx.plugin(BasicCompactService, { auto: false }) + + const svc = ctx.compact as BasicCompactService + const session = multiTurnSession(2, 1) + const nodes = session.surface.nodes + const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') + expect(result.summary).toEqual([{ type: 'text', text: 'CONDENSED' }]) + + // HMR: disposing the fiber tears the service registration down. + await fiber.dispose() + expect(ctx.get('compact')).toBeUndefined() + }) +}) + +describe('BasicCompactService under the real invariants plugin', () => { + /** + * Drive compaction through a session whose `session/event` listeners include + * the real dev-mode invariants plugin (as a real app loads it via agent-core). + * The invariants throw on append, so a passing run proves the compaction + * sequence is contract-valid: every event is turn-enclosed, and the positional + * replace op is accepted even when the surface is no longer seq-ordered. + */ + async function setup(): Promise<{ ctx: Context; session: Session; svc: BasicCompactService }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(Invariants, {}) + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter('CONDENSED')) + await ctx.plugin(BasicCompactService, { auto: false }) + const session = ctx.sessions.create() + return { ctx, session, svc: ctx.compact as BasicCompactService } + } + + /** Append one closed turn of [user, assistant] surface nodes via the store. */ + function closedTurn(session: Session, turn: number): void { + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn, step: 1 }) + session.append('user/message', { content: [{ type: 'text', text: `turn ${turn} user` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + session.append('assistant/message', { turn, step: 1, content: [{ type: 'text', text: `turn ${turn} assistant` }] }, { surfaceOp: 'append' }) + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + + it('runs a turn-enclosed compaction whose positional replace the invariants accept', async () => { + const { session, svc } = await setup() + closedTurn(session, 1) + closedTurn(session, 2) + // Open turn 3, as the loop has when the auto-compaction listener fires. + session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) + + const nodes = session.surface.nodes + // No invariant throws here: compact/* + the replacement are all in turn 3. + const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') + expect(result.shadowedSeqs.length).toBe(2) + expect(session.surface.nodes[0]!.seq).toBeGreaterThan(session.surface.nodes[1]!.seq) + }) + + it('accepts a second compaction over the non-monotonic surface left by the first', async () => { + const { session, svc } = await setup() + closedTurn(session, 1) + closedTurn(session, 2) + closedTurn(session, 3) + session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }) + + const n0 = session.surface.nodes + await svc.compactRegion(session, n0[0]!.seq, n0[1]!.seq, 'test-model') + + // Surface head now carries a higher seq than the older retained nodes. A + // second compaction spanning [head … a later closed-step end] must pass the + // invariants' positional replace check even though startSeq > endSeq. + const n1 = session.surface.nodes + expect(n1[0]!.seq).toBeGreaterThan(n1[2]!.seq) + const second = await svc.compactRegion(session, n1[0]!.seq, n1[2]!.seq, 'test-model') + expect(second.shadowedSeqs).toEqual([n1[0]!.seq, n1[1]!.seq, n1[2]!.seq]) + }) +}) + diff --git a/packages/compact/compact-basic/tsconfig.json b/packages/compact/compact-basic/tsconfig.json new file mode 100644 index 0000000000..075c64cb61 --- /dev/null +++ b/packages/compact/compact-basic/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../llm/llm" }, + { "path": "../../core/session" }, + { "path": "../../core/agent" }, + { "path": "../compact" } + ] +} diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 9ef5b73005..43737a4231 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -19,7 +19,7 @@ Both methods are **abstract** — the backend owns the entire strategy (token es | Member | Semantics | |---|---| | `compactIfNeeded(session, systemPrompt?, model?, signal?)` | Estimate the history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. | -| `compactRegion(session, start, end, model, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start > end`. | +| `compactRegion(session, start, end, model, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | Both methods take an optional `signal: AbortSignal`. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is not a parameter — it is recoverable from the log (the currently-open turn), so the backend stamps it without the caller supplying it. diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 9e58e5c905..9ff7898468 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -89,6 +89,16 @@ export abstract class CompactService extends Service { * summarizes their content and appends a replacement surface node. Used by the * (future) `/compact` tool and internally by {@link compactIfNeeded}. * + * The region MUST contain whole steps — `start` and `end` must each sit on a + * step boundary (the first / last surface node of a step) or on a node that + * belongs to no step (a pre-step user message, inter-step steering, or an + * injection context message). A boundary that falls INSIDE a step would split + * that step's `assistant/message` tool-calls from their `tool/result`s, leaving + * the rehydrated transcript with a dangling tool-call or an orphaned + * tool-result that every provider rejects. An `end` inside an open (unclosed) + * tail step is likewise invalid — its tool-calls have no results yet. + * `dsh-session` exports `isStepAlignedStart` / `isStepAlignedEnd` for this check. + * * @param session - the session whose surface is mutated. * @param start - inclusive seq of the first surface node to compact. * @param end - inclusive seq of the last surface node to compact. @@ -97,8 +107,12 @@ export abstract class CompactService extends Service { * `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal` * so an abort/dispose tears down the in-flight summarization rather than * leaving an orphaned model call running past the cancellation. - * @throws if compaction is already in progress, or if `start`/`end` are not - * valid surface nodes, or if `start > end`. + * @throws if compaction is already in progress, if `start`/`end` are not + * valid surface nodes, if `start` is positioned after `end` on the surface + * (the range is a surface-POSITION span, not a numeric seq interval — a + * prior replace can leave the surface non-monotonic in seq order), or if + * either boundary is not step-aligned (would split a step's tool-call/result + * pair). */ abstract compactRegion( session: Session, diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index 36dd5fb629..df001ff41a 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -48,9 +48,16 @@ export interface CompactionResult { endSeq: number /** The summary content blocks produced by the backend. */ summary: ContentBlock[] - /** The seq range that was shadowed [start, end] inclusive. */ + /** + * The surface-boundary pair that was shadowed: the seqs of the first + * (`start`) and last (`end`) surface nodes of the replaced range. A + * surface-POSITION span, not a numeric seq interval — after a prior replace + * lands a fresh high-seq summary node at an older range's position, `start` + * can be GREATER than `end`. {@link CompactionResult.shadowedSeqs} is the + * authoritative set of shadowed nodes, in surface order. + */ shadowedRange: { start: number; end: number } - /** The seq numbers of all shadowed surface nodes. */ + /** The seqs of all shadowed surface nodes, in surface order. */ shadowedSeqs: number[] /** Estimated token count of the shadowed content. */ shadowedTokenCount: number diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 2002c93051..0fb44f3299 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -19,6 +19,7 @@ export { isJsonValue } from './json.ts' export { interruptedTurnClosers } from './repair.ts' export type { SurfaceNode } from './surface.ts' export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' +export { isStepAlignedStart, isStepAlignedEnd } from './step-boundary.ts' declare module 'cordis' { interface Context { diff --git a/packages/core/session/src/step-boundary.ts b/packages/core/session/src/step-boundary.ts new file mode 100644 index 0000000000..e8ed91d74c --- /dev/null +++ b/packages/core/session/src/step-boundary.ts @@ -0,0 +1,97 @@ +/** + * Step-boundary predicates over a session log: is a given surface node a SAFE + * place to start or end a region that will be collapsed (e.g. by compaction)? + * + * The invariant a consumer needs: a collapsed region must NOT partially overlap + * a step. A step's surface nodes form a contiguous run, and a region must + * contain either ALL of a step's nodes or NONE of them — otherwise it can split + * an `assistant/message`'s `tool-call` blocks from their `tool/result`s, leaving + * the rehydrated transcript with a dangling tool-call or an orphaned tool-result + * (which every provider rejects). This is the compaction-time mirror of the + * crash-recovery imbalance that {@link interruptedTurnClosers} repairs on load. + * + * Nodes that belong to NO step — a pre-step `user/message` (drained before the + * first `step/start`), inter-step `steering/message`, or an injection + * `context/message` (wrapped in a bare `turn/start → context/message → turn/end` + * with no step) — carry no tool pairing and are free boundaries on both sides. + * + * The scans classify each neighbor event into three buckets: a turn/step + * BOUNDARY marker (the region edge is clean), a SURFACE node (the region edge + * is mid-step), or NOISE to skip (`assistant/chunk`, the log-only `compact/*` + * records, and any future non-surface event). "Surface node" is decided by the + * shared {@link isSurfaceEvent} guard so the two notions can't drift. + * + * @module @deepseek-ai/dsh-session/step-boundary + */ + +import type { SessionEvent } from './types.ts' +import { isSurfaceEvent } from './surface.ts' + +/** Turn/step boundary marker types — the walls the scans stop on. */ +const BOUNDARY_TYPES = new Set(['turn/start', 'turn/end', 'step/start', 'step/end']) + +/** + * Whether the surface node at `seq` is a SAFE START for a collapsed region — + * i.e. it is the first surface node of its step, or it belongs to no step at + * all (a free inter-step / pre-step / injection node). + * + * Scans BACKWARD from `seq`, skipping noise, and stops at the first significant + * event: a turn/step boundary marker ⇒ aligned (nothing of `seq`'s step lies + * before it), a surface node ⇒ NOT aligned (a predecessor surface node sits in + * the same step, so starting here would orphan it), start-of-log ⇒ aligned. + * + * No open-step check is needed on the start side: an open (unclosed) step can + * only ever be the LAST turn's last step, never before a valid region start. + */ +export function isStepAlignedStart(events: readonly SessionEvent[], seq: number): boolean { + for (let i = seq - 1; i >= 0; i--) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const event = events[i]! + if (BOUNDARY_TYPES.has(event.type)) return true + if (isSurfaceEvent(event)) return false + } + return true +} + +/** + * Whether the surface node at `seq` is a SAFE END for a collapsed region — + * i.e. it is the last surface node of a CLOSED step, or it belongs to no step + * at all. + * + * Scans FORWARD from `seq`, skipping noise, and stops at the first significant + * event: a turn/step boundary marker ⇒ aligned (the step/turn closes after + * `seq`, or a new one begins because `seq` was inter-step), a surface node ⇒ + * NOT aligned (a later surface node sits in the same step). Reaching + * end-of-log is aligned ONLY when `seq` is not inside an OPEN step — an open + * trailing step's `tool-call`s have no `tool/result`s yet, so collapsing it + * would defer the orphan to when those results land later. {@link isInOpenStep} + * decides that via a backward scan. + */ +export function isStepAlignedEnd(events: readonly SessionEvent[], seq: number): boolean { + for (let i = seq + 1; i < events.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const event = events[i]! + if (BOUNDARY_TYPES.has(event.type)) return true + if (isSurfaceEvent(event)) return false + } + // End of log: aligned only if `seq` is not inside a still-open step. + return !isInOpenStep(events, seq) +} + +/** + * Whether `seq` sits inside an OPEN step — a `step/start` with no later + * `step/end`. Only meaningful at the tail (the EOL branch of + * {@link isStepAlignedEnd}): scans BACKWARD for the nearest turn/step boundary. + * The nearest one being `step/start` means a step opened before `seq` and never + * closed (no `step/end` lies after `seq`, or the forward scan would not have + * reached EOL) — so `seq` is mid-open-step. Any other nearest boundary (or none) + * means `seq` is inter-step / pre-step. + */ +function isInOpenStep(events: readonly SessionEvent[], seq: number): boolean { + for (let i = seq - 1; i >= 0; i--) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const type = events[i]!.type + if (BOUNDARY_TYPES.has(type)) return type === 'step/start' + } + return false +} diff --git a/packages/core/session/tests/step-boundary.spec.ts b/packages/core/session/tests/step-boundary.spec.ts new file mode 100644 index 0000000000..a24a6f7596 --- /dev/null +++ b/packages/core/session/tests/step-boundary.spec.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest' +import { CallId } from '@deepseek-ai/dsh-llm' +import { isStepAlignedStart, isStepAlignedEnd } from '../src/index.ts' +import type { SessionEvent } from '../src/index.ts' + +/** + * Unit coverage for the step-alignment predicates. They decide whether a + * surface node is a safe START / END for a collapsed region (compaction): a + * region must contain whole steps, never split an `assistant/message`'s + * tool-calls from their `tool/result`s. Nodes belonging to no step (pre-step + * user message, inter-step steering, injection context) are free boundaries. + * + * Builders mirror the agent loop's real append order so the fixtures are + * representative: queued user messages land BEFORE `step/start`; within a step + * the order is `assistant/message` then `tool/result`(s); injection turns are a + * bare `turn/start → context/message → turn/end` with no step. + */ + +const SURFACE = { surfaceOp: 'append' as const } + +/** A closed turn with one closed step holding an assistant + its tool result. */ +function toolStepLog(): SessionEvent[] { + return [ + { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'user/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, ...SURFACE }, + { type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, + { type: 'assistant/message', seq: 3, time: 3, data: { turn: 1, step: 1, content: [ + { type: 'text', text: 'calling' }, + { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }, + ] }, ...SURFACE }, + { type: 'tool/call', seq: 4, time: 4, data: { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{}' } }, + { type: 'tool/result', seq: 5, time: 5, data: { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, ...SURFACE }, + { type: 'step/end', seq: 6, time: 6, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: 7, time: 7, data: { turn: 1, reason: { kind: 'completed' } } }, + ] +} + +describe('isStepAlignedStart', () => { + it('is true for a pre-step user/message (belongs to no step)', () => { + // seq 1 user/message sits before step/start at seq 2 → free boundary. + expect(isStepAlignedStart(toolStepLog(), 1)).toBe(true) + }) + + it('is true for the first surface node of a step (the assistant/message)', () => { + // Backward from seq 3 the first significant event is step/start → aligned. + expect(isStepAlignedStart(toolStepLog(), 3)).toBe(true) + }) + + it('is false for a tool/result whose assistant/message precedes it in the same step', () => { + // Backward from seq 5 the first significant event is the assistant/message + // surface node (seq 3) → starting here would orphan that assistant's call. + expect(isStepAlignedStart(toolStepLog(), 5)).toBe(false) + }) + + it('is true at start-of-log (nothing precedes)', () => { + const log: SessionEvent[] = [ + { type: 'user/message', seq: 0, time: 0, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, ...SURFACE }, + ] + expect(isStepAlignedStart(log, 0)).toBe(true) + }) + + it('skips noise (assistant/chunk, compact/* records) when scanning back', () => { + // A compacted region landed compact/* log-only records between the prior + // step boundary and this surface node; they must be skipped, not treated as + // walls. Backward from seq 4 skips compact/end, compact/summary, compact/start + // and stops at step/start (seq 0) → aligned. + const log: SessionEvent[] = [ + { type: 'step/start', seq: 0, time: 0, data: { turn: 1, step: 1 } }, + { type: 'compact/start', seq: 1, time: 1, data: { turn: 1 } } as unknown as SessionEvent, + { type: 'compact/summary', seq: 2, time: 2, data: { summary: [], shadowedRange: { start: 0, end: 0 }, shadowedSeqs: [], shadowedTokenCount: 0 } } as unknown as SessionEvent, + { type: 'compact/end', seq: 3, time: 3, data: { turn: 1 } } as unknown as SessionEvent, + { type: 'assistant/message', seq: 4, time: 4, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, ...SURFACE }, + ] + expect(isStepAlignedStart(log, 4)).toBe(true) + }) +}) + +describe('isStepAlignedEnd', () => { + it('is true for the last surface node of a closed step (the tool/result)', () => { + // Forward from seq 5 the first significant event is step/end → aligned. + expect(isStepAlignedEnd(toolStepLog(), 5)).toBe(true) + }) + + it('is false for an assistant/message with a later tool/result in the same step', () => { + // Forward from seq 3 the first significant event is the tool/result surface + // node (seq 5) → ending here would strand that result. + expect(isStepAlignedEnd(toolStepLog(), 3)).toBe(false) + }) + + it('is true for a pre-step user/message (next significant event is step/start)', () => { + expect(isStepAlignedEnd(toolStepLog(), 1)).toBe(true) + }) + + it('is false at EOL when the node is inside an open (unclosed) step', () => { + // step/start then an assistant tool-call, but no step/end / tool/result yet + // (mid-flight). Ending the region on seq 3 would summarize away a tool-call + // whose result lands later → orphan. EOL + open step ⇒ not aligned. + const log: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, + { type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [ + { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }, + ] }, ...SURFACE }, + ] + expect(isStepAlignedEnd(log, 2)).toBe(false) + }) + + it('is false at EOL when the node is inside an open step, skipping noise on the back-scan', () => { + // The open-step back-scan must skip non-boundary events (here an + // assistant/chunk) before it reaches step/start. Without the skip it would + // mis-read the chunk as the nearest "boundary" and never confirm the open step. + const log: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, + { type: 'assistant/chunk', seq: 2, time: 2, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } } }, + { type: 'assistant/message', seq: 3, time: 3, data: { turn: 1, step: 1, content: [ + { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }, + ] }, ...SURFACE }, + ] + expect(isStepAlignedEnd(log, 3)).toBe(false) + }) + + it('is true at EOL when the node is a trailing inter-step node (step already closed)', () => { + // A steering message appended after step/end, at the tail. Backward the + // nearest boundary is step/end → not in an open step → aligned. + const log: SessionEvent[] = [ + { type: 'step/start', seq: 0, time: 0, data: { turn: 1, step: 1 } }, + { type: 'assistant/message', seq: 1, time: 1, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, ...SURFACE }, + { type: 'step/end', seq: 2, time: 2, data: { turn: 1, step: 1 } }, + { type: 'steering/message', seq: 3, time: 3, data: { turn: 1, content: [{ type: 'text', text: 's' }], source: { kind: 'user' } }, ...SURFACE }, + ] + expect(isStepAlignedEnd(log, 3)).toBe(true) + }) + + it('is true at EOL when no step ever opened (start-of-log fallback in open-step check)', () => { + // A lone surface node, no turn/step markers at all → not in an open step. + const log: SessionEvent[] = [ + { type: 'user/message', seq: 0, time: 0, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, ...SURFACE }, + ] + expect(isStepAlignedEnd(log, 0)).toBe(true) + }) + + it('skips noise (assistant/chunk) when scanning forward', () => { + // assistant/chunk events precede the assistant/message in a real step; the + // forward scan from an inter-step node must skip them and stop on step/start. + const log: SessionEvent[] = [ + { type: 'user/message', seq: 0, time: 0, data: { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, ...SURFACE }, + { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, + { type: 'assistant/chunk', seq: 2, time: 2, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } } }, + ] + // Forward from seq 0 hits step/start at seq 1 → aligned (noise after is moot). + expect(isStepAlignedEnd(log, 0)).toBe(true) + }) +}) + +describe('step-alignment on an injection turn (no step)', () => { + // An idle inject() wraps a context/message in a bare turn/start → context/message + // → turn/end with NO step/start. The context node is a free boundary both ways. + const injectionLog = (): SessionEvent[] => [ + { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } } }, + { type: 'context/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, ...SURFACE }, + { type: 'turn/end', seq: 2, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + + it('start: aligned (backward hits turn/start)', () => { + expect(isStepAlignedStart(injectionLog(), 1)).toBe(true) + }) + + it('end: aligned (forward hits turn/end)', () => { + expect(isStepAlignedEnd(injectionLog(), 1)).toBe(true) + }) +}) diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index b2372e28db..08fbf49b57 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -162,9 +162,6 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void { trace.surface.push(event.seq) } else { const { start, end } = se.surfaceOp - if (start > end) { - throw new InvariantError(`surface replace: start ${start} must be <= end ${end}`) - } const startIdx = trace.surface.indexOf(start) if (startIdx === -1) { throw new InvariantError(`surface replace: start seq ${start} is not on the surface`) diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 6ae2b0edf1..d7e95c1103 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -530,16 +530,17 @@ describe('surface invariants', () => { }).toThrow(/unknown seq 2/) }) - it('rejects replace op with start > end', async () => { + it('rejects a replace whose start is positioned after its end on the surface', async () => { const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 - // start > end is invalid (reversed order). + session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3 + // Reversed range: start seq 3 is at a later surface position than end seq 2. expect(() => { - session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 1 }, sourceEventSeqs: [2] }) - }).toThrow(/must be <= end/) + session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 3, end: 2 }, sourceEventSeqs: [2, 3] }) + }).toThrow(/is after end seq 2 .* on the surface/) }) it('rejects a replace whose sourceEventSeqs omits a shadowed surface node', async () => { @@ -608,6 +609,23 @@ describe('surface invariants', () => { }).toThrow(/is after end seq 4 .* on the surface/) }) + it('accepts a replace whose start seq exceeds its end seq when the surface position order is valid', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 + session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3 + // Replace node 2 (position 0) with seq 4 — surface becomes [4, 3], so the + // head seq (4) is numerically GREATER than the tail seq (3): the surface is + // not seq-ordered. A replace spanning start=4 (pos 0) … end=3 (pos 1) is + // valid positionally and must be accepted even though start seq > end seq. + session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] }) // seq 4 + expect(() => { + session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 4, end: 3 }, sourceEventSeqs: [4, 3] }) // seq 5 + }).not.toThrow() + }) + it('rejects a replace that omits sourceEventSeqs entirely', async () => { const { ctx } = await setup() const session = ctx.sessions.create() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2afc331514..70a1a9973a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,6 +130,27 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/compact/compact-basic: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../compact + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/core/agent: devDependencies: '@deepseek-ai/dsh-brand': diff --git a/tsconfig.build.json b/tsconfig.build.json index 9d76a33385..19f9d65bb0 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -23,6 +23,7 @@ { "path": "./packages/core/agent-core" }, { "path": "./packages/bash/bash" }, { "path": "./packages/compact/compact" }, + { "path": "./packages/compact/compact-basic" }, { "path": "./packages/llm/llm-deepseek" }, { "path": "./packages/llm/llm-pi-ai" }, { "path": "./packages/bash/bash-local" }, diff --git a/tsconfig.json b/tsconfig.json index 81f52357d5..3da543318d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,6 +38,7 @@ { "path": "./packages/bash/bash-local" }, { "path": "./packages/bash/tool-bash" }, { "path": "./packages/compact/compact" }, + { "path": "./packages/compact/compact-basic" }, { "path": "./packages/support/invariants" }, { "path": "./packages/ui/acp" }, { "path": "./packages/ui/acp-agent" }, From cec32faa4ef11f0b74011f8d1b27d92719ca53ae Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 26 Jun 2026 08:59:33 +0800 Subject: [PATCH 05/46] refactor(compact): turn-agnostic retention + dedicated agent/pre-request seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reform the compaction blueprint so a runaway turn survives and the design stops drifting across review rounds: - Drop in-flight-turn protection ("layer 2"). Retention is a uniform tail→head whole-unit walk; the only structural guard is step-alignment. A single turn that alone exceeds the window now compacts its own early closed steps instead of being retained verbatim (the failure mode that motivated this). - Move auto-compaction off the agent/request waterfall onto a new awaited agent/pre-request loop seam, fired before history derivation. Compaction mutates the surface; the loop derives once from the result — no double-derive, and a listener structurally cannot act on not-yet-derived messages. - Tighten compactIfNeeded to required (session, system, model, signal). - Enforce a single-pass convergence invariant in resolveConfig: reject configs where summarizationMaxTokens + retainTokens exceeds the threshold, so a compaction can never immediately re-trigger. - Document the crash vs recoverable failure taxonomy; core session repair stays compaction-agnostic (a log-only orphaned compact/start is inert). - Wire dsh-compact-basic into examples/coding-agent and add a with-key compaction e2e (compaction's first real-world exercise + runaway net). - Rewrite the RFC to encode the blueprint and move it to implemented/. The runaway-turn snapshot is a named deferred follow-up: dsh-llm-replay cannot yet serve the interleaved summarization model call. --- docs/architecture.md | 7 +- docs/cordis-catalog/events-and-services.md | 32 +- docs/core-data-structures/compaction.md | 6 +- docs/rfc/README.md | 2 +- .../2026-06-18-compaction-capability-seam.md | 118 ++++++ .../2026-06-18-compaction-capability-seam.md | 59 --- examples/coding-agent/cordis.yml | 10 + examples/coding-agent/tests/compaction.e2e.ts | 95 +++++ examples/coding-agent/tests/harness.ts | 21 +- examples/coding-agent/tests/resume.e2e.ts | 4 +- packages/compact/README.md | 2 +- packages/compact/compact-basic/README.md | 10 +- packages/compact/compact-basic/src/index.ts | 228 ++++------- packages/compact/compact-basic/src/types.ts | 32 +- .../compact-basic/tests/compact-basic.spec.ts | 363 +++++++++--------- packages/compact/compact/README.md | 6 +- packages/compact/compact/src/index.ts | 37 +- packages/compact/compact/src/types.ts | 2 +- packages/core/agent-loop/src/loop.ts | 10 +- packages/core/agent-loop/tests/loop.spec.ts | 59 +++ packages/core/agent/src/types.ts | 26 +- packages/core/session/tests/surface.spec.ts | 19 +- 22 files changed, 724 insertions(+), 424 deletions(-) create mode 100644 docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md delete mode 100644 docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md create mode 100644 examples/coding-agent/tests/compaction.e2e.ts diff --git a/docs/architecture.md b/docs/architecture.md index bff7b03091..344ea08e26 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -135,8 +135,9 @@ forever: drain steering (late steering from previous step's listeners) session('step/start'); emit agent/step-start assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble + await ctx.parallel('agent/pre-request') ⟵ surface mutation (compaction) before derive req = {model, system, tools, messages: session.deriveMessages(), signal} - req = waterfall agent/request ⟵ hooks, compaction, model switch + req = waterfall agent/request ⟵ hooks, model switch stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks) session('assistant/chunk'); emit agent/stream-chunk if assembler.finish is error/aborted: throw ⟵ adapter's in-band error path → @@ -192,7 +193,7 @@ Every MVP feature (including the TODO-marked ones), with the mechanism that impl | `/loop` | on `agent/turn-end`, `send()` the next iteration; or force-continue | | Dynamic workflow | orchestrator plugin on `agent/turn-end` / `agent/step-end` driving `send`/`steer` (+ sub-agents later) | | Queued + steering messages | core `Agent.send()` / `Agent.steer()` | -| Context compaction (auto + manual) | the `dsh-compact` seam (`ctx.compact`) + a backend (`dsh-compact-basic`) wrapping `agent/request`: a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure before each model call, manual = a (deferred) `/compact` tool invoking the same `ctx.compact` routine. See the [compaction capability-seam RFC](rfc/proposed/feature/2026-06-18-compaction-capability-seam.md) | +| Context compaction (auto + manual) | the `dsh-compact` seam (`ctx.compact`) + a backend (`dsh-compact-basic`) on the awaited `agent/pre-request` seam: a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure before each model call (every step — runaway-turn survival), manual = a (deferred) `/compact` tool invoking the same `ctx.compact` routine. See the [compaction capability-seam RFC](rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) | | System prompt configurability | `ctx.systemPrompt.section()` with ordering | | AGENTS.md (root) | a section provider reading the file | | AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener | @@ -220,6 +221,6 @@ Code skeletons for the three plugin shapes (tool, hook/permission-gate, UI) and Tracked here deliberately — each is designed-for but not implemented: - **Sub-agent spawn/fork semantics** (seam: `AgentLoop.create()`); inter-agent channels beyond `send`/`steer`/events. -- **Compaction** — the `dsh-compact` seam (`ctx.compact`) and the `dsh-compact-basic` backend exist (auto thresholds, summarization on the `agent/request` seam, `compact/*` session events via declaration merging). The model-facing `/compact` consumer tool is still deferred. See [the compaction capability-seam RFC](rfc/proposed/feature/2026-06-18-compaction-capability-seam.md). +- **Compaction** — the `dsh-compact` seam (`ctx.compact`) and the `dsh-compact-basic` backend exist (auto thresholds, summarization on the awaited `agent/pre-request` seam, `compact/*` session events via declaration merging). The model-facing `/compact` consumer tool is still deferred. See [the compaction capability-seam RFC](rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). - **Parallel tool execution** (concurrency-safety hints on ToolDefinition). - **Session branching/tree** (pi-style entry tree) if needed beyond seed-based forking. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index b8758bef00..7514a5fe41 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -11,7 +11,7 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary ## Events -Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 24 events across 6 scopes. +Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 25 events across 6 scopes. ### `agent/*` @@ -49,7 +49,21 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:220`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:242`](../../packages/core/agent/src/types.ts) + +#### `agent/pre-request` — parallel + +Awaited surface-mutation checkpoint, fired BEFORE the step's message history is derived (and thus before agent/request). The loop awaits `ctx.parallel('agent/pre-request', …)` after assembling the system prompt but before `session.deriveMessages()`, then derives ONCE from whatever the surface now holds. This is where compaction belongs: it mutates the session surface in place (shadowing an older range with a summary node), and the single subsequent derive reflects the mutation — so there is no double-derive and no listener can see (or be expected to act on) an assembled `messages` array that does not exist yet. + +Awaited (parallel), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform or veto, but the loop must wait for the mutation to complete before deriving. `system`/`model` are the assembled values a listener needs to measure pressure (system counts toward the budget) and to summarize (the model). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call). + +```ts cordis-catalog +'agent/pre-request'(agent: Agent, turn: number, step: number, system: string, model: string, signal: AbortSignal): Promise | void +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -65,7 +79,7 @@ Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/t #### `agent/request` — waterfall -Waterfall: mutate the fully-assembled GenerateOptions before the model call (hooks, compaction, model switching, tool filtering, …). Call `next()` to delegate, or return without it to short-circuit. +Waterfall: mutate the fully-assembled 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 agent/pre-request instead — by the time this fires, `options.messages` is already derived. ```ts cordis-catalog 'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise): Promise @@ -73,7 +87,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:189`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:211`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -97,7 +111,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:236`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -121,7 +135,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:195`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -145,7 +159,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:231`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -157,7 +171,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -373,7 +387,7 @@ Implementations MUST honor: - **Blocking**: no compaction begins while another is in progress for the same session. The recommended mechanism is the log-recorded lock — append `compact/start` before the slow work and `compact/end` after (even on failure) — so the lock is visible to replay and crash recovery. ```ts cordis-catalog -abstract compactIfNeeded( session: Session, systemPrompt?: string, model?: string, signal?: AbortSignal, ): Promise +abstract compactIfNeeded( session: Session, system: string, model: string, signal: AbortSignal, ): Promise abstract compactRegion( session: Session, start: number, end: number, model: string, signal?: AbortSignal, ): Promise ``` diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index ef22d79c94..e637961784 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -1,6 +1,6 @@ # Compaction -The compaction seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) split like bash: interface ([dsh-compact](../../packages/compact/compact), `ctx.compact`), implementation (a backend such as [dsh-compact-basic](../../packages/compact/compact-basic)), and consumer (a `/compact` tool, deferred). Compaction is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A tokenizer- or template-based backend is a sibling package implementing the same interface. Unlike bash, the interface necessarily depends on `dsh-session` and `dsh-llm`: its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary (see the [compaction capability-seam RFC](../rfc/proposed/feature/2026-06-18-compaction-capability-seam.md)). +The compaction seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) split like bash: interface ([dsh-compact](../../packages/compact/compact), `ctx.compact`), implementation (a backend such as [dsh-compact-basic](../../packages/compact/compact-basic)), and consumer (a `/compact` tool, deferred). Compaction is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A tokenizer- or template-based backend is a sibling package implementing the same interface. Unlike bash, the interface necessarily depends on `dsh-session` and `dsh-llm`: its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary (see the [compaction capability-seam RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)). Source: [`packages/compact/compact/src/types.ts`](../../packages/compact/compact/src/types.ts) @@ -50,4 +50,6 @@ interface CompactionResult { ## The service -`CompactService` (`ctx.compact`, abstract — defined in [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts)) declares two abstract methods: `compactIfNeeded(session, systemPrompt?, model?, signal?)` checks token pressure and compacts an older range if the history is too large (returning `null` when nothing needs it), and `compactRegion(session, start, end, model, signal?)` forcibly summarizes surface nodes `[start, end]` into a single replacement node. Both take an optional `signal: AbortSignal` that a backend summarizing via `ctx.llm.stream()` must forward into the call's `GenerateOptions.signal`, so an abort or dispose tears down the in-flight summarization. The entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. +`CompactService` (`ctx.compact`, abstract — defined in [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts)) declares two abstract methods: `compactIfNeeded(session, system, model, signal)` checks token pressure and compacts an older range if the history is too large (returning `null` when nothing needs it), and `compactRegion(session, start, end, model, signal?)` forcibly summarizes surface nodes `[start, end]` into a single replacement node. `compactIfNeeded`'s parameters are all required — the loop's `agent/pre-request` checkpoint always supplies the assembled `system`, the `model`, and the turn `signal`. A backend summarizing via `ctx.llm.stream()` must forward `signal` into the call's `GenerateOptions.signal`, so an abort or dispose tears down the in-flight summarization. The entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. + +Auto-compaction runs on the awaited `agent/pre-request` loop seam (fired once per step, BEFORE the request history is derived), not the `agent/request` waterfall: compaction mutates the session surface in place, and the loop derives the request from the already-compacted surface. Retention is turn-agnostic — the only structural guard is step-alignment (a compacted region never splits a step's tool-calls from their results), so a single runaway turn that alone exceeds the window compacts its own early closed steps rather than being retained verbatim. The backend that ships this (`dsh-compact-basic`) documents the retention walk, the single-pass convergence invariant, and the crash/recoverable failure taxonomy. diff --git a/docs/rfc/README.md b/docs/rfc/README.md index a731aa8ff9..07d3418bcb 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -44,7 +44,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Agent Client Protocol (ACP) support for external editors](proposed/feature/2026-06-14-acp-agent-client-protocol.md) | 2026-06-14 | | [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 | | [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/feature/2026-06-15-optional-code-mode.md) | 2026-06-15 | -| [Compaction as a capability seam (abstract contract + basic backend)](proposed/feature/2026-06-18-compaction-capability-seam.md) | 2026-06-18 | ### Simplification @@ -83,6 +82,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | Title | First proposed | |---|---| | [Rich ACP bash rendering — the terminal card (`_meta`) and command classification](implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 | +| [Compaction as a capability seam (abstract contract + basic backend)](implemented/feature/2026-06-18-compaction-capability-seam.md) | 2026-06-18 | | [Subagent capability seam](implemented/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 | | [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 | diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md new file mode 100644 index 0000000000..56139cc750 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -0,0 +1,118 @@ +# RFC: Compaction as a capability seam (abstract contract + basic backend) + +Status: implemented (2026-06-18; retention/seam reform 2026-06-26) + +## Context + +A long-running agent conversation grows without bound. As the event log accumulates turns, the derived message history eventually approaches the model's context window — the model then truncates mid-response (`max-tokens`) or degrades. **Compaction** is the mitigation: replace a run of older history with a concise summary, keeping recent context intact. + +The [session surface](../../implemented/architecture/2026-06-18-session-surface.md) was built as the foundation for exactly this — a linked list over the event log with a `surfaceOp: { op: 'replace', start, end }` operation purpose-built to shadow a range of nodes and insert a replacement, with `sourceEventSeqs` recording provenance so the decision replays deterministically. What remained was the plugin that *decides what to compact and produces the summary*. + +Two forces shape the design. First, compaction is **swappable**: token counting can be a char/4 heuristic or a real tokenizer, and summarization can be a model call, a template, or a remote service — these vary independently of *when* and *which range* to compact. Second, a later commit (`ce43c25`) closed `SurfaceEventType` to five event types (`user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`); only those may carry `surfaceOp`. A bespoke `compaction/*` event therefore **cannot** itself appear on the surface — the compiler rejects `surfaceOp` on it and the invariants plugin rejects it at runtime. + +## Decision + +### Compaction is a capability seam, split interface / implementation + +Per the [capability-seams RFC](../../implemented/architecture/2026-06-13-capability-seams.md), compaction ships as separate packages so the contract, the algorithm, and (later) the consumer surface evolve independently: + +1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, and the `compact/*` session events. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*. +2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that owns the entire algorithm — token estimation (char/4 + per-block overhead), the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, and the `agent/pre-request` auto-compaction listener. A tokenizer-based or template-based backend is a sibling package (or a subclass overriding the two protected estimation/summarization hooks). +3. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first. + +### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation + +The capability-seams RFC states the interface package "depends only on cordis" (true of `dsh-bash`, whose vocabulary is self-contained). Compaction **cannot** honor that: its verbs are defined *over* a `Session` (`compactRegion(session, start, end)`) and its output *is* the content vocabulary (`CompactionResult.summary: ContentBlock[]`). There is no way to express the contract without naming `Session`/`SessionEvent` (from `dsh-session`) and `ContentBlock` (from `dsh-llm`). + +This is not a coupling smell — it is the contract's domain. The "only cordis" guidance was always shorthand for "the interface depends only on what the contract genuinely names, and never on an implementation." `dsh-session` and `dsh-llm` are themselves interface/vocabulary packages, not implementations; `dsh-compact` still imports no backend. The seam's real invariant — *consumers and implementations evolve independently behind an abstract service* — holds intact. + +### Abstract `compactIfNeeded` / `compactRegion`, algorithm in the backend + +An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface, with only `estimateContentTokens()` and `summarize()` abstract. That recouples the contract to one strategy: a backend that wants a different retention policy or a different event-sequencing would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend, where it belongs, and keeps the interface a pure statement of *what*. The backend remains internally factored — `estimateContentTokens()` and `summarize()` are `protected` hooks a sub-backend can override without reimplementing the walk — but that factoring is the backend's private concern, not the contract's. + +`compactIfNeeded(session, system, model, signal)` takes **required** parameters (not the original all-optional shape). The auto-compaction seam (below) always supplies all four — the assembled system prompt (counted toward the estimate), the model (summarization fallback), and the turn's abort signal — so optionality would only invite a hidden default at the seam. `compactRegion(session, start, end, model, signal?)` keeps an optional signal (a manual caller may omit it). + +### Auto-compaction runs on `agent/pre-request`, a dedicated surface-mutation seam + +Compaction is a **surface mutation**, not a request transform — and that distinction is the seam it belongs on. The loop's request lifecycle, per step, is: assemble the system prompt → derive the message history from the surface → run the `agent/request` waterfall → call the model. An earlier cut wedged compaction into the `agent/request` waterfall, which forced two problems: (1) the loop had already derived `messages` from the *stale* surface, so the listener had to mutate the surface and then *re-derive* and overwrite `request.messages` — a double-derive whose only purpose was to undo the premature first derive; and (2) `agent/request` also carries downstream-injected context a listener might have added to `request.messages`, which compaction cannot act on (it can only compact the surface), inviting the confusion of measuring tokens compaction can't shed. + +The fix is a new awaited loop seam, **`agent/pre-request`** (`@mode parallel`), fired by the loop *after* system assembly and *before* `deriveMessages()`: + +``` +assembly = ctx.systemPrompt.assemble() +await ctx.parallel('agent/pre-request', agent, turn, step, system, model, signal) ⟵ compaction mutates the surface here +messages = session.deriveMessages() ⟵ single derive, reflects the compaction +request = waterfall agent/request ⟵ pure request transform (hooks, model switch) +``` + +This makes the layering correct *by construction*: compaction mutates the surface, the loop derives **once** from the result (no double-derive), and at `pre-request` the assembled `messages` do not yet exist — so a listener structurally *cannot* see or be expected to act on downstream-injected context. `agent/request` reverts to a pure request transformer. The seam is `parallel` (awaited fan-out, no veto), like `session/flush`: a listener mutates the surface as a side effect; there is nothing to transform or return. + +This **amends** the original RFC's claim of "NO changes to `dsh-agent-loop`; compaction is a pure plugin." That claim was load-bearing for a wrong design — reusing `agent/request` was the mistake. Per the pre-release "foundation over blast radius" stance, adding the correct seam (one event declaration in `dsh-agent`, one awaited emit in the loop) beats preserving a no-change boast that locked in the double-derive. + +### Retention is turn-agnostic; step-alignment is the only structural guard + +Auto-compaction fires before **every** model call (every step), not once per turn. This is **load-bearing for runaway-turn survival**: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows *within* a turn. A single turn can grow past the window on its own (a "runaway turn") — and the only moment to rescue it before the next model call overflows is the next step's `pre-request`. Gating compaction to a turn's first step (or, worse, retaining the whole in-flight turn verbatim) re-opens exactly the hole compaction exists to close: the harness would die when compaction is most needed. + +So retention does **not** protect the in-flight turn, and turn boundaries play no role in it. `compactIfNeeded` walks the surface nodes tail→head, summing per-node token estimates, and retains the smallest tail-run of **whole units** whose total reaches `retainTokens`; everything older is compacted (head-anchored — see below). A *unit* is either a whole closed step (its `assistant/message` plus its `tool/result`s) or a single no-step node (a pre-step `user/message`, inter-step `steering/message`, or injection `context/message`). The walk rounds toward retaining *more*: when the raw token cutoff lands inside a step, it extends the retained side head-ward until the boundary is a step-aligned start. The single structural guard is therefore **step-alignment** — the compacted region always ends on a step boundary, so it never splits a step's tool-calls from their `tool/result`s (which would produce a transcript every provider rejects). `compactRegion` enforces step-alignment strictly, throwing on a splitting boundary. + +A runaway turn thus compacts exactly like any other history: its early *closed* steps get summarized while its recent steps stay verbatim. When the only compactable content left is an un-splittable open tail step (its tool-calls have no results yet), compaction declines (`null`) and retries once that step closes. + +**Single-unit overflow is out of scope, by design.** If a single retained unit — one closed step, or a large free node such as a pasted `user/message` — *alone* exceeds the budget, compaction cannot help and the next model call may go out over-budget. Bounding an individual unit's size is a separate concern (output truncation), handled elsewhere; compaction makes no promise about it, and the harness without such a mechanism can still break on a single oversized unit. This is named honestly rather than papered over. + +### Head-anchoring: one auto checkpoint, always at the head + +`compactIfNeeded` always anchors the compacted range at the surface **head** (`nodes[0]`). After a first compaction lands a summary node at the head, the *second* compaction's range starts at that summary node and re-summarizes it together with the steps accumulated since — so the surface holds **at most one** auto-generated checkpoint, always at the head, re-consolidated each cycle (the backend's checkpoint-merge prompt makes this a cheap incremental merge — see below). This is *why* `CompactionResult.shadowedRange` is a **surface-position span, not a numeric seq interval**: after a replace lands a fresh high-seq summary node at an older range's position, `start` can be numerically **greater** than `end`. The range is resolved positionally (index into the ordered node list and slice), and `shadowedSeqs` is the authoritative set in surface order. (Manual `compactRegion` may target any aligned mid-range and so *can* leave several checkpoints; the checkpoint framing does not claim everything after it is recent.) + +### Single-pass convergence invariant + +`resolveConfig` **rejects** (throws at construction) any config where `summarizationMaxTokens + retainTokens > contextWindow * thresholdRatio`. The invariant guarantees the post-compaction history — the bounded summary plus the retained recent tail — is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction, with no thrash throttle needed. `summarizationMaxTokens` stays an explicit *quality* knob (terse summaries); the invariant only forbids setting it so high it breaks convergence. The sole residual is the single-unit-overflow case above (a backward-rounded oversized step can push the retained tail over budget) — which is exactly the out-of-scope concern, not a thrash bug. Per the pre-release reject-don't-migrate stance, a config that cannot guarantee convergence is a bug at the call site, not something to silently clamp. + +### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary + +Because `SurfaceEventType` is closed, the summary cannot ride on a `compact/*` event. The backend instead appends a **single `user/message`** with `surfaceOp: { op: 'replace', start, end }` whose `content` is the (framed) summary and whose `sourceEventSeqs` covers the shadowed nodes *and* the bookkeeping events. The `compact/*` events are pure log records (lock + provenance). The surface mutation sits **inside** the lock — `compact/end` is the last event appended: + +``` +compact/start → log-only. Acquires the lock. +[summarize older range via the backend] +compact/summary → log-only. Provenance: raw summary, range, shadowed seqs, token count. +user/message → surfaceOp { op:'replace', start, end }. THE surface mutation (framed summary). + deriveMessages() renders it as a user-role message. +compact/end → log-only. Releases the lock (carries `error` on a recoverable failure). +``` + +`deriveMessages()` then yields `[summary_as_user_message, ...retained_nodes]`. Reusing `user/message` is honest rather than a workaround: a summary genuinely *is* user-role context. + +### Checkpoint framing + incremental merge (backend-private) + +The landed `user/message` is not the raw summary: the backend wraps it in a checkpoint preamble (so a resuming model reads it as established background, not a fresh request) and `` tags. The tags make a prior checkpoint detectable on the next cycle, and the summarization prompt then instructs the model to *merge it in place* (preserve still-true facts, drop stale) rather than re-summarize verbatim — a cheap incremental merge that needs no extra log/event machinery. The raw, unframed summary stays on the `compact/summary` provenance event. This framing is entirely a **backend HOW decision** — the contract only promises "a single replace `user/message` carries the (possibly framed) summary; the raw summary lives on `compact/summary`." A template or remote backend may frame differently or not at all. + +### Blocking via a log-recorded lock, plus a crash/recoverable failure taxonomy + +The `compact/start … compact/end` bracket is justified, in order of what now does the work: + +1. **Crash-detectable orphan + provenance** (primary). Summarization is a slow model call persisted *after* `compact/start`. A crash mid-summarization leaves a `compact/start` with no matching `compact/end` — a detectable orphan. Releasing the lock last (rather than first) converts the crash window from *silent corruption* into that detectable orphan. +2. **Prevents concurrent compaction.** `compactRegion` refuses to start if the current turn holds an unmatched `compact/start`. (The loop is single-threaded across the awaited `pre-request`, so this is also a re-entry tripwire — a thrown "already in progress" signals a real bug.) + +Two failure paths, both documented: + +- **Crash** (the loop dies mid-summarization): a dangling `compact/start`, no closer. Because `compact/*` are **log-only**, the orphan is **inert** — the surface replacement never landed, so the full, uncompacted history derives correctly. Generic turn-repair (`interruptedTurnClosers`) closes the turn with a synthetic `turn/end`; the orphan sits *before* that `turn/end`, so the turn-scoped in-progress check never sees it and a crash can't wedge future compaction. Compaction simply re-attempts at the next `pre-request`. +- **Recoverable** (summarization throws but the loop survives): the backend appends `compact/end` with its **`error`** field set, leaving the surface untouched, and the model call proceeds with full history. + +`compact/end` keeps its `error?` field (mirroring `tool/result`'s self-contained error — one event tells success from failure without correlating a sibling). There is no separate `compact/error` event. + +**Core session repair stays compaction-agnostic — deliberately.** `interruptedTurnClosers` is never taught about `compact/*`. Teaching it would force every future `xxx/start … xxx/end` plugin pair to patch a core module — exactly the coupling the capability-seam architecture exists to avoid. Because the log-only orphan is inert, no special repair is needed: generic turn-repair plus the inertness of an un-landed surface mutation is sufficient. + +## Consequences + +- **New packages**: `packages/compact/compact` (interface) and a sibling `compact-basic` (backend) under `packages/compact/`, wired into the root tsconfigs. The consumer tier is deferred. +- **New loop seam**: `agent/pre-request` (`@mode parallel`) declared in `dsh-agent` and emitted by `dsh-agent-loop` between system assembly and history derivation. This is a documented change to the loop — `docs/architecture.md` records it and the generated cordis catalog carries its signature. +- **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. +- **No changes** to `dsh-session` or `dsh-invariants`: the surface replace op, the surface-metadata runtime guard, and the turn-enclosure invariant all already exist and are reused. +- **Wiring**: `dsh-compact-basic` is loaded in `examples/coding-agent`'s `cordis.yml`, so the seam ships in the real demo (it was previously loaded nowhere). + +## Testing + +- **Unit** (`dsh-compact-basic`): the whole-unit retention walk, the convergence-invariant throw, both failure paths (`compact/end` with/without `error`), head-anchoring producing a non-monotonic `shadowedRange`, decline-on-open-tail, crash-orphan inertness, and the **runaway-turn regression** — a single oversized open turn compacts its early closed steps (proven to fail on the layer-2 protection it replaced). Driven through the real `dsh-invariants` plugin and the real Loader/inject path. +- **Loop** (`dsh-agent-loop`): `agent/pre-request` fires once per step, before derive, awaited; a surface mutation in a `pre-request` listener is reflected in the single derived request. +- **With-key e2e** (`examples/coding-agent`): a real model + real bash session with a lowered `contextWindow`/`retainTokens` triggers compaction mid-session; the test verifies the WORLD (a `compact/start…end` pair landed, the surface shrank, the agent still completed the task after compaction). This is compaction's first real-world exercise and the runaway-survival net. +- **Snapshot (deferred, named gap)**: a full-transcript snapshot of a runaway-turn compaction is NOT yet possible — `dsh-llm-replay` derives one model call per `(turn, step)` from `assistant/chunk` events, but the summarization call records no `assistant/chunk`s and carries no `sessionId` (it binds to the anonymous cursor and claims a non-existent extra script). Covering it needs net-new replay infrastructure (record/replay an interleaved summarization call) and is scheduled as a follow-up rather than discovered mid-build. diff --git a/docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md deleted file mode 100644 index 2d559fa65c..0000000000 --- a/docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md +++ /dev/null @@ -1,59 +0,0 @@ -# RFC: Compaction as a capability seam (abstract contract + basic backend) - -Status: proposed (2026-06-18) - -## Context - -A long-running agent conversation grows without bound. As the event log accumulates turns, the derived message history eventually approaches the model's context window — the model then truncates mid-response (`max-tokens`) or degrades. **Compaction** is the mitigation: replace a run of older history with a concise summary, keeping recent context intact. - -The [session surface](../../implemented/architecture/2026-06-18-session-surface.md) was built as the foundation for exactly this — a linked list over the event log with a `surfaceOp: { op: 'replace', start, end }` operation purpose-built to shadow a range of nodes and insert a replacement, with `sourceEventSeqs` recording provenance so the decision replays deterministically. What remained was the plugin that *decides what to compact and produces the summary*. - -Two forces shape the design. First, compaction is **swappable**: token counting can be a char/4 heuristic or a real tokenizer, and summarization can be a model call, a template, or a remote service — these vary independently of *when* and *which range* to compact. Second, a later commit (`ce43c25`) closed `SurfaceEventType` to five event types (`user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`); only those may carry `surfaceOp`. A bespoke `compaction/*` event therefore **cannot** itself appear on the surface — the compiler rejects `surfaceOp` on it and the invariants plugin rejects it at runtime. - -## Decision - -### Compaction is a capability seam, split interface / implementation - -Per the [capability-seams RFC](../../implemented/architecture/2026-06-13-capability-seams.md), compaction ships as separate packages so the contract, the algorithm, and (later) the consumer surface evolve independently: - -1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, and the `compact/*` session events. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*. -2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that owns the entire algorithm — token estimation (char/4 + per-block overhead), the tail→head retention walk, summarization via `ctx.llm.generate()`, the surface replacement, the lock, and the `agent/request` auto-compaction listener. A tokenizer-based or template-based backend is a sibling package (or a subclass overriding the two protected estimation/summarization hooks). -3. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first. - -### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation - -The capability-seams RFC states the interface package "depends only on cordis" (true of `dsh-bash`, whose vocabulary is self-contained). Compaction **cannot** honor that: its verbs are defined *over* a `Session` (`compactRegion(session, start, end)`) and its output *is* the content vocabulary (`CompactionResult.summary: ContentBlock[]`). There is no way to express the contract without naming `Session`/`SessionEvent` (from `dsh-session`) and `ContentBlock` (from `dsh-llm`). - -This is not a coupling smell — it is the contract's domain. The "only cordis" guidance was always shorthand for "the interface depends only on what the contract genuinely names, and never on an implementation." `dsh-session` and `dsh-llm` are themselves interface/vocabulary packages, not implementations; `dsh-compact` still imports no backend. The seam's real invariant — *consumers and implementations evolve independently behind an abstract service* — holds intact. We record the deviation here so a future reader doesn't mistake it for an accident or "fix" it by smuggling `Session` behind an opaque handle. - -### Abstract `compactIfNeeded` / `compactRegion`, algorithm in the backend - -An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface, with only `estimateContentTokens()` and `summarize()` abstract. That recouples the contract to one strategy: a backend that wants a different retention policy (e.g. turn-count instead of token-budget) or a different event-sequencing would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend, where it belongs, and keeps the interface a pure statement of *what*. The backend remains internally factored — `estimateContentTokens()` and `summarize()` are `protected` hooks a sub-backend can override without reimplementing the walk — but that factoring is the backend's private concern, not the contract's. - -### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary - -Because `SurfaceEventType` is closed, the summary cannot ride on a `compact/*` event. The backend instead appends a **single `user/message`** with `surfaceOp: { op: 'replace', start, end }` whose `content` is the summary `ContentBlock[]` and whose `sourceEventSeqs` covers the shadowed nodes *and* the bookkeeping events. The `compact/*` events are pure log records (lock + provenance), never on the surface. The surface mutation sits **inside** the lock — `compact/end` is the last event appended: - -``` -compact/start → log-only. Acquires the lock. -[summarize older range via the backend] -compact/summary → log-only. Provenance: summary, range, shadowed seqs, token count. -user/message → surfaceOp { op:'replace', start, end }. THE surface mutation. - deriveMessages() renders it as a user-role message. -compact/end → log-only. Releases the lock. -``` - -Ordering the surface mutation **before** `compact/end` is deliberate: `session.append()` commits one event at a time, so there is no multi-event transaction to make the sequence atomic. Releasing the lock last converts the crash window from *silent corruption* (a `compact/end` that claims compaction finished while the surface was never shadowed) into a *detectable orphaned lock* (a `compact/start` with no matching `compact/end`), which a persistence backend already detects on reload. A `session/event` listener on `compact/end` likewise never sees the lock free before the replacement has landed. - -`deriveMessages()` then yields `[summary_as_user_message, ...retained_nodes]`. An alternative — extending `SurfaceEventType` to admit a `compact/*` type — was rejected: the closed union is a deliberate safety boundary (only message-producing events reach the model), and a summary genuinely *is* user-role context, so reusing `user/message` is honest rather than a workaround. - -### Blocking via a log-recorded lock, not a mutex - -Compaction must be serialized: no second compaction starts before the first finishes, and no ordinary events interleave the slow summarization. Rather than an in-memory mutex (invisible to replay, lost on crash), the lock **is** the log: `compactRegion` refuses to start if the last `compact/start` has no matching `compact/end` after it. `compact/start` is appended first (fast, synchronous), the slow model call runs, then the `compact/summary` and `user/message` replacement land, and only then is `compact/end` appended — in a `catch` that records the error, so a failed summarization can never wedge the lock. Because the backend runs compaction synchronously inside the `agent/request` waterfall, the loop is single-threaded for that window; the lock additionally gives observability and lets a persistence backend detect an orphaned `compact/start` on reload. - -## Consequences - -- **New packages**: `packages/compact/compact` (interface) and a sibling `compact-basic` (backend) under `packages/compact/`, wired into the three root tsconfigs. The consumer tier is deferred. -- **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. -- **No changes** to `dsh-session`, `dsh-invariants`, or `dsh-agent-loop`: the surface replace op, the surface-metadata runtime guard, and the `agent/request` waterfall all already exist. Compaction is a pure plugin on documented seams. -- The capability-seams convention gains a second reference beyond bash, and a documented case where "interface depends only on cordis" relaxes to "depends only on interface/vocabulary packages the contract genuinely names." On acceptance, [AGENTS.md](../../../../AGENTS.md) § Conventions and [architecture.md](../../../architecture.md) § "Capability seams" should note this relaxation. diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 0347115cd5..e3a3016dda 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -65,6 +65,16 @@ failures before moving on. Verify your work by running the code or tests. Keep answers brief and factual. +# Automatic context compaction: when the derived history approaches the model's +# context window, summarize an older range into a checkpoint so a long-running +# or tool-heavy session keeps fitting. A leaf entry (needs ctx.llm + the +# agent-loop's `agent/pre-request` seam from the app above). +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + config: + contextWindow: 128000 + retainTokens: 20480 + # The subagent seam + BOTH in-process backends + two model-facing tools, as leaf # entries after the app (which provides ctx.agents/ctx.tools). spawn (a fresh # child) and fork (a child seeded with the parent's completed-turn prefix) are diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts new file mode 100644 index 0000000000..fefcd579d0 --- /dev/null +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -0,0 +1,95 @@ +import { mkdtemp, rm, writeFile } 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 { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' + +/** + * The compaction smoke test: a real model runs a multi-step bash task with a + * deliberately tiny context window, so the auto-compaction listener fires + * MID-SESSION and summarizes the older history into a checkpoint. This is the + * first end-to-end exercise of the compaction seam (it is wired nowhere else), + * and the runaway-survival regression net — it proves a session that grows past + * the window keeps running rather than overflowing. Key-gated. + * + * Verifies the WORLD, not the agent's self-report: a compact/start…end pair + * landed in the real session log, the surface actually shrank (a replace node + * exists and shadowed older nodes), and the agent still produced a final answer + * after compaction (so the summarized history did not break the conversation). + */ + +let workdir: string | undefined +let ctx: Context | undefined + +afterEach(async () => { + await ctx?.fiber.dispose() + ctx = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compacts mid-flight and keeps running', () => { + it('summarizes older history into a checkpoint without breaking the task', async () => { + workdir = await mkdtemp(join(tmpdir(), 'dsh-compaction-')) + // A few files for the model to read, so multiple bash steps accumulate + // surface nodes (tool calls + results) and grow the history. + for (let i = 1; i <= 4; i++) { + await writeFile(join(workdir, `file${i}.txt`), `This is file number ${i}. `.repeat(40)) + } + + // Tiny window so a handful of steps crosses the threshold. The convergence + // invariant requires summarizationMaxTokens + retainTokens <= window * + // ratio = floor(8000 * 0.5) = 4000; 1500 + 2000 = 3500 <= 4000. + ctx = await codingHarness(workdir, { + compact: { + contextWindow: 8000, + thresholdRatio: 0.5, + retainTokens: 2000, + summarizationMaxTokens: 1500, + }, + }) + const agent = ctx.agentLoop.create(AgentId('e2e-compaction'), { + model: 'deepseek-v4-flash', + systemPrompt: SYSTEM_PROMPT, + }) + + agent.send([{ + type: 'text', + text: 'Read file1.txt, file2.txt, file3.txt, and file4.txt one at a time using cat ' + + '(a separate bash command for each). After reading all four, tell me how many ' + + 'files you read and the number mentioned in file1.txt.', + }]) + await waitForIdle(ctx, agent) + + const events = [...agent.session.events] + + // A compaction ran: the start…end bracket landed in the real log. + const starts = events.filter(e => e.type === 'compact/start') + const ends = events.filter(e => e.type === 'compact/end') + expect(starts.length).toBeGreaterThan(0) + expect(ends.length).toBe(starts.length) // every start was released + + // It succeeded at least once: a compact/summary provenance event and a + // replace-op user/message (the surface mutation) both landed. + const summaries = events.filter(e => e.type === 'compact/summary') + expect(summaries.length).toBeGreaterThan(0) + const replaceNode = events.find((e) => { + const se = e as unknown as { type: string; surfaceOp?: unknown } + return se.type === 'user/message' && typeof se.surfaceOp === 'object' && se.surfaceOp !== null + }) + expect(replaceNode).toBeDefined() + + // The summary shadowed real older nodes (the surface shrank vs. the raw + // message-producing event count). + const summaryData = summaries[0]!.data as { shadowedSeqs: number[] } + expect(summaryData.shadowedSeqs.length).toBeGreaterThan(0) + + // The conversation survived compaction: the agent produced a final answer + // that reflects the work (it read four files). + const answer = finalText(events).toLowerCase() + expect(answer.length).toBeGreaterThan(0) + expect(answer).toMatch(/\b(4|four)\b/) + }, 240_000) +}) diff --git a/examples/coding-agent/tests/harness.ts b/examples/coding-agent/tests/harness.ts index fbe9b10db5..207652f69b 100644 --- a/examples/coding-agent/tests/harness.ts +++ b/examples/coding-agent/tests/harness.ts @@ -10,6 +10,8 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' +import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' /** * Shared harness for the coding-agent e2e suites: the full plugin stack @@ -21,7 +23,19 @@ export const SYSTEM_PROMPT = 'You are a coding agent. Your only tool is bash; ' + 'do file operations with cat/grep/heredocs, check [exit code: N] markers, ' + 'and report results briefly.' -export async function codingHarness(workdir: string, persistenceRoot?: string): Promise { +/** Options for {@link codingHarness}. */ +export interface CodingHarnessOptions { + /** Durable JSONL persistence root (the resume suite needs it; others stay file-free). */ + persistenceRoot?: string + /** + * Load {@link BasicCompactService} with this config so the compaction e2e can + * trigger compaction at a small, controlled history size. Omitted ⇒ no + * compaction plugin (the default suites run without it). + */ + compact?: BasicCompactConfig +} + +export async function codingHarness(workdir: string, options: CodingHarnessOptions = {}): Promise { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) @@ -32,10 +46,13 @@ export async function codingHarness(workdir: string, persistenceRoot?: string): await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) + // Compaction is opt-in: only the compaction e2e loads it, with a lowered + // contextWindow/retainTokens so a short real session crosses the threshold. + if (options.compact !== undefined) await ctx.plugin(BasicCompactService, options.compact) // Durable JSONL persistence is opt-in: only the resume e2e needs it, and the // other suites stay file-free. Loaded last so a resume's deferred // `ctx.inject(['sessionPersistence'])` resolves once this is present. - if (persistenceRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: persistenceRoot }) + if (options.persistenceRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot }) return ctx } diff --git a/examples/coding-agent/tests/resume.e2e.ts b/examples/coding-agent/tests/resume.e2e.ts index 450938fc6d..4be11ed3ea 100644 --- a/examples/coding-agent/tests/resume.e2e.ts +++ b/examples/coding-agent/tests/resume.e2e.ts @@ -38,7 +38,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // Run 1: a fresh agent on a KNOWN session id learns a secret, then we // dispose the whole context (simulating process exit) so only the JSONL // log on disk survives. - ctx = await codingHarness(process.cwd(), root) + ctx = await codingHarness(process.cwd(), { persistenceRoot: root }) const first = ctx.agents.create({ agentId: AgentId('resume-1'), sessionId: SESSION_ID, @@ -52,7 +52,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // Run 2: a brand-new context over the SAME root resumes the persisted // session. The loaded event log seeds the live session, so the model sees // run 1's exchange as conversation history. - ctx = await codingHarness(process.cwd(), root) + ctx = await codingHarness(process.cwd(), { persistenceRoot: root }) const resumed = (await ctx.agents.resume({ agentId: AgentId('resume-2'), resumeSessionId: SESSION_ID, diff --git a/packages/compact/README.md b/packages/compact/README.md index 384fe98ffe..10eaf1617a 100644 --- a/packages/compact/README.md +++ b/packages/compact/README.md @@ -8,4 +8,4 @@ A three-package capability seam (see [capability seams](../../docs/rfc/implement | `compact-basic/` | A backend: char/4 estimation + 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/proposed/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. +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. diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 8aa64a1111..848c0cd6ae 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -2,18 +2,20 @@ The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a char/4 token heuristic, token-budget retention, and `ctx.llm.stream()` summarization. -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/proposed/feature/2026-06-18-compaction-capability-seam.md) for the design. +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. ## What it owns 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). -- **Retention policy** — `compactIfNeeded()` ALWAYS retains the in-flight turn's surface nodes verbatim (its initiating request and any mid-turn tool results — the exact input/observation the model is acting on, even if they exceed the budget), then walks the OLDER (closed-turn) nodes tail→head, summing per-node token estimates, and compacts everything older than the first node that overflows the `retainTokens` budget. The cutoff is snapped to a step boundary so the compacted region never splits a step's `assistant/message` tool-calls from their `tool/result`s (the budget is a soft target): it prefers snapping FORWARD to the next clean boundary, and falls back to snapping BACKWARD when the forward snap would reach the protected in-flight turn. If no step-aligned cutoff exists in the older range (e.g. its only content is an open tail step), it declines (returns `null`) and retries once an older step closes. `compactRegion()` enforces step-alignment strictly, throwing on a boundary that would split a step. Token-based (not turn-count) retention keeps more short turns and compacts tool-heavy turns sooner. +- **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 **step-alignment**: the compacted region always ends on a step boundary, 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 step-alignment strictly, throwing on a boundary that would split a step. +- **Single-pass convergence** — `resolveConfig()` rejects (throws) any config where `summarizationMaxTokens + retainTokens > contextWindow * thresholdRatio`. The invariant guarantees the post-compaction history (the bounded summary plus the retained recent tail) is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction. - **Summarization** — `summarize()`: a `ctx.llm.stream()` call assembled via `BlockAssembler` (the single model-call surface) 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 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. - **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 `` 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/request` waterfall listener delegates to `compactIfNeeded()` before every model call (every step, not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts) and re-derives messages after compacting; the listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`). +- **Auto-compaction** — an `agent/pre-request` listener delegates to `compactIfNeeded()` before every model call (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-request` is an awaited surface-mutation checkpoint that fires BEFORE the loop derives the request history, so compaction mutates the surface 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()`). +- **Failure handling** — the `compact/start … compact/end` bracket is a log-recorded lock: it makes a crash mid-summarization a detectable orphan (a `compact/start` with no `compact/end`), records provenance, and prevents a concurrent compaction. Two failure paths: a **crash** (the loop dies mid-summarization) leaves a dangling `compact/start` that is inert — `compact/*` events are log-only, the surface replacement never landed, so the full history derives fine and generic turn-repair closes the turn; a **recoverable** failure (summarization throws but the loop survives) appends `compact/end` with its `error` field set, leaving the surface untouched so the call proceeds with full history. Core session repair stays compaction-agnostic by design — it never learns about `compact/*`. `estimateContentTokens()` and `summarize()` are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the retention walk and surface plumbing. @@ -26,7 +28,7 @@ The abstract contract states only WHAT compaction does; this backend owns every | `retainTokens` | `20480` | Tokens of recent context to keep intact. | | `summarizationModel` | `''` | Model for summarization (empty → use the agent's model). | | `summarizationMaxTokens` | `2048` | Max tokens for the summary response. | -| `auto` | `true` | Register the `agent/request` auto-compaction listener. Set `false` for manual-only. | +| `auto` | `true` | Register the `agent/pre-request` auto-compaction listener. Set `false` for manual-only. | ## Usage diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 28a0040848..2ee0d82133 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -32,7 +32,7 @@ import { CompactService } from '@deepseek-ai/dsh-compact' import type { CompactionResult } from '@deepseek-ai/dsh-compact' import { BlockAssembler } from '@deepseek-ai/dsh-llm' import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { isStepAlignedStart, isStepAlignedEnd } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { BasicCompactConfig, ResolvedConfig } from './types.ts' @@ -170,40 +170,40 @@ export class BasicCompactService extends CompactService { if (this.config.auto) { // Auto-compaction: delegate to compactIfNeeded before EVERY model call — - // every step, not just the first. A tool-heavy ReAct turn appends an - // assistant/message and a tool/result per step, so the surface (and the - // derived token count) grows within a turn; gating to step 1 would let a - // runaway turn overflow the window before the next turn's check. The - // listener stays agnostic — it owns NO threshold logic; compactIfNeeded is - // the single place that decides whether to compact, and its in-progress - // lock serializes concurrent attempts. - ctx.on('agent/request', async (agent: Agent, _turn, _step, request, next) => { - const before = this.estimateTokens(request.messages, request.system) + // every step, not just the first. This is LOAD-BEARING for runaway-turn + // survival: a tool-heavy ReAct turn appends an assistant/message and a + // tool/result per step, so the surface (and the derived token count) grows + // WITHIN a turn. The only moment to rescue a turn that alone approaches the + // window is the next step's pre-request; gating to a turn's first step + // would let a runaway turn overflow before the next turn's check. The + // listener owns NO threshold logic — compactIfNeeded is the single place + // that decides whether to compact, and its in-progress lock serializes + // concurrent attempts. + // + // It runs on `agent/pre-request` (a parallel surface-mutation checkpoint), + // NOT `agent/request`: compaction mutates the session surface, and the loop + // derives the request `messages` AFTER this fires — so a single derive + // already reflects the compaction, with no double-derive and no need to + // rewrite an already-assembled `messages` array. + ctx.on('agent/pre-request', async (agent: Agent, _turn: number, _step: number, system: string, model: string, signal: AbortSignal) => { try { - const result = await this.compactIfNeeded(agent.session, request.system, request.model, request.signal) + const result = await this.compactIfNeeded(agent.session, system, model, signal) if (result) { - // The surface has been mutated — re-derive messages for the call. - const rederived = agent.session.deriveMessages() - const afterTokens = this.estimateTokens(rederived, request.system) - + const after = this.estimateTokens(agent.session.deriveMessages(), system) ctx.logger.info( `compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` + `(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` + `~${result.shadowedTokenCount} tokens) ` + - `→ ${afterTokens} estimated tokens after compaction ` + - `(pressure was ~${before})`, + `→ ${after} estimated tokens after compaction`, ) - - request.messages = rederived } } catch (error: unknown) { - // A failed compaction must not prevent the model call — proceed - // with the original messages. + // A failed compaction must not prevent the model call — the surface is + // untouched on failure, so the loop derives the full history and the + // call proceeds. const msg = error instanceof Error ? error.message : String(error) ctx.logger.warn(`compaction failed: ${msg}; proceeding with full history`) } - - return next() }) } } @@ -312,89 +312,89 @@ export class BasicCompactService extends CompactService { // ---- Core API (implements the abstract contract) ---- /** - * The sole token-pressure gate: estimate the current history, and if it - * exceeds the threshold (`contextWindow * thresholdRatio`), compact the oldest - * surface nodes outside the `retainTokens` budget. The auto-compaction listener - * delegates here rather than pre-checking, so this is the only place the - * decision lives. + * The sole token-pressure gate: estimate the current surface-derived history, + * and if it exceeds the threshold (`contextWindow * thresholdRatio`), compact + * the oldest surface nodes outside the `retainTokens` budget. The auto- + * compaction listener delegates here rather than pre-checking, so this is the + * only place the decision lives. + * + * Retention is a UNIFORM tail→head walk over the whole surface — turn + * boundaries play NO role. Walking node-by-node from the tail and summing + * token estimates, once the retained total reaches `retainTokens` the cutoff + * is rounded to a step-aligned boundary: if the walk stopped INSIDE a step, + * it continues head-ward past that step's `step/start` so the whole step is + * retained (never splitting a step's tool-calls from their results); if it + * stopped on a free node (a node belonging to no step), that is already a + * clean boundary. This always rounds toward retaining MORE (retained ≥ + * `retainTokens`) and is step-aligned by construction — no separate snap pass. + * + * The compacted range is always anchored at the surface HEAD (`nodes[0]`): + * auto-compaction re-consolidates any prior head checkpoint into one fresh + * checkpoint. Declines (`null`) when nothing is over threshold, when the whole + * surface fits the retain budget, or when no step-aligned cutoff exists in the + * compactable range (its only content is an open tail step — retry once it + * closes). */ override async compactIfNeeded( session: Session, - systemPrompt?: string, - model?: string, - signal?: AbortSignal, + system: string, + model: string, + signal: AbortSignal, ): Promise { const messages = session.deriveMessages() - const totalTokens = this.estimateTokens(messages, systemPrompt) + const totalTokens = this.estimateTokens(messages, system) const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio) if (totalTokens < threshold) return null - // Walk surface nodes tail→head, accumulating token estimates. const nodes = session.surface.nodes if (nodes.length === 0) return null + const events = session.events const retainBudget = this.config.retainTokens - // ALWAYS retain the IN-FLIGHT turn's surface nodes verbatim — its initiating - // user request and any mid-turn tool results are the exact input/observation - // the model is acting on right now, even if they exceed the soft retain - // budget. Compacting them would hand the model a lossy summary of its own - // current task. Only nodes in PRIOR (closed) turns are eligible to compact; - // `protectedIdx` is the first surface node of the open turn (or `nodes.length` - // when the open turn has no surface nodes yet, e.g. before step 1). - const protectedIdx = this._openTurnFirstSurfaceIdx(session, nodes) - if (protectedIdx === 0) return null + // Walk tail→head summing per-node token estimates. `keepFromIdx` is the + // index of the OLDEST node we retain verbatim; everything strictly older + // (`[0, keepFromIdx - 1]`) is the compactable range. let accumulated = 0 - let cutoffIdx = -1 - // Seed the accumulator with the protected suffix so the retain budget is - // measured against what actually stays, then look for a cutoff only among - // the older (compactable) nodes. - for (let i = nodes.length - 1; i >= protectedIdx; i--) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const event = session.events[nodes[i]!.seq] - if (event) accumulated += this.estimateEventTokens(event) - } - - for (let i = protectedIdx - 1; i >= 0; i--) { - // nodes[i] bounded by i >= 0 and i < nodes.length — never undefined. + let keepFromIdx = nodes.length // nothing retained yet + for (let i = nodes.length - 1; i >= 0; i--) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const node = nodes[i]! - const event = session.events[node.seq] + const event = events[node.seq] /* v8 ignore next -- node.seq is a surface-node seq, always a valid log index by construction */ - if (!event) continue - accumulated += this.estimateEventTokens(event) - if (accumulated > retainBudget) { - cutoffIdx = i - break - } + if (event) accumulated += this.estimateEventTokens(event) + keepFromIdx = i + if (accumulated >= retainBudget) break } - // If we walked the entire compactable range without exceeding the budget, - // everything outside the protected in-flight turn fits — no compaction - // needed. - if (cutoffIdx === -1) return null + // The whole surface fits the retain budget — nothing to compact. + if (keepFromIdx === 0) return null - // Snap the cutoff to a step-aligned end so the compacted region never splits - // a step (which would orphan a tool-call or its tool/result). The token - // budget is a soft target. PREFER snapping FORWARD (compact slightly more - // recent context to reach a clean boundary), but never into the protected - // in-flight turn: if the forward snap would reach `protectedIdx`, fall back - // to snapping BACKWARD to the previous step-aligned end (compact slightly - // less), and decline only if no step-aligned end exists in the compactable - // range at all. - const events = session.events - cutoffIdx = this._snapCutoff(events, nodes, cutoffIdx, protectedIdx) - if (cutoffIdx === -1) return null + // Round the cutoff to a step boundary: if `keepFromIdx` sits INSIDE a step, + // extend the retained side head-ward until the boundary is a step-aligned + // start, so the compacted range ends on a clean step edge. A node that + // belongs to no step is already a valid start. Decline if no step-aligned + // start exists at or below `keepFromIdx` (the compactable range is only an + // un-splittable open tail step — retry once it closes). + while (keepFromIdx > 0) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (isStepAlignedStart(events, nodes[keepFromIdx]!.seq)) break + keepFromIdx -= 1 + } + if (keepFromIdx === 0) return null - // nodes is non-empty (checked above) and cutoffIdx is a valid index. + // The compacted range is [head … keepFromIdx - 1], anchored at the head. + // The cutoff node `nodes[keepFromIdx - 1]` is necessarily a step-aligned END: + // the retained start `nodes[keepFromIdx]` is a step-aligned START (a boundary + // marker sits between them in the log), and that same boundary makes the node + // before it a step-aligned end — so no separate end check is needed. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const firstSeq = nodes[0]!.seq // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const cutoffSeq = nodes[cutoffIdx]!.seq - const resolvedModel = model ?? '' + const cutoffSeq = nodes[keepFromIdx - 1]!.seq - return this.compactRegion(session, firstSeq, cutoffSeq, resolvedModel, signal) + return this.compactRegion(session, firstSeq, cutoffSeq, model, signal) } override async compactRegion( @@ -521,74 +521,6 @@ export class BasicCompactService extends CompactService { // ---- Internal helpers ---- /** - * The index of the first surface node that belongs to the currently-open turn - * — the boundary of the protected, never-compacted suffix. Returns - * `nodes.length` when the open turn has contributed no verbatim surface node - * yet (e.g. before step 1 appends anything), so the whole surface is - * compaction-eligible up to the tail. - * - * The in-flight turn's verbatim nodes (its request, mid-turn assistant - * messages, tool results — all `append` ops) form a CONTIGUOUS run at the TAIL - * of the surface. A compaction replacement node, though also appended during - * the open turn (seq > `turn/start`), lands at the position of the older range - * it shadowed — earlier in the surface, NOT in the tail run — so it is itself - * compaction-eligible (a later cycle can merge it). The protected suffix is - * therefore the contiguous tail run of nodes whose seq exceeds the open turn's - * `turn/start`, found by walking from the tail. With no open turn (a closed - * session — only manual `compactRegion`, never the auto path), nothing is - * protected and this returns `nodes.length`. - */ - private _openTurnFirstSurfaceIdx(session: Session, nodes: readonly SurfaceNode[]): number { - const openTurn = this._openTurn(session) - if (openTurn === null) return nodes.length - // Find the open turn's turn/start seq (scanning back from the tail). - let turnStartSeq = -1 - for (let i = session.events.length - 1; i >= 0; i--) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const e = session.events[i]! - if (e.type === 'turn/start' && e.data.turn === openTurn) { turnStartSeq = e.seq; break } - } - /* v8 ignore next -- _openTurn returned non-null, so its turn/start exists */ - if (turnStartSeq === -1) return nodes.length - // Walk from the tail while nodes belong to the open turn (seq > turn/start), - // taking only the CONTIGUOUS run — a compaction summary node appended this - // turn but sitting earlier in the surface stops the run and stays eligible. - let idx = nodes.length - while (idx > 0 && nodes[idx - 1]!.seq > turnStartSeq) idx -= 1 // eslint-disable-line @typescript-eslint/no-non-null-assertion - return idx - } - - /** - * Snap a raw token-budget cutoff index to a step-aligned end among the nodes - * BELOW the protected suffix (`protectedIdx`, the first node of the in-flight - * turn). Returns the snapped index, or `-1` if no step-aligned end exists in - * the compactable range (e.g. it is empty, or its only content is an open tail - * step). - * - * Prefers snapping FORWARD to the next step-aligned end (compact slightly more - * recent context for a clean boundary); if the forward scan reaches - * `protectedIdx` without finding one, falls back to scanning BACKWARD from the - * raw cutoff (compact slightly less). The protected suffix is never returned — - * it stays verbatim so the model sees its current task, not a summary. - */ - private _snapCutoff( - events: readonly SessionEvent[], - nodes: readonly SurfaceNode[], - rawCutoffIdx: number, - protectedIdx: number, - ): number { - // Forward: the next step-aligned end strictly below the protected suffix. - for (let i = rawCutoffIdx; i < protectedIdx; i++) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (isStepAlignedEnd(events, nodes[i]!.seq)) return i - } - // Backward: the nearest step-aligned end at or below the raw cutoff. - for (let i = rawCutoffIdx - 1; i >= 0; i--) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (isStepAlignedEnd(events, nodes[i]!.seq)) return i - } - return -1 - } /** * Frame the raw summary blocks into the content that lands on the surface: diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index 120fad0b19..7273150d8e 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -38,7 +38,35 @@ export const DEFAULTS: ResolvedConfig = { auto: true, } -/** Apply defaults to a partial config. */ +/** + * Apply defaults to a partial config and enforce the single-pass convergence + * invariant. + * + * `summarizationMaxTokens + retainTokens` must not exceed the compaction + * threshold (`contextWindow * thresholdRatio`). The invariant guarantees that + * after a compaction the derived history — the (bounded) summary plus the + * retained recent tail — is structurally BELOW the threshold, so the very next + * pre-request check passes and a second compaction cannot fire on the same + * content. Without it, a too-large summary budget or retain budget would leave + * the post-compaction history still over threshold, triggering compaction again + * and again. Pre-release we reject rather than clamp: a config that cannot + * guarantee convergence is a bug at the call site, not something to silently + * paper over. + * + * @throws if `summarizationMaxTokens + retainTokens > contextWindow * thresholdRatio`. + */ export function resolveConfig(config: BasicCompactConfig): ResolvedConfig { - return { ...DEFAULTS, ...config } + const resolved = { ...DEFAULTS, ...config } + const threshold = Math.floor(resolved.contextWindow * resolved.thresholdRatio) + const postCompactionFloor = resolved.summarizationMaxTokens + resolved.retainTokens + if (postCompactionFloor > threshold) { + throw new Error( + `BasicCompactConfig: summarizationMaxTokens (${resolved.summarizationMaxTokens}) + ` + + `retainTokens (${resolved.retainTokens}) = ${postCompactionFloor} exceeds the compaction ` + + `threshold contextWindow * thresholdRatio = ${threshold}; post-compaction history would ` + + 'stay over threshold and re-compact endlessly. Lower retainTokens/summarizationMaxTokens ' + + 'or raise contextWindow/thresholdRatio.', + ) + } + return resolved } diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 3852b825f7..255a59a5b7 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -9,6 +9,9 @@ import type { SessionEvent, SurfaceEvent } from '@deepseek-ai/dsh-session' import * as Invariants from '@deepseek-ai/dsh-invariants' import type { Agent } from '@deepseek-ai/dsh-agent' +/** A never-aborted signal for the required `compactIfNeeded`/listener arg. */ +const SIGNAL = new AbortController().signal + /** * A BasicCompactService with summarize() stubbed (no real model call) and a * predictable token estimate, for deterministic unit tests of the algorithm. @@ -33,31 +36,14 @@ class TestCompactService extends BasicCompactService { } } -/** Create a test service with a throwaway context (auto disabled — no model). */ -function createTestService(config: BasicCompactConfig = {}): TestCompactService { - return new TestCompactService(new Context(), { auto: false, ...config }) -} - /** - * A test service where specific surface seqs (in `bigSeqs`) weigh 1000 tokens - * and every other message-producing event weighs 10 — for exercising the - * "newest node alone exceeds retainTokens" retention path. summarize() is - * stubbed (no model call). + * Create a test service with a throwaway context (auto disabled — no model). + * A small `summarizationMaxTokens` baseline keeps the convergence invariant + * (`summarizationMaxTokens + retainTokens <= contextWindow * thresholdRatio`) + * satisfied for the tiny windows these tests use; a test may override it. */ -class TestCompactServiceVarTokens extends BasicCompactService { - bigSeqs = new Set() - constructor(config: BasicCompactConfig = {}) { - super(new Context(), { auto: false, ...config }) - } - - override estimateEventTokens(event: SessionEvent): number { - if (this.bigSeqs.has(event.seq)) return 1000 - return super.estimateEventTokens(event) - } - - override async summarize(): Promise { - return [{ type: 'text', text: 'summary' }] - } +function createTestService(config: BasicCompactConfig = {}): TestCompactService { + return new TestCompactService(new Context(), { auto: false, summarizationMaxTokens: 1, ...config }) } /** @@ -188,44 +174,48 @@ function expectNoOrphanToolResults(messages: Message[]): void { } describe('BasicCompactService step-alignment (never split a tool-call/result pair)', () => { - it('compactIfNeeded snaps the cutoff forward past a mid-step boundary (no orphaned tool-result)', async () => { - // 3 turns, each one step = { assistant(tool-call) , tool/result }. Surface + it('compactIfNeeded rounds the retained boundary head-ward to keep a whole step (no orphaned tool-result)', async () => { + // 3 turns, each one step = { assistant(tool-call), tool/result }. Surface // (9 nodes): user1, asst1, res1, user2, asst2, res2, user3, asst3, res3 — - // 10/20/10 tokens. With retainTokens=55 the tail→head walk overflows at - // asst2 (idx4), so the RAW cutoff falls BETWEEN asst2 and its result res2 - // (idx5) — splitting turn 2's step. The fix snaps the cutoff forward to res2 - // so the whole step is compacted and no dangling result survives. + // 10/20/10 tokens. The tail→head walk retains by whole units; the compacted + // region always ends on a step boundary, so no step's tool-call is split + // from its result. retainTokens=55 keeps the recent tail; the older steps + // compact intact. const svc = createTestService({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 55 }) const session = toolTurnSession(3) - const result = await svc.compactIfNeeded(session) + const result = await svc.compactIfNeeded(session, '', 'm', SIGNAL) expect(result).not.toBeNull() - // res2 (idx5) was pulled into the compacted region by the snap, not stranded. + expect(result!.shadowedSeqs.length).toBeGreaterThan(0) + // No dangling tool-result: every compacted/retained step stayed whole. expectNoOrphanToolResults(session.deriveMessages()) - // Turn 3's step is retained intact (summary + user3 + asst3 + res3 = 4 msgs). - expect(session.deriveMessages().length).toBe(4) + // The most-recent step's result is retained verbatim (still on the surface). + const lastResultSeq = session.events.findLast(e => e.type === 'tool/result')!.seq + expect(result!.shadowedSeqs).not.toContain(lastResultSeq) }) - it('compactIfNeeded returns null when the only cutoff would enter an open tail step', async () => { - // A pre-step user/message then an OPEN step (assistant issued a tool-call, no - // tool/result / step/end yet — mid-flight). The token walk wants to compact - // into that open step, but its tool-call has no result yet; compacting it - // would defer the orphan. With no safe step-aligned cutoff, compactIfNeeded - // declines (returns null) rather than summarizing a pending tool-call away. - const s = new Session(SessionId('open-step')) + it('compactIfNeeded returns null when the only compactable region is an un-splittable single step', async () => { + // The surface is exactly ONE step: [assistant(tool-call), tool/result]. Over + // threshold (by the derived role overhead), the tail→head walk stops with the + // retained boundary at the tool/result — which is NOT a step-aligned start (its + // issuing assistant precedes it in the same step). Rounding head-ward to find a + // clean boundary reaches index 0, so there is no step-aligned cutoff in the + // compactable range: compactIfNeeded declines rather than splitting the step. + const s = new Session(SessionId('one-step')) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) s.append('step/start', { turn: 1, step: 1 }) s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'calling' }, { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], }, { surfaceOp: 'append' }) - // no tool/result, no step/end — the step is open at the tail. + s.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{}' }) + s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, { surfaceOp: 'append' }) + s.append('step/end', { turn: 1, step: 1 }) + // Turn stays open. - const svc = createTestService({ contextWindow: 50, thresholdRatio: 0.5, retainTokens: 5 }) - const result = await svc.compactIfNeeded(s) + const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5 }) + const result = await svc.compactIfNeeded(s, '', 'm', SIGNAL) expect(result).toBeNull() - // The open step's assistant survived — its tool-call is intact for the result. expect(s.events.some(e => e.type === 'compact/start')).toBe(false) }) @@ -516,107 +506,103 @@ describe('BasicCompactService.compactIfNeeded', () => { it('returns null when tokens are under threshold', async () => { const svc = createTestService({ contextWindow: 128000, thresholdRatio: 0.8 }) const session = multiTurnSession(1, 1) - expect(await svc.compactIfNeeded(session)).toBeNull() + expect(await svc.compactIfNeeded(session, '', 'm', SIGNAL)).toBeNull() }) it('compacts when tokens exceed threshold', async () => { const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) const session = multiTurnSession(3, 1) // 6 surface nodes, 10 tokens each = 60 - const result = await svc.compactIfNeeded(session) + const result = await svc.compactIfNeeded(session, '', 'm', SIGNAL) expect(result).not.toBeNull() expect(result!.shadowedSeqs.length).toBeGreaterThan(0) }) it('walks tail→head and retains nodes within token budget', async () => { - const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 15 }) + const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.2, retainTokens: 15 }) const session = multiTurnSession(5, 1) // 10 surface nodes = ~100 tokens - const result = await svc.compactIfNeeded(session) + const result = await svc.compactIfNeeded(session, '', 'm', SIGNAL) expect(result).not.toBeNull() const nodes = session.surface.nodes expect(result!.shadowedSeqs.length).toBeGreaterThan(0) expect(result!.shadowedSeqs).not.toContain(nodes[nodes.length - 1]!.seq) }) - it('returns null when total tokens fit within budget', async () => { - const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 1000 }) + it('returns null when the whole surface fits the retain budget (over threshold by role/system overhead)', async () => { + // threshold = floor(460*0.1) = 46. The 4 surface nodes weigh 10 each (raw 40 + // for the retention walk), but the derived estimate adds 4 role tokens per + // message → 56 ≥ 46, so the threshold check passes and the walk runs. The + // walk accumulates all 40 < retainTokens (45) without crossing the budget, + // so keepFromIdx reaches 0 and compaction declines. The invariant holds: + // summarizationMaxTokens (1) + retainTokens (45) = 46 ≤ threshold 46. + const svc = createTestService({ contextWindow: 460, thresholdRatio: 0.1, retainTokens: 45 }) const session = multiTurnSession(2, 1) - expect(await svc.compactIfNeeded(session)).toBeNull() + expect(await svc.compactIfNeeded(session, '', 'm', SIGNAL)).toBeNull() }) - it('retains the in-flight turn verbatim even when its newest node exceeds retainTokens', async () => { - // The current turn's first step has CLOSED (so its last node is step-aligned - // and would otherwise be a valid compaction cutoff), and that node — a fresh - // tool result — is larger than the whole retain budget. It must NOT be - // compacted: it is the observation the model needs for the turn's next step. - // Only the older closed turns are eligible. - const svc = new TestCompactServiceVarTokens({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5 }) - const s = new Session(SessionId('big-tail')) - // Two closed turns (compactable older context). - for (const t of [1, 2]) { - s.append('turn/start', { turn: t, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('step/start', { turn: t, step: 1 }) - s.append('user/message', { content: [{ type: 'text', text: `turn ${t}` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - s.append('assistant/message', { turn: t, step: 1, content: [{ type: 'text', text: `reply ${t}` }] }, { surfaceOp: 'append' }) - s.append('step/end', { turn: t, step: 1 }) - s.append('turn/end', { turn: t, reason: { kind: 'completed' } }) + it('compacts a runaway turn: its early CLOSED steps summarize while recent steps stay verbatim', async () => { + // The REGRESSION that motivated dropping turn-protection. A single in-flight + // (open) turn has grown past the threshold on its own: several CLOSED steps, + // each [assistant(tool-call), tool/result]. Retention is turn-agnostic, so + // the turn's OWN early closed steps are eligible — they compact while the + // recent tail stays verbatim, and the harness survives. + // + // On the OLD layer-2 code this test FAILS: the entire open turn was retained + // verbatim (protectedIdx = first open-turn node = 0), so compactIfNeeded + // returned null and shadowedSeqs would be empty — the runaway turn could + // never compact and the next model call would overflow the window. + const svc = createTestService({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 25 }) + const s = new Session(SessionId('runaway')) + // ONE open turn with 5 closed steps; each step is [asst(tool-call), result]. + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('user/message', { content: [{ type: 'text', text: 'do a big multi-step task' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + for (let step = 1; step <= 5; step++) { + s.append('step/start', { turn: 1, step }) + s.append('assistant/message', { + turn: 1, step, + content: [{ type: 'text', text: `step ${step}` }, { type: 'tool-call', id: CallId(`c${step}`), name: 'bash', arguments: '{}' }], + }, { surfaceOp: 'append' }) + s.append('tool/call', { turn: 1, step, callId: CallId(`c${step}`), name: 'bash', arguments: '{}' }) + s.append('tool/result', { turn: 1, step, callId: CallId(`c${step}`), content: [{ type: 'text', text: `out ${step}` }], isError: false }, { surfaceOp: 'append' }) + s.append('step/end', { turn: 1, step }) } - // The in-flight turn 3: a user request, then a CLOSED step 1 whose tool - // result is HUGE (1000 tokens). The step is closed (step/end), so the result - // node is step-aligned — without the in-flight-turn protection the retention - // walk would pick it as the cutoff and compact it away. The turn itself is - // still open (no turn/end): the model is mid-turn, about to run step 2. - s.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('user/message', { content: [{ type: 'text', text: 'current request' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - s.append('step/start', { turn: 3, step: 1 }) - s.append('assistant/message', { turn: 3, step: 1, content: [{ type: 'text', text: 'calling' }, { type: 'tool-call', id: CallId('huge'), name: 'bash', arguments: '{}' }] }, { surfaceOp: 'append' }) - s.append('tool/call', { turn: 3, step: 1, callId: CallId('huge'), name: 'bash', arguments: '{}' }) - const hugeSeq = s.append('tool/result', { - turn: 3, step: 1, callId: CallId('huge'), - content: [{ type: 'text', text: 'HUGE' }], isError: false, - }, { surfaceOp: 'append' }).seq - s.append('step/end', { turn: 3, step: 1 }) - svc.bigSeqs.add(hugeSeq) // make this node weigh 1000 tokens + // The turn stays OPEN (no turn/end) — the model is mid-turn, about to run + // step 6. Surface: user + 5×[asst, result] = 11 nodes. + const nodesBefore = s.surface.nodes.length + expect(nodesBefore).toBe(11) - const result = await svc.compactIfNeeded(s) + const result = await svc.compactIfNeeded(s, '', 'm', SIGNAL) expect(result).not.toBeNull() - // The in-flight turn's nodes — the request, the assistant, AND the huge - // result — are retained: none shadowed, all survive on the surface verbatim. - expect(result!.shadowedSeqs).not.toContain(hugeSeq) - const survivingSeqs = new Set(s.surface.nodes.map(n => n.seq)) - expect(survivingSeqs.has(hugeSeq)).toBe(true) - const requestSeq = s.events.find(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text === 'current request'))!.seq - expect(survivingSeqs.has(requestSeq)).toBe(true) - // The older closed turns WERE compacted. + // Early steps of the SAME open turn were shadowed (impossible under layer 2). expect(result!.shadowedSeqs.length).toBeGreaterThan(0) + // The most-recent step's tool result is retained verbatim (still on surface). + const lastResultSeq = s.events.findLast(e => e.type === 'tool/result')!.seq + expect(result!.shadowedSeqs).not.toContain(lastResultSeq) + expect(s.surface.nodes.some(n => n.seq === lastResultSeq)).toBe(true) + // No orphaned tool-result survives (whole-step boundaries respected). + expectNoOrphanToolResults(s.deriveMessages()) }) it('returns null for an empty surface', async () => { - const svc = createTestService({ contextWindow: 10, thresholdRatio: 0.1 }) + const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) const session = new Session(SessionId('empty')) - expect(await svc.compactIfNeeded(session)).toBeNull() + expect(await svc.compactIfNeeded(session, '', 'm', SIGNAL)).toBeNull() }) - it('compacts again within the same open turn (the prior summary node is still eligible)', async () => { - // After the first compaction lands a replacement summary node, that node is - // appended DURING the open turn (seq > turn/start) but sits earlier in the - // surface (at the shadowed range's position), NOT in the verbatim tail run. - // It must stay compaction-eligible: a second step in the SAME turn, still - // over threshold, must be able to compact older context — protectedIdx must - // not collapse to 0 and silently disable per-step auto-compaction. - // retainTokens=25 leaves a couple of retained closed-turn nodes after the - // first compaction (so the surface is [summary, …retained], not [summary]). - const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 25 }) + it('compacts again after a prior summary node heads the surface (the summary stays eligible)', async () => { + // After the first compaction lands a replacement summary node at the head, + // a second compaction (still over threshold) re-consolidates it with newer + // context — head-anchoring means the prior checkpoint is always re-included, + // never stranded. retainTokens=25 leaves a couple of retained nodes after + // the first compaction (so the surface is [summary, …retained], not just + // [summary]). + const svc = createTestService({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 25 }) const s = multiTurnSession(4, 1) // turns 1-4 closed, turn 5 open (no surface yet) - const first = await svc.compactIfNeeded(s) + const first = await svc.compactIfNeeded(s, '', 'm', SIGNAL) expect(first).not.toBeNull() - // The summary node now heads the surface; the open turn has no verbatim tail - // node yet, so the whole surface (incl. the summary) is eligible — the - // protected suffix is the contiguous tail run of open-turn nodes (none yet). - // The summary node's seq exceeds turn 5's turn/start, yet it sits at the - // head (not the tail), so it must NOT be counted as protected. + // The summary node now heads the surface with a fresh high seq. const summaryHeadSeq = s.surface.nodes[0]!.seq const turn5StartSeq = s.events.filter(e => e.type === 'turn/start').at(-1)!.seq expect(summaryHeadSeq).toBeGreaterThan(turn5StartSeq) @@ -629,7 +615,7 @@ describe('BasicCompactService.compactIfNeeded', () => { s.append('assistant/message', { turn: 5, step: 1, content: [{ type: 'text', text: 'reply 5' }] }, { surfaceOp: 'append' }) s.append('step/end', { turn: 5, step: 1 }) - const second = await svc.compactIfNeeded(s) + const second = await svc.compactIfNeeded(s, '', 'm', SIGNAL) expect(second).not.toBeNull() expect(second!.shadowedSeqs.length).toBeGreaterThan(0) // The fresh open-turn nodes were NOT compacted. @@ -751,6 +737,27 @@ describe('BasicCompactService HMR safety', () => { }) }) +describe('BasicCompactService convergence invariant (config)', () => { + it('throws when summarizationMaxTokens + retainTokens exceeds the threshold', () => { + // threshold = floor(1000 * 0.5) = 500; 200 + 400 = 600 > 500 → reject. + expect(() => new BasicCompactService(new Context(), { + auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 400, summarizationMaxTokens: 200, + })).toThrow(/exceeds the compaction threshold/) + }) + + it('accepts the boundary case (sum equals the threshold)', () => { + // threshold = floor(1000 * 0.5) = 500; 100 + 400 = 500 ≤ 500 → allowed. + expect(() => new BasicCompactService(new Context(), { + auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 400, summarizationMaxTokens: 100, + })).not.toThrow() + }) + + it('the default config satisfies the invariant', () => { + // 2048 + 20480 = 22528 ≤ floor(128000 * 0.8) = 102400. + expect(() => new BasicCompactService(new Context(), { auto: false })).not.toThrow() + }) +}) + /** An adapter that emits a fixed summary text, for exercising the real summarize() path. */ class ScriptedAdapter extends LlmAdapter { lastOptions: GenerateOptions | null = null @@ -876,81 +883,73 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { }) }) -describe('BasicCompactService auto-compaction (agent/request listener)', () => { - /** Fire the agent/request waterfall as the loop does. */ - function fireRequest(ctx: Context, agent: Agent, step: number, options: GenerateOptions): Promise { - return ctx.waterfall('agent/request', agent, 1, step, options, () => Promise.resolve(options)) +describe('BasicCompactService auto-compaction (agent/pre-request listener)', () => { + /** Fire the agent/pre-request parallel checkpoint as the loop does. */ + function firePreRequest(ctx: Context, agent: Agent, step: number, system: string, model: string): Promise { + return ctx.parallel('agent/pre-request', agent, 1, step, system, model, SIGNAL) } - it('compacts and rewrites request.messages when over threshold', async () => { - // Tiny window so the (large) session is over threshold; char/4 estimate. + it('compacts (mutating the surface) when over threshold', async () => { const { ctx } = await ctxWithModel('SUMMARY') - const svc = new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 }) + void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20, summarizationMaxTokens: 50 }) const session = multiTurnSession(5, 1) // 10 surface nodes const agent = stubAgent(session, 'test-model') + const before = session.surface.nodes.length - const messages = session.deriveMessages() - const before = messages.length - const options: GenerateOptions = { model: 'test-model', messages } + await firePreRequest(ctx, agent, 1, '', 'test-model') - const out = await fireRequest(ctx, agent, 1, options) - // The surface shrank — request.messages was re-derived to fewer entries. - expect(out.messages.length).toBeLessThan(before) + // The surface shrank in place, and a summary checkpoint landed. + expect(session.surface.nodes.length).toBeLessThan(before) expect(session.events.some(e => e.type === 'compact/summary')).toBe(true) - // Re-derived first message is the framed summary checkpoint. - expect(out.messages[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) - expect(svc).toBeDefined() + // The re-derived head message is the framed summary checkpoint. + expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) }) it('compacts mid-turn on steps after the first (the surface grows within a turn)', async () => { const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) + void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10, summarizationMaxTokens: 30 }) const session = multiTurnSession(3, 1) // over the 0.5 threshold const agent = stubAgent(session, 'test-model') - const options: GenerateOptions = { model: 'test-model', messages: session.deriveMessages() } - // A step-2 request (a tool-heavy turn's later step) must still compact — the - // surface accumulated assistant/message + tool/result nodes since step 1. - await fireRequest(ctx, agent, 2, options) + // A step-2 checkpoint (a tool-heavy turn's later step) must still compact — + // the surface accumulated assistant/message + tool/result nodes since step 1. + await firePreRequest(ctx, agent, 2, '', 'test-model') expect(session.events.some(e => e.type === 'compact/start')).toBe(true) }) - it('passes through unchanged when under threshold', async () => { + it('does nothing when under threshold', async () => { const { ctx } = await ctxWithModel('SUMMARY') void new BasicCompactService(ctx, { contextWindow: 128000, thresholdRatio: 0.8 }) const session = multiTurnSession(1, 1) const agent = stubAgent(session, 'test-model') - const msgs = session.deriveMessages() - const options: GenerateOptions = { model: 'test-model', messages: msgs } - const out = await fireRequest(ctx, agent, 1, options) - expect(out.messages).toBe(msgs) + await firePreRequest(ctx, agent, 1, '', 'test-model') expect(session.events.some(e => e.type === 'compact/start')).toBe(false) }) - it('proceeds with original history when compaction fails', async () => { - // No adapter registered for this model → summarize() rejects → caught, proceeds. + it('leaves the surface intact when compaction fails (summarize rejects)', async () => { + // No adapter registered for this model → summarize() rejects → caught, the + // surface is untouched (the loop derives the full history). const ctx = new Context() await ctx.plugin(LlmService) - void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.1, retainTokens: 10 }) + void new BasicCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10, summarizationMaxTokens: 1 }) const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'missing-model') - const msgs = session.deriveMessages() - const options: GenerateOptions = { model: 'missing-model', messages: msgs } + const before = session.surface.nodes.length - const out = await fireRequest(ctx, agent, 1, options) - // Listener swallowed the failure and left messages intact. - expect(out.messages).toBe(msgs) + await firePreRequest(ctx, agent, 1, '', 'missing-model') + // No summary landed; the surface is unchanged. + expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) + expect(session.surface.nodes.length).toBe(before) }) it('does not register the listener when auto is false', async () => { const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, { auto: false, contextWindow: 10, thresholdRatio: 0.1, retainTokens: 1 }) + void new BasicCompactService(ctx, { auto: false, contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5, summarizationMaxTokens: 1 }) const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'test-model') - const options: GenerateOptions = { model: 'test-model', messages: session.deriveMessages() } - await fireRequest(ctx, agent, 1, options) + await firePreRequest(ctx, agent, 1, '', 'test-model') expect(session.events.some(e => e.type === 'compact/start')).toBe(false) }) }) @@ -1049,46 +1048,65 @@ describe('BasicCompactService edge cases', () => { expect(svc.estimateContentTokens([unknown])).toBeGreaterThan(0) }) - it('compacts and re-derives without re-checking a post-compaction threshold', async () => { + it('compacts once without re-checking a post-compaction threshold', async () => { const { ctx } = await ctxWithModel('SUMMARY') // Even with a window so tiny the post-compaction history still exceeds the // threshold, the agnostic listener does NOT re-gate or warn — it compacts // once (the single check lives in compactIfNeeded) and proceeds. const warnings: string[] = [] ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn - void new BasicCompactService(ctx, { contextWindow: 10, thresholdRatio: 0.1, retainTokens: 5 }) + void new BasicCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 5, summarizationMaxTokens: 5 }) const session = multiTurnSession(4, 1) const agent = stubAgent(session, 'test-model') - const options: GenerateOptions = { model: 'test-model', messages: session.deriveMessages() } - await ctx.waterfall('agent/request', agent, 1, 1, options, () => Promise.resolve(options)) + await ctx.parallel('agent/pre-request', agent, 1, 1, '', 'test-model', SIGNAL) expect(session.events.some(e => e.type === 'compact/summary')).toBe(true) - // The surface was re-derived into the request; no cascade warning is emitted. - expect(options.messages[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) + // The surface was mutated; the head message is the framed summary checkpoint. + expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) + // No cascade warning is emitted. expect(warnings.length).toBe(0) }) it('rejects compaction when no turn is open (compaction events must be turn-enclosed)', async () => { const svc = createTestService() - // A session with surface nodes but NO open turn — compaction's compact/* and - // replacement events would be appended outside any turn, which the session-log - // contract forbids. + // A session whose only turn has CLOSED — scanning back from the tail hits + // turn/end before any turn/start, so there is no open turn to enclose + // compaction's compact/* + replacement events, which the log contract forbids. const s = new Session(SessionId('noturn')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) s.append('user/message', { content: [{ type: 'text', text: 'orphan' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'reply' }] }, { surfaceOp: 'append' }) + s.append('step/end', { turn: 1, step: 1 }) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const nodes = s.surface.nodes - await expect(svc.compactRegion(s, nodes[0]!.seq, nodes[0]!.seq, 'm')) + await expect(svc.compactRegion(s, nodes[0]!.seq, nodes[1]!.seq, 'm')) .rejects.toThrow(/no open turn/) // The lock was never acquired — no compact/start landed. expect(s.events.some(e => e.type === 'compact/start')).toBe(false) }) + it('rejects compaction on a session with no turn boundaries at all', async () => { + const svc = createTestService() + // No turn events whatsoever — the open-turn scan falls through to the end + // of the log and finds none, so compaction is rejected (its events have no + // turn to enclose them). + const s = new Session(SessionId('turnless')) + s.append('user/message', { content: [{ type: 'text', text: 'orphan' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const nodes = s.surface.nodes + + await expect(svc.compactRegion(s, nodes[0]!.seq, nodes[0]!.seq, 'm')) + .rejects.toThrow(/no open turn/) + expect(s.events.some(e => e.type === 'compact/start')).toBe(false) + }) + it('compactIfNeeded returns null for empty surface even when over threshold', async () => { - const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1 }) + const svc = createTestService({ contextWindow: 1000, thresholdRatio: 0.1, retainTokens: 5 }) const session = new Session(SessionId('empty-but-pressured')) // No surface nodes, but a large system prompt pushes the estimate over threshold. - const bigPrompt = 'x'.repeat(400) // ceil(400/4) = 100 tokens >> threshold 10 - expect(await svc.compactIfNeeded(session, bigPrompt)).toBeNull() + const bigPrompt = 'x'.repeat(800) // ceil(800/4) = 200 tokens >> threshold 100 + expect(await svc.compactIfNeeded(session, bigPrompt, 'm', SIGNAL)).toBeNull() }) it('compactRegion throws when end is not a surface node (start valid)', async () => { @@ -1116,15 +1134,16 @@ describe('BasicCompactService edge cases', () => { const { ctx } = await ctxWithModel('SUMMARY') const warnings: string[] = [] ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn - const svc = new TestCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.1, retainTokens: 10 }) + const svc = new TestCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10, summarizationMaxTokens: 10 }) svc.summarizeError = 'boom' as unknown as Error const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'test-model') - const msgs = session.deriveMessages() - const options: GenerateOptions = { model: 'test-model', messages: msgs } + const before = session.surface.nodes.length - const out = await ctx.waterfall('agent/request', agent, 1, 1, options, () => Promise.resolve(options)) - expect(out.messages).toBe(msgs) // proceeded with original history + await ctx.parallel('agent/pre-request', agent, 1, 1, '', 'test-model', SIGNAL) + // The failure was swallowed; the surface is untouched and a warning logged. + expect(session.surface.nodes.length).toBe(before) + expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) expect(warnings.some(w => w.includes('compaction failed: boom'))).toBe(true) }) @@ -1132,16 +1151,14 @@ describe('BasicCompactService edge cases', () => { const { ctx } = await ctxWithModel('SUMMARY') // A large system prompt pushes the listener's estimate over threshold, but // retainTokens is huge so compactIfNeeded walks everything and returns null. - const svc = new TestCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.1, retainTokens: 100000 }) + // threshold = floor(2000*0.1) = 200; invariant: 5 + 150 = 155 ≤ 200. + const svc = new TestCompactService(ctx, { contextWindow: 2000, thresholdRatio: 0.1, retainTokens: 150, summarizationMaxTokens: 5 }) const session = multiTurnSession(2, 1) const agent = stubAgent(session, 'test-model') - const bigSystem = 'x'.repeat(400) - const msgs = session.deriveMessages() - const options: GenerateOptions = { model: 'test-model', messages: msgs, system: bigSystem } + const bigSystem = 'x'.repeat(900) // ceil(900/4)=225 > threshold 200 - const out = await ctx.waterfall('agent/request', agent, 1, 1, options, () => Promise.resolve(options)) + await ctx.parallel('agent/pre-request', agent, 1, 1, bigSystem, 'test-model', SIGNAL) expect(session.events.some(e => e.type === 'compact/start')).toBe(false) - expect(out.messages).toBe(msgs) expect(svc.summarizeCalls.length).toBe(0) }) diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 43737a4231..d75a5da774 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -10,7 +10,7 @@ This package is the interface tier of the compaction capability, split so each c | `@deepseek-ai/dsh-compact-basic` | a backend: char/4 estimation + 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/proposed/feature/2026-06-18-compaction-capability-seam.md). +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). ## Service API (`ctx.compact`) @@ -18,10 +18,10 @@ Both methods are **abstract** — the backend owns the entire strategy (token es | Member | Semantics | |---|---| -| `compactIfNeeded(session, systemPrompt?, model?, signal?)` | Estimate the history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. | +| `compactIfNeeded(session, system, model, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-request` checkpoint always supplies the assembled `system`, the `model`, and the turn `signal`. | | `compactRegion(session, start, end, model, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | -Both methods take an optional `signal: AbortSignal`. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is not a parameter — it is recoverable from the log (the currently-open turn), so the backend stamps it without the caller supplying it. +`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is not a parameter — it is recoverable from the log (the currently-open turn), so the backend stamps it without the caller supplying it. ## Surface contract diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 9ff7898468..5e63169fa1 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -16,7 +16,7 @@ * depends on `dsh-session` and `dsh-llm`: the contract's verbs are defined over * a `Session` and its output is the `ContentBlock` vocabulary. That deviation * from the "interface depends only on cordis" guidance is intentional and - * recorded in the [compaction capability-seam RFC](../../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md). + * recorded in the [compaction capability-seam RFC](../../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). * * @module @deepseek-ai/dsh-compact */ @@ -62,14 +62,31 @@ export abstract class CompactService extends Service { /** * Check token pressure and compact if the conversation is too large. * - * Estimates the current history size (optionally including a system prompt), - * and if it exceeds the backend's threshold, compacts an older range via - * {@link compactRegion}, keeping recent context intact. + * Estimates the current surface-derived history size (including the system + * prompt), and if it exceeds the backend's threshold, compacts an older range + * via {@link compactRegion}, keeping recent context intact. Returns `null` + * when no compaction is needed. + * + * Scope and guarantees a backend MUST honor: + * - **Surface-derived history only.** The decision is made against the history + * derived from the session surface — the only thing compaction can act on. + * Non-surface context injected downstream (into the request `messages` by a + * later listener) is out of this accounting by construction. + * - **Head-anchored, best-effort.** Auto-compaction consolidates from the + * surface HEAD up to a step-aligned cutoff, so a prior head checkpoint is + * re-summarized into one fresh checkpoint (the surface holds at most one + * auto-generated checkpoint, always at the head). It is best-effort over + * CLOSED steps: when the only compactable content left is an un-splittable + * open tail step, it declines (`null`) and retries once that step closes. + * - **Single-unit overflow is out of scope.** If a single retained unit (one + * closed step, or a large free node such as a 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. * * @param session - the session whose surface may be compacted. - * @param systemPrompt - optional system prompt, counted toward the estimate. - * @param model - optional summarization model (falls back to backend config). - * @param signal - optional cancellation signal. A backend that summarizes via + * @param system - the assembled system prompt, counted toward the estimate. + * @param model - the summarization model (a backend may override via config). + * @param signal - cancellation signal. A backend summarizing via * `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal` * so an abort/dispose tears down the in-flight summarization rather than * leaving an orphaned model call running past the cancellation. @@ -77,9 +94,9 @@ export abstract class CompactService extends Service { */ abstract compactIfNeeded( session: Session, - systemPrompt?: string, - model?: string, - signal?: AbortSignal, + system: string, + model: string, + signal: AbortSignal, ): Promise /** diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index df001ff41a..ba886685d5 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -6,7 +6,7 @@ * events are log-only markers (lock + provenance); only the five * surface-eligible types can carry `surfaceOp`. The actual surface mutation is * performed by a separate `user/message` event carrying the summary (see the - * [compaction capability-seam RFC](../../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md)). + * [compaction capability-seam RFC](../../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)). * * Configuration lives in the backend, not here: the contract states WHAT * compaction produces, while every tunable (context window, thresholds, diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index ceef1bca8e..4aeef18eec 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -149,8 +149,9 @@ export interface LoopHandle { * drain steering → session('steering/message') ⟵ catches late steering * session('step/start'); emit agent/step-start ⟵ append before emit (the event-sourcing RFC) * assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble + * await ctx.parallel('agent/pre-request') ⟵ surface mutation (compaction) BEFORE derive * req = {model, system, tools, messages: session.deriveMessages(), signal} - * req = waterfall agent/request ⟵ hooks/compaction/model-switch + * req = waterfall agent/request ⟵ hooks/model-switch * stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks) * session('assistant/chunk'); emit agent/stream-chunk * msg = waterfall agent/step-result ⟵ BEFORE the log append, so the @@ -565,6 +566,13 @@ async function runStep( .filter(text => text.length > 0) .join('\n\n') + // Surface-mutation checkpoint BEFORE deriving history: compaction shadows an + // older range with a summary node here, and the single derive below reflects + // it. Awaited (no veto) — a listener mutates the surface as a side effect. + // `model` is resolved to '' when unset; a compaction listener that needs a + // model falls back to its own config. + await ctx.parallel('agent/pre-request', agent, turn, step, system, options.model ?? '', signal) + let request: GenerateOptions = { model: options.model ?? '', messages: session.deriveMessages(), diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index d018eff7a2..f74e160936 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -320,6 +320,65 @@ describe('agent loop', () => { expect(adapter.requests[0]!.model).toBe('other-model') }) + it('agent/pre-request fires once per step before the request is derived', async () => { + // Two steps (a tool call, then a final text turn) → two model calls → two + // pre-request fires, each carrying the assembled system + model, BEFORE the + // request messages are derived (the request the adapter sees reflects any + // surface state at fire time). + const adapter = new MockAdapter([ + toolCallResponse('c1', 'echo', {}, 'calling echo'), + textResponse('done'), + ]) + const ctx = await harness(adapter) + ctx.tools.register(defineTool({ + name: 'echo', description: 'echo', parameters: {}, + async execute() { return [{ type: 'text', text: 'echoed' }] }, + })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + const fires: { turn: number; step: number; model: string }[] = [] + ctx.on('agent/pre-request', (subject, turn, step, _system, model) => { + if (subject === agent) fires.push({ turn, step, model }) + }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + // One fire per step, in order, each with the agent's model. + expect(fires).toEqual([ + { turn: 1, step: 1, model: 'mock' }, + { turn: 1, step: 2, model: 'mock' }, + ]) + }) + + it('a surface mutation in agent/pre-request is reflected in the derived request (single derive)', async () => { + // pre-request fires BEFORE deriveMessages(), so a listener that appends a + // surface node there sees it land in the SAME step's request — proving the + // loop derives once, after the checkpoint, with no stale pre-derive. + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + let injected = false + ctx.on('agent/pre-request', (subject, turn) => { + if (subject === agent && !injected) { + injected = true + subject.session.append('context/message', { + content: [{ type: 'text', text: 'INJECTED-IN-PRE-REQUEST' }], + source: { kind: 'plugin', plugin: 'test' }, + }, { surfaceOp: 'append' }) + void turn + } + }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + // The adapter's request includes the node injected during pre-request. + const text = JSON.stringify(adapter.requests[0]!.messages) + expect(text).toContain('INJECTED-IN-PRE-REQUEST') + }) + it('cancel() mid-stream ends the turn with reason aborted', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index efe392155c..407201ea5d 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -180,10 +180,32 @@ declare module 'cordis' { 'agent/step-end'(agent: Agent, turn: number, step: number): void // ---- interception seams (waterfall) ---- + /** + * Awaited surface-mutation checkpoint, fired BEFORE the step's message + * history is derived (and thus before {@link agent/request}). The loop + * awaits `ctx.parallel('agent/pre-request', …)` after assembling the system + * prompt but before `session.deriveMessages()`, then derives ONCE from + * whatever the surface now holds. This is where compaction belongs: it + * mutates the session surface in place (shadowing an older range with a + * summary node), and the single subsequent derive reflects the mutation — + * so there is no double-derive and no listener can see (or be expected to + * act on) an assembled `messages` array that does not exist yet. + * + * Awaited (parallel), not a waterfall: a listener mutates the surface as a + * side effect; there is nothing to transform or veto, but the loop must wait + * for the mutation to complete before deriving. `system`/`model` are the + * assembled values a listener needs to measure pressure (system counts + * toward the budget) and to summarize (the model). `signal` cancels any + * in-flight work a listener starts (e.g. a summarization model call). + * @mode parallel + */ + 'agent/pre-request'(agent: Agent, turn: number, step: number, system: string, model: string, signal: AbortSignal): Promise | void /** * Waterfall: mutate the fully-assembled {@link GenerateOptions} before the - * model call (hooks, compaction, model switching, tool filtering, …). Call - * `next()` to delegate, or return without it to short-circuit. + * 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-request} + * instead — by the time this fires, `options.messages` is already derived. * @mode waterfall */ 'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise): Promise diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index a7f4c13dad..cd30142773 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { Session, SessionId, isSurfaceEvent } from '@deepseek-ai/dsh-session' import { CallId } from '@deepseek-ai/dsh-llm' /** Build a minimal session with turn boundaries and a single user message. */ @@ -278,4 +278,21 @@ describe('Session.append surface opts', () => { // The string 'append' is a primitive — identity-preserving is fine. expect(event.surfaceOp).toBe('append') }) + + it('isSurfaceEvent rejects a surface-eligible type missing its surfaceOp marker', () => { + // A raw event (not built via append, which mandates the marker) of a + // surface-eligible type but with no surfaceOp must NOT narrow to a + // SurfaceEvent — it would otherwise be silently dropped from the surface. + const noMarker: SessionEvent = { + type: 'user/message', seq: 0, time: 1, + data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, + } + expect(isSurfaceEvent(noMarker)).toBe(false) + // A non-surface type is rejected too (the type gate). + const boundary: SessionEvent = { type: 'turn/start', seq: 1, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } + expect(isSurfaceEvent(boundary)).toBe(false) + // A properly-marked surface event narrows. + const marked = { ...noMarker, surfaceOp: 'append' } as SurfaceEvent + expect(isSurfaceEvent(marked)).toBe(true) + }) }) From f962fda8c1cda2186acc82dc917391e8fe74ac32 Mon Sep 17 00:00:00 2001 From: ZiyaZhang Date: Thu, 25 Jun 2026 22:42:48 -0700 Subject: [PATCH 06/46] docs: add Chinese terminology table --- docs/i18n/terminology.md | 110 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/i18n/terminology.md diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md new file mode 100644 index 0000000000..a0b145117a --- /dev/null +++ b/docs/i18n/terminology.md @@ -0,0 +1,110 @@ +# Terminology + +| English | 中文 | 备注 | +|---|---|---| +| ACP | ACP | | +| AI | AI | 首次出现可写:人工智能(AI) | +| API | API | | +| CLI | CLI | | +| Cordis | Cordis | | +| Function Calling | Function Calling | | +| HMR | HMR | | +| JSON Schema | JSON Schema | | +| JSONL | JSONL | | +| lint | lint | | +| loader | loader | | +| LLM | LLM | 首次出现可写:大语言模型(LLM) | +| MCP | MCP | | +| RAG | RAG | 首次出现可写:检索增强生成(RAG) | +| SDK | SDK | | +| SSE | SSE | | +| agent | agent | 首次出现可写:agent(智能体);不要译作:代理 | +| agent loop | agent loop | | +| fiber | fiber | | +| fixture | fixture | 首次出现可写:fixture(测试样例) | +| fork | fork | 首次出现可写:fork(派生) | +| harness | harness | 不要译作:测试框架、脚手架 | +| manifest | 清单 | 指文件名或字段名时保留 `manifest` | +| schema DSL | schema DSL | | +| schema | schema | API/类型名保留 `schema`;一般 prose 可译为“模式” | +| seam | seam | 首次出现可写:seam(扩展点);不要译作:接缝 | +| skill | skill | 首次出现可写:skill(技能) | +| spawn | spawn | 首次出现可写:spawn(新建) | +| steering | steering | 首次出现可写:steering(中途引导) | +| subagent | subagent | 首次出现可写:subagent(子 agent);不要译作:子代理 | +| transcript | 交互记录 | | +| waterfall | waterfall | 首次出现可写:waterfall(瀑布式事件);不要译作:瀑布流 | +| wire format | 协议格式 | | +| adapter contract | 适配器契约 | | +| adapter | 适配器 | | +| append-only | 仅追加 | | +| artifact | 产物 | | +| block | 块 | | +| background task | 后台任务 | | +| backend | 后端 | | +| capability | 能力 | | +| cancel | 取消 | | +| checkpoint | 检查点 | | +| chunk | 分片 | | +| compaction | 压缩 | | +| consumer | 消费方 | | +| content block | 内容块 | | +| config | 配置 | | +| context | 上下文 | | +| context compaction | 上下文压缩 | | +| coverage | 覆盖率 | | +| crash recovery | 崩溃恢复 | | +| dispose | 释放 | | +| durability | 持久性 | | +| event log | 事件日志 | | +| event | 事件 | | +| event stream | 事件流 | | +| executor | 执行器 | | +| extension | 扩展 | | +| finish reason | 结束原因 | | +| foreground run | 前台运行 | | +| hook | 钩子 | | +| implementation | 实现 | | +| inference | 推理 | | +| injection | 注入 | | +| interface | 接口 | | +| integration | 集成 | | +| memory | 记忆 | 指 agent memory;不要译作:内存 | +| message | 消息 | | +| model provider | 模型提供方 | | +| module | 模块 | | +| permission | 权限 | | +| persistence | 持久化 | | +| pipeline | 流水线 | | +| plugin | 模组 | 不要译作:插件 | +| prompt | 提示词 | | +| provider | 提供方 | | +| provider-neutral | 提供方无关 | | +| quality gate | 质量门禁 | | +| registry | 注册表 | | +| reasoning | 推理 | `reasoning_content` 译为“思考内容” | +| replay | 回放 | | +| resume | 恢复 | | +| runtime | 运行时 | | +| sandbox | 沙箱 | | +| service | 服务 | | +| session | 会话 | | +| session event | 会话事件 | | +| snapshot | 快照 | | +| spine | 主干 | | +| step | 步骤 | | +| stream | 流 | | +| streaming | 流式输出 | | +| system prompt | 系统提示词 | | +| taxonomy | 分类体系 | | +| token usage | token 用量 | | +| thinking | thinking | API 字段保留;模型模式译为“思考” | +| tool | 工具 | | +| tool call | 工具调用 | | +| tool result | 工具结果 | | +| tool schema | 工具 schema | | +| toolkit | 工具包 | | +| turn | 轮次 | | +| typecheck | 类型检查 | | +| vocabulary | 词汇 | | +| workflow | 工作流 | | From d6da8ca29aa027df8928f0cdb60e583899b17fb5 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 26 Jun 2026 13:51:01 +0800 Subject: [PATCH 07/46] fix(compact): decide step-alignment from surface tool-pairing, fire compaction pre-step (CBR-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 1 CBR-001: a head-anchored compaction checkpoint was mis-classified by the log-position step-alignment scan, so a second auto-compaction over a checkpoint-headed surface silently failed. Root cause: `isStepAlignedStart/End` scanned the LOG by seq, but a `replace` op lands a checkpoint at a high log seq whose SURFACE position is the head — its log neighbours (the open step's assistant/message) are not its surface neighbours, so the forward scan wrongly reported mid-step. Fix, per the agreed direction: - Replace the two log-position predicates with one surface-anchored helper `isToolPairingBalanced(nodes, events, beforeSeq)` in `dsh-session` (renamed step-boundary.ts → tool-pairing.ts). A cut is balanced when no unanswered tool-call precedes it on the surface; a region is collapsible iff both edges are balanced cuts. The open-tail and free-node cases fall out of the same counter. It also throws on a corrupt surface (a tool/result with no matching call). - Move compaction off the in-step seam to a new "pre-step" seam fired after turn/start and before step/start, so a compaction's log-only compact/* records and its replacement node land cleanly OUTSIDE any step (the honest structure crash-safety relies on). Renamed the event agent/pre-request → agent/pre-step and switched its dispatch from parallel → serial (listeners mutate the surface as a side effect; serial isolates them so concurrent appends can't interleave). Extended the catalog generator to accept @mode serial. Regression coverage: a real-loop test driving an auto-compaction asserts the landed checkpoint is a balanced cut on both sides; unit tests pin the checkpoint case, the mid-step injection case, multi-call steps, and the corrupt-surface guard. Proven red on the old log-position logic. --- docs/cordis-catalog/events-and-services.md | 26 +- packages/compact/compact-basic/package.json | 3 + packages/compact/compact-basic/src/index.ts | 127 +++---- .../compact-basic/tests/compact-basic.spec.ts | 86 +++-- .../tests/compact-loop-repro.spec.ts | 155 +++++++++ packages/compact/compact/src/index.ts | 25 +- packages/core/agent-loop/src/loop.ts | 82 +++-- packages/core/agent-loop/tests/cancel.spec.ts | 30 ++ packages/core/agent-loop/tests/loop.spec.ts | 73 +++- packages/core/agent/src/types.ts | 43 ++- packages/core/session/src/index.ts | 2 +- packages/core/session/src/step-boundary.ts | 97 ------ packages/core/session/src/tool-pairing.ts | 100 ++++++ .../core/session/tests/step-boundary.spec.ts | 172 ---------- .../core/session/tests/tool-pairing.spec.ts | 314 ++++++++++++++++++ pnpm-lock.yaml | 9 + scripts/gen-cordis-catalog.ts | 16 +- 17 files changed, 912 insertions(+), 448 deletions(-) create mode 100644 packages/compact/compact-basic/tests/compact-loop-repro.spec.ts delete mode 100644 packages/core/session/src/step-boundary.ts create mode 100644 packages/core/session/src/tool-pairing.ts delete mode 100644 packages/core/session/tests/step-boundary.spec.ts create mode 100644 packages/core/session/tests/tool-pairing.spec.ts diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 7514a5fe41..5ace95c782 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -11,7 +11,7 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary ## Events -Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 25 events across 6 scopes. +Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto), **serial** (awaited, in registration order, no veto). The harness declares 25 events across 6 scopes. ### `agent/*` @@ -49,21 +49,21 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:242`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:249`](../../packages/core/agent/src/types.ts) -#### `agent/pre-request` — parallel +#### `agent/pre-step` — serial -Awaited surface-mutation checkpoint, fired BEFORE the step's message history is derived (and thus before agent/request). The loop awaits `ctx.parallel('agent/pre-request', …)` after assembling the system prompt but before `session.deriveMessages()`, then derives ONCE from whatever the surface now holds. This is where compaction belongs: it mutates the session surface in place (shadowing an older range with a summary node), and the single subsequent derive reflects the mutation — so there is no double-derive and no listener can see (or be expected to act on) an assembled `messages` array that does not exist yet. +Awaited pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step's `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`. `step` is the number of the step about to start. The loop awaits `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then opens the step and derives the request history ONCE from whatever the surface now holds. This is where compaction belongs: it mutates the session surface in place (shadowing an older range with a summary node) with its log-only `compact/*` records cleanly outside any step, and the single subsequent derive reflects the mutation — so there is no double-derive and no listener can see (or be expected to act on) an assembled `messages` array that does not exist yet. -Awaited (parallel), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform or veto, but the loop must wait for the mutation to complete before deriving. `system`/`model` are the assembled values a listener needs to measure pressure (system counts toward the budget) and to summarize (the model). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call). +Serial (awaited, in registration order, no veto), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform or veto, but the loop must wait for the mutation to complete before opening the step and deriving, and serial isolates listeners from each other (one finishes its surface append before the next runs). `system`/`model` are the assembled values a listener needs to measure pressure (system counts toward the budget) and to summarize (the model). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call). ```ts cordis-catalog -'agent/pre-request'(agent: Agent, turn: number, step: number, system: string, model: string, signal: AbortSignal): Promise | void +'agent/pre-step'(agent: Agent, turn: number, step: number, system: string, model: string, signal: AbortSignal): Promise | void ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -79,7 +79,7 @@ Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/t #### `agent/request` — waterfall -Waterfall: mutate the fully-assembled 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 agent/pre-request instead — by the time this fires, `options.messages` is already derived. +Waterfall: mutate the fully-assembled 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 agent/pre-step instead — by the time this fires, `options.messages` is already derived. ```ts cordis-catalog 'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise): Promise @@ -87,7 +87,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:211`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -111,7 +111,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:236`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -135,7 +135,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -159,7 +159,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:231`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:238`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -171,7 +171,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:231`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index c745fda233..c019796e0d 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -30,10 +30,13 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 2ee0d82133..7648a245b0 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -5,18 +5,18 @@ * - **Token estimation** — char/4 heuristic 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 step boundary so a compacted region never splits a step's - * tool-call/result pair (an open tail step is never crossed — compaction - * declines and retries once it closes). + * to the next balanced tool-pairing boundary so a compacted region never + * splits a step's tool-call/result pair (an open tail step is never crossed — + * compaction declines and retries once it closes). * - **Summarization** — `ctx.llm.stream()` assembled via `BlockAssembler` * (the single model-call surface; same path the loop uses) with a fixed * condense-the-history system prompt. * - **Surface mutation** — a single `user/message` replace node carries the * summary; `compact/*` events are log-only lock + provenance records. - * - **Auto-compaction** — an `agent/request` waterfall listener delegates to - * {@link BasicCompactService.compactIfNeeded} before EVERY model call (every - * step, so a tool-heavy turn that grows the surface mid-turn still compacts); - * it owns the sole token-pressure check. + * - **Auto-compaction** — an `agent/pre-step` listener delegates to + * {@link BasicCompactService.compactIfNeeded} before EVERY step (so a + * tool-heavy turn that grows the surface mid-turn still compacts); it owns the + * sole token-pressure check. * * A different backend (real tokenizer, template summarizer, turn-count * retention) either subclasses this and overrides the {@link @@ -33,7 +33,7 @@ import type { CompactionResult } from '@deepseek-ai/dsh-compact' import { BlockAssembler } from '@deepseek-ai/dsh-llm' import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import { isStepAlignedStart, isStepAlignedEnd } from '@deepseek-ai/dsh-session' +import { isToolPairingBalanced } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { BasicCompactConfig, ResolvedConfig } from './types.ts' import { resolveConfig } from './types.ts' @@ -169,23 +169,26 @@ export class BasicCompactService extends CompactService { this.config = resolveConfig(config) if (this.config.auto) { - // Auto-compaction: delegate to compactIfNeeded before EVERY model call — - // every step, not just the first. This is LOAD-BEARING for runaway-turn - // survival: a tool-heavy ReAct turn appends an assistant/message and a - // tool/result per step, so the surface (and the derived token count) grows - // WITHIN a turn. The only moment to rescue a turn that alone approaches the - // window is the next step's pre-request; gating to a turn's first step - // would let a runaway turn overflow before the next turn's check. The - // listener owns NO threshold logic — compactIfNeeded is the single place - // that decides whether to compact, and its in-progress lock serializes - // concurrent attempts. + // Auto-compaction: delegate to compactIfNeeded before EVERY step. This is + // LOAD-BEARING for runaway-turn survival: a tool-heavy ReAct turn appends + // an assistant/message and a tool/result per step, so the surface (and the + // derived token count) grows WITHIN a turn. The only moment to rescue a + // turn that alone approaches the window is the next step's pre-step + // checkpoint; gating to a turn's first step would let a runaway turn + // overflow before the next turn's check. The listener owns NO threshold + // logic — compactIfNeeded is the single place that decides whether to + // compact, and its in-progress lock serializes concurrent attempts. // - // It runs on `agent/pre-request` (a parallel surface-mutation checkpoint), - // NOT `agent/request`: compaction mutates the session surface, and the loop - // derives the request `messages` AFTER this fires — so a single derive - // already reflects the compaction, with no double-derive and no need to - // rewrite an already-assembled `messages` array. - ctx.on('agent/pre-request', async (agent: Agent, _turn: number, _step: number, system: string, model: string, signal: AbortSignal) => { + // It runs on `agent/pre-step` (a serial surface-mutation checkpoint fired + // AFTER turn/start but BEFORE step/start), NOT `agent/request`: compaction + // mutates the session surface, and the loop derives the request `messages` + // AFTER this fires — so a single derive already reflects the compaction, + // with no double-derive and no need to rewrite an already-assembled + // `messages` array. Firing pre-step (outside any open step) keeps the + // log-only `compact/*` records and the replacement node cleanly outside a + // step, so a crash mid-compaction leaves an inert orphan the turn-repair + // closes — never a half-open step. + ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, system: string, model: string, signal: AbortSignal) => { try { const result = await this.compactIfNeeded(agent.session, system, model, signal) if (result) { @@ -321,17 +324,19 @@ export class BasicCompactService extends CompactService { * Retention is a UNIFORM tail→head walk over the whole surface — turn * boundaries play NO role. Walking node-by-node from the tail and summing * token estimates, once the retained total reaches `retainTokens` the cutoff - * is rounded to a step-aligned boundary: if the walk stopped INSIDE a step, - * it continues head-ward past that step's `step/start` so the whole step is - * retained (never splitting a step's tool-calls from their results); if it - * stopped on a free node (a node belonging to no step), that is already a - * clean boundary. This always rounds toward retaining MORE (retained ≥ - * `retainTokens`) and is step-aligned by construction — no separate snap pass. + * is rounded to a balanced tool-pairing boundary: if the cut before the + * retained node is unbalanced (an unanswered tool-call sits before it — i.e. + * it is mid-step), the walk continues head-ward until the cut is balanced so + * the whole step is retained (never splitting a step's tool-calls from their + * results); if it stopped on a free node (a node belonging to no step), that + * cut is already balanced. This always rounds toward retaining MORE (retained + * ≥ `retainTokens`) and is boundary-safe by construction — no separate snap + * pass. * * The compacted range is always anchored at the surface HEAD (`nodes[0]`): * auto-compaction re-consolidates any prior head checkpoint into one fresh * checkpoint. Declines (`null`) when nothing is over threshold, when the whole - * surface fits the retain budget, or when no step-aligned cutoff exists in the + * surface fits the retain budget, or when no balanced cutoff exists in the * compactable range (its only content is an open tail step — retry once it * closes). */ @@ -371,24 +376,26 @@ export class BasicCompactService extends CompactService { // The whole surface fits the retain budget — nothing to compact. if (keepFromIdx === 0) return null - // Round the cutoff to a step boundary: if `keepFromIdx` sits INSIDE a step, - // extend the retained side head-ward until the boundary is a step-aligned - // start, so the compacted range ends on a clean step edge. A node that - // belongs to no step is already a valid start. Decline if no step-aligned - // start exists at or below `keepFromIdx` (the compactable range is only an - // un-splittable open tail step — retry once it closes). + // Round the cutoff to a tool-pairing boundary: if the cut before + // `nodes[keepFromIdx]` is unbalanced (an unanswered tool-call sits before + // it — i.e. it is mid-step), extend the retained side head-ward until the + // cut is balanced, so the compacted range ends without splitting an + // assistant↔result pair. A node that belongs to no step is already a + // balanced (free) boundary. Decline if no balanced cut exists at or below + // `keepFromIdx` (the compactable range is only an un-splittable open tail + // step — retry once it closes). while (keepFromIdx > 0) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (isStepAlignedStart(events, nodes[keepFromIdx]!.seq)) break + if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!.seq)) break keepFromIdx -= 1 } if (keepFromIdx === 0) return null // The compacted range is [head … keepFromIdx - 1], anchored at the head. - // The cutoff node `nodes[keepFromIdx - 1]` is necessarily a step-aligned END: - // the retained start `nodes[keepFromIdx]` is a step-aligned START (a boundary - // marker sits between them in the log), and that same boundary makes the node - // before it a step-aligned end — so no separate end check is needed. + // The cutoff node `nodes[keepFromIdx - 1]` is necessarily a balanced END: + // the retained start `nodes[keepFromIdx]` opens on a balanced cut, and that + // same cut is the cut AFTER `nodes[keepFromIdx - 1]` — so no separate end + // check is needed. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const firstSeq = nodes[0]!.seq // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -420,19 +427,24 @@ export class BasicCompactService extends CompactService { throw new Error(`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`) } - // The region must contain whole steps, never split a step's - // assistant-message tool-calls from their tool/results (which would orphan - // one side and produce a transcript every provider rejects). A boundary is - // valid when it sits on a step edge or on a node that belongs to no step - // (pre-step user message, inter-step steering, injection context); an `end` - // inside an open (unclosed) tail step is also rejected — its tool-calls have - // no results yet. See dsh-session's step-boundary predicates. + // The region must never split a step's assistant-message tool-calls from + // their tool/results (which would orphan one side and produce a transcript + // every provider rejects). A region is safe iff BOTH its edges are balanced + // cuts: the cut before `start`, and the cut after `end`. A node that belongs + // to no step (pre-step user message, inter-step steering, injection context) + // is a balanced (free) boundary; an `end` inside an open (unclosed) tail step + // leaves the cut after it unbalanced (the open tool-call has no result yet), + // so it is rejected. See dsh-session's tool-pairing balance check. const events = session.events - if (!isStepAlignedStart(events, start)) { - throw new Error(`compactRegion: start seq ${start} is not on a step boundary (would split a step's tool-call/result pair)`) + if (!isToolPairingBalanced(nodes, events, start)) { + throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`) } - if (!isStepAlignedEnd(events, end)) { - throw new Error(`compactRegion: end seq ${end} is not on a step boundary (would split a step, or the step is still open)`) + // The cut after `end` is named by `end`'s surface successor, or `null` when + // `end` is the tail. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const afterEnd: number | null = nodes[endIdx]!.next + if (!isToolPairingBalanced(nodes, events, afterEnd)) { + throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`) } if (this._isCompactionInProgress(session)) { @@ -441,10 +453,11 @@ export class BasicCompactService extends CompactService { // Compaction's events (compact/* and the replacement user/message) must be // turn-enclosed: the session-log contract rejects any plugin event appended - // outside an open turn. Auto-compaction satisfies this — it runs inside the - // `agent/request` waterfall, strictly between a turn's start and end. A - // manual call on a fully-closed session has no turn to enclose the events, - // so reject rather than emit an un-enclosed run. + // outside an open turn. Auto-compaction satisfies this — it runs on the + // `agent/pre-step` seam, after `turn/start` and before `step/start`, so + // strictly inside the open turn (but outside any step). A manual call on a + // fully-closed session has no turn to enclose the events, so reject rather + // than emit an un-enclosed run. const turn = this._openTurn(session) if (turn === null) { throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn') diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 255a59a5b7..4c91f11173 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -49,9 +49,9 @@ function createTestService(config: BasicCompactConfig = {}): TestCompactService /** * Build a multi-turn session with surface markers (simulating real agent-loop * output). Compaction always runs inside an OPEN turn (the loop fires the - * `agent/request` waterfall between a turn's start and its end), so by default - * the session is left with a trailing open turn: turns `1..turns` close, then - * one more `turn/start` opens with no matching `turn/end`. Pass + * `agent/pre-step` seam after a turn's start and before a step's start), so by + * default the session is left with a trailing open turn: turns `1..turns` + * close, then one more `turn/start` opens with no matching `turn/end`. Pass * `{ leaveOpen: false }` for a fully-closed session (e.g. to assert that manual * compaction is rejected when no turn is open). */ @@ -219,7 +219,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai expect(s.events.some(e => e.type === 'compact/start')).toBe(false) }) - it('compactRegion rejects a start that is not a step boundary (splits a step)', async () => { + it('compactRegion rejects a start that splits a step (unbalanced boundary)', async () => { const svc = createTestService() const session = toolTurnSession(1) const nodes = session.surface.nodes // [user, asst(tool-call), result] @@ -228,11 +228,11 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai // start = the tool/result: its issuing assistant precedes it IN THE SAME STEP, // so starting here would orphan that assistant's tool-call. end is fine (user). await expect(svc.compactRegion(session, resultSeq, resultSeq, 'm')) - .rejects.toThrow(/start seq .* is not on a step boundary/) + .rejects.toThrow(/start seq .* is not a balanced boundary/) expect(userSeq).toBeLessThan(resultSeq) // sanity: ordering as expected }) - it('compactRegion rejects an end that is not a step boundary (splits a step)', async () => { + it('compactRegion rejects an end that splits a step (unbalanced boundary)', async () => { const svc = createTestService() const session = toolTurnSession(1) const nodes = session.surface.nodes @@ -241,7 +241,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai // end = the assistant/message: its tool/result follows IN THE SAME STEP, so // ending here would strand that result. start is fine (the pre-step user). await expect(svc.compactRegion(session, userSeq, asstSeq, 'm')) - .rejects.toThrow(/end seq .* is not on a step boundary/) + .rejects.toThrow(/end seq .* is not a balanced boundary/) }) it('compactRegion rejects an end inside an open tail step', async () => { @@ -258,7 +258,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai const userSeq = nodes[0]!.seq const asstSeq = nodes[1]!.seq await expect(svc.compactRegion(s, userSeq, asstSeq, 'm')) - .rejects.toThrow(/end seq .* is not on a step boundary/) + .rejects.toThrow(/end seq .* is not a balanced boundary/) }) it('compactRegion accepts step-aligned boundaries (pre-step user → last result of a closed step)', async () => { @@ -883,10 +883,10 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { }) }) -describe('BasicCompactService auto-compaction (agent/pre-request listener)', () => { - /** Fire the agent/pre-request parallel checkpoint as the loop does. */ - function firePreRequest(ctx: Context, agent: Agent, step: number, system: string, model: string): Promise { - return ctx.parallel('agent/pre-request', agent, 1, step, system, model, SIGNAL) +describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => { + /** Fire the agent/pre-step serial checkpoint as the loop does. */ + function firePreStep(ctx: Context, agent: Agent, step: number, system: string, model: string): Promise { + return ctx.serial('agent/pre-step', agent, 1, step, system, model, SIGNAL) } it('compacts (mutating the surface) when over threshold', async () => { @@ -896,7 +896,7 @@ describe('BasicCompactService auto-compaction (agent/pre-request listener)', () const agent = stubAgent(session, 'test-model') const before = session.surface.nodes.length - await firePreRequest(ctx, agent, 1, '', 'test-model') + await firePreStep(ctx, agent, 1, '', 'test-model') // The surface shrank in place, and a summary checkpoint landed. expect(session.surface.nodes.length).toBeLessThan(before) @@ -913,7 +913,7 @@ describe('BasicCompactService auto-compaction (agent/pre-request listener)', () // A step-2 checkpoint (a tool-heavy turn's later step) must still compact — // the surface accumulated assistant/message + tool/result nodes since step 1. - await firePreRequest(ctx, agent, 2, '', 'test-model') + await firePreStep(ctx, agent, 2, '', 'test-model') expect(session.events.some(e => e.type === 'compact/start')).toBe(true) }) @@ -923,7 +923,7 @@ describe('BasicCompactService auto-compaction (agent/pre-request listener)', () const session = multiTurnSession(1, 1) const agent = stubAgent(session, 'test-model') - await firePreRequest(ctx, agent, 1, '', 'test-model') + await firePreStep(ctx, agent, 1, '', 'test-model') expect(session.events.some(e => e.type === 'compact/start')).toBe(false) }) @@ -937,7 +937,7 @@ describe('BasicCompactService auto-compaction (agent/pre-request listener)', () const agent = stubAgent(session, 'missing-model') const before = session.surface.nodes.length - await firePreRequest(ctx, agent, 1, '', 'missing-model') + await firePreStep(ctx, agent, 1, '', 'missing-model') // No summary landed; the surface is unchanged. expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) expect(session.surface.nodes.length).toBe(before) @@ -949,7 +949,7 @@ describe('BasicCompactService auto-compaction (agent/pre-request listener)', () const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'test-model') - await firePreRequest(ctx, agent, 1, '', 'test-model') + await firePreStep(ctx, agent, 1, '', 'test-model') expect(session.events.some(e => e.type === 'compact/start')).toBe(false) }) }) @@ -992,6 +992,10 @@ describe('BasicCompactService._extractText branches', () => { s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) s.append('step/start', { turn: 1, step: 1 }) s.append('user/message', { content: [{ type: 'text', text: 'run it' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('assistant/message', { + turn: 1, step: 1, + content: [{ type: 'tool-call', id: CallId('c9'), name: 'bash', arguments: '{}' }], + }, { surfaceOp: 'append' }) s.append('tool/call', { turn: 1, step: 1, callId: CallId('c9'), name: 'bash', arguments: '{}' }) s.append('tool/result', { turn: 1, step: 1, callId: CallId('c9'), @@ -1014,12 +1018,15 @@ describe('BasicCompactService edge cases', () => { const s = new Session(SessionId('toolresult')) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) s.append('step/start', { turn: 1, step: 1 }) - // assistant/message carrying a nested tool-result block and an unknown block. + // assistant/message carrying a nested tool-result block, an unknown block, + // and the tool-call that the following tool/result answers (so the surface + // is tool-pairing balanced). s.append('assistant/message', { turn: 1, step: 1, content: [ { type: 'tool-result', toolCallId: CallId('n1'), content: [{ type: 'image', url: 'https://x/n.png' }] }, { type: 'custom-widget', payload: 'x' } as unknown as ContentBlock, + { type: 'tool-call', id: CallId('b1'), name: 'bash', arguments: '{}' }, ], }, { surfaceOp: 'append' }) // tool/result whose content is itself only non-text → bare '[tool-result]'. @@ -1059,7 +1066,7 @@ describe('BasicCompactService edge cases', () => { const session = multiTurnSession(4, 1) const agent = stubAgent(session, 'test-model') - await ctx.parallel('agent/pre-request', agent, 1, 1, '', 'test-model', SIGNAL) + await ctx.serial('agent/pre-step', agent, 1, 1, '', 'test-model', SIGNAL) expect(session.events.some(e => e.type === 'compact/summary')).toBe(true) // The surface was mutated; the head message is the framed summary checkpoint. expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) @@ -1140,7 +1147,7 @@ describe('BasicCompactService edge cases', () => { const agent = stubAgent(session, 'test-model') const before = session.surface.nodes.length - await ctx.parallel('agent/pre-request', agent, 1, 1, '', 'test-model', SIGNAL) + await ctx.serial('agent/pre-step', agent, 1, 1, '', 'test-model', SIGNAL) // The failure was swallowed; the surface is untouched and a warning logged. expect(session.surface.nodes.length).toBe(before) expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) @@ -1157,7 +1164,7 @@ describe('BasicCompactService edge cases', () => { const agent = stubAgent(session, 'test-model') const bigSystem = 'x'.repeat(900) // ceil(900/4)=225 > threshold 200 - await ctx.parallel('agent/pre-request', agent, 1, 1, bigSystem, 'test-model', SIGNAL) + await ctx.serial('agent/pre-step', agent, 1, 1, bigSystem, 'test-model', SIGNAL) expect(session.events.some(e => e.type === 'compact/start')).toBe(false) expect(svc.summarizeCalls.length).toBe(0) }) @@ -1166,23 +1173,37 @@ describe('BasicCompactService edge cases', () => { const svc = createTestService() const s = new Session(SessionId('empties')) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + // Step 1: an empty-text user, an empty-reasoning assistant with NO tool-call + // (balanced: nothing to answer), and empty context/steering — all extract to + // nothing and are skipped. s.append('step/start', { turn: 1, step: 1 }) - // Empty-text text/reasoning blocks contribute nothing → message skipped. s.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'reasoning', text: '' }] }, { surfaceOp: 'append' }) - // tool/result with empty content → empty extraction → skipped. - s.append('tool/call', { turn: 1, step: 1, callId: CallId('z1'), name: 'bash', arguments: '{}' }) - s.append('tool/result', { turn: 1, step: 1, callId: CallId('z1'), content: [], isError: false }, { surfaceOp: 'append' }) s.append('context/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' }) s.append('steering/message', { turn: 1, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) s.append('step/end', { turn: 1, step: 1 }) + // Step 2: a tool exchange whose tool/result has empty content → empty + // extraction → skipped. The assistant carries the matching tool-call so the + // surface stays tool-pairing balanced; its text extracts to the tool-call + // placeholder (the one surviving line). + s.append('step/start', { turn: 1, step: 2 }) + s.append('assistant/message', { + turn: 1, step: 2, + content: [{ type: 'tool-call', id: CallId('z1'), name: 'bash', arguments: '{}' }], + }, { surfaceOp: 'append' }) + s.append('tool/call', { turn: 1, step: 2, callId: CallId('z1'), name: 'bash', arguments: '{}' }) + s.append('tool/result', { turn: 1, step: 2, callId: CallId('z1'), content: [], isError: false }, { surfaceOp: 'append' }) + s.append('step/end', { turn: 1, step: 2 }) s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const nodes = s.surface.nodes await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') - // Every message extracted to empty text — the conversation is empty. - expect(svc.summarizeCalls[0]!.text).toBe('') + // Every empty-content message (user text, empty reasoning, empty-content + // tool/result, empty context, empty steering) extracted to nothing and was + // skipped — the only surviving line is the assistant's tool-call (which a + // balanced surface requires to answer the tool/result). + expect(svc.summarizeCalls[0]!.text).toBe('Assistant: [tool-call: bash({})]') }) it('renders non-text blocks as type-tagged placeholders across all message kinds', async () => { @@ -1192,8 +1213,15 @@ describe('BasicCompactService edge cases', () => { 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 only an image block → '[image]' placeholder. - s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'image', url: 'https://x/z.png' }] }, { 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. + s.append('assistant/message', { + turn: 1, step: 1, + content: [ + { type: 'image', url: 'https://x/z.png' }, + { type: 'tool-call', id: CallId('e1'), name: 'bash', arguments: '{}' }, + ], + }, { surfaceOp: 'append' }) // tool/result with an image block → '[image]' 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' }) diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts new file mode 100644 index 0000000000..e12861a43a --- /dev/null +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import { isToolPairingBalanced } 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 AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import * as Invariants from '@deepseek-ai/dsh-invariants' +import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' +import type { SurfaceEvent } from '@deepseek-ai/dsh-session' + +/** + * CBR-001 regression: a compaction checkpoint that the REAL loop lands is a + * free surface boundary (it carries no tool-call/result pair), so it must be a + * valid region edge on BOTH sides. A surface-anchored balance check sees that; + * the abandoned log-position scan did not. + * + * The loop fires the compaction seam mid-flight, so the landed checkpoint + * `user/message{replace}` sits at a HIGH log seq positioned beside the current + * step even though its SURFACE position is the head. A log-position forward scan + * from the checkpoint reaches the step's own later `assistant/message` and + * wrongly reports the checkpoint as mid-step — refusing it as a region end. A + * SECOND compaction that re-summarizes just that head checkpoint (region end == + * checkpoint) therefore throws and is swallowed, so the surface never + * re-consolidates. + * + * This drives a real auto-compaction through the agent-loop and asserts the + * landed checkpoint balances on both sides AND that re-compacting it (end == + * checkpoint) succeeds. RED on the log-position predicates; GREEN once alignment + * is decided from surface tool-pairing balance. + */ + +const TOKENS_PER_BLOCK = 10 + +class ReproCompactService extends BasicCompactService { + override estimateContentTokens(blocks: readonly ContentBlock[]): number { + return blocks.length * TOKENS_PER_BLOCK + } + + override async summarize(): Promise { + return [{ type: 'text', text: 'CHECKPOINT SUMMARY' }] + } +} + +/** Each call emits one tool-call until exhausted, then a final text answer. */ +class StepwiseToolAdapter extends LlmAdapter { + calls = 0 + constructor(private toolSteps: number) { + super() + } + + async * stream(_options: GenerateOptions): AsyncIterable { + const n = this.calls + this.calls += 1 + if (n < this.toolSteps) { + const id = CallId(`c${n}`) + const args = `{"i":${n}}` + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: `step ${n}` } } + yield { type: 'block-start', index: 1, blockType: 'tool-call' } + yield { type: 'block-end', index: 1, block: { type: 'tool-call', id, name: 'work', arguments: args } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + return + } + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: 'all done' } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +async function harness(toolSteps: number): Promise<{ ctx: Context; compact: ReproCompactService }> { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(Invariants, {}) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps)) + ctx.tools.register(defineTool({ + name: 'work', + description: 'does work', + parameters: { i: { type: 'number' } }, + async execute() { + return [{ type: 'text', text: 'work result' }] + }, + })) + // Tiny window so a couple of tool steps cross the threshold and compaction + // fires within the runaway turn. Convergence invariant holds: + // summarizationMaxTokens(1) + retainTokens(20) = 21 <= floor(60*0.5) = 30. + const compact = new ReproCompactService(ctx, { + auto: true, + contextWindow: 60, + thresholdRatio: 0.5, + retainTokens: 20, + summarizationMaxTokens: 1, + }) + return { ctx, compact } +} + +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} + +describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => { + it('the head checkpoint the loop lands is a balanced cut on both sides', async () => { + const { ctx } = await harness(8) + try { + const agent = ctx.agentLoop.create(AgentId('repro'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'do a long multi-step task' }]) + await waitForIdle(ctx, agent) + + const events = [...agent.session.events] + // A compaction ran: at least one checkpoint landed on the surface. + const checkpoints = events.filter( + (e): e is SurfaceEvent => + e.type === 'user/message' + && typeof (e as SurfaceEvent).surfaceOp === 'object', + ) + expect(checkpoints.length).toBeGreaterThan(0) + + // The loop fired compaction mid-flight, so each landed checkpoint sits at a + // high log seq beside the step it landed in, even though its SURFACE + // position is the head of the range it shadowed. A checkpoint carries no + // tool-call/result pair (only summarized prose), so every checkpoint still + // on the surface must be a balanced cut on BOTH sides — the cut before it + // (region START) and the cut after it (region END). The abandoned + // log-position scan reported the END as mis-aligned because the forward log + // scan reached the neighbouring step's assistant/message. + const nodes = agent.session.surface.nodes + for (const cp of checkpoints) { + const node = nodes.find(n => n.seq === cp.seq) + if (!node) continue // shadowed by a later checkpoint — no longer an edge. + expect(isToolPairingBalanced(nodes, events, node.seq), + `checkpoint seq ${node.seq} must be a balanced region START`).toBe(true) + expect(isToolPairingBalanced(nodes, events, node.next), + `checkpoint seq ${node.seq} must be a balanced region END`).toBe(true) + } + } finally { + await ctx.fiber.dispose() + } + }) +}) diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 5e63169fa1..c84c147ca7 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -73,7 +73,8 @@ export abstract class CompactService extends Service { * Non-surface context injected downstream (into the request `messages` by a * later listener) is out of this accounting by construction. * - **Head-anchored, best-effort.** Auto-compaction consolidates from the - * surface HEAD up to a step-aligned cutoff, so a prior head checkpoint is + * surface HEAD up to a balanced tool-pairing cutoff, so a prior head + * checkpoint is * re-summarized into one fresh checkpoint (the surface holds at most one * auto-generated checkpoint, always at the head). It is best-effort over * CLOSED steps: when the only compactable content left is an un-splittable @@ -106,15 +107,15 @@ export abstract class CompactService extends Service { * summarizes their content and appends a replacement surface node. Used by the * (future) `/compact` tool and internally by {@link compactIfNeeded}. * - * The region MUST contain whole steps — `start` and `end` must each sit on a - * step boundary (the first / last surface node of a step) or on a node that - * belongs to no step (a pre-step user message, inter-step steering, or an - * injection context message). A boundary that falls INSIDE a step would split - * that step's `assistant/message` tool-calls from their `tool/result`s, leaving - * the rehydrated transcript with a dangling tool-call or an orphaned - * tool-result that every provider rejects. An `end` inside an open (unclosed) - * tail step is likewise invalid — its tool-calls have no results yet. - * `dsh-session` exports `isStepAlignedStart` / `isStepAlignedEnd` for this check. + * The region MUST NOT split a step's `assistant/message` tool-calls from their + * `tool/result`s, leaving the rehydrated transcript with a dangling tool-call + * or an orphaned tool-result that every provider rejects. A region is safe iff + * both its edges are balanced cuts on the surface: the cut before `start` and + * the cut after `end` each have no unanswered tool-call before them. A node + * that belongs to no step (a pre-step user message, inter-step steering, or an + * injection context message) is a balanced (free) boundary; an `end` inside an + * open (unclosed) tail step is invalid — its tool-calls have no results yet. + * `dsh-session` exports `isToolPairingBalanced` for this check. * * @param session - the session whose surface is mutated. * @param start - inclusive seq of the first surface node to compact. @@ -128,8 +129,8 @@ export abstract class CompactService extends Service { * valid surface nodes, if `start` is positioned after `end` on the surface * (the range is a surface-POSITION span, not a numeric seq interval — a * prior replace can leave the surface non-monotonic in seq order), or if - * either boundary is not step-aligned (would split a step's tool-call/result - * pair). + * either boundary is not a balanced tool-pairing cut (would split a step's + * tool-call/result pair). */ abstract compactRegion( session: Session, diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 4aeef18eec..f6b286cb28 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -12,6 +12,7 @@ import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-ll import { BlockAssembler, HarnessError } from '@deepseek-ai/dsh-llm' 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' import type {} from '@deepseek-ai/dsh-tools' import type { ReactLoopAgent } from './agent.ts' @@ -147,9 +148,9 @@ export interface LoopHandle { * drain queued → 'turn/start' → session('user/message'…) → emit agent/turn-start * STEP loop: * drain steering → session('steering/message') ⟵ catches late steering - * session('step/start'); emit agent/step-start ⟵ append before emit (the event-sourcing RFC) * assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble - * await ctx.parallel('agent/pre-request') ⟵ surface mutation (compaction) BEFORE derive + * 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) * 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) @@ -387,20 +388,54 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, // (or turn-start listeners on the first step) joins before the request. drainSteering(ctx, agent, turn) + // Assemble the system prompt for this step. Done HERE (before step/start) + // because the pre-step seam needs it: compaction measures token pressure + // against the system prompt (it counts toward the budget) and a listener + // also receives the model to summarize with. runStep reuses this same + // assembly for the request, so the prompt is assembled once per step. + const assembly = await ctx.systemPrompt.assemble() + const system = [renderPrompt(assembly), agent.options.systemPrompt ?? ''] + .filter(text => text.length > 0) + .join('\n\n') + + // The step's AbortController exists BEFORE the pre-step seam so a cancel() + // during the seam aborts any in-flight work a listener started (e.g. a + // compaction summarization call). Cleared on every exit path below. + const abort = new AbortController() + handle.setAbort(abort) + + // Cancel landing before the seam: a synchronous `agent/turn-start` listener + // (or the previous step's continuation listeners) can have called + // `cancel()`. Drop the about-to-start step WITHOUT running the seam — no + // step is open yet, so end the turn `aborted` directly. + if (handle.isCancelled()) { + handle.setAbort(undefined) + reason = { kind: 'aborted', reason: handle.cancelReason() } + break + } + + // Pre-step surface-mutation checkpoint (compaction), fired OUTSIDE the + // step: after `turn/start` (and the prior step's close) but before + // `step/start`, so a compaction's log-only `compact/*` records and its + // replacement node land cleanly outside any step (honest structure that + // crash-safety relies on — a dangling `compact/start` sits before the + // synthetic `turn/end` repair appends). Serial (awaited, in order, no + // veto): each listener completes its surface mutation before the next, so + // concurrent listeners cannot interleave their `session.append`s. A + // throwing listener escapes to the outer catch, which closes the (not-yet- + // open) step as a no-op and ends the turn via failTurn — a broken + // pre-step plugin ends the turn, not the loop. + await ctx.serial('agent/pre-step', agent, turn, step, system, agent.options.model ?? '', abort.signal) + session.append('step/start', { turn, step }) stepOpen = true ctx.emit('agent/step-start', agent, turn, step) - const abort = new AbortController() - handle.setAbort(abort) - - // Cancel landing in the step-start window: a synchronous `agent/turn-start` - // or `agent/step-start` listener (both fire before this point) can have - // called `cancel()`, and `runStep` would otherwise run a full extra step - // with no AbortController having observed it. Check the marker AFTER - // setAbort (so the next-iteration drain sees a clean controller) and before - // `runStep`: drop the step, end the turn `aborted`. closeStep balances the - // already-appended step/start. + // Cancel landing in the seam / step-start window: a `cancel()` during the + // pre-step seam (it aborted `abort.signal` above) OR a synchronous + // `agent/step-start` listener that cancels. Check AFTER setAbort/step-start + // and before `runStep`: drop the step, end the turn `aborted`. closeStep + // balances the already-appended step/start. if (handle.isCancelled()) { handle.setAbort(undefined) reason = { kind: 'aborted', reason: handle.cancelReason() } @@ -410,7 +445,7 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error } try { - stepOutcome = await runStep(ctx, agent, turn, step, abort.signal) + stepOutcome = await runStep(ctx, agent, turn, step, assembly, system, abort.signal) } catch (error: unknown) { stepOutcome = { error: toError(error) } } finally { @@ -550,29 +585,22 @@ function drainSteering(ctx: Context, agent: ReactLoopAgent, turn: number): boole return messages.length > 0 } -/** One step: assemble request → stream model → record → execute tools. */ +/** One step: derive request from the (already pre-step-mutated) surface → + * stream model → record → execute tools. The caller assembles the system prompt + * and fires the `agent/pre-step` seam BEFORE opening the step, then passes the + * resulting `assembly`/`system` here, so the surface this step derives from + * already reflects any compaction. */ async function runStep( ctx: Context, agent: ReactLoopAgent, turn: number, step: number, + assembly: PromptAssembly, + system: string, signal: AbortSignal, ): Promise<{ hadToolCalls: boolean; finish: FinishReason }> { const { session, options } = agent - // --- Request assembly --- - const assembly = await ctx.systemPrompt.assemble() - const system = [renderPrompt(assembly), options.systemPrompt ?? ''] - .filter(text => text.length > 0) - .join('\n\n') - - // Surface-mutation checkpoint BEFORE deriving history: compaction shadows an - // older range with a summary node here, and the single derive below reflects - // it. Awaited (no veto) — a listener mutates the surface as a side effect. - // `model` is resolved to '' when unset; a compaction listener that needs a - // model falls back to its own config. - await ctx.parallel('agent/pre-request', agent, turn, step, system, options.model ?? '', signal) - let request: GenerateOptions = { model: options.model ?? '', messages: session.deriveMessages(), diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 9cdaa1973b..d6394acd54 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -194,6 +194,36 @@ 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 () => { + 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. + 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') + }) + + const reasons: TurnEndReason[] = [] + ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + + send(agent, 'go') + await waitForIdle(ctx, agent) + dispose() + + // No step streamed, the turn ended aborted with the caller's reason, and the + // log is balanced (the open step was closed by the cancel branch). + expect(streamed).toBe(false) + expect(reasons).toEqual([{ kind: 'aborted', reason: 'from step-start' }]) + const types = agent.session.events.map(e => e.type) + expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length) + }) + it('cancel during the continuation window ends the turn aborted and runs no further step', async () => { // A continuation-waterfall listener cancels DURING the continuation decision // (the finished step's AbortController is already cleared), and votes to diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index f74e160936..2c6f9e06e8 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -320,11 +320,11 @@ describe('agent loop', () => { expect(adapter.requests[0]!.model).toBe('other-model') }) - it('agent/pre-request fires once per step before the request is derived', async () => { + it('agent/pre-step fires once per step before the step is opened', async () => { // Two steps (a tool call, then a final text turn) → two model calls → two - // pre-request fires, each carrying the assembled system + model, BEFORE the - // request messages are derived (the request the adapter sees reflects any - // surface state at fire time). + // pre-step fires, each carrying the assembled system + model, BEFORE the + // step is opened and its request is derived (the request the adapter sees + // reflects any surface state at fire time). const adapter = new MockAdapter([ toolCallResponse('c1', 'echo', {}, 'calling echo'), textResponse('done'), @@ -337,7 +337,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const fires: { turn: number; step: number; model: string }[] = [] - ctx.on('agent/pre-request', (subject, turn, step, _system, model) => { + ctx.on('agent/pre-step', (subject, turn, step, _system, model) => { if (subject === agent) fires.push({ turn, step, model }) }) @@ -351,32 +351,77 @@ describe('agent loop', () => { ]) }) - it('a surface mutation in agent/pre-request is reflected in the derived request (single derive)', async () => { - // pre-request fires BEFORE deriveMessages(), so a listener that appends a - // surface node there sees it land in the SAME step's request — proving the - // loop derives once, after the checkpoint, with no stale pre-derive. + it('agent/pre-step fires BEFORE the step it precedes opens (events land outside the step)', async () => { + // A listener appending a surface node in pre-step lands it BEFORE step/start + // in the log — proving the seam fires outside the step. The node is still in + // the derived request for that step (derive happens after step/start). const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let injected = false - ctx.on('agent/pre-request', (subject, turn) => { + ctx.on('agent/pre-step', (subject) => { if (subject === agent && !injected) { injected = true subject.session.append('context/message', { - content: [{ type: 'text', text: 'INJECTED-IN-PRE-REQUEST' }], + content: [{ type: 'text', text: 'INJECTED-IN-PRE-STEP' }], source: { kind: 'plugin', plugin: 'test' }, }, { surfaceOp: 'append' }) - void turn } }) send(agent, 'go') await waitForIdle(ctx, agent) - // The adapter's request includes the node injected during pre-request. + // The adapter's request includes the node injected during pre-step (derive + // reflects it). const text = JSON.stringify(adapter.requests[0]!.messages) - expect(text).toContain('INJECTED-IN-PRE-REQUEST') + expect(text).toContain('INJECTED-IN-PRE-STEP') + + // And the injected event sits BEFORE the first step/start in the log — + // the seam fired outside the step. + const events = agent.session.events + const injectedSeq = events.find(e => e.type === 'context/message')!.seq + const firstStepStartSeq = events.find(e => e.type === 'step/start')!.seq + expect(injectedSeq).toBeLessThan(firstStepStartSeq) + }) + + it('a throwing agent/pre-step listener ends the turn (error), not the loop', async () => { + // The seam fires before step/start, so a throw escapes to runTurn's outer + // catch: the not-yet-open step closes as a no-op, the failure surfaces via + // agent/error, and the turn ends `error` (recorded on the durable turn/end). + // The loop survives and a follow-up prompt still runs. + const adapter = new MockAdapter([textResponse('second turn ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + let throwOnce = true + ctx.on('agent/pre-step', () => { + if (throwOnce) { throwOnce = false; throw new Error('boom in pre-step') } + }) + + const errors: Error[] = [] + ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) + + send(agent, 'first') + await waitForIdle(ctx, agent) + // The first turn failed at step 1 (no model call happened), surfaced via + // agent/error, with the durable failure on turn/end.reason. + expect(errors).toHaveLength(1) + expect(errors[0]!.message).toContain('boom in pre-step') + expect(adapter.requests.length).toBe(0) + const firstTurnEnd = agent.session.events.find(e => e.type === 'turn/end') + expect(firstTurnEnd?.type === 'turn/end' && firstTurnEnd.data.reason).toMatchObject({ kind: 'error', step: 1 }) + // The step opened-and-closed count stays balanced even though it never ran. + const types = agent.session.events.map(e => e.type) + expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length) + + // The loop survived: a second prompt runs a normal completed turn. + send(agent, 'second') + await waitForIdle(ctx, agent) + expect(adapter.requests.length).toBe(1) + const lastTurnEnd = agent.session.events.findLast(e => e.type === 'turn/end') + expect(lastTurnEnd?.type === 'turn/end' && lastTurnEnd.data.reason).toEqual({ kind: 'completed' }) }) it('cancel() mid-stream ends the turn with reason aborted', async () => { diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 407201ea5d..5bb603f1f6 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -181,30 +181,37 @@ declare module 'cordis' { // ---- interception seams (waterfall) ---- /** - * Awaited surface-mutation checkpoint, fired BEFORE the step's message - * history is derived (and thus before {@link agent/request}). The loop - * awaits `ctx.parallel('agent/pre-request', …)` after assembling the system - * prompt but before `session.deriveMessages()`, then derives ONCE from - * whatever the surface now holds. This is where compaction belongs: it - * mutates the session surface in place (shadowing an older range with a - * summary node), and the single subsequent derive reflects the mutation — - * so there is no double-derive and no listener can see (or be expected to - * act on) an assembled `messages` array that does not exist yet. + * Awaited pre-step surface-mutation checkpoint, fired once per step AFTER + * `turn/start` (and after the prior step closed) but BEFORE this step's + * `step/start` — so anything a listener appends lands OUTSIDE the step, + * between `turn/start`/`step/end` and the upcoming `step/start`. `step` is + * the number of the step about to start. The loop awaits + * `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then + * opens the step and derives the request history ONCE from whatever the + * surface now holds. This is where compaction belongs: it mutates the session + * surface in place (shadowing an older range with a summary node) with its + * log-only `compact/*` records cleanly outside any step, and the single + * subsequent derive reflects the mutation — so there is no double-derive and + * no listener can see (or be expected to act on) an assembled `messages` + * array that does not exist yet. * - * Awaited (parallel), not a waterfall: a listener mutates the surface as a - * side effect; there is nothing to transform or veto, but the loop must wait - * for the mutation to complete before deriving. `system`/`model` are the - * assembled values a listener needs to measure pressure (system counts - * toward the budget) and to summarize (the model). `signal` cancels any - * in-flight work a listener starts (e.g. a summarization model call). - * @mode parallel + * Serial (awaited, in registration order, no veto), not a waterfall: a + * listener mutates the surface as a side effect; there is nothing to + * transform or veto, but the loop must wait for the mutation to complete + * before opening the step and deriving, and serial isolates listeners from + * each other (one finishes its surface append before the next runs). + * `system`/`model` are the assembled values a listener needs to measure + * pressure (system counts toward the budget) and to summarize (the model). + * `signal` cancels any in-flight work a listener starts (e.g. a summarization + * model call). + * @mode serial */ - 'agent/pre-request'(agent: Agent, turn: number, step: number, system: string, model: string, signal: AbortSignal): Promise | void + 'agent/pre-step'(agent: Agent, turn: number, step: number, system: string, model: string, signal: AbortSignal): Promise | void /** * 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-request} + * must precede history derivation (compaction), use {@link agent/pre-step} * instead — by the time this fires, `options.messages` is already derived. * @mode waterfall */ diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 0fb44f3299..ad6fd59dd3 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -19,7 +19,7 @@ export { isJsonValue } from './json.ts' export { interruptedTurnClosers } from './repair.ts' export type { SurfaceNode } from './surface.ts' export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' -export { isStepAlignedStart, isStepAlignedEnd } from './step-boundary.ts' +export { isToolPairingBalanced } from './tool-pairing.ts' declare module 'cordis' { interface Context { diff --git a/packages/core/session/src/step-boundary.ts b/packages/core/session/src/step-boundary.ts deleted file mode 100644 index e8ed91d74c..0000000000 --- a/packages/core/session/src/step-boundary.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Step-boundary predicates over a session log: is a given surface node a SAFE - * place to start or end a region that will be collapsed (e.g. by compaction)? - * - * The invariant a consumer needs: a collapsed region must NOT partially overlap - * a step. A step's surface nodes form a contiguous run, and a region must - * contain either ALL of a step's nodes or NONE of them — otherwise it can split - * an `assistant/message`'s `tool-call` blocks from their `tool/result`s, leaving - * the rehydrated transcript with a dangling tool-call or an orphaned tool-result - * (which every provider rejects). This is the compaction-time mirror of the - * crash-recovery imbalance that {@link interruptedTurnClosers} repairs on load. - * - * Nodes that belong to NO step — a pre-step `user/message` (drained before the - * first `step/start`), inter-step `steering/message`, or an injection - * `context/message` (wrapped in a bare `turn/start → context/message → turn/end` - * with no step) — carry no tool pairing and are free boundaries on both sides. - * - * The scans classify each neighbor event into three buckets: a turn/step - * BOUNDARY marker (the region edge is clean), a SURFACE node (the region edge - * is mid-step), or NOISE to skip (`assistant/chunk`, the log-only `compact/*` - * records, and any future non-surface event). "Surface node" is decided by the - * shared {@link isSurfaceEvent} guard so the two notions can't drift. - * - * @module @deepseek-ai/dsh-session/step-boundary - */ - -import type { SessionEvent } from './types.ts' -import { isSurfaceEvent } from './surface.ts' - -/** Turn/step boundary marker types — the walls the scans stop on. */ -const BOUNDARY_TYPES = new Set(['turn/start', 'turn/end', 'step/start', 'step/end']) - -/** - * Whether the surface node at `seq` is a SAFE START for a collapsed region — - * i.e. it is the first surface node of its step, or it belongs to no step at - * all (a free inter-step / pre-step / injection node). - * - * Scans BACKWARD from `seq`, skipping noise, and stops at the first significant - * event: a turn/step boundary marker ⇒ aligned (nothing of `seq`'s step lies - * before it), a surface node ⇒ NOT aligned (a predecessor surface node sits in - * the same step, so starting here would orphan it), start-of-log ⇒ aligned. - * - * No open-step check is needed on the start side: an open (unclosed) step can - * only ever be the LAST turn's last step, never before a valid region start. - */ -export function isStepAlignedStart(events: readonly SessionEvent[], seq: number): boolean { - for (let i = seq - 1; i >= 0; i--) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const event = events[i]! - if (BOUNDARY_TYPES.has(event.type)) return true - if (isSurfaceEvent(event)) return false - } - return true -} - -/** - * Whether the surface node at `seq` is a SAFE END for a collapsed region — - * i.e. it is the last surface node of a CLOSED step, or it belongs to no step - * at all. - * - * Scans FORWARD from `seq`, skipping noise, and stops at the first significant - * event: a turn/step boundary marker ⇒ aligned (the step/turn closes after - * `seq`, or a new one begins because `seq` was inter-step), a surface node ⇒ - * NOT aligned (a later surface node sits in the same step). Reaching - * end-of-log is aligned ONLY when `seq` is not inside an OPEN step — an open - * trailing step's `tool-call`s have no `tool/result`s yet, so collapsing it - * would defer the orphan to when those results land later. {@link isInOpenStep} - * decides that via a backward scan. - */ -export function isStepAlignedEnd(events: readonly SessionEvent[], seq: number): boolean { - for (let i = seq + 1; i < events.length; i++) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const event = events[i]! - if (BOUNDARY_TYPES.has(event.type)) return true - if (isSurfaceEvent(event)) return false - } - // End of log: aligned only if `seq` is not inside a still-open step. - return !isInOpenStep(events, seq) -} - -/** - * Whether `seq` sits inside an OPEN step — a `step/start` with no later - * `step/end`. Only meaningful at the tail (the EOL branch of - * {@link isStepAlignedEnd}): scans BACKWARD for the nearest turn/step boundary. - * The nearest one being `step/start` means a step opened before `seq` and never - * closed (no `step/end` lies after `seq`, or the forward scan would not have - * reached EOL) — so `seq` is mid-open-step. Any other nearest boundary (or none) - * means `seq` is inter-step / pre-step. - */ -function isInOpenStep(events: readonly SessionEvent[], seq: number): boolean { - for (let i = seq - 1; i >= 0; i--) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const type = events[i]!.type - if (BOUNDARY_TYPES.has(type)) return type === 'step/start' - } - return false -} diff --git a/packages/core/session/src/tool-pairing.ts b/packages/core/session/src/tool-pairing.ts new file mode 100644 index 0000000000..638daaf654 --- /dev/null +++ b/packages/core/session/src/tool-pairing.ts @@ -0,0 +1,100 @@ +/** + * Tool-pairing balance over a session's SURFACE: is a given cut point in the + * surface a safe edge for a collapsed region (e.g. compaction)? + * + * The invariant a consumer needs: a collapsed region must never separate an + * `assistant/message`'s `tool-call` blocks from their answering `tool/result`s + * — that would leave the rehydrated transcript with a dangling tool-call or an + * orphaned tool-result, which every provider rejects. (This is the + * compaction-time mirror of the crash-recovery imbalance that + * {@link interruptedTurnClosers} repairs on load.) Steps were once used as a + * proxy for this bracketing, but a compaction REWRITES the surface — it lands a + * replacement node at a high log seq whose SURFACE position is the head — so a + * scan over the LOG's `step/*` markers mis-reads such a node's neighbours. The + * pairing the invariant actually protects lives in the surface nodes' own + * content (a `tool-call` block's id, a `tool/result`'s `callId`), which travels + * with the node through any reshaping, so alignment is decided over the surface + * directly. + * + * A **cut** is a gap between two adjacent surface nodes (named by the node it + * sits immediately before), or the after-tail gap (`null`). Walking the surface + * head→tail and assigning each node a delta — `+1` per `tool-call` block on an + * `assistant/message`, `-1` per `tool/result`, `0` otherwise — the depth at a + * cut is the number of still-unanswered tool calls before it. A cut is + * **balanced** when that depth is `0`. A region `[start..end]` is safe to + * collapse iff BOTH its edges are balanced cuts: the cut before `start` and the + * cut after `end`. Nodes that belong to no step (a pre-step `user/message`, an + * inter-step `steering/message`, an injection `context/message`) carry no + * pairing, contribute `0`, and so are free boundaries — exactly as before, but + * now as a consequence of the balance rather than a special case. An open + * trailing step (an assistant whose `tool/result`s have not landed yet) keeps + * the depth positive through the tail, so no cut inside it is balanced — the + * old explicit open-step check falls out of the same counter. + * + * @module @deepseek-ai/dsh-session/tool-pairing + */ + +import type { SessionEvent } from './types.ts' +import type { SurfaceNode } from './surface.ts' + +/** + * The tool-pairing delta of a surface node: how it shifts the count of + * unanswered tool calls. An `assistant/message` opens one bracket per + * `tool-call` block; a `tool/result` closes one; every other surface node + * (`user/message`, `context/message`, `steering/message`, a usage-only + * `assistant/message` with no tool-call blocks) is pairing-neutral. + */ +function nodeDelta(event: SessionEvent): number { + switch (event.type) { + case 'assistant/message': + return event.data.content.filter(block => block.type === 'tool-call').length + case 'tool/result': + return -1 + // Non-pairing surface nodes and every non-surface event contribute nothing. + default: + return 0 + } +} + +/** + * Whether the surface prefix ending at the given cut has BALANCED tool-call / + * tool-result brackets — i.e. every `tool-call` block on the surface before the + * cut has its answering `tool/result` before the cut too, so the cut is a safe + * edge for a collapsed region (it cannot split an assistant↔result pair). + * + * `nodes` is the surface linked list in head→tail order (e.g. + * `session.surface.nodes`); `events` is the session log, used to look each + * node's event up by `seq`. `beforeSeq` names the cut by the surface node it + * sits immediately before; the after-tail cut (the whole surface) is `null`, + * as is any `beforeSeq` not present on the surface. + * + * A region `[start..end]` is collapsible iff both edges are balanced cuts: call + * `isToolPairingBalanced(nodes, events, start)` for the cut before `start`, and + * `isToolPairingBalanced(nodes, events, after)` — where `after` is `end`'s + * surface successor (`SurfaceNode.next`), or `null` when `end` is the tail — + * for the cut after `end`. + * + * @throws if the surface prefix drives the unanswered-call depth negative — a + * `tool/result` with no preceding open `tool-call` on the surface. That is a + * corrupt surface (a structural invariant violation), surfaced loudly here + * rather than silently mis-classifying a boundary. + */ +export function isToolPairingBalanced( + nodes: readonly SurfaceNode[], + events: readonly SessionEvent[], + beforeSeq: number | null, +): boolean { + let depth = 0 + for (const node of nodes) { + if (node.seq === beforeSeq) return depth === 0 + // node.seq is a surface-node seq, always a valid log index by construction. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + depth += nodeDelta(events[node.seq]!) + if (depth < 0) { + throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`) + } + } + // Reached the after-tail cut (beforeSeq === null, or a seq not on the + // surface): the whole-surface prefix is balanced iff depth returned to 0. + return depth === 0 +} diff --git a/packages/core/session/tests/step-boundary.spec.ts b/packages/core/session/tests/step-boundary.spec.ts deleted file mode 100644 index a24a6f7596..0000000000 --- a/packages/core/session/tests/step-boundary.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { CallId } from '@deepseek-ai/dsh-llm' -import { isStepAlignedStart, isStepAlignedEnd } from '../src/index.ts' -import type { SessionEvent } from '../src/index.ts' - -/** - * Unit coverage for the step-alignment predicates. They decide whether a - * surface node is a safe START / END for a collapsed region (compaction): a - * region must contain whole steps, never split an `assistant/message`'s - * tool-calls from their `tool/result`s. Nodes belonging to no step (pre-step - * user message, inter-step steering, injection context) are free boundaries. - * - * Builders mirror the agent loop's real append order so the fixtures are - * representative: queued user messages land BEFORE `step/start`; within a step - * the order is `assistant/message` then `tool/result`(s); injection turns are a - * bare `turn/start → context/message → turn/end` with no step. - */ - -const SURFACE = { surfaceOp: 'append' as const } - -/** A closed turn with one closed step holding an assistant + its tool result. */ -function toolStepLog(): SessionEvent[] { - return [ - { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'user/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, ...SURFACE }, - { type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, - { type: 'assistant/message', seq: 3, time: 3, data: { turn: 1, step: 1, content: [ - { type: 'text', text: 'calling' }, - { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }, - ] }, ...SURFACE }, - { type: 'tool/call', seq: 4, time: 4, data: { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{}' } }, - { type: 'tool/result', seq: 5, time: 5, data: { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, ...SURFACE }, - { type: 'step/end', seq: 6, time: 6, data: { turn: 1, step: 1 } }, - { type: 'turn/end', seq: 7, time: 7, data: { turn: 1, reason: { kind: 'completed' } } }, - ] -} - -describe('isStepAlignedStart', () => { - it('is true for a pre-step user/message (belongs to no step)', () => { - // seq 1 user/message sits before step/start at seq 2 → free boundary. - expect(isStepAlignedStart(toolStepLog(), 1)).toBe(true) - }) - - it('is true for the first surface node of a step (the assistant/message)', () => { - // Backward from seq 3 the first significant event is step/start → aligned. - expect(isStepAlignedStart(toolStepLog(), 3)).toBe(true) - }) - - it('is false for a tool/result whose assistant/message precedes it in the same step', () => { - // Backward from seq 5 the first significant event is the assistant/message - // surface node (seq 3) → starting here would orphan that assistant's call. - expect(isStepAlignedStart(toolStepLog(), 5)).toBe(false) - }) - - it('is true at start-of-log (nothing precedes)', () => { - const log: SessionEvent[] = [ - { type: 'user/message', seq: 0, time: 0, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, ...SURFACE }, - ] - expect(isStepAlignedStart(log, 0)).toBe(true) - }) - - it('skips noise (assistant/chunk, compact/* records) when scanning back', () => { - // A compacted region landed compact/* log-only records between the prior - // step boundary and this surface node; they must be skipped, not treated as - // walls. Backward from seq 4 skips compact/end, compact/summary, compact/start - // and stops at step/start (seq 0) → aligned. - const log: SessionEvent[] = [ - { type: 'step/start', seq: 0, time: 0, data: { turn: 1, step: 1 } }, - { type: 'compact/start', seq: 1, time: 1, data: { turn: 1 } } as unknown as SessionEvent, - { type: 'compact/summary', seq: 2, time: 2, data: { summary: [], shadowedRange: { start: 0, end: 0 }, shadowedSeqs: [], shadowedTokenCount: 0 } } as unknown as SessionEvent, - { type: 'compact/end', seq: 3, time: 3, data: { turn: 1 } } as unknown as SessionEvent, - { type: 'assistant/message', seq: 4, time: 4, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, ...SURFACE }, - ] - expect(isStepAlignedStart(log, 4)).toBe(true) - }) -}) - -describe('isStepAlignedEnd', () => { - it('is true for the last surface node of a closed step (the tool/result)', () => { - // Forward from seq 5 the first significant event is step/end → aligned. - expect(isStepAlignedEnd(toolStepLog(), 5)).toBe(true) - }) - - it('is false for an assistant/message with a later tool/result in the same step', () => { - // Forward from seq 3 the first significant event is the tool/result surface - // node (seq 5) → ending here would strand that result. - expect(isStepAlignedEnd(toolStepLog(), 3)).toBe(false) - }) - - it('is true for a pre-step user/message (next significant event is step/start)', () => { - expect(isStepAlignedEnd(toolStepLog(), 1)).toBe(true) - }) - - it('is false at EOL when the node is inside an open (unclosed) step', () => { - // step/start then an assistant tool-call, but no step/end / tool/result yet - // (mid-flight). Ending the region on seq 3 would summarize away a tool-call - // whose result lands later → orphan. EOL + open step ⇒ not aligned. - const log: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, - { type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [ - { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }, - ] }, ...SURFACE }, - ] - expect(isStepAlignedEnd(log, 2)).toBe(false) - }) - - it('is false at EOL when the node is inside an open step, skipping noise on the back-scan', () => { - // The open-step back-scan must skip non-boundary events (here an - // assistant/chunk) before it reaches step/start. Without the skip it would - // mis-read the chunk as the nearest "boundary" and never confirm the open step. - const log: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, - { type: 'assistant/chunk', seq: 2, time: 2, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } } }, - { type: 'assistant/message', seq: 3, time: 3, data: { turn: 1, step: 1, content: [ - { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }, - ] }, ...SURFACE }, - ] - expect(isStepAlignedEnd(log, 3)).toBe(false) - }) - - it('is true at EOL when the node is a trailing inter-step node (step already closed)', () => { - // A steering message appended after step/end, at the tail. Backward the - // nearest boundary is step/end → not in an open step → aligned. - const log: SessionEvent[] = [ - { type: 'step/start', seq: 0, time: 0, data: { turn: 1, step: 1 } }, - { type: 'assistant/message', seq: 1, time: 1, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, ...SURFACE }, - { type: 'step/end', seq: 2, time: 2, data: { turn: 1, step: 1 } }, - { type: 'steering/message', seq: 3, time: 3, data: { turn: 1, content: [{ type: 'text', text: 's' }], source: { kind: 'user' } }, ...SURFACE }, - ] - expect(isStepAlignedEnd(log, 3)).toBe(true) - }) - - it('is true at EOL when no step ever opened (start-of-log fallback in open-step check)', () => { - // A lone surface node, no turn/step markers at all → not in an open step. - const log: SessionEvent[] = [ - { type: 'user/message', seq: 0, time: 0, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, ...SURFACE }, - ] - expect(isStepAlignedEnd(log, 0)).toBe(true) - }) - - it('skips noise (assistant/chunk) when scanning forward', () => { - // assistant/chunk events precede the assistant/message in a real step; the - // forward scan from an inter-step node must skip them and stop on step/start. - const log: SessionEvent[] = [ - { type: 'user/message', seq: 0, time: 0, data: { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, ...SURFACE }, - { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, - { type: 'assistant/chunk', seq: 2, time: 2, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } } }, - ] - // Forward from seq 0 hits step/start at seq 1 → aligned (noise after is moot). - expect(isStepAlignedEnd(log, 0)).toBe(true) - }) -}) - -describe('step-alignment on an injection turn (no step)', () => { - // An idle inject() wraps a context/message in a bare turn/start → context/message - // → turn/end with NO step/start. The context node is a free boundary both ways. - const injectionLog = (): SessionEvent[] => [ - { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } } }, - { type: 'context/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, ...SURFACE }, - { type: 'turn/end', seq: 2, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, - ] - - it('start: aligned (backward hits turn/start)', () => { - expect(isStepAlignedStart(injectionLog(), 1)).toBe(true) - }) - - it('end: aligned (forward hits turn/end)', () => { - expect(isStepAlignedEnd(injectionLog(), 1)).toBe(true) - }) -}) diff --git a/packages/core/session/tests/tool-pairing.spec.ts b/packages/core/session/tests/tool-pairing.spec.ts new file mode 100644 index 0000000000..307b0d8658 --- /dev/null +++ b/packages/core/session/tests/tool-pairing.spec.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from 'vitest' +import { CallId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId, isToolPairingBalanced } from '../src/index.ts' +import type { SessionEvent, SurfaceNode } from '../src/index.ts' + +/** + * Unit coverage for the tool-pairing balance check. It decides whether a CUT in + * the surface (a gap before a given surface node, or the after-tail gap) is a + * safe edge for a collapsed region (compaction): a region must never split an + * `assistant/message`'s tool-calls from their `tool/result`s. A cut is balanced + * when no unanswered tool-call sits before it on the surface. Nodes belonging to + * no step (pre-step user message, inter-step steering, injection context) are + * pairing-neutral, so their cuts are free boundaries. + * + * The fixtures are built through a real {@link Session} so the surface linked + * list is derived exactly as production does — including the non-monotonic + * surface a `replace` op leaves (a compaction checkpoint at a high log seq + * sitting at the surface head), which is the case the abandoned log-position + * scan mis-classified. + * + * Builders mirror the agent loop's real append order: queued user messages land + * BEFORE `step/start`; within a step the order is `assistant/message` then + * `tool/result`(s); injection turns are a bare `turn/start → context/message → + * turn/end` with no step. + */ + +const SURFACE = { surfaceOp: 'append' as const } + +/** Surface nodes + log for a session, the two args the balance check takes. */ +function surfaceOf(session: Session): { nodes: readonly SurfaceNode[]; events: readonly SessionEvent[] } { + return { nodes: session.surface.nodes, events: session.events } +} + +/** The cut BEFORE the surface node at `seq` is balanced (safe region start). */ +function startBalanced(session: Session, seq: number): boolean { + const { nodes, events } = surfaceOf(session) + return isToolPairingBalanced(nodes, events, seq) +} + +/** The cut AFTER the surface node at `seq` is balanced (safe region end). */ +function endBalanced(session: Session, seq: number): boolean { + const { nodes, events } = surfaceOf(session) + const node = nodes.find(n => n.seq === seq) + if (!node) throw new Error(`seq ${seq} is not a surface node`) + return isToolPairingBalanced(nodes, events, node.next) +} + +/** Surface seq of the nth (0-based) event of a given type. */ +function seqOf(s: Session, type: SessionEvent['type'], nth = 0): number { + return s.events.filter(e => e.type === type)[nth]!.seq +} + +/** A closed turn with one closed step holding an assistant + its tool result. */ +function toolStepSession(): Session { + const s = new Session(SessionId('tool-step')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, SURFACE) + s.append('step/start', { turn: 1, step: 1 }) + s.append('assistant/message', { + turn: 1, step: 1, + content: [ + { type: 'text', text: 'calling' }, + { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }, + ], + }, SURFACE) + s.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{}' }) + s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE) + s.append('step/end', { turn: 1, step: 1 }) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return s +} + +describe('isToolPairingBalanced — region START (cut before a node)', () => { + it('is true for a pre-step user/message (belongs to no step)', () => { + const s = toolStepSession() + expect(startBalanced(s, seqOf(s, 'user/message'))).toBe(true) + }) + + it('is true for the first surface node of a step (the assistant/message)', () => { + // The cut before the assistant is balanced — nothing unanswered precedes it. + const s = toolStepSession() + expect(startBalanced(s, seqOf(s, 'assistant/message'))).toBe(true) + }) + + it('is false for a tool/result whose assistant/message precedes it in the same step', () => { + // The cut before the tool/result has one unanswered tool-call (the + // assistant's) → starting the region here would orphan that call. + const s = toolStepSession() + expect(startBalanced(s, seqOf(s, 'tool/result'))).toBe(false) + }) + + it('is true at the surface head (nothing precedes)', () => { + const s = new Session(SessionId('lone')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, SURFACE) + expect(startBalanced(s, seqOf(s, 'user/message'))).toBe(true) + }) +}) + +describe('isToolPairingBalanced — region END (cut after a node)', () => { + it('is true for the last surface node of a closed step (the tool/result)', () => { + // After the tool/result the assistant's single call is answered → balanced. + const s = toolStepSession() + expect(endBalanced(s, seqOf(s, 'tool/result'))).toBe(true) + }) + + it('is false for an assistant/message with a later tool/result in the same step', () => { + // After the assistant its tool-call is still unanswered → ending here strands + // the result. + const s = toolStepSession() + expect(endBalanced(s, seqOf(s, 'assistant/message'))).toBe(false) + }) + + it('is true for a pre-step user/message', () => { + const s = toolStepSession() + expect(endBalanced(s, seqOf(s, 'user/message'))).toBe(true) + }) + + it('is false at the tail when the node is inside an open (unclosed) step', () => { + // step/start then an assistant tool-call, but no tool/result yet (mid-flight). + // The after-tail cut still has one unanswered call → not balanced. + const s = new Session(SessionId('open-step')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('assistant/message', { + turn: 1, step: 1, + content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], + }, SURFACE) + expect(endBalanced(s, seqOf(s, 'assistant/message'))).toBe(false) + }) + + it('is true at the tail when the node is a trailing inter-step node (step already closed)', () => { + // A steering message appended after step/end, at the tail. The prior step's + // pair is balanced and steering is neutral → the after-tail cut is balanced. + const s = new Session(SessionId('trailing-steer')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, SURFACE) + s.append('step/end', { turn: 1, step: 1 }) + s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 's' }], source: { kind: 'user' } }, SURFACE) + expect(endBalanced(s, seqOf(s, 'steering/message'))).toBe(true) + }) + + it('is true at the tail when no step ever opened', () => { + const s = new Session(SessionId('no-step')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, SURFACE) + expect(endBalanced(s, seqOf(s, 'user/message'))).toBe(true) + }) +}) + +describe('isToolPairingBalanced — multiple tool calls in one assistant message', () => { + // An assistant message with two tool-calls needs BOTH results before the cut + // after it is balanced — depth +2, then -1, -1. + function twoCallStep(): Session { + const s = new Session(SessionId('two-call')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('assistant/message', { + turn: 1, step: 1, + content: [ + { type: 'tool-call', id: CallId('c1'), name: 'a', arguments: '{}' }, + { type: 'tool-call', id: CallId('c2'), name: 'b', arguments: '{}' }, + ], + }, SURFACE) + s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: '1' }], isError: false }, SURFACE) + s.append('tool/result', { turn: 1, step: 1, callId: CallId('c2'), content: [{ type: 'text', text: '2' }], isError: false }, SURFACE) + s.append('step/end', { turn: 1, step: 1 }) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return s + } + + it('is unbalanced after the first of two results (one call still open)', () => { + const s = twoCallStep() + expect(endBalanced(s, seqOf(s, 'tool/result', 0))).toBe(false) + }) + + it('is balanced after the second result (both calls answered)', () => { + const s = twoCallStep() + expect(endBalanced(s, seqOf(s, 'tool/result', 1))).toBe(true) + }) +}) + +describe('isToolPairingBalanced — a mid-step injection context/message', () => { + // A background task-done inject() lands a context/message INSIDE an open step, + // between the assistant (with a tool-call) and its tool/result. It is + // pairing-neutral, so the cut on EITHER side of it is unbalanced (the call is + // still open across it) — it is NOT a free boundary in this position. + function midStepInjection(): Session { + const s = new Session(SessionId('mid-inject')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('assistant/message', { + turn: 1, step: 1, + content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], + }, SURFACE) + s.append('context/message', { content: [{ type: 'text', text: 'bg task done' }], source: { kind: 'plugin', plugin: 'tool-bash' } }, SURFACE) + s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE) + s.append('step/end', { turn: 1, step: 1 }) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return s + } + + it('start cut before the mid-step context/message is unbalanced (call still open)', () => { + const s = midStepInjection() + expect(startBalanced(s, seqOf(s, 'context/message'))).toBe(false) + }) + + it('end cut after the mid-step context/message is unbalanced (call still open)', () => { + const s = midStepInjection() + expect(endBalanced(s, seqOf(s, 'context/message'))).toBe(false) + }) +}) + +describe('isToolPairingBalanced on an injection turn (no step)', () => { + // An idle inject() wraps a context/message in a bare turn/start → + // context/message → turn/end with NO step. The context node is a free boundary + // both ways (pairing-neutral, nothing open around it). + function injectionSession(): Session { + const s = new Session(SessionId('injection')) + s.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } }) + s.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, SURFACE) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return s + } + + it('start: balanced', () => { + const s = injectionSession() + expect(startBalanced(s, seqOf(s, 'context/message'))).toBe(true) + }) + + it('end: balanced', () => { + const s = injectionSession() + expect(endBalanced(s, seqOf(s, 'context/message'))).toBe(true) + }) +}) + +describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace op', () => { + // The case the log-position scan got wrong. After a compaction, a replacement + // user/message lands at a HIGH log seq but sits at the SURFACE head, beside + // the still-open step whose events follow it in the log. It carries no + // tool-call/result pair (just summarized prose), so it must be a balanced cut + // on BOTH sides regardless of its log neighbours. + function checkpointHeadedSession(): Session { + const s = new Session(SessionId('checkpoint')) + // A closed turn with a tool step → surface [u1, asst(call), result]. + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('user/message', { content: [{ type: 'text', text: 'u1' }], source: { kind: 'user' } }, SURFACE) + s.append('assistant/message', { + turn: 1, step: 1, + content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], + }, SURFACE) + s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE) + s.append('step/end', { turn: 1, step: 1 }) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + // An OPEN turn whose step is in progress (loop fires compaction here). + s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 2, step: 1 }) + // Compaction replaces the whole turn-1 surface ([u1, asst, result]) with one + // summary user/message — appended now, so it carries a high log seq. + const u1 = seqOf(s, 'user/message') + const result = s.events.find(e => e.type === 'tool/result')!.seq + s.append('user/message', { + content: [{ type: 'text', text: 'CHECKPOINT' }], + source: { kind: 'plugin', plugin: 'compact' }, + }, { surfaceOp: { op: 'replace', start: u1, end: result } }) + // The step's own assistant/message lands AFTER the checkpoint in the log, + // still inside the open step. + s.append('assistant/message', { turn: 2, step: 1, content: [{ type: 'text', text: 'a2' }] }, SURFACE) + return s + } + + it('the head checkpoint sits at the surface head while a later surface node follows it in the log', () => { + const s = checkpointHeadedSession() + const nodes = s.surface.nodes + const checkpointSeq = nodes[0]!.seq + // The checkpoint heads the surface, yet a surface node (the open step's + // assistant) follows it in LOG order — the exact split between surface + // position and log position that the log-position scan tripped on. + const laterSurfaceInLog = s.events.find( + e => e.seq > checkpointSeq && nodes.some(n => n.seq === e.seq), + ) + expect(laterSurfaceInLog).toBeDefined() + expect(nodes[0]!.seq).toBe(checkpointSeq) + }) + + it('start cut before the head checkpoint is balanced (it is the head)', () => { + const s = checkpointHeadedSession() + expect(startBalanced(s, s.surface.nodes[0]!.seq)).toBe(true) + }) + + it('end cut after the head checkpoint is balanced (it carries no tool pair)', () => { + // This is the exact assertion the log-position scan failed: the forward log + // scan from the checkpoint reached the open step's assistant/message and + // wrongly reported mid-step. The surface balance sees a neutral node whose + // following cut closes no open call. + const s = checkpointHeadedSession() + expect(endBalanced(s, s.surface.nodes[0]!.seq)).toBe(true) + }) +}) + +describe('isToolPairingBalanced — corrupt surface guard', () => { + it('throws when a tool/result has no preceding tool-call (depth goes negative)', () => { + // A surface that opens with a tool/result (no assistant call before it) is + // structurally corrupt — surfaced loudly rather than mis-classified. + const s = new Session(SessionId('corrupt')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'x' }], isError: false }, SURFACE) + const { nodes, events } = surfaceOf(s) + expect(() => isToolPairingBalanced(nodes, events, null)).toThrow(/no matching tool-call/) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70a1a9973a..15b61280cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,6 +135,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-compact': specifier: workspace:^ version: link:../compact @@ -147,6 +150,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 7479f5306c..5bf7a344ca 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -23,8 +23,8 @@ * * The HARNESS tier (the `@deepseek-ai/dsh-*` events + services) is rendered in * full from source: signature, the `@mode` badge, and the declaration's JSDoc. - * Every harness event MUST carry an `@mode emit|waterfall|parallel` tag — the - * generator hard-errors on a missing tag, and where the signature shape is + * Every harness event MUST carry an `@mode emit|waterfall|parallel|serial` tag + * — the generator hard-errors on a missing tag, and where the signature shape is * conclusive (a trailing `next: () => …` parameter is structurally a waterfall) * it asserts the tag agrees and hard-errors on a contradiction. The INHERITED * tier (cordis core + loader/hmr/timer) is pinned vendor source a plugin author @@ -48,7 +48,7 @@ const OUT = 'docs/cordis-catalog/events-and-services.md' const FENCE = 'ts cordis-catalog' /** A dispatch mode, rendered as the badge after an event name. */ -type Mode = 'emit' | 'waterfall' | 'parallel' +type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial' /** * Cross-link map: a type name that appears in a signature → the @@ -165,7 +165,7 @@ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } { para = [] } for (const line of inner) { - const m = /^@mode\s+(emit|waterfall|parallel)\s*$/.exec(line) + const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(line) if (m) { mode = m[1] as Mode; continue } if (line.startsWith('@')) { flushPara(); continue } // other tags end the prose if (line.trim() === '') { flushPara(); continue } @@ -223,11 +223,11 @@ export function collectEvents(scanRoot: string = root): EventEntry[] { const { doc, mode } = parseJsDoc(rawJsDoc(text, member)) const src = pointer(rel, sf, member) if (!mode) { - throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is missing an @mode tag. Add '@mode emit|waterfall|parallel' to its JSDoc (see AGENTS.md).`) + throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`) } // Conclusive structural check: a trailing `next: () => …` parameter is a - // waterfall. (emit vs parallel is not structurally distinguishable, so - // it is trusted from the tag.) + // waterfall. (emit vs parallel vs serial is not structurally + // distinguishable, so it is trusted from the tag.) const last = member.parameters.at(-1) const hasNext = !!last && last.name.getText(sf) === 'next' if (hasNext && mode !== 'waterfall') { @@ -394,7 +394,7 @@ function render(events: EventEntry[], services: ServiceEntry[]): string { '', '## Events', '', - `Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets \`next()\` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares ${events.length} events across ${new Set(events.map(e => e.scope)).size} scopes.`, + `Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets \`next()\` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto), **serial** (awaited, in registration order, no veto). The harness declares ${events.length} events across ${new Set(events.map(e => e.scope)).size} scopes.`, '', ] const scopes = [...new Set(events.map(e => e.scope))].sort() From 6cac3e6476d8490d5e9842330b66048c72a2e552 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 26 Jun 2026 13:51:20 +0800 Subject: [PATCH 08/46] fix(compact): reject threshold-equality config to keep compaction convergent (CBR-002) Codex round 1 CBR-002: `resolveConfig` rejected only `summarizationMaxTokens + retainTokens > threshold` (allowing equality), but `compactIfNeeded` declines only when the estimate is `< threshold`. At exact equality the post-compaction history sits at the threshold and re-triggers on the very next check. Make the bound strict (`>=` rejects), so post-compaction history is guaranteed strictly below the threshold. Updated the boundary test (the sum-equals-threshold case is now rejected, not accepted) and added an "accepts just below the threshold" case; nudged one unrelated config that incidentally sat at the equality boundary. --- packages/compact/compact-basic/src/types.ts | 28 ++++++++++--------- .../compact-basic/tests/compact-basic.spec.ts | 24 ++++++++++------ 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index 7273150d8e..b7261eb093 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -21,7 +21,7 @@ export interface BasicCompactConfig { summarizationModel?: string /** Maximum tokens for the summarization response (default 2048). */ summarizationMaxTokens?: number - /** Enable automatic compaction on the `agent/request` waterfall (default true). */ + /** Enable automatic compaction on the `agent/pre-step` seam (default true). */ auto?: boolean } @@ -42,29 +42,31 @@ export const DEFAULTS: ResolvedConfig = { * Apply defaults to a partial config and enforce the single-pass convergence * invariant. * - * `summarizationMaxTokens + retainTokens` must not exceed the compaction + * `summarizationMaxTokens + retainTokens` must be strictly BELOW the compaction * threshold (`contextWindow * thresholdRatio`). The invariant guarantees that * after a compaction the derived history — the (bounded) summary plus the - * retained recent tail — is structurally BELOW the threshold, so the very next - * pre-request check passes and a second compaction cannot fire on the same - * content. Without it, a too-large summary budget or retain budget would leave - * the post-compaction history still over threshold, triggering compaction again - * and again. Pre-release we reject rather than clamp: a config that cannot - * guarantee convergence is a bug at the call site, not something to silently - * paper over. + * retained recent tail — is structurally below the threshold, so the very next + * pre-step check passes and a second compaction cannot fire on the same + * content. The bound is strict (`>=` rejects) because `compactIfNeeded` declines + * only when the estimate is `< threshold`: a post-compaction history sitting + * EXACTLY at the threshold would re-trigger on the next check. Without the + * invariant, a too-large summary or retain budget would leave the + * post-compaction history at/over threshold, triggering compaction again and + * again. Pre-release we reject rather than clamp: a config that cannot guarantee + * convergence is a bug at the call site, not something to silently paper over. * - * @throws if `summarizationMaxTokens + retainTokens > contextWindow * thresholdRatio`. + * @throws if `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. */ export function resolveConfig(config: BasicCompactConfig): ResolvedConfig { const resolved = { ...DEFAULTS, ...config } const threshold = Math.floor(resolved.contextWindow * resolved.thresholdRatio) const postCompactionFloor = resolved.summarizationMaxTokens + resolved.retainTokens - if (postCompactionFloor > threshold) { + if (postCompactionFloor >= threshold) { throw new Error( `BasicCompactConfig: summarizationMaxTokens (${resolved.summarizationMaxTokens}) + ` - + `retainTokens (${resolved.retainTokens}) = ${postCompactionFloor} exceeds the compaction ` + + `retainTokens (${resolved.retainTokens}) = ${postCompactionFloor} is not below the compaction ` + `threshold contextWindow * thresholdRatio = ${threshold}; post-compaction history would ` - + 'stay over threshold and re-compact endlessly. Lower retainTokens/summarizationMaxTokens ' + + 'stay at/over threshold and re-compact endlessly. Lower retainTokens/summarizationMaxTokens ' + 'or raise contextWindow/thresholdRatio.', ) } diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 4c91f11173..3a6d9a0360 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -530,13 +530,13 @@ describe('BasicCompactService.compactIfNeeded', () => { }) it('returns null when the whole surface fits the retain budget (over threshold by role/system overhead)', async () => { - // threshold = floor(460*0.1) = 46. The 4 surface nodes weigh 10 each (raw 40 + // threshold = floor(470*0.1) = 47. The 4 surface nodes weigh 10 each (raw 40 // for the retention walk), but the derived estimate adds 4 role tokens per - // message → 56 ≥ 46, so the threshold check passes and the walk runs. The + // message → 56 ≥ 47, so the threshold check passes and the walk runs. The // walk accumulates all 40 < retainTokens (45) without crossing the budget, // so keepFromIdx reaches 0 and compaction declines. The invariant holds: - // summarizationMaxTokens (1) + retainTokens (45) = 46 ≤ threshold 46. - const svc = createTestService({ contextWindow: 460, thresholdRatio: 0.1, retainTokens: 45 }) + // summarizationMaxTokens (1) + retainTokens (45) = 46 < threshold 47. + const svc = createTestService({ contextWindow: 470, thresholdRatio: 0.1, retainTokens: 45 }) const session = multiTurnSession(2, 1) expect(await svc.compactIfNeeded(session, '', 'm', SIGNAL)).toBeNull() }) @@ -739,16 +739,24 @@ describe('BasicCompactService HMR safety', () => { describe('BasicCompactService convergence invariant (config)', () => { it('throws when summarizationMaxTokens + retainTokens exceeds the threshold', () => { - // threshold = floor(1000 * 0.5) = 500; 200 + 400 = 600 > 500 → reject. + // threshold = floor(1000 * 0.5) = 500; 200 + 400 = 600 is not below 500 → reject. expect(() => new BasicCompactService(new Context(), { auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 400, summarizationMaxTokens: 200, - })).toThrow(/exceeds the compaction threshold/) + })).toThrow(/not below the compaction threshold/) }) - it('accepts the boundary case (sum equals the threshold)', () => { - // threshold = floor(1000 * 0.5) = 500; 100 + 400 = 500 ≤ 500 → allowed. + it('rejects the boundary case (sum equals the threshold — would re-trigger)', () => { + // threshold = floor(1000 * 0.5) = 500; 100 + 400 = 500 is NOT below 500, so + // post-compaction history would sit exactly at threshold and re-compact. expect(() => new BasicCompactService(new Context(), { auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 400, summarizationMaxTokens: 100, + })).toThrow(/not below the compaction threshold/) + }) + + it('accepts the case just below the threshold', () => { + // threshold = floor(1000 * 0.5) = 500; 99 + 400 = 499 < 500 → allowed. + expect(() => new BasicCompactService(new Context(), { + auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 400, summarizationMaxTokens: 99, })).not.toThrow() }) From b13586ff8e0b31c45b414321fb8a6f9a7174d548 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 26 Jun 2026 13:51:45 +0800 Subject: [PATCH 09/46] docs(compact): align seam docs with the pre-step seam and record session/invariants changes (CBR-003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 1 CBR-003: several docs still described compaction as an `agent/request` waterfall concern, and the implemented compaction RFC claimed "No changes to dsh-session or dsh-invariants" while the diff changed both. - Package READMEs / JSDoc (agent, agent-loop, system-prompt, compact, compact-basic): compaction now lives on the serial `agent/pre-step` seam (fired after turn/start, before step/start); the structural guard is tool-pairing balance (`isToolPairingBalanced`), not step-alignment; the convergence bound is strict (`>=` rejects). - architecture.md / core-data-structures/compaction.md: same seam + predicate + dispatch-mode updates; regenerated cordis catalog. - Implemented compaction RFC, updated in place to describe shipped reality: the seam is `agent/pre-step` (@mode serial) fired before step/start; alignment is surface tool-pairing balance; the convergence invariant rejects `>=`; and the "no dsh-session/dsh-invariants changes" claim is corrected — dsh-session gains the tool-pairing predicate and dsh-invariants drops its `start <= end` replace assertion (a positional replace makes start > end normal). --- docs/architecture.md | 8 ++--- docs/core-data-structures/compaction.md | 4 +-- .../2026-06-18-compaction-capability-seam.md | 32 ++++++++++--------- packages/compact/compact-basic/README.md | 8 ++--- packages/compact/compact/README.md | 2 +- packages/core/agent-loop/README.md | 6 ++-- packages/core/agent/README.md | 9 +++--- packages/core/system-prompt/README.md | 2 +- 8 files changed, 38 insertions(+), 33 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 344ea08e26..f5e5a52dce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -133,9 +133,9 @@ forever: drain queued → 'turn/start' → session('user/message'…) → emit agent/turn-start STEP loop: drain steering (late steering from previous step's listeners) - session('step/start'); emit agent/step-start assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble - await ctx.parallel('agent/pre-request') ⟵ surface mutation (compaction) before derive + await ctx.serial('agent/pre-step') ⟵ surface mutation (compaction) OUTSIDE the step + session('step/start'); emit agent/step-start 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) @@ -193,7 +193,7 @@ Every MVP feature (including the TODO-marked ones), with the mechanism that impl | `/loop` | on `agent/turn-end`, `send()` the next iteration; or force-continue | | Dynamic workflow | orchestrator plugin on `agent/turn-end` / `agent/step-end` driving `send`/`steer` (+ sub-agents later) | | Queued + steering messages | core `Agent.send()` / `Agent.steer()` | -| Context compaction (auto + manual) | the `dsh-compact` seam (`ctx.compact`) + a backend (`dsh-compact-basic`) on the awaited `agent/pre-request` seam: a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure before each model call (every step — runaway-turn survival), manual = a (deferred) `/compact` tool invoking the same `ctx.compact` routine. See the [compaction capability-seam RFC](rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) | +| Context compaction (auto + manual) | the `dsh-compact` seam (`ctx.compact`) + a backend (`dsh-compact-basic`) on the serial `agent/pre-step` seam: a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure before each step — runaway-turn survival, manual = a (deferred) `/compact` tool invoking the same `ctx.compact` routine. See the [compaction capability-seam RFC](rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) | | System prompt configurability | `ctx.systemPrompt.section()` with ordering | | AGENTS.md (root) | a section provider reading the file | | AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener | @@ -221,6 +221,6 @@ Code skeletons for the three plugin shapes (tool, hook/permission-gate, UI) and Tracked here deliberately — each is designed-for but not implemented: - **Sub-agent spawn/fork semantics** (seam: `AgentLoop.create()`); inter-agent channels beyond `send`/`steer`/events. -- **Compaction** — the `dsh-compact` seam (`ctx.compact`) and the `dsh-compact-basic` backend exist (auto thresholds, summarization on the awaited `agent/pre-request` seam, `compact/*` session events via declaration merging). The model-facing `/compact` consumer tool is still deferred. See [the compaction capability-seam RFC](rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). +- **Compaction** — the `dsh-compact` seam (`ctx.compact`) and the `dsh-compact-basic` backend exist (auto thresholds, summarization on the serial `agent/pre-step` seam, `compact/*` session events via declaration merging). The model-facing `/compact` consumer tool is still deferred. See [the compaction capability-seam RFC](rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). - **Parallel tool execution** (concurrency-safety hints on ToolDefinition). - **Session branching/tree** (pi-style entry tree) if needed beyond seed-based forking. diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index e637961784..d8a05dc8cf 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -50,6 +50,6 @@ interface CompactionResult { ## The service -`CompactService` (`ctx.compact`, abstract — defined in [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts)) declares two abstract methods: `compactIfNeeded(session, system, model, signal)` checks token pressure and compacts an older range if the history is too large (returning `null` when nothing needs it), and `compactRegion(session, start, end, model, signal?)` forcibly summarizes surface nodes `[start, end]` into a single replacement node. `compactIfNeeded`'s parameters are all required — the loop's `agent/pre-request` checkpoint always supplies the assembled `system`, the `model`, and the turn `signal`. A backend summarizing via `ctx.llm.stream()` must forward `signal` into the call's `GenerateOptions.signal`, so an abort or dispose tears down the in-flight summarization. The entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. +`CompactService` (`ctx.compact`, abstract — defined in [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts)) declares two abstract methods: `compactIfNeeded(session, system, model, signal)` checks token pressure and compacts an older range if the history is too large (returning `null` when nothing needs it), and `compactRegion(session, start, end, model, signal?)` forcibly summarizes surface nodes `[start, end]` into a single replacement node. `compactIfNeeded`'s parameters are all required — the loop's `agent/pre-step` checkpoint always supplies the assembled `system`, the `model`, and the turn `signal`. A backend summarizing via `ctx.llm.stream()` must forward `signal` into the call's `GenerateOptions.signal`, so an abort or dispose tears down the in-flight summarization. The entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. -Auto-compaction runs on the awaited `agent/pre-request` loop seam (fired once per step, BEFORE the request history is derived), not the `agent/request` waterfall: compaction mutates the session surface in place, and the loop derives the request from the already-compacted surface. Retention is turn-agnostic — the only structural guard is step-alignment (a compacted region never splits a step's tool-calls from their results), so a single runaway turn that alone exceeds the window compacts its own early closed steps rather than being retained verbatim. The backend that ships this (`dsh-compact-basic`) documents the retention walk, the single-pass convergence invariant, and the crash/recoverable failure taxonomy. +Auto-compaction runs on the serial `agent/pre-step` loop seam (fired once per step, after `turn/start` and BEFORE the step opens and its request history is derived), not the `agent/request` waterfall: compaction mutates the session surface in place — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives the request from the already-compacted surface. Retention is turn-agnostic — the only structural guard is tool-pairing balance (a compacted region's edges are balanced cuts on the surface, so it never splits a step's tool-calls from their results), so a single runaway turn that alone exceeds the window compacts its own early closed steps rather than being retained verbatim. The backend that ships this (`dsh-compact-basic`) documents the retention walk, the single-pass convergence invariant, and the crash/recoverable failure taxonomy. diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index 56139cc750..f1ca9dad2f 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -17,7 +17,7 @@ Two forces shape the design. First, compaction is **swappable**: token counting Per the [capability-seams RFC](../../implemented/architecture/2026-06-13-capability-seams.md), compaction ships as separate packages so the contract, the algorithm, and (later) the consumer surface evolve independently: 1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, and the `compact/*` session events. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*. -2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that owns the entire algorithm — token estimation (char/4 + per-block overhead), the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, and the `agent/pre-request` auto-compaction listener. A tokenizer-based or template-based backend is a sibling package (or a subclass overriding the two protected estimation/summarization hooks). +2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that owns the entire algorithm — token estimation (char/4 + per-block overhead), the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, and the `agent/pre-step` auto-compaction listener. A tokenizer-based or template-based backend is a sibling package (or a subclass overriding the two protected estimation/summarization hooks). 3. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first. ### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation @@ -32,28 +32,29 @@ An earlier draft put the full algorithm (the retention walk, token-summing, text `compactIfNeeded(session, system, model, signal)` takes **required** parameters (not the original all-optional shape). The auto-compaction seam (below) always supplies all four — the assembled system prompt (counted toward the estimate), the model (summarization fallback), and the turn's abort signal — so optionality would only invite a hidden default at the seam. `compactRegion(session, start, end, model, signal?)` keeps an optional signal (a manual caller may omit it). -### Auto-compaction runs on `agent/pre-request`, a dedicated surface-mutation seam +### Auto-compaction runs on `agent/pre-step`, a dedicated surface-mutation seam -Compaction is a **surface mutation**, not a request transform — and that distinction is the seam it belongs on. The loop's request lifecycle, per step, is: assemble the system prompt → derive the message history from the surface → run the `agent/request` waterfall → call the model. An earlier cut wedged compaction into the `agent/request` waterfall, which forced two problems: (1) the loop had already derived `messages` from the *stale* surface, so the listener had to mutate the surface and then *re-derive* and overwrite `request.messages` — a double-derive whose only purpose was to undo the premature first derive; and (2) `agent/request` also carries downstream-injected context a listener might have added to `request.messages`, which compaction cannot act on (it can only compact the surface), inviting the confusion of measuring tokens compaction can't shed. +Compaction is a **surface mutation**, not a request transform — and that distinction is the seam it belongs on. The loop's request lifecycle, per step, is: assemble the system prompt → open the step → derive the message history from the surface → run the `agent/request` waterfall → call the model. An earlier cut wedged compaction into the `agent/request` waterfall, which forced two problems: (1) the loop had already derived `messages` from the *stale* surface, so the listener had to mutate the surface and then *re-derive* and overwrite `request.messages` — a double-derive whose only purpose was to undo the premature first derive; and (2) `agent/request` also carries downstream-injected context a listener might have added to `request.messages`, which compaction cannot act on (it can only compact the surface), inviting the confusion of measuring tokens compaction can't shed. -The fix is a new awaited loop seam, **`agent/pre-request`** (`@mode parallel`), fired by the loop *after* system assembly and *before* `deriveMessages()`: +The fix is a dedicated loop seam, **`agent/pre-step`** (`@mode serial`), fired by the loop *after* system assembly and *before* the step opens (`step/start`): ``` assembly = ctx.systemPrompt.assemble() -await ctx.parallel('agent/pre-request', agent, turn, step, system, model, signal) ⟵ compaction mutates the surface here +await ctx.serial('agent/pre-step', agent, turn, step, system, model, signal) ⟵ compaction mutates the surface here +session('step/start') ⟵ the step opens AFTER the seam messages = session.deriveMessages() ⟵ single derive, reflects the compaction request = waterfall agent/request ⟵ pure request transform (hooks, model switch) ``` -This makes the layering correct *by construction*: compaction mutates the surface, the loop derives **once** from the result (no double-derive), and at `pre-request` the assembled `messages` do not yet exist — so a listener structurally *cannot* see or be expected to act on downstream-injected context. `agent/request` reverts to a pure request transformer. The seam is `parallel` (awaited fan-out, no veto), like `session/flush`: a listener mutates the surface as a side effect; there is nothing to transform or return. +This makes the layering correct *by construction*: compaction mutates the surface, the loop derives **once** from the result (no double-derive), and at `pre-step` the assembled `messages` do not yet exist — so a listener structurally *cannot* see or be expected to act on downstream-injected context. `agent/request` reverts to a pure request transformer. Firing the seam **before** `step/start` (not inside the open step) is load-bearing for crash-safety: compaction's log-only `compact/*` records and its replacement node land *outside* any step, so the honest log structure a crash leaves (a dangling `compact/start` sitting before the synthetic `turn/end` that turn-repair appends) holds without a half-open step to reconcile. The seam is `serial` (awaited, in registration order, no veto), not `parallel`: a listener mutates the surface as a side effect — there is nothing to transform or return — and serial isolates listeners from each other so two surface-mutating listeners can never interleave their `session.append`s. This **amends** the original RFC's claim of "NO changes to `dsh-agent-loop`; compaction is a pure plugin." That claim was load-bearing for a wrong design — reusing `agent/request` was the mistake. Per the pre-release "foundation over blast radius" stance, adding the correct seam (one event declaration in `dsh-agent`, one awaited emit in the loop) beats preserving a no-change boast that locked in the double-derive. -### Retention is turn-agnostic; step-alignment is the only structural guard +### Retention is turn-agnostic; tool-pairing balance is the only structural guard -Auto-compaction fires before **every** model call (every step), not once per turn. This is **load-bearing for runaway-turn survival**: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows *within* a turn. A single turn can grow past the window on its own (a "runaway turn") — and the only moment to rescue it before the next model call overflows is the next step's `pre-request`. Gating compaction to a turn's first step (or, worse, retaining the whole in-flight turn verbatim) re-opens exactly the hole compaction exists to close: the harness would die when compaction is most needed. +Auto-compaction fires before **every** step, not once per turn. This is **load-bearing for runaway-turn survival**: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows *within* a turn. A single turn can grow past the window on its own (a "runaway turn") — and the only moment to rescue it before the next model call overflows is the next step's `pre-step` checkpoint. Gating compaction to a turn's first step (or, worse, retaining the whole in-flight turn verbatim) re-opens exactly the hole compaction exists to close: the harness would die when compaction is most needed. -So retention does **not** protect the in-flight turn, and turn boundaries play no role in it. `compactIfNeeded` walks the surface nodes tail→head, summing per-node token estimates, and retains the smallest tail-run of **whole units** whose total reaches `retainTokens`; everything older is compacted (head-anchored — see below). A *unit* is either a whole closed step (its `assistant/message` plus its `tool/result`s) or a single no-step node (a pre-step `user/message`, inter-step `steering/message`, or injection `context/message`). The walk rounds toward retaining *more*: when the raw token cutoff lands inside a step, it extends the retained side head-ward until the boundary is a step-aligned start. The single structural guard is therefore **step-alignment** — the compacted region always ends on a step boundary, so it never splits a step's tool-calls from their `tool/result`s (which would produce a transcript every provider rejects). `compactRegion` enforces step-alignment strictly, throwing on a splitting boundary. +So retention does **not** protect the in-flight turn, and turn boundaries play no role in it. `compactIfNeeded` walks the surface nodes tail→head, summing per-node token estimates, and retains the smallest tail-run of **whole units** whose total reaches `retainTokens`; everything older is compacted (head-anchored — see below). A *unit* is either a whole closed step (its `assistant/message` plus its `tool/result`s) or a single no-step node (a pre-step `user/message`, inter-step `steering/message`, or injection `context/message`). The walk rounds toward retaining *more*: when the raw token cutoff lands mid-step, it extends the retained side head-ward until the cut before the retained node is **tool-pairing balanced**. The single structural guard is therefore **tool-pairing balance** — a region's edges are balanced cuts on the *surface* (no unanswered `tool-call` crosses either edge), so a compacted region never splits a step's tool-calls from their `tool/result`s (which would produce a transcript every provider rejects). The check is decided over the surface linked list, **not** the log's `step/*` markers: a compaction lands a replacement node at a high log seq whose surface position is the head, so a log-position scan mis-reads its neighbours — `dsh-session` exports `isToolPairingBalanced(nodes, events, beforeSeq)` for the surface-anchored check. `compactRegion` enforces it strictly, throwing on a boundary that would split a step. A runaway turn thus compacts exactly like any other history: its early *closed* steps get summarized while its recent steps stay verbatim. When the only compactable content left is an un-splittable open tail step (its tool-calls have no results yet), compaction declines (`null`) and retries once that step closes. @@ -65,7 +66,7 @@ A runaway turn thus compacts exactly like any other history: its early *closed* ### Single-pass convergence invariant -`resolveConfig` **rejects** (throws at construction) any config where `summarizationMaxTokens + retainTokens > contextWindow * thresholdRatio`. The invariant guarantees the post-compaction history — the bounded summary plus the retained recent tail — is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction, with no thrash throttle needed. `summarizationMaxTokens` stays an explicit *quality* knob (terse summaries); the invariant only forbids setting it so high it breaks convergence. The sole residual is the single-unit-overflow case above (a backward-rounded oversized step can push the retained tail over budget) — which is exactly the out-of-scope concern, not a thrash bug. Per the pre-release reject-don't-migrate stance, a config that cannot guarantee convergence is a bug at the call site, not something to silently clamp. +`resolveConfig` **rejects** (throws at construction) any config where `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. The invariant guarantees the post-compaction history — the bounded summary plus the retained recent tail — is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction, with no thrash throttle needed. The bound is **strict** (`>=` rejects, not `>`): the token-pressure gate declines only when the estimate is `< threshold`, so a post-compaction history sitting *exactly* at the threshold would re-trigger on the very next check — equality is a leak, not a safe boundary. `summarizationMaxTokens` stays an explicit *quality* knob (terse summaries); the invariant only forbids setting it so high it breaks convergence. The sole residual is the single-unit-overflow case above (a backward-rounded oversized step can push the retained tail over budget) — which is exactly the out-of-scope concern, not a thrash bug. Per the pre-release reject-don't-migrate stance, a config that cannot guarantee convergence is a bug at the call site, not something to silently clamp. ### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary @@ -91,11 +92,11 @@ The landed `user/message` is not the raw summary: the backend wraps it in a chec The `compact/start … compact/end` bracket is justified, in order of what now does the work: 1. **Crash-detectable orphan + provenance** (primary). Summarization is a slow model call persisted *after* `compact/start`. A crash mid-summarization leaves a `compact/start` with no matching `compact/end` — a detectable orphan. Releasing the lock last (rather than first) converts the crash window from *silent corruption* into that detectable orphan. -2. **Prevents concurrent compaction.** `compactRegion` refuses to start if the current turn holds an unmatched `compact/start`. (The loop is single-threaded across the awaited `pre-request`, so this is also a re-entry tripwire — a thrown "already in progress" signals a real bug.) +2. **Prevents concurrent compaction.** `compactRegion` refuses to start if the current turn holds an unmatched `compact/start`. (The loop is single-threaded across the awaited `pre-step`, so this is also a re-entry tripwire — a thrown "already in progress" signals a real bug.) Two failure paths, both documented: -- **Crash** (the loop dies mid-summarization): a dangling `compact/start`, no closer. Because `compact/*` are **log-only**, the orphan is **inert** — the surface replacement never landed, so the full, uncompacted history derives correctly. Generic turn-repair (`interruptedTurnClosers`) closes the turn with a synthetic `turn/end`; the orphan sits *before* that `turn/end`, so the turn-scoped in-progress check never sees it and a crash can't wedge future compaction. Compaction simply re-attempts at the next `pre-request`. +- **Crash** (the loop dies mid-summarization): a dangling `compact/start`, no closer. Because `compact/*` are **log-only**, the orphan is **inert** — the surface replacement never landed, so the full, uncompacted history derives correctly. Generic turn-repair (`interruptedTurnClosers`) closes the turn with a synthetic `turn/end`; the orphan sits *before* that `turn/end`, so the turn-scoped in-progress check never sees it and a crash can't wedge future compaction. Compaction simply re-attempts at the next `pre-step`. - **Recoverable** (summarization throws but the loop survives): the backend appends `compact/end` with its **`error`** field set, leaving the surface untouched, and the model call proceeds with full history. `compact/end` keeps its `error?` field (mirroring `tool/result`'s self-contained error — one event tells success from failure without correlating a sibling). There is no separate `compact/error` event. @@ -105,14 +106,15 @@ Two failure paths, both documented: ## Consequences - **New packages**: `packages/compact/compact` (interface) and a sibling `compact-basic` (backend) under `packages/compact/`, wired into the root tsconfigs. The consumer tier is deferred. -- **New loop seam**: `agent/pre-request` (`@mode parallel`) declared in `dsh-agent` and emitted by `dsh-agent-loop` between system assembly and history derivation. This is a documented change to the loop — `docs/architecture.md` records it and the generated cordis catalog carries its signature. +- **New loop seam**: `agent/pre-step` (`@mode serial`) declared in `dsh-agent` and emitted by `dsh-agent-loop` after system assembly and before `step/start`. This is a documented change to the loop — `docs/architecture.md` records it and the generated cordis catalog carries its signature. - **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. -- **No changes** to `dsh-session` or `dsh-invariants`: the surface replace op, the surface-metadata runtime guard, and the turn-enclosure invariant all already exist and are reused. +- **`dsh-session`** gains the tool-pairing balance predicate (`isToolPairingBalanced`, in `tool-pairing.ts`, exported from the package index) that `compactRegion`/`compactIfNeeded` use to keep a collapsed region from splitting a step's tool-call/result pair. The surface `replace` op and the surface-metadata runtime guard already existed and are reused. +- **`dsh-invariants`** drops its `surface replace: start must be <= end` assertion: a head-anchored compaction lands a high-seq replacement node at an older range's *position*, so `start > end` numerically is normal and valid (the range is positional, validated by the surface's `indexOf` checks that remain). The turn-enclosure invariant is reused unchanged. - **Wiring**: `dsh-compact-basic` is loaded in `examples/coding-agent`'s `cordis.yml`, so the seam ships in the real demo (it was previously loaded nowhere). ## Testing - **Unit** (`dsh-compact-basic`): the whole-unit retention walk, the convergence-invariant throw, both failure paths (`compact/end` with/without `error`), head-anchoring producing a non-monotonic `shadowedRange`, decline-on-open-tail, crash-orphan inertness, and the **runaway-turn regression** — a single oversized open turn compacts its early closed steps (proven to fail on the layer-2 protection it replaced). Driven through the real `dsh-invariants` plugin and the real Loader/inject path. -- **Loop** (`dsh-agent-loop`): `agent/pre-request` fires once per step, before derive, awaited; a surface mutation in a `pre-request` listener is reflected in the single derived request. +- **Loop** (`dsh-agent-loop`): `agent/pre-step` fires once per step, after `turn/start` and before `step/start`, awaited; a surface mutation in a `pre-step` listener lands outside the step and is reflected in the single derived request. - **With-key e2e** (`examples/coding-agent`): a real model + real bash session with a lowered `contextWindow`/`retainTokens` triggers compaction mid-session; the test verifies the WORLD (a `compact/start…end` pair landed, the surface shrank, the agent still completed the task after compaction). This is compaction's first real-world exercise and the runaway-survival net. - **Snapshot (deferred, named gap)**: a full-transcript snapshot of a runaway-turn compaction is NOT yet possible — `dsh-llm-replay` derives one model call per `(turn, step)` from `assistant/chunk` events, but the summarization call records no `assistant/chunk`s and carries no `sessionId` (it binds to the anonymous cursor and claims a non-existent extra script). Covering it needs net-new replay infrastructure (record/replay an interleaved summarization call) and is scheduled as a follow-up rather than discovered mid-build. diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 848c0cd6ae..cc171f62b4 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -9,12 +9,12 @@ 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). -- **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 **step-alignment**: the compacted region always ends on a step boundary, 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 step-alignment strictly, throwing on a boundary that would split a step. -- **Single-pass convergence** — `resolveConfig()` rejects (throws) any config where `summarizationMaxTokens + retainTokens > contextWindow * thresholdRatio`. The invariant guarantees the post-compaction history (the bounded summary plus the retained recent tail) is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction. +- **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. +- **Single-pass convergence** — `resolveConfig()` rejects (throws) any config where `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. The invariant guarantees the post-compaction history (the bounded summary plus the retained recent tail) is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction. The bound is strict (`>=` rejects) because the token-pressure gate declines only when the estimate is `< threshold` — a post-compaction history sitting exactly at the threshold would re-trigger. - **Summarization** — `summarize()`: a `ctx.llm.stream()` call assembled via `BlockAssembler` (the single model-call surface) 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 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. - **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 `` 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-request` listener delegates to `compactIfNeeded()` before every model call (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-request` is an awaited surface-mutation checkpoint that fires BEFORE the loop derives the request history, so compaction mutates the surface 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()`). +- **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, no-veto) 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()`). - **Failure handling** — the `compact/start … compact/end` bracket is a log-recorded lock: it makes a crash mid-summarization a detectable orphan (a `compact/start` with no `compact/end`), records provenance, and prevents a concurrent compaction. Two failure paths: a **crash** (the loop dies mid-summarization) leaves a dangling `compact/start` that is inert — `compact/*` events are log-only, the surface replacement never landed, so the full history derives fine and generic turn-repair closes the turn; a **recoverable** failure (summarization throws but the loop survives) appends `compact/end` with its `error` field set, leaving the surface untouched so the call proceeds with full history. Core session repair stays compaction-agnostic by design — it never learns about `compact/*`. `estimateContentTokens()` and `summarize()` are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the retention walk and surface plumbing. @@ -28,7 +28,7 @@ The abstract contract states only WHAT compaction does; this backend owns every | `retainTokens` | `20480` | Tokens of recent context to keep intact. | | `summarizationModel` | `''` | Model for summarization (empty → use the agent's model). | | `summarizationMaxTokens` | `2048` | Max tokens for the summary response. | -| `auto` | `true` | Register the `agent/pre-request` auto-compaction listener. Set `false` for manual-only. | +| `auto` | `true` | Register the `agent/pre-step` auto-compaction listener. Set `false` for manual-only. | ## Usage diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index d75a5da774..8a95277c17 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -18,7 +18,7 @@ Both methods are **abstract** — the backend owns the entire strategy (token es | Member | Semantics | |---|---| -| `compactIfNeeded(session, system, model, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-request` checkpoint always supplies the assembled `system`, the `model`, and the turn `signal`. | +| `compactIfNeeded(session, system, model, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-step` checkpoint always supplies the assembled `system`, the `model`, and the turn `signal`. | | `compactRegion(session, start, end, model, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | `compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is not a parameter — it is recoverable from the log (the currently-open turn), so the backend stamps it without the caller supplying it. diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 4892b357bf..11873a56e4 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -52,6 +52,8 @@ forever: STEP loop: drain steering assembly = systemPrompt.assemble() + 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 @@ -73,8 +75,8 @@ 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/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation` -- Compaction: `agent/request` +- Hooks: `agent/pre-step`, `agent/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation` +- Compaction: `agent/pre-step` - Sandbox, permission, plan mode: `tools/execute` - Sub-agents: TODO seam on `AgentLoop.create()` - Persistence: `session/event` + `session/flush` diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index d0ec0ee614..537211889e 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -37,11 +37,12 @@ The full `agent/*` event taxonomy is declared via declaration merging in `dsh-ag - `agent/turn-start`, `agent/turn-end` (carries `TurnEndReason`) - `agent/step-start`, `agent/step-end` -#### Interception seams (waterfall) +#### Interception seams -- `agent/request` — mutate `GenerateOptions` before the model call (hooks, compaction, 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 (force-continue /loop, force-stop budget guard) +- `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) #### Streaming + tool (emit) diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 6f14e40f88..1c18bdf1d6 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -34,4 +34,4 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` via decla ### What is NOT here - Any hardcoded prompt text — every section comes from plugins. -- Prompt compaction (belongs on the `agent/request` seam in `dsh-agent`). +- Prompt compaction (belongs on the `agent/pre-step` seam in `dsh-agent`). From 6de5e3a20a58fdd41475f197be6865ece6355ee7 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 26 Jun 2026 14:02:02 +0800 Subject: [PATCH 10/46] docs(compact): fix stale agent/pre-request comment in coding-agent cordis.yml (CBR-004) codex review round 2 (non-blocking) CBR-004: the example's compaction wiring comment still named the old `agent/pre-request` seam. Renamed to `agent/pre-step` to match the shipped seam. --- examples/coding-agent/cordis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index e3a3016dda..d239db3a7e 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -68,7 +68,7 @@ # Automatic context compaction: when the derived history approaches the model's # context window, summarize an older range into a checkpoint so a long-running # or tool-heavy session keeps fitting. A leaf entry (needs ctx.llm + the -# agent-loop's `agent/pre-request` seam from the app above). +# agent-loop's `agent/pre-step` seam from the app above). - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' config: From f4ace256485baf5622a76ef06df9ed69267563be Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 26 Jun 2026 14:40:20 +0800 Subject: [PATCH 11/46] fix(compact): correct _extractText surface-order JSDoc; add disposal to the HMR-safety suite (CBR-005, CBR-006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual review round, two non-blocking findings: - CBR-005: _extractText's JSDoc claimed it "walks events in log order", but it walks the seqs in surface order (the inline comment already said so) — the exact distinction CBR-001 paid for, since after a replace a high-seq checkpoint heads the surface before lower-seq retained nodes. Corrected the JSDoc to match. - CBR-006: the "HMR safety" suite only asserted registration; the actual dispose-and-confirm-cleanup test lived under "llm inject", so a reader searching by name could miss it. Added a disposal test to the HMR-safety suite (mount via the real plugin fiber with LlmService present so inject resolves, dispose, assert ctx.get('compact') is undefined) and reframed the llm-inject test's trailing teardown to point at it. --- packages/compact/compact-basic/src/index.ts | 7 +++++-- .../compact-basic/tests/compact-basic.spec.ts | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 7648a245b0..479553a17c 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -601,8 +601,11 @@ export class BasicCompactService extends CompactService { /** * Extract plain-text conversation from a set of surface node seqs, for - * feeding into the summarization model. Walks events in log order so the - * summary captures chronological flow. + * feeding into the summarization model. Walks the seqs in the order given + * (surface order, as `compactRegion` slices the surface-node list) so the + * summary follows the conversation as the model sees it — which, after a + * `replace`, is NOT ascending log-seq order (a high-seq summary node heads the + * surface before older retained lower-seq nodes). */ private _extractText(session: Session, seqs: number[]): string { const lines: string[] = [] diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 3a6d9a0360..971aadb9dd 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -735,6 +735,21 @@ describe('BasicCompactService HMR safety', () => { expect(ctx.compact).toBeDefined() expect(ctx.compact).toBeInstanceOf(BasicCompactService) }) + + it('disposing the plugin fiber unregisters ctx.compact', async () => { + // Mount through the real plugin fiber (the Loader path), then dispose it and + // confirm the service registration is torn down. LlmService is mounted first + // so the service's `inject: ['llm']` resolves and the fiber activates. (The + // sibling-fiber ctx.llm resolution this same setup also exercises is covered + // under the "llm inject (real plugin-load path)" suite.) + const ctx = new Context() + await ctx.plugin(LlmService) + const fiber = await ctx.plugin(BasicCompactService, { auto: false }) + expect(ctx.get('compact')).toBeInstanceOf(BasicCompactService) + + await fiber.dispose() + expect(ctx.get('compact')).toBeUndefined() + }) }) describe('BasicCompactService convergence invariant (config)', () => { @@ -1343,7 +1358,8 @@ describe('BasicCompactService llm inject (real plugin-load path)', () => { const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') expect(result.summary).toEqual([{ type: 'text', text: 'CONDENSED' }]) - // HMR: disposing the fiber tears the service registration down. + // Tear the fiber down so this test owns no leaked registration; the + // dedicated cleanup assertion lives in the "HMR safety" suite. await fiber.dispose() expect(ctx.get('compact')).toBeUndefined() }) From 05c1bb628c14116b4decb47f562441b78d279f17 Mon Sep 17 00:00:00 2001 From: Ni Shentu <87308515+NI0317@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:18:15 +0800 Subject: [PATCH 12/46] Add GitHub Actions workflow to mirror to GitLab --- .github/workflows/mirror-to-gitlab.yml | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/mirror-to-gitlab.yml diff --git a/.github/workflows/mirror-to-gitlab.yml b/.github/workflows/mirror-to-gitlab.yml new file mode 100644 index 0000000000..671915ba49 --- /dev/null +++ b/.github/workflows/mirror-to-gitlab.yml @@ -0,0 +1,33 @@ +name: Mirror to GitLab + +on: + push: + branches: ['**'] + tags: ['**'] + delete: + workflow_dispatch: + +concurrency: + group: mirror-to-gitlab + cancel-in-progress: false + +jobs: + mirror: + runs-on: ubuntu-latest + steps: + - name: Checkout full history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.GITLAB_SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -t rsa,ecdsa,ed25519 gitlab.com >> ~/.ssh/known_hosts + + - name: Push to GitLab + run: | + git remote add gitlab "${{ secrets.GITLAB_REPO_URL }}" + git push --mirror gitlab From c76b7042b6086e90c2d50ced83bf6a1fd7898257 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 26 Jun 2026 16:42:51 +0800 Subject: [PATCH 13/46] fix(compact): size the compaction e2e to actually cross threshold; sync stale docs (CBR-007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compaction e2e never exercised compaction: its window/fixture combo (contextWindow 8000, thresholdRatio 0.5 → threshold 4000; four small files) peaked at ~1389 estimated tokens, so compactIfNeeded declined every pre-step and compact/start never landed. Shrink the window (contextWindow 2400 → threshold 1200; retainTokens 500 + summarizationMaxTokens 300 = 800 < 1200, convergence holds) and grow the fixture to six files so a couple of bash steps reliably cross the threshold. Verified compaction fires and the suite passes across repeated real-API runs. Sync docs left stale by the landed compaction work: list compaction.e2e.ts and keyless-smoke.e2e.ts in the coding-agent README (and fix the wrong "Both self-skip" count), add compaction to the examples with-key inventory, and replace the hypothetical compaction/marker / "future plugin" naming in the session README, session types JSDoc, and the core-data-structures catalog with the real compact/start, compact/summary, compact/end events. --- docs/core-data-structures/core.md | 2 +- docs/core-data-structures/session.md | 2 +- examples/AGENTS.md | 2 +- examples/coding-agent/README.md | 3 +- examples/coding-agent/tests/compaction.e2e.ts | 30 ++++++++++--------- packages/core/session/README.md | 6 ++-- packages/core/session/src/types.ts | 5 ++-- 7 files changed, 27 insertions(+), 23 deletions(-) diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 6d20900c93..ee4f06e14a 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -205,7 +205,7 @@ type SessionEvent = { /** * Seq numbers of events that are provenance sources of this event * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, - * or the surface nodes shadowed by a compaction marker). + * or the surface nodes shadowed by a compaction replace node). */ sourceEventSeqs?: number[] /** How this event entered the surface; absent for non-surface events. */ diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 8f8ef4a800..890a3a0dee 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -55,7 +55,7 @@ type SessionEvent = { /** * Seq numbers of events that are provenance sources of this event * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, - * or the surface nodes shadowed by a compaction marker). + * or the surface nodes shadowed by a compaction replace node). */ sourceEventSeqs?: number[] /** How this event entered the surface; absent for non-surface events. */ diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 67ea68ae6f..2c2b2a44f1 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -20,7 +20,7 @@ A keyless smoke that spawns the example from a temp cwd must set `TSX_TSCONFIG_P | Example | Keyless smoke | With-key smoke | |---|---|---| | `echo-agent` | `tests/echo.e2e.ts` — boots the real `cordis.yml`, drives the echo tool round-trip and the direct canned reply | **N/A — keyless by nature** (the `mock-echo` model has no real provider) | -| `coding-agent` | `tests/keyless-smoke.e2e.ts` — boots the full real tree (dummy key, no prompt → no model call), asserts banner + clean exit | `tests/{full-loop,coding-task,resume}.e2e.ts` — real model + real bash, world-verified | +| `coding-agent` | `tests/keyless-smoke.e2e.ts` — boots the full real tree (dummy key, no prompt → no model call), asserts banner + clean exit | `tests/{full-loop,coding-task,resume,compaction}.e2e.ts` — real model + real bash, world-verified | | `acp-agent` | `pnpm run test:snapshot` — boots the real ACP subprocess and replays a recorded session keyless; `tests/acp.e2e.ts` also asserts stdout purity without a key | `tests/acp.e2e.ts` — real ACP prompt, verifies a file the agent wrote | See [the root AGENTS.md](../AGENTS.md) for repo-wide conventions and [docs/architecture.md](../docs/architecture.md) for the design. diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index 7585129382..76fbb7a237 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -48,5 +48,6 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends and lo - `tests/full-loop.e2e.ts` — the canary: real model runs `echo e2e-ok` through the real bash tool; asserts `tool/call`/`tool/result` session events and the final answer. - `tests/coding-task.e2e.ts` — the swebench-style smoke: a temp dir holds `add.js` (with `a - b` where `a + b` belongs) and a failing `add.test.js`; the agent must fix the bug and verify. The test re-runs `node add.test.js` ITSELF and inspects the files — agent claims are not trusted. - `tests/resume.e2e.ts` — durable continuity across processes: run 1 tells the real model a secret code and persists the turn to a temp JSONL root, then the whole context is disposed; run 2 is a fresh context over the same root that RESUMES the session id and asks the model to recall the code. The recall can only come from the rehydrated log. +- `tests/compaction.e2e.ts` — the compaction smoke: a real multi-step bash task runs with a deliberately tiny context window so the auto-compaction listener fires MID-SESSION. Verifies the WORLD — a `compact/start…end` pair landed in the real log, the surface shrank (a replace node shadowed older nodes), and the agent still produced a correct final answer after compaction. -Both self-skip without `DEEPSEEK_API_KEY`. +All four self-skip without `DEEPSEEK_API_KEY`. The keyless boot smoke is `tests/keyless-smoke.e2e.ts` (boots the full real tree with a dummy key and no prompt, so no model call), which runs in the default e2e gate. diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index fefcd579d0..2c9814c79d 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -33,21 +33,23 @@ afterEach(async () => { describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compacts mid-flight and keeps running', () => { it('summarizes older history into a checkpoint without breaking the task', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-compaction-')) - // A few files for the model to read, so multiple bash steps accumulate - // surface nodes (tool calls + results) and grow the history. - for (let i = 1; i <= 4; i++) { + // A handful of files for the model to read, so multiple bash steps + // accumulate surface nodes (tool calls + results) and grow the history past + // the (deliberately tiny) window. + for (let i = 1; i <= 6; i++) { await writeFile(join(workdir, `file${i}.txt`), `This is file number ${i}. `.repeat(40)) } - // Tiny window so a handful of steps crosses the threshold. The convergence - // invariant requires summarizationMaxTokens + retainTokens <= window * - // ratio = floor(8000 * 0.5) = 4000; 1500 + 2000 = 3500 <= 4000. + // Tiny window so a couple of steps crosses the threshold. The convergence + // invariant requires summarizationMaxTokens + retainTokens to be strictly + // BELOW the threshold = floor(contextWindow * thresholdRatio) = + // floor(2400 * 0.5) = 1200; 300 + 500 = 800 < 1200. ctx = await codingHarness(workdir, { compact: { - contextWindow: 8000, + contextWindow: 2400, thresholdRatio: 0.5, - retainTokens: 2000, - summarizationMaxTokens: 1500, + retainTokens: 500, + summarizationMaxTokens: 300, }, }) const agent = ctx.agentLoop.create(AgentId('e2e-compaction'), { @@ -57,9 +59,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa agent.send([{ type: 'text', - text: 'Read file1.txt, file2.txt, file3.txt, and file4.txt one at a time using cat ' - + '(a separate bash command for each). After reading all four, tell me how many ' - + 'files you read and the number mentioned in file1.txt.', + text: 'Read file1.txt, file2.txt, file3.txt, file4.txt, file5.txt, and file6.txt one at a ' + + 'time using cat (a separate bash command for each). After reading all six, tell me how ' + + 'many files you read and the number mentioned in file1.txt.', }]) await waitForIdle(ctx, agent) @@ -87,9 +89,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa expect(summaryData.shadowedSeqs.length).toBeGreaterThan(0) // The conversation survived compaction: the agent produced a final answer - // that reflects the work (it read four files). + // that reflects the work (it read six files). const answer = finalText(events).toLowerCase() expect(answer.length).toBeGreaterThan(0) - expect(answer).toMatch(/\b(4|four)\b/) + expect(answer).toMatch(/\b(6|six)\b/) }, 240_000) }) diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 2a90d0f792..cde29d091a 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -51,13 +51,13 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. 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`. Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`. -Merge-extensible via `SessionEventMap` — a compaction plugin adds `compaction/marker`, etc. +Merge-extensible via `SessionEventMap` — the compaction plugin (`dsh-compact-basic`) adds `compact/start`, `compact/summary`, `compact/end`, etc. Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). Every `SessionEvent` carries two optional top-level fields (structural metadata): -- `sourceEventSeqs?: number[]` — seq numbers of provenance sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed nodes behind a compaction marker). +- `sourceEventSeqs?: number[]` — seq numbers of provenance sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed nodes behind a compaction replace node). - `surfaceOp?: SurfaceOp` — how this event entered the surface. Absent for non-surface events (boundaries, chunks, usage, errors). ### Metadata types (`types.ts`) @@ -68,7 +68,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) - Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log. - Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME invariants `append` enforces — including that every surface-eligible event (`SurfaceEventType`) carries a `surfaceOp` marker — so a marker-less message event is rejected at construction rather than silently vanishing from `deriveMessages()` (the surface is the sole derivation path) on resume. -- Compaction: a future plugin appends a new event with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes. +- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint. ### What is NOT here (TODO) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 41f878892f..2a32c7b3f0 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -156,7 +156,8 @@ export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap] * same events; trace/telemetry = subscribe to the log. * * Merge-extensible: plugins declare extra event types via declaration merging - * (e.g. a compaction plugin adds `'compaction/marker'`). + * (e.g. the compaction plugin adds `'compact/start'`, `'compact/summary'`, + * `'compact/end'`). * * Durability contract (what a persistence backend relies on): the durable log * persists every event verbatim, INCLUDING `assistant/chunk` — `seq` must stay @@ -279,7 +280,7 @@ export type SessionEvent = { /** * Seq numbers of events that are provenance sources of this event * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, - * or the surface nodes shadowed by a compaction marker). + * or the surface nodes shadowed by a compaction replace node). */ sourceEventSeqs?: number[] /** How this event entered the surface; absent for non-surface events. */ From ef37ce3b9dea48a63e41706fe82a2ffb12086914 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 26 Jun 2026 17:23:18 +0800 Subject: [PATCH 14/46] refactor(fs): split filesystem seam into provider ctx.fs + policy ctx.fileContext Implements the split-the-filesystem-seam RFC. ctx.fs shrinks to a text-storage provider seam (resolve/stat/readText/streamText/writeText/editText with branded FsTargetKey/FsVersion and an explicit FsWriteExpectation); the new dsh-file-context package owns the model-facing policy (read windowing, observed-state, write/edit freshness) as the concrete ctx.fileContext service. Authorization is now freshness-based rather than full/partial view: a windowed read records the file version and authorizes a later edit when the file is unchanged, removing the dead-end where reading lines 100-150 of a large file could not edit line 120. editText stays a provider primitive so version guard + literal match + atomic rewrite remain one critical section, and the stale check runs before matching so a stale edit reports FS_STALE_VERSION. tool-fs injects fileContext, never reaching around to ctx.fs (the no-bypass contract). --- AGENTS.md | 1 + docs/architecture.md | 8 +- docs/cordis-catalog/events-and-services.md | 47 ++- docs/core-data-structures/filesystem.md | 119 +++--- docs/module-graph.md | 8 +- docs/rfc/README.md | 1 + .../2026-06-26-fsspec-style-fs-seam.md | 120 ++++++ examples/coding-agent/cordis.yml | 2 +- packages/README.md | 8 +- packages/fs/README.md | 7 +- packages/fs/file-context/README.md | 43 +++ packages/fs/file-context/package.json | 31 ++ packages/fs/file-context/src/index.ts | 184 ++++++++++ packages/fs/file-context/src/types.ts | 56 +++ packages/fs/file-context/src/window.ts | 139 +++++++ packages/fs/file-context/tests/policy.spec.ts | 305 +++++++++++++++ packages/fs/file-context/tests/window.spec.ts | 102 ++++++ packages/fs/file-context/tsconfig.json | 14 + packages/fs/fs-local/README.md | 12 +- packages/fs/fs-local/src/fsio.ts | 301 ++++----------- packages/fs/fs-local/src/index.ts | 91 +++-- packages/fs/fs-local/tests/filesystem.spec.ts | 346 ++++++++---------- packages/fs/fs-local/tests/fsio.spec.ts | 310 ++++++---------- packages/fs/fs/README.md | 45 ++- packages/fs/fs/package.json | 2 + packages/fs/fs/src/index.ts | 255 ++++--------- packages/fs/fs/src/types.ts | 157 +++----- packages/fs/fs/tests/service.spec.ts | 312 +++------------- packages/fs/fs/tsconfig.json | 1 + packages/fs/tool-fs/README.md | 19 +- packages/fs/tool-fs/package.json | 2 + packages/fs/tool-fs/src/edit.ts | 10 +- packages/fs/tool-fs/src/index.ts | 15 +- packages/fs/tool-fs/src/read.ts | 15 +- packages/fs/tool-fs/src/write.ts | 12 +- packages/fs/tool-fs/tests/integration.spec.ts | 63 +++- packages/fs/tool-fs/tests/subpaths.spec.ts | 30 +- packages/fs/tool-fs/tests/tools.spec.ts | 134 ++++--- packages/fs/tool-fs/tsconfig.json | 3 +- pnpm-lock.yaml | 18 + scripts/type-equiv.manifest.json | 16 +- tsconfig.build.json | 1 + 42 files changed, 1899 insertions(+), 1466 deletions(-) create mode 100644 docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md create mode 100644 packages/fs/file-context/README.md create mode 100644 packages/fs/file-context/package.json create mode 100644 packages/fs/file-context/src/index.ts create mode 100644 packages/fs/file-context/src/types.ts create mode 100644 packages/fs/file-context/src/window.ts create mode 100644 packages/fs/file-context/tests/policy.spec.ts create mode 100644 packages/fs/file-context/tests/window.spec.ts create mode 100644 packages/fs/file-context/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index a68e13ccef..04ce3b49fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,6 +199,7 @@ Dev/test/demo run **unbuilt** via tsx + the `paths` map in the root `tsconfig.js - **Plugins, not loop changes**: new behavior goes into a plugin on the documented extension seams (see the plugin sanity checklist in docs/architecture.md). Changing `agent-loop` requires updating that doc. - **Capability seams are three packages**: when adding a swappable capability (an execution backend, a provider integration, …), split it into *interface* (abstract service + vocabulary types, e.g. `bash/`), *implementation* (a concrete subclass, e.g. `bash-local/`), and *consumer* (what the model/plugins see, e.g. `tool-bash/`). Implementations and consumers then evolve independently — a sandboxed executor replaces `bash-local` without touching tool schemas. The LLM seam follows the same shape (`llm/` is interface + consumer surface; adapters are implementations). See docs/architecture.md § "Capability seams" for when NOT to split. - **Explicit > implicit at package seams**: interface/vocabulary types spell out every field a consumer must supply — no optional field that the implementation silently fills with a hidden `?? default`. Put defaulting in the owning implementation as an explicit step (a `resolve(request): Spec` method that turns the optional-field request into the required-field spec), not smuggled inside `run()`/`start()`. Example: `dsh-bash` splits `BashExecRequest` (optional `workdir`/`timeoutMs`, model-facing) from `BashExecSpec` (required, what `run`/`start` act on); the tool layer calls `ctx.bash.resolve()` between them. The reader of a `BashExecSpec` never has to wonder where the working directory came from. +- **Opaque cross-boundary ids are branded, never bare `string`**: an identity that crosses a package seam and that a consumer must store-and-return but never parse (a backend-defined version token, a target key, a task/session/call id) is a `Branded` from `@deepseek-ai/dsh-brand` with a same-named cast factory in the owning package — a zero-cost compile-time guard so semantically-distinct strings stop being interchangeable. Not every string needs it: author-readable names (`ToolName`) and closed code unions (`ErrorCode`) don't. See [Branded IDs everywhere they belong](docs/rfc/implemented/architecture/2026-06-20-branded-ids.md). - **An empty `catch` must name what it swallows and why nothing else can hit it**: a bare `catch {}` hides bugs. When you deliberately ignore a throw, the comment must (a) name the single expected failure, (b) say why ignoring it is correct — usually because the useful state was already captured *before* the `try` — and (c) make clear nothing else of consequence can reach the catch (ideally the `try` wraps a single statement). Example: the error-body `response.json()` parse in `dsh-llm-deepseek`'s adapter sets `code` + HTTP `status` from the status line before the `try`, so a malformed provider body can only cost a richer message, never the real error. - **Symmetry is usually more correct**: when two related values play parallel roles (a test fixture and its expected output, a request shape and its response shape, a buggy input and the test that checks the fix), give them parallel form — both named consts, or both inline, not one each way. Asymmetry is a smell that usually points at a missed extraction. - **Merging PRs**: always merge with a **merge commit** (`gh pr merge --merge`), never squash or rebase. The per-PR commit history is intentional — review-fix commits, regression-test commits, and the reasoning in each message are part of the record — and squashing flattens it away. diff --git a/docs/architecture.md b/docs/architecture.md index 8f78a0b086..796026bb10 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,6 +25,7 @@ For a catalog of the **data structures** this architecture moves around — the │ @deepseek-ai/dsh-bash-local (bash impl) │ │ @deepseek-ai/dsh-tool-bash (bash tool schemas) │ │ @deepseek-ai/dsh-fs-local (filesystem impl) │ +│ @deepseek-ai/dsh-file-context (filesystem policy) │ │ @deepseek-ai/dsh-tool-fs (filesystem tool schemas) │ │ @deepseek-ai/dsh-session-persistence-jsonl (persistence impl)│ ├─────────────────────────────────────────────────────────────┤ @@ -35,7 +36,7 @@ For a catalog of the **data structures** this architecture moves around — the │ @deepseek-ai/dsh-session-persistence (persistence seam) │ │ @deepseek-ai/dsh-llm (abstract model service) │ │ @deepseek-ai/dsh-bash (abstract bash executor) │ -│ @deepseek-ai/dsh-fs (abstract filesystem) │ +│ @deepseek-ai/dsh-fs (filesystem provider seam) │ ├─────────────────────────────────────────────────────────────┤ │ vendor/: cordis, loader, include, group, timer, hmr, │ │ logger-console, cosmokit, schemastery │ @@ -56,7 +57,8 @@ Dependency rule: **extension** plugins depend on interface packages, never on `d | `ctx.agents` | `AgentRegistry` | dsh-agent | live `Agent` handles + the create/resume factory seam (returns an `AgentHandle` = `{ agent, dispose() }` for owned per-agent teardown) | | `ctx.agentLoop` | `AgentLoop` | dsh-agent-loop | creates `ReactLoopAgent`s and drives their loops | | `ctx.bash` | `BashExecutor` (abstract) | dsh-bash | bash execution seam: foreground runs + background tasks | -| `ctx.fs` | `FileSystem` (abstract) | dsh-fs | filesystem seam: path resolution, text reads, writes, edits, and observed-file policy | +| `ctx.fs` | `FileSystem` (abstract) | dsh-fs | filesystem provider seam: path resolution, stat, text read/stream, guarded writes/edits | +| `ctx.fileContext` | `FileContext` | dsh-file-context | filesystem policy: read windowing, observed-state, write/edit freshness over `ctx.fs` | All registrations (`registerAdapter`, `section`, `tools`, `register`, …) go through `ctx.effect()` and return disposers, so plugin hot-reload (vendored HMR) and fiber disposal clean up automatically. @@ -72,7 +74,7 @@ Swappable capabilities are split into **three packages** so each part evolves in The LLM seam has the same topology folded differently: `dsh-llm` carries the interface (`LlmAdapter`) AND the consumer surface (`ctx.llm.stream()`), with adapters as implementation packages — there the consumer is the loop itself, not a swappable schema surface. Use the full three-package split when the consumer is independently replaceable; keep interface + consumer together when they are one concern. Don't split preemptively: a capability with one conceivable implementation and one consumer stays one package until proven otherwise. -The filesystem capability follows the bash topology: `dsh-fs` owns the abstract `ctx.fs` service and observed-file policy, `dsh-fs-local` provides the local backend, and `dsh-tool-fs` exposes the model-facing `read`/`write`/`edit` schemas over the interface. +The filesystem capability follows the bash topology with a fourth layer: `dsh-fs` owns the abstract `ctx.fs` provider seam (text IO + guarded mutation primitives), `dsh-fs-local` provides the local backend, `dsh-file-context` is a concrete `ctx.fileContext` policy service (read windowing + observed-state + write/edit freshness, injecting `fs`), and `dsh-tool-fs` exposes the model-facing `read`/`write`/`edit` schemas over `ctx.fileContext`. The policy layer is a concrete service, not a second swappable seam — it owns the model-facing observation policy a sandboxed/remote backend has no business carrying. > **"Capability" — two unrelated meanings.** (1) The *seam pattern* above ("one plugin provides a capability, another needs it") is realized by plain Cordis **services + `inject`**: a provider registers a service (`ctx.bash`, declared in `interface Context`); a consumer declares `inject: ['bash']` and its fiber stays pending until the service exists, tearing down via HMR if it later vanishes. No extra library is needed. (2) `@cordisjs/plugin-capability` is a different axis entirely — a **permission/capability-security** service (named permissions with inheritance/dependency, tested against a session via `ctx.capability.test`). It is a candidate for the deferred permissions/sandbox work (the `tools/execute` veto seam), NOT a mechanism for swapping implementations. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 0d0cd7b90b..c4896ec0ad 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -279,7 +279,7 @@ Source: [`packages/core/tools/src/index.ts:43`](../../packages/core/tools/src/in ## Services -The 9 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. +The 10 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. ### `ctx.agentLoop` — `AgentLoop` @@ -339,33 +339,46 @@ Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../c Source: [`packages/bash/bash/src/index.ts:59`](../../packages/bash/bash/src/index.ts) +### `ctx.fileContext` — `FileContext` + +The file-context policy service. Injects `fs`, registers as `ctx.fileContext`, and is the only read/write/edit path the model-facing tools use. + +```ts cordis-catalog +owner(exec?: FileContextExec): object | undefined +async resolve(path: string): Promise +async read(target: FsTarget, request: FileReadRequest, exec?: FileContextExec, signal?: AbortSignal): Promise +async write(target: FsTarget, content: string, exec?: FileContextExec, signal?: AbortSignal): Promise +async edit(target: FsTarget, edit: FsEditRequest, exec?: FileContextExec, signal?: AbortSignal): Promise +``` + +Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) + +Source: [`packages/fs/file-context/src/index.ts:65`](../../packages/fs/file-context/src/index.ts) + ### `ctx.fs` — `FileSystem` (abstract seam) -Abstract filesystem service. Subclass, implement the four backend primitives (resolve, readPage, createOrReplace, applyEdit), 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). - -Consumers call the concrete public API (read/write/ edit), which derives the file-state owner, enforces the read-before-write/edit policy, and refreshes recorded state — then delegates the actual I/O to the backend primitives. +Abstract filesystem provider service. Subclass, implement the six text-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: -- resolve returns a stable FsTarget; the same underlying file reached by different input paths must yield the same `targetKey` so stale guards and file-state lookup agree across paths (e.g. through symlinks). -- readPage returns line-numbered UTF-8 content with a `version` and a `view` (`full` only when the page covered the whole file). -- createOrReplace honors the FsExpectation: `observed` rejects with `FS_STALE_VERSION` if the file changed since `version`; `partial` rejects existing targets because the owner saw only a non-editable view; `unobserved` creates iff the target is absent and otherwise rejects. -- applyEdit verifies the expected version (stale guard) and is atomic (read-modify-write must not interleave with a concurrent edit). +- resolve returns a stable 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). +- stat returns FsInfo metadata (never content) or `undefined` when the target is absent. +- readText/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`. +- writeText is atomic temp-file + rename honoring the FsWriteExpectation. +- 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. ```ts cordis-catalog abstract resolve(path: string): Promise -abstract readPage(target: FsTarget, request: FsReadRequest, signal?: AbortSignal): Promise -abstract createOrReplace(target: FsTarget, content: string, expected: FsExpectation, signal?: AbortSignal): Promise -abstract applyEdit(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise -owner(exec?: FsExecContext): object | undefined -async read(target: FsTarget, request: FsReadRequest, exec?: FsExecContext, signal?: AbortSignal): Promise -async write(target: FsTarget, content: string, exec?: FsExecContext, signal?: AbortSignal): Promise -async edit(target: FsTarget, edit: FsEditRequest, exec?: FsExecContext, signal?: AbortSignal): Promise +abstract stat(target: FsTarget, signal?: AbortSignal): Promise +abstract readText(target: FsTarget, signal?: AbortSignal): Promise +abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> +abstract writeText(target: FsTarget, content: string, expected: FsWriteExpectation, signal?: AbortSignal): Promise +abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise ``` -Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsExecContext](../core-data-structures/filesystem.md) · [FsExpectation](../core-data-structures/filesystem.md) · [FsReadOutcome](../core-data-structures/filesystem.md) · [FsReadRequest](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) +Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:94`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:90`](../../packages/fs/fs/src/index.ts) ### `ctx.llm` — `LlmService` diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index fc80891ef6..99d073c6d4 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -1,80 +1,49 @@ # Filesystem -The filesystem execution seam is split across three packages: interface ([dsh-fs](../../packages/fs/fs), `ctx.fs`), implementation ([dsh-fs-local](../../packages/fs/fs-local), local disk), and consumer ([dsh-tool-fs](../../packages/fs/tool-fs), the model-facing `read`/`write`/`edit` tools). Filesystem access is an optional capability, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). A sandboxed, remote, virtual, or project-scoped backend can implement the same `FileSystem` service without changing the tool schemas. +The filesystem stack is split across four packages: a provider seam ([dsh-fs](../../packages/fs/fs), `ctx.fs`, text IO + guarded mutation), a local implementation ([dsh-fs-local](../../packages/fs/fs-local), local disk), a policy layer ([dsh-file-context](../../packages/fs/file-context), `ctx.fileContext`, read windowing + write/edit freshness), and a consumer ([dsh-tool-fs](../../packages/fs/tool-fs), the model-facing `read`/`write`/`edit` tools). Filesystem access is an optional capability, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). A sandboxed, remote, virtual, or project-scoped backend can implement the same `FileSystem` service without changing the policy layer or the tool schemas. -Source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) and [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts) +Provider source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) and [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts). Policy source: [`packages/fs/file-context/src/types.ts`](../../packages/fs/file-context/src/types.ts). -## Execution context and target identity +## Target identity and metadata (provider seam) -The filesystem seam needs just enough execution context to derive the observed-file owner. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through without making `dsh-fs` import the tool, agent, or session packages. - -```ts type-equiv -interface FsExecContext { - agent?: { - session?: object - } -} -``` - -Every operation resolves a user-supplied path to an opaque backend target first. Consumers may display `displayPath`, but must not parse `targetKey` or assume it is a local absolute path. +Every operation resolves a user-supplied path to an opaque backend target first. Consumers may display `displayPath`, but must not parse `targetKey` (a branded opaque id) or assume it is a local absolute path. ```ts type-equiv interface FsTarget { inputPath: string - targetKey: string + targetKey: FsTargetKey displayPath: string } ``` -The backend also owns file-version tokens. `ctx.fs` stores them for stale checks; consumers do not interpret them. +The backend owns file-version tokens — the freshness token a write/edit guards against. The policy layer stores them for stale checks; consumers do not interpret them. Both ids are branded opaque strings. ```ts type-equiv -type FsVersion = string -``` - -## Reads and editable views - -A text read is bounded by line window, byte cap, and backend limits. The returned view records whether the owner saw the whole file or only a partial page; only a `full` view authorizes later write/edit. - -```ts type-equiv -interface FsReadRequest { - offset: number - limit: number -} +type FsTargetKey = Branded<'FsTargetKey'> ``` ```ts type-equiv -interface FsTextLine { - number: number - text: string -} +type FsVersion = Branded<'FsVersion'> ``` -```ts type-equiv -type FsView = 'full' | 'partial' -``` +`stat` returns metadata (never content), or `undefined` when the target is absent. `type` lets the policy layer reject directories/special files before reading, and `size` lets it choose `readText` vs `streamText` without probing by failure. ```ts type-equiv -interface FsReadOutcome { - offset: number - limit: number - lines: FsTextLine[] - totalLines: number - truncatedByBytes?: true +interface FsInfo { version: FsVersion - view: FsView + type: 'file' | 'directory' | 'other' + size?: number } ``` -## Write and edit guards +## Write and edit guards (provider seam) -The base `FileSystem` service converts recorded state into an `FsExpectation` before calling the backend. `observed` carries the stale guard, `partial` means the owner saw a non-editable view, and `unobserved` allows create-if-absent but rejects blind overwrite. +`writeText` takes an explicit write expectation rather than inferring intent. `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. ```ts type-equiv -type FsExpectation = - | { kind: 'observed'; version: FsVersion } - | { kind: 'partial'; version: FsVersion } - | { kind: 'unobserved' } +type FsWriteExpectation = + | { kind: 'createIfAbsent' } + | { kind: 'replaceIfVersion'; version: FsVersion } ``` ```ts type-equiv @@ -84,7 +53,7 @@ interface FsWriteOutcome { } ``` -Literal edit is a backend operation, not a `read` plus `write` composed in the tool wrapper. That keeps matching, line-ending handling, stale checks, and atomic replacement inside the filesystem seam. +`editText` is a provider-level guarded mutation, not a `read` plus `write` composed in the policy layer. It verifies the expected version BEFORE literal matching (so a stale edit reports `FS_STALE_VERSION`, not a match failure against newer content), then applies the replacement and writes atomically — keeping matching, line-ending handling, stale checks, and atomic replacement inside one mutation critical section. ```ts type-equiv interface FsEditRequest { @@ -102,26 +71,43 @@ interface FsEditOutcome { } ``` -## Observed-file state +## Execution context and read outcome (policy layer) -Observed state is keyed inside the service by owner object and `FsTarget.targetKey`. The owner is normally `exec.agent.session`, but `dsh-fs` treats it as opaque and never reads its fields. A successful read/write/edit refreshes this state for that owner. +The policy layer needs just enough execution context to derive the observed-state owner. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through without making `dsh-file-context` import the tool, agent, or session packages. ```ts type-equiv -type FsStateSource = 'read' | 'write' | 'edit' -``` - -```ts type-equiv -interface FileState { - targetKey: string - displayPath: string - version: FsVersion - view: FsView - updatedAt: number - source: FsStateSource +interface FileContextExec { + agent?: { + session?: object + } } ``` -## Error taxonomy +A text read is bounded by line window, byte cap, and backend limits. The outcome the model-facing `read` tool renders carries the file's version at read time; there is no `full`/`partial` view — authorization is freshness-based, so any windowed read can authorize a later write/edit when the file is unchanged. + +```ts type-equiv +interface FileReadRequest { + offset: number + limit: number +} +``` + +```ts type-equiv +interface FileReadOutcome { + offset: number + limit: number + lines: FileTextLine[] + totalLines: number + truncatedByBytes?: true + version: FsVersion +} +``` + +## Observed-file state (policy layer) + +Observed state is a `WeakMap>` inside `ctx.fileContext`. An entry exists **iff** the owner has read that target through `ctx.fileContext.read`, so its presence *is* the read record — there is no separate `hasRead` flag and no view distinction. The owner is normally `exec.agent.session`, but the policy layer treats it as opaque and never reads its fields. A successful read/write/edit refreshes the recorded version for that owner; disposal drops everything (HMR safety). + +## Error taxonomy (provider seam) Filesystem failures use stable `FsErrorCode` strings carried by `FsError` (`HarnessError`). The tool registry preserves `{ name, code }` on error results, so retry, permission, and UI layers can branch without parsing text. @@ -132,14 +118,13 @@ type FsErrorCode = | 'FS_NOT_REGULAR_FILE' | 'FS_STALE_VERSION' | 'FS_NOT_OBSERVED' - | 'FS_PARTIAL_OBSERVATION' | 'FS_AMBIGUOUS_EDIT' | 'FS_EDIT_NOT_FOUND' | 'FS_ABORTED' ``` -`FS_NOT_OBSERVED` means no usable prior observation exists. `FS_PARTIAL_OBSERVATION` means the owner saw only a partial read. `FS_STALE_VERSION` means there was a prior full observation, but the backend version no longer matches. +`FS_NOT_OBSERVED` means no recorded read exists for this owner (or a `createIfAbsent` hit an existing file). `FS_STALE_VERSION` means the backend version no longer matches the observed one. Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`. -## The service +## The services -`FileSystem` (`ctx.fs`, abstract) owns the shared orchestration: `resolve`, `readPage`, `createOrReplace`, and `applyEdit` are backend primitives; public `read`, `write`, and `edit` derive/record owner state and enforce the read-before-write/edit policy before delegating to the backend. The generated wiring catalog shows the exact service signatures on [events-and-services.md](../cordis-catalog/events-and-services.md#ctxfs--filesystem-abstract-seam). +`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `stat`, `readText`, `streamText`, `writeText`, and `editText`. `FileContext` (`ctx.fileContext`, concrete) injects `fs` and owns the model-facing policy: `read` windows text and records observed state, `write`/`edit` derive the freshness expectation and refresh state. The generated wiring catalog shows the exact service signatures on [events-and-services.md](../cordis-catalog/events-and-services.md#ctxfs--filesystem-abstract-seam). diff --git a/docs/module-graph.md b/docs/module-graph.md index 5a69585ef8..ad3d7f7b89 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -10,6 +10,7 @@ graph TD bash --> brand llm --> brand bash-local --> bash + fs --> brand fs --> llm llm-deepseek --> llm llm-pi-ai --> llm @@ -19,6 +20,7 @@ graph TD agent --> brand agent --> llm agent --> session + file-context --> fs fs-local --> fs llm-replay --> llm llm-replay --> session @@ -51,6 +53,7 @@ graph TD tool-bash --> bash tool-bash --> llm tool-bash --> tools + tool-fs --> file-context tool-fs --> fs tool-fs --> llm tool-fs --> system-prompt @@ -79,12 +82,13 @@ graph TD | `bash` | `brand` | | `llm` | `brand` | | `bash-local` | `bash` | -| `fs` | `llm` | +| `fs` | `brand`, `llm` | | `llm-deepseek` | `llm` | | `llm-pi-ai` | `llm` | | `session` | `brand`, `llm` | | `system-prompt` | `llm` | | `agent` | `brand`, `llm`, `session` | +| `file-context` | `fs` | | `fs-local` | `fs` | | `llm-replay` | `llm`, `session` | | `session-persistence` | `session` | @@ -96,7 +100,7 @@ graph TD | `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` | | `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` | | `tool-bash` | `agent`, `bash`, `llm`, `tools` | -| `tool-fs` | `fs`, `llm`, `system-prompt`, `tools` | +| `tool-fs` | `file-context`, `fs`, `llm`, `system-prompt`, `tools` | | `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` | | `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` | | `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `ui-stdio` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 7f56757be0..cf9c343206 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -94,6 +94,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Prune dead methods from the persistence seam](implemented/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | | [Keep one public stop primitive](implemented/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | | [Fold trace-only session facts into load-bearing events](implemented/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | +| [Split the filesystem seam — provider text mutations plus policy `ctx.fileContext`](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | ### Architecture diff --git a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md new file mode 100644 index 0000000000..25ff66816f --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md @@ -0,0 +1,120 @@ +# RFC: Split the filesystem seam — provider text mutations plus policy `ctx.fileContext` + +Status: implemented + +## Problem + +The filesystem capability from [filesystem-capability-seam](../../implemented/architecture/2026-06-17-filesystem-capability-seam.md) currently makes one abstract `FileSystem` service own two different jobs: + +1. **Provider operations** — resolving targets, stat/version metadata, text reads/streams, atomic writes, and guarded literal edits. +2. **Agent-facing policy** — line windows, literal edit semantics, and read-before-write/edit observed-state. + +That makes every future backend reimplement model-facing read semantics and observation policy. `readPage` returns numbered lines and view metadata; the base service stores per-owner file state and distinguishes `full` from `partial` reads. Those are useful policies, but they are not filesystem-provider primitives. Literal text mutation is different: version guard, literal match, ambiguity detection, and atomic rewrite must stay together inside the provider mutation boundary, but the current `applyEdit` name and surrounding seam tie that provider operation to the old read-before-edit policy shape. + +This also creates a real UX dead-end: a windowed read records `view: partial`, and partial views cannot authorize `edit`. A model that reads lines 100-150 of a large file therefore cannot edit line 120 unless it first gets a `full` read, which may be impossible for a file past the read cap. Literal edit only needs freshness: the bytes being matched must still be from the version the model read. + +The old RFC already deferred a separate `@deepseek-ai/dsh-file-context` package. This RFC builds that layer and keeps `ctx.fs` close to fsspec-style storage primitives (`info`/`cat`/`open`), without turning it into full fsspec. + +## Decision + +Split the stack into four layers: + +```text +tool dsh-tool-fs model-facing schemas + text rendering +policy dsh-file-context ctx.fileContext (concrete service): observed-state, read windowing, write/edit freshness +provider seam dsh-fs ctx.fs: text IO + guarded mutation primitives +provider dsh-fs-local local implementation of ctx.fs +``` + +`dsh-tool-fs` keeps the same model-facing `read`/`write`/`edit` schemas. It injects `fileContext`, not `fs`, and never reaches around the policy layer for model reads/writes/edits. + +## Provider Contract + +`@deepseek-ai/dsh-fs` shrinks to provider text IO plus guarded text mutation: + +```ts ignore-check +abstract resolve(path: string): Promise +abstract stat(target: FsTarget, signal?: AbortSignal): Promise +abstract readText(target: FsTarget, signal?: AbortSignal): Promise +abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> +abstract writeText(target: FsTarget, content: string, expected: FsWriteExpectation, signal?: AbortSignal): Promise +abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise + +interface FsInfo { + version: FsVersion + type: 'file' | 'directory' | 'other' + size?: number +} + +type FsWriteExpectation = + | { kind: 'createIfAbsent' } + | { kind: 'replaceIfVersion'; version: FsVersion } +``` + +`stat` returns metadata, not content. `version` is the freshness token; `type` lets the policy reject directories/special files before reading; `size` lets `ctx.fileContext.read` choose `readText` vs `streamText` without probing by failure. `undefined` means absent. + +`readText` reads the whole regular text file. `streamText` streams the same text semantics for large files. Both provider primitives own regular-file checks, UTF-8 decoding, binary/NUL rejection, and `FS_NOT_TEXT`; the policy layer never handles raw bytes or reimplements cross-chunk decoding. `readText` is the small-file/direct whole-file primitive, while large model-facing reads use `streamText`. + +`writeText` is atomic temp-file + rename with an explicit write expectation. `createIfAbsent` creates a missing target and rejects an existing target with `FS_NOT_OBSERVED`; it is the path used when the owner has no prior read. `replaceIfVersion` replaces only when the target exists at the observed version; a missing target or version mismatch throws `FS_STALE_VERSION`. + +`editText` is a provider-level guarded text mutation. It first verifies the target still exists at `expected.version`, then reads the current text, applies literal replacement, and writes atomically. The stale check must happen before literal matching so an edit based on an old read reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND` or `FS_AMBIGUOUS_EDIT` from matching against newer content. Keeping this primitive on the provider seam also preserves backend-local locking and lets a future remote backend implement native compare-and-edit without forcing `ctx.fileContext` to pull the whole file through the policy layer. + +This is a *text-storage* seam, deliberately half a level above byte-level fsspec (`cat`/`open` hand back raw bytes). UTF-8 decoding, binary/NUL rejection, guarded full-file writes, and guarded literal text edits live in the provider so the policy layer never touches raw bytes, reimplements cross-chunk decoding, or separates stale checks from the mutation critical section. Model-facing concepts still stay out of the provider: no line windows, numbered lines, rendered footers, or observed-state store leak down. + +Deleted from `dsh-fs`: `readPage`, `FsExpectation`, `FsView`, `FsStateSource`, `FsReadRequest`, `FsTextLine`, line/window constants, `formatReadBody`, and the observed-state `WeakMap`. `applyEdit` is replaced by the narrower provider primitive `editText`, whose contract is version-guarded literal text mutation rather than policy-layer read authorization. The `FS_PARTIAL_OBSERVATION` code also leaves the `FsErrorCode` taxonomy: freshness authorization has no partial/full distinction, so nothing can raise it. `FsTargetKey` and `FsVersion` become branded opaque ids under the existing [branded-ids RFC](../../implemented/architecture/2026-06-20-branded-ids.md). + +## Policy Contract + +`@deepseek-ai/dsh-file-context` registers concrete service `ctx.fileContext` and injects `fs`. It is a concrete service, not a seam: it owns the read-windowing and write/edit freshness policy that does not belong on the `FileSystem` provider base class (where a sandboxed/remote backend would otherwise inherit model-facing observation policy it has no business carrying). + +Observed state lives here as `WeakMap>`. An entry exists iff the owner has read that target through `ctx.fileContext.read`, so its presence *is* the read record — there is no separate `hasRead` flag. The owner is still derived structurally from `{ agent?: { session? } }`, but that shape no longer belongs to `dsh-fs`. + +`read(target, request, exec?, signal?)` is the only read path used by the model-facing `read` tool. It stats the target, rejects absent/non-regular targets, chooses `readText` or `streamText` from `FsInfo`, builds the requested line window from text chunks, records `{ version: info.version }`, and returns the structured outcome that the tool renders. + +`write(target, content, exec?, signal?)` uses freshness policy: no recorded read calls `writeText({ kind: 'createIfAbsent' })`, so only new files can be created blindly; a recorded read calls `writeText({ kind: 'replaceIfVersion', version: vObserved })`, so existing files are replaced only if they are unchanged since the read. A successful write refreshes recorded state from the returned outcome or a post-write `stat`. + +`edit(target, edit, exec?, signal?)` requires a recorded read at `vObserved`, then calls `ctx.fs.editText(target, edit, { version: vObserved })` and refreshes recorded state from the returned version. `ctx.fileContext` does not implement literal replacement itself; it authorizes the operation and passes the observed version to the provider. The provider owns the mutation critical section, so concurrent edits based on the same observed version remain one-wins/one-stale rather than being merged or re-applied. If a backend needs a defensive whole-file edit cap, it should surface that as the same filesystem error taxonomy, but large model-facing reads should stream instead of failing just because the file is large. + +## Tool Contract + +`dsh-tool-fs` keeps the same schemas and prompt surface. `read` still exposes `file_path`, `offset`, and `limit`; `write` and `edit` are unchanged. + +The tool package only validates model args, calls `ctx.fileContext`, and renders results (`N: text`, footer, `/` envelope). The no-bypass rule is part of the contract: a model-facing `read` must call `ctx.fileContext.read`, never `ctx.fs.readText` or `ctx.fs.streamText`, so every successful read records observed-state before rendering. + +Direct `ctx.fs` calls are still allowed for non-tool consumers. They are explicit escape hatches: a direct `ctx.fs.readText` records no observed-state, so a later `ctx.fileContext.edit` rejects with `FS_NOT_OBSERVED` until the file is read through `ctx.fileContext`. + +## Concurrency Boundary + +In-process updates are safe: the local backend keeps the existing per-target mutation lock, so version-check-then-rename is serialized and a losing update sees `FS_STALE_VERSION`. + +In-process creates are guarded by the same per-target mutation lock: two callers racing with `createIfAbsent` serialize, one creates, and the next sees the target exists and receives `FS_NOT_OBSERVED`. Cross-process creates are best-effort only; a local stat-then-rename guard cannot make portable create-exclusive guarantees across all future backends. + +Cross-process writes are best-effort freshness plus atomic replacement: `mtime:size` usually catches editor saves, but same-tick same-size writes can miss; atomic temp+rename prevents torn files but not every lost update. + +## Supersedes + +This RFC reverses two decisions from [filesystem-capability-seam](../../implemented/architecture/2026-06-17-filesystem-capability-seam.md) and narrows a third: + +- Read-before-write/edit policy moves out of `ctx.fs` and into `ctx.fileContext`. +- Text reads no longer return backend-numbered line records or `full`/`partial` views; authorization is based on version freshness, so a windowed read can authorize edit when the file is unchanged. +- Literal edit no longer sits behind the old `applyEdit` API that mixed backend mutation with seam-owned observation policy. It remains a provider primitive as `editText`, because version guard + literal match + atomic rewrite must stay inside the provider's mutation critical section. + +It keeps the interface/implementation/consumer discipline, consumer-never-imports-backend rule, backend-defined target/version/display metadata, atomic local writes, and the shared `FsError` taxonomy. + +## Acceptance Criteria + +- `dsh-fs` exposes exactly `resolve`/`stat`/`readText`/`streamText`/`writeText`/`editText`; `stat` returns `FsInfo | undefined`; `writeText` uses `FsWriteExpectation` (`createIfAbsent` or `replaceIfVersion`); removed types/primitives are gone, and the old `applyEdit` API is replaced by `editText`. +- `dsh-file-context` registers `ctx.fileContext`, owns observed-state plus `read`/`write`/`edit` policy, injects `fs`, and has HMR/disposal coverage. +- `dsh-tool-fs` injects `fileContext`; model-facing schemas stay byte-for-byte unchanged; the no-bypass contract and escape-hatch contract are documented and tested. +- Windowed read authorizing edit is shown to fail on the pre-refit code and pass after the refit. Existing version-CAS behavior is preserved with a regression test; it is not claimed as a pre-refit failure. An edit based on a stale read must report `FS_STALE_VERSION` before attempting literal matching. +- `dsh-fs-local` carries no line, view, or `formatReadBody` logic; it does carry provider-level `editText` logic. +- Docs and generated artifacts are updated: `docs/architecture.md`, `packages/README.md`, fs package READMEs, `docs/core-data-structures/filesystem.md`, affected `type-equiv` blocks and `scripts/type-equiv.manifest.json`, Cordis catalog, module graph, and doc references. +- Gates stay green: normal `doc-sync`, `pnpm run knip`, and `pnpm run test:coverage` with 100% per-file coverage. + +## Risks + +- Adds a fourth fs package and a new service. This is intentional: it is the previously deferred policy layer, not a second abstract backend seam. +- Direct `ctx.fs` use can surprise callers who later use `ctx.fileContext`. The failure is explicit (`FS_NOT_OBSERVED`) and documented. +- Large-file line windowing moves from the backend to `ctx.fileContext.read`; text decoding and binary rejection stay in `ctx.fs.streamText`, so this is relocation of windowing only, not a second text-IO implementation. +- Keeping `editText` in the provider seam means every backend must implement the literal replacement contract. This is intentional: the operation is not pure storage, but stale guard + literal match + atomic rewrite is the unit that must stay together for correct error attribution and concurrency behavior. The contract should stay narrow and text-only so future backends can implement it natively or by whole-file rewrite. +- Freshness permits full-file `write` after a windowed read. That is weaker than the old view check, but avoids making large files impossible to edit; prompt guidance should still discourage blind full replaces. diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 497aa8896a..57c1e3dd2f 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -25,8 +25,8 @@ apiKey: !!js process.env.DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_BASE_URL models: - - deepseek-v4-flash - deepseek-v4-pro + - deepseek-v4-flash # Local bash executor (the model's only tool, via agent-core's tool-bash schema). - id: bash diff --git a/packages/README.md b/packages/README.md index dd418f97ca..265fdd8676 100644 --- a/packages/README.md +++ b/packages/README.md @@ -31,9 +31,10 @@ dsh-agent ← dsh-llm, dsh-session, dsh-brand dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent dsh-bash-local ← dsh-bash (BashExecutor impl) dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas) -dsh-fs ← dsh-llm (abstract filesystem seam) +dsh-fs ← dsh-llm, dsh-brand (filesystem provider seam) dsh-fs-local ← dsh-fs (FileSystem impl) -dsh-tool-fs ← dsh-fs, dsh-tools (file tool schemas) +dsh-file-context ← dsh-fs (read windowing + write/edit freshness policy) +dsh-tool-fs ← dsh-file-context, dsh-fs, dsh-tools (file 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-system-prompt, dsh-tools, dsh-agent @@ -62,8 +63,9 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop | `bash/` | `bash` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` | | `bash-local/` | `bash` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) | | `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) | -| `fs/` | `fs` | Abstract filesystem seam (interface + vocabulary + observed-file policy) | `ctx.fs` | +| `fs/` | `fs` | Filesystem provider seam: text IO + guarded mutation primitives | `ctx.fs` | | `fs-local/` | `fs` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | +| `file-context/` | `fs` | Policy layer: read windowing, observed-state, write/edit freshness | `ctx.fileContext` | | `tool-fs/` | `fs` | Model-facing `read`/`write`/`edit` tool schemas | (registers on `ctx.tools`) | | `llm-deepseek/` | `llm` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | | `llm-pi-ai/` | `llm` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) | diff --git a/packages/fs/README.md b/packages/fs/README.md index 15757698b2..a793a94b4b 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -1,11 +1,12 @@ # fs/ - filesystem capability family -The filesystem capability seam: an abstract filesystem interface, a local implementation, and the model-facing file tools. All **product** packages. +The filesystem stack: a provider seam (text IO + guarded mutation), a local implementation, a policy layer (read windowing + write/edit freshness), and the model-facing file tools. All **product** packages. | Package | Role | ctx key | |---|---|---| -| `fs/` | Abstract filesystem seam (interface + vocabulary + observed-file policy) | `ctx.fs` | +| `fs/` | Provider seam: text IO + guarded mutation primitives | `ctx.fs` | | `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | +| `file-context/` | Policy layer: observed-state, read windowing, write/edit freshness | `ctx.fileContext` | | `tool-fs/` | Model-facing `read`/`write`/`edit` tool schemas | (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 interface or model-facing tool schemas. +The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy layer, or the model-facing tool schemas. The policy layer (`file-context/`) is a concrete service, not a swappable seam — it owns the model-facing observation policy that does not belong on a provider backend. diff --git a/packages/fs/file-context/README.md b/packages/fs/file-context/README.md new file mode 100644 index 0000000000..373fb2759b --- /dev/null +++ b/packages/fs/file-context/README.md @@ -0,0 +1,43 @@ +# @deepseek-ai/dsh-file-context + +The **file-context policy layer**: a concrete `ctx.fileContext` service that owns model-facing read windowing and write/edit freshness on top of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). This is the policy third of the filesystem stack — it is **not** a swappable seam, but the deferred policy layer that does not belong on the `FileSystem` provider base class. + +```ts +import type { Context } from 'cordis' +import FileContext from '@deepseek-ai/dsh-file-context' + +declare const ctx: Context + +// A ctx.fs provider must already be loaded (e.g. @deepseek-ai/dsh-fs-local); +// FileContext injects `fs` and registers ctx.fileContext. Load +// @deepseek-ai/dsh-tool-fs afterwards to expose read/write/edit to the model. +await ctx.plugin(FileContext) +``` + +## The four-layer split + +| Layer | Package | Role | +|---|---|---| +| tool | `@deepseek-ai/dsh-tool-fs` | model-facing schemas + text rendering | +| policy | `@deepseek-ai/dsh-file-context` (this) | `ctx.fileContext`: observed-state, read windowing, write/edit freshness | +| provider seam | `@deepseek-ai/dsh-fs` | `ctx.fs`: text IO + guarded mutation primitives | +| provider | `@deepseek-ai/dsh-fs-local` | local implementation of `ctx.fs` | + +## Service API (`ctx.fileContext`) + +| Member | Semantics | +|---|---| +| `read(target, request, exec?, signal?)` | Stats the target, rejects absent/non-regular targets, chooses `readText`/`streamText` by size, builds the requested line window, records the version, and returns the `FileReadOutcome` the tool renders. | +| `write(target, content, exec?, signal?)` | No recorded read → `writeText({ kind: 'createIfAbsent' })` (only new files create blindly); a recorded read → `writeText({ kind: 'replaceIfVersion', version })`. Refreshes recorded state on success. | +| `edit(target, edit, exec?, signal?)` | Requires a recorded read by this owner (else `FS_NOT_OBSERVED`); passes the observed version to `ctx.fs.editText` as the stale guard and refreshes recorded state. | +| `owner(exec?)` | Derives the observed-state owner (`exec.agent.session`) — `undefined` when there is none. | + +## Observed state is the read record, freshness is the authorization + +Observed state is a `WeakMap>`. An entry exists **iff** the owner has read that target through `read`, so its presence *is* the read record — there is no `hasRead` flag and no `full`/`partial` view. Authorization is based on version freshness only: 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 (the provider's stale guard enforces it). State is held weakly and dropped on disposal (HMR safety); persistence across sessions is deferred. + +## The no-bypass contract + +A model-facing read MUST go through `ctx.fileContext.read`, never `ctx.fs.readText`/`streamText`, so every successful read records observed state before the tool renders. Direct `ctx.fs` calls remain an explicit escape hatch for non-tool consumers: a direct `ctx.fs.readText` records nothing, so a later `ctx.fileContext.edit` rejects with `FS_NOT_OBSERVED` until the file is read through `ctx.fileContext`. + +The line-windowing mechanics live in `src/window.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the service wiring and policy. diff --git a/packages/fs/file-context/package.json b/packages/fs/file-context/package.json new file mode 100644 index 0000000000..77c905703b --- /dev/null +++ b/packages/fs/file-context/package.json @@ -0,0 +1,31 @@ +{ + "name": "@deepseek-ai/dsh-file-context", + "description": "File-context policy layer (ctx.fileContext) for the DeepSeek Harness — read windowing and write/edit freshness over the ctx.fs provider seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "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" + } +} diff --git a/packages/fs/file-context/src/index.ts b/packages/fs/file-context/src/index.ts new file mode 100644 index 0000000000..6dbb33d1d2 --- /dev/null +++ b/packages/fs/file-context/src/index.ts @@ -0,0 +1,184 @@ +/** + * The file-context policy layer (`ctx.fileContext`): a concrete service that + * owns model-facing read windowing and write/edit freshness on top of the + * `ctx.fs` provider seam. It is NOT a swappable seam — it is the previously + * deferred policy layer that does not belong on the `FileSystem` provider base + * class (where a sandboxed/remote backend would otherwise inherit model-facing + * observation policy it has no business carrying). + * + * ## Observed state IS the read record + * + * Observed state lives here as `WeakMap>`. An + * entry exists iff the owner has read that target through {@link read}, so its + * presence *is* the read record — there is no separate `hasRead` flag. The owner + * is derived structurally from `{ agent?: { session? } }` and held weakly, so a + * collected session frees its state; disposal drops everything (HMR safety). + * + * ## Freshness, not full/partial views + * + * Authorization is based on version freshness only. A windowed read records the + * file's version, and any later write/edit at that version is authorized — a + * model that read lines 100-150 of a large file can still edit line 120 as long + * as the file is unchanged. There is no `full`/`partial` distinction: the bytes + * the edit matches must merely come from the version the model read, which the + * provider's stale guard enforces. + * + * ## The no-bypass contract + * + * A model-facing read MUST go through {@link read} (never `ctx.fs.readText`/ + * `streamText` directly), so every successful read records observed state before + * the tool renders. Direct `ctx.fs` calls are allowed for non-tool consumers but + * record nothing, so a later {@link edit} rejects with `FS_NOT_OBSERVED` until + * the file is read through `ctx.fileContext`. + * + * @module @deepseek-ai/dsh-file-context + */ + +import { Context, Service } from 'cordis' +import { FsError } from '@deepseek-ai/dsh-fs' +import type { FsTarget, FsVersion, FsEditRequest, FsEditOutcome, FsWriteOutcome } from '@deepseek-ai/dsh-fs' +import { buildWindow } from './window.ts' +import type { FileContextExec, FileReadRequest, FileReadOutcome } from './types.ts' + +export type { FileTextLine, ReadWindow, WindowResult } from './window.ts' +export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow } from './window.ts' +export type { FileContextExec, FileReadRequest, FileReadOutcome } from './types.ts' + +/** Files at or above this size stream; smaller files read whole into memory. */ +export const STREAM_MIN_SIZE = 10 * 1024 * 1024 + +declare module 'cordis' { + interface Context { + fileContext: FileContext + } +} + +/** What an owner has observed about one target: just the version it last saw. */ +interface ObservedState { + version: FsVersion +} + +/** + * The file-context policy service. Injects `fs`, registers as `ctx.fileContext`, + * and is the only read/write/edit path the model-facing tools use. + */ +export class FileContext extends Service { + static inject = ['fs'] + + /** + * 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 read record. + */ + private observed = new WeakMap>() + + constructor(ctx: Context) { + super(ctx, 'fileContext') + ctx.effect(() => () => { + // Drop all recorded state on disposal so a reloaded service starts clean + // (HMR safety). The WeakMap itself would be GC'd, but replacing it makes + // the release observable and immediate for tests. + this.observed = new WeakMap() + }, 'fileContext observed-state teardown') + } + + /** + * Derive the observed-state owner from an execution context — 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. + */ + owner(exec?: FileContextExec): object | undefined { + return exec?.agent?.session + } + + private getObserved(owner: object, targetKey: string): ObservedState | undefined { + return this.observed.get(owner)?.get(targetKey) + } + + private record(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 }) + } + + /** + * Resolve a path into a stable {@link FsTarget}, delegating to the provider. + * Exposed here so the model-facing tools never need to inject `ctx.fs` + * directly — they resolve and then read/write/edit entirely through + * `ctx.fileContext`. + */ + async resolve(path: string): Promise { + return this.ctx.fs.resolve(path) + } + + /** + * Read a bounded line window from a target. Stats first (rejecting an absent + * target with `FS_NOT_FOUND` and a non-regular one with `FS_NOT_REGULAR_FILE`), + * chooses `readText` vs `streamText` by size, builds the window, and — when an + * owner is derivable — records the version so a later write/edit is authorized. + */ + async read(target: FsTarget, request: FileReadRequest, exec?: FileContextExec, signal?: AbortSignal): Promise { + const info = await this.ctx.fs.stat(target, 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') + + const chunks = info.size !== undefined && info.size >= STREAM_MIN_SIZE + ? await this.ctx.fs.streamText(target, signal) + : [await this.ctx.fs.readText(target, signal)] + const window = await buildWindow(chunks, request, target.displayPath) + + const owner = this.owner(exec) + if (owner) this.record(owner, target.targetKey, info.version) + return { + offset: request.offset, + limit: request.limit, + lines: window.lines, + totalLines: window.totalLines, + version: info.version, + ...window.truncatedByBytes ? { truncatedByBytes: true } : {}, + } + } + + /** + * Create or fully replace a file. With no recorded read, writes + * `createIfAbsent` (only new files can be created blindly); with a recorded + * read, writes `replaceIfVersion` at the observed version (existing files are + * replaced only if unchanged since the read). Refreshes recorded state from + * the returned version on success. + */ + async write(target: FsTarget, content: string, exec?: FileContextExec, signal?: AbortSignal): Promise { + const owner = this.owner(exec) + const prior = owner ? this.getObserved(owner, target.targetKey) : undefined + const outcome = await this.ctx.fs.writeText( + target, + content, + prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }, + signal, + ) + if (owner) this.record(owner, target.targetKey, outcome.version) + return outcome + } + + /** + * Apply a literal edit. Requires a recorded read by this owner (else + * `FS_NOT_OBSERVED`); passes the observed version to `ctx.fs.editText` as the + * stale guard and refreshes recorded state from the returned version. The + * provider owns the mutation critical section and the literal match. + */ + async edit(target: FsTarget, edit: FsEditRequest, exec?: FileContextExec, signal?: AbortSignal): Promise { + const owner = this.owner(exec) + const prior = owner ? this.getObserved(owner, target.targetKey) : undefined + if (!owner || !prior) { + throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED') + } + const outcome = await this.ctx.fs.editText(target, edit, { version: prior.version }, signal) + this.record(owner, target.targetKey, outcome.version) + return outcome + } +} + +export default FileContext diff --git a/packages/fs/file-context/src/types.ts b/packages/fs/file-context/src/types.ts new file mode 100644 index 0000000000..842d9a08c7 --- /dev/null +++ b/packages/fs/file-context/src/types.ts @@ -0,0 +1,56 @@ +/** + * Vocabulary for the file-context policy layer (`ctx.fileContext`): the + * minimal execution-context shape used to derive an observed-state owner, the + * resolved read window, and the structured read outcome the model-facing `read` + * tool renders. + * + * The provider vocabulary (`FsTarget`, `FsVersion`, write/edit shapes) is + * re-used from `@deepseek-ai/dsh-fs` — this package owns only the model-facing + * read-windowing and observation policy on top of it. + * + * @module @deepseek-ai/dsh-file-context/types + */ + +import type { FsVersion } from '@deepseek-ai/dsh-fs' +import type { FileTextLine } from './window.ts' + +/** + * Minimal structural view of a tool execution the policy layer needs to derive + * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies + * this shape, so the consumer passes its `exec` straight through without + * `dsh-file-context` 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 FileContextExec { + /** 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 + } +} + +/** Resolved read window. The consumer applies its defaults/caps before calling. */ +export interface FileReadRequest { + /** 1-based first line to return. */ + offset: number + /** Maximum number of lines to return. */ + limit: number +} + +/** Outcome of a bounded text read — what the model-facing `read` tool renders. */ +export interface FileReadOutcome { + /** 1-based first line requested. */ + offset: number + /** Maximum number of lines requested. */ + limit: 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 + /** Opaque version of the file at read time. */ + version: FsVersion +} diff --git a/packages/fs/file-context/src/window.ts b/packages/fs/file-context/src/window.ts new file mode 100644 index 0000000000..97e51e2ee4 --- /dev/null +++ b/packages/fs/file-context/src/window.ts @@ -0,0 +1,139 @@ +/** + * Cordis-free line-windowing for `@deepseek-ai/dsh-file-context`. Relocated + * from the local backend: turning a file's decoded text into a bounded, + * line-numbered window (offset/limit, byte cap, per-line truncation) is + * model-facing READ POLICY, not a storage primitive, so it lives in the policy + * layer rather than in every `ctx.fs` backend. + * + * The provider (`ctx.fs.readText`/`streamText`) hands back already-decoded text + * (UTF-8 validated, binary rejected); this module 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. + * + * @module @deepseek-ai/dsh-file-context/window + */ + +import { FsError } from '@deepseek-ai/dsh-fs' + +/** Maximum characters returned for a single line. */ +export const READ_MAX_LINE_LENGTH = 2000 + +/** Maximum bytes returned for selected file lines. */ +export const READ_MAX_BYTES = 50 * 1024 + +const READ_MAX_LINE_SUFFIX = `... (line truncated to ${READ_MAX_LINE_LENGTH} chars)` +const LINE_BUFFER_CAP = READ_MAX_LINE_LENGTH + 1 + +/** Resolved read window. The consumer applies its defaults/caps before calling. */ +export interface ReadWindow { + /** 1-based first line to return. */ + offset: number + /** Maximum number of lines to return. */ + limit: number +} + +/** 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 this module builds 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 +} + +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): string { + return line.length > READ_MAX_LINE_LENGTH ? `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}` : 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) + const bytes = lineByteSize(text, acc.lines.length) + if (acc.outputBytes + bytes > READ_MAX_BYTES) { + 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` (a chunked `streamText`) or an + * `Iterable` (a whole-file `readText` wrapped as `[text]`), so one code + * path serves both. Scans for newlines with a capped line buffer (a newline-free + * giant line is truncated, never buffered past {@link READ_MAX_LINE_LENGTH}), + * enforces the byte cap, and throws `FS_NOT_FOUND` for an offset past EOF. + */ +export async function buildWindow( + chunks: AsyncIterable | Iterable, + request: ReadWindow, + displayPath: string, +): Promise { + const acc = newAccumulator() + let lineBuffer = '' + + function appendToLineBuffer(segment: string): void { + if (lineBuffer.length >= LINE_BUFFER_CAP) return + lineBuffer += segment + if (lineBuffer.length > LINE_BUFFER_CAP) lineBuffer = lineBuffer.slice(0, LINE_BUFFER_CAP) + } + + 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) +} diff --git a/packages/fs/file-context/tests/policy.spec.ts b/packages/fs/file-context/tests/policy.spec.ts new file mode 100644 index 0000000000..e15398eb54 --- /dev/null +++ b/packages/fs/file-context/tests/policy.spec.ts @@ -0,0 +1,305 @@ +/** + * Tests for the file-context policy layer: registration/disposal/HMR, owner + * derivation, observed-state-as-read-record, read windowing over a fake + * provider, freshness-based write/edit authorization (including the key + * windowed-read-authorizes-edit behavior), the read→streamText size routing, + * and multi-owner isolation. The provider is a fake `ctx.fs` recording the + * expectations it was handed. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' +import type { + FsEditOutcome, + FsEditRequest, + FsInfo, + FsTarget, + FsWriteExpectation, + FsWriteOutcome, +} from '@deepseek-ai/dsh-fs' +import FileContext, { STREAM_MIN_SIZE } from '@deepseek-ai/dsh-file-context' +import type { FileContextExec, FileReadRequest } from '@deepseek-ai/dsh-file-context' + +/** A fake provider: in-memory files, recording every expectation/version it is handed. */ +class FakeFs extends FileSystem { + files = new Map() + versions = new Map() + /** Size to report from stat (lets a test push read onto the streaming path). */ + reportSize?: number + /** Whether streamText was used for the last read (vs readText). */ + lastReadStreamed = false + writeExpectations: FsWriteExpectation[] = [] + editExpectedVersions: string[] = [] + + private ver(key: string): FsVersion { + return FsVersion(`v${this.versions.get(key) ?? 0}`) + } + private bump(key: string): FsVersion { + const next = (this.versions.get(key) ?? 0) + 1 + this.versions.set(key, next) + return FsVersion(`v${next}`) + } + + override async resolve(path: string): Promise { + return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path } + } + override async stat(target: FsTarget): Promise { + const content = this.files.get(target.targetKey) + if (content === undefined) return undefined + return { version: this.ver(target.targetKey), type: 'file', size: this.reportSize ?? content.length } + } + override async readText(target: FsTarget): Promise { + this.lastReadStreamed = false + return this.files.get(target.targetKey) ?? '' + } + override async streamText(target: FsTarget): Promise> { + this.lastReadStreamed = true + const content = this.files.get(target.targetKey) ?? '' + return (async function* () { yield content })() + } + override async writeText(target: FsTarget, content: string, expected: FsWriteExpectation): Promise { + this.writeExpectations.push(expected) + const existed = this.files.has(target.targetKey) + this.files.set(target.targetKey, content) + return { operation: existed ? 'update' : 'create', version: this.bump(target.targetKey) } + } + override async editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }): Promise { + this.editExpectedVersions.push(expected.version) + const content = this.files.get(target.targetKey) ?? '' + this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString)) + return { replacements: 1, replaceAll: edit.replaceAll, version: this.bump(target.targetKey) } + } +} + +async function setup() { + const ctx = new Context() + await ctx.plugin(FakeFs) + await ctx.plugin(FileContext) + const fs = ctx.fs as FakeFs + const fileContext = ctx.fileContext + return { ctx, fs, fileContext } +} + +const READ_ALL: FileReadRequest = { offset: 1, limit: 2000 } +const ownerExec = (session: object): FileContextExec => ({ agent: { session } }) + +describe('registration / disposal', () => { + it('registers as ctx.fileContext and injects fs', async () => { + const { fileContext } = await setup() + expect(fileContext).toBeDefined() + }) + + it('stays pending until ctx.fs exists', async () => { + const ctx = new Context() + await ctx.plugin(FileContext) // no fs provider + expect(ctx.fileContext).toBeUndefined() + }) + + it('withdraws ctx.fileContext when its fiber is disposed (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(FakeFs) + const fiber = await ctx.plugin(FileContext) + expect(ctx.fileContext).toBeDefined() + await fiber.dispose() + expect(ctx.fileContext).toBeUndefined() + }) +}) + +describe('owner derivation', () => { + it('derives the owner from exec.agent.session', async () => { + const { fileContext } = await setup() + const session = {} + expect(fileContext.owner(ownerExec(session))).toBe(session) + }) + + it('returns undefined with no exec, no agent, or no session', async () => { + const { fileContext } = await setup() + expect(fileContext.owner()).toBeUndefined() + expect(fileContext.owner({})).toBeUndefined() + expect(fileContext.owner({ agent: {} })).toBeUndefined() + }) +}) + +describe('read', () => { + it('returns a windowed outcome and rejects an absent target', async () => { + const { fs, fileContext } = await setup() + fs.files.set('a.txt', 'one\ntwo') + const outcome = await fileContext.read(await fs.resolve('a.txt'), READ_ALL) + expect(outcome.lines).toEqual([{ number: 1, text: 'one' }, { number: 2, text: 'two' }]) + expect(outcome.version).toBe('v0') + + await expect(fileContext.read(await fs.resolve('missing.txt'), READ_ALL)) + .rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + }) + + it('rejects a non-regular target', async () => { + const { fs, fileContext } = await setup() + fs.files.set('d', '') + const target = await fs.resolve('d') + // Force stat to report a directory. + fs.stat = async () => ({ version: FsVersion('v0'), type: 'directory' }) + await expect(fileContext.read(target, READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + }) + + it('reads small files whole and large files via streamText', async () => { + const { fs, fileContext } = await setup() + fs.files.set('a.txt', 'one\ntwo') + + await fileContext.read(await fs.resolve('a.txt'), READ_ALL) + expect(fs.lastReadStreamed).toBe(false) + + fs.reportSize = STREAM_MIN_SIZE + await fileContext.read(await fs.resolve('a.txt'), READ_ALL) + expect(fs.lastReadStreamed).toBe(true) + }) + + it('surfaces truncatedByBytes when the window hits the byte cap', async () => { + const { fs, fileContext } = await setup() + fs.files.set('big.txt', Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')) + const outcome = await fileContext.read(await fs.resolve('big.txt'), READ_ALL) + expect(outcome.truncatedByBytes).toBe(true) + }) +}) + +describe('observed-state is the read record', () => { + it('a read authorizes a later in-place write at the observed version', async () => { + const { fs, fileContext } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await fileContext.read(target, READ_ALL, exec) + await fileContext.write(target, 'goodbye', exec) + + expect(fs.writeExpectations).toEqual([{ kind: 'replaceIfVersion', version: 'v0' }]) + }) + + it('a windowed (partial) read still authorizes edit — freshness, not full/partial', async () => { + const { fs, fileContext } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'one\ntwo\nthree\nfour') + const target = await fs.resolve('a.txt') + + // Read only lines 2-3 — a partial window. + const outcome = await fileContext.read(target, { offset: 2, limit: 2 }, exec) + expect(outcome.lines.map(l => l.number)).toEqual([2, 3]) + + // Edit is authorized anyway: the file is unchanged since the read. + await fileContext.edit(target, { oldString: 'one', newString: 'X', replaceAll: false }, exec) + expect(fs.editExpectedVersions).toEqual(['v0']) + }) + + it('skips recording when there is no owner, so write is createIfAbsent', async () => { + const { fs, fileContext } = await setup() + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await fileContext.read(target, READ_ALL) // no exec + // No recorded read → createIfAbsent → the provider rejects an existing target. + fs.writeText = async () => { throw new FsError('exists', 'FS_NOT_OBSERVED') } + await expect(fileContext.write(target, 'x')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) +}) + +describe('write policy', () => { + it('a create (no prior read) uses createIfAbsent', async () => { + const { fs, fileContext } = await setup() + const exec = ownerExec({}) + const target = await fs.resolve('new.txt') + const outcome = await fileContext.write(target, 'fresh', exec) + expect(outcome.operation).toBe('create') + expect(fs.writeExpectations).toEqual([{ kind: 'createIfAbsent' }]) + }) + + it('refreshes state after a write, so a follow-up edit needs no re-read', async () => { + const { fs, fileContext } = await setup() + const exec = ownerExec({}) + const target = await fs.resolve('a.txt') + await fileContext.write(target, 'one', exec) // create → state now at v1 + await fileContext.edit(target, { oldString: 'one', newString: 'two', replaceAll: false }, exec) + expect(fs.editExpectedVersions).toEqual(['v1']) + }) +}) + +describe('edit policy', () => { + it('rejects with FS_NOT_OBSERVED when the file was never read', async () => { + const { fs, fileContext } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec)) + .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + + it('rejects when there is no owner (cannot prove prior observation)', async () => { + const { fs, fileContext } = await setup() + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false })) + .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + + it('passes the recorded version as the stale guard after a read', async () => { + const { fs, fileContext } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + fs.versions.set('a.txt', 7) + const target = await fs.resolve('a.txt') + await fileContext.read(target, READ_ALL, exec) + await fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec) + expect(fs.editExpectedVersions).toEqual(['v7']) + }) +}) + +describe('multi-owner isolation', () => { + it('owner A reading does not grant owner B edit authority', async () => { + const { fs, fileContext } = await setup() + const a = ownerExec({}) + const b = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await fileContext.read(target, READ_ALL, a) + await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, b)) + .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, a)) + .resolves.toMatchObject({ replacements: 1 }) + }) + + it('each owner records its own observed version independently', async () => { + const { fs, fileContext } = await setup() + const a = ownerExec({}) + const b = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await fileContext.read(target, READ_ALL, a) // A sees v0 + await fileContext.write(target, 'mid', b) // B has no read → createIfAbsent + await fileContext.write(target, 'late', a) // A still holds its v0 observation + + expect(fs.writeExpectations).toEqual([ + { kind: 'createIfAbsent' }, + { kind: 'replaceIfVersion', version: 'v0' }, + ]) + }) +}) + +describe('disposal releases recorded state', () => { + it('a fresh service after disposal starts with no inherited state', async () => { + const ctx = new Context() + await ctx.plugin(FakeFs) + const fs = ctx.fs as FakeFs + const fiber = await ctx.plugin(FileContext) + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + await ctx.fileContext.read(await fs.resolve('a.txt'), READ_ALL, exec) + await fiber.dispose() + + await ctx.plugin(FileContext) + const target = await fs.resolve('a.txt') + // Same owner object, but state was released on disposal. + await expect(ctx.fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec)) + .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) +}) diff --git a/packages/fs/file-context/tests/window.spec.ts b/packages/fs/file-context/tests/window.spec.ts new file mode 100644 index 0000000000..6b1a8b5b93 --- /dev/null +++ b/packages/fs/file-context/tests/window.spec.ts @@ -0,0 +1,102 @@ +/** + * 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_LINE_LENGTH } from '@deepseek-ai/dsh-file-context' +import type { ReadWindow } from '@deepseek-ai/dsh-file-context' + +const READ_ALL: ReadWindow = { offset: 1, limit: 2000 } + +/** Yield `text` as one chunk (whole-file read shape). */ +async function* whole(text: string): AsyncIterable { + yield text +} + +/** Yield `text` split into fixed-size chunks (streamed read shape). */ +async function* chunked(text: string, size: number): AsyncIterable { + 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 }, '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 }, '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('chunked input (streamed read shape)', () => { + it('windows identically when text arrives in small chunks', async () => { + const result = await buildWindow(chunked('one\ntwo\nthree', 2), { offset: 2, limit: 1 }, 'f') + 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']) + }) + }) +}) diff --git a/packages/fs/file-context/tsconfig.json b/packages/fs/file-context/tsconfig.json new file mode 100644 index 0000000000..dc4518f7f0 --- /dev/null +++ b/packages/fs/file-context/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../llm/llm" }, + { "path": "../fs" } + ] +} diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 794239ed2d..a4bb087aea 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -1,20 +1,22 @@ # @deepseek-ai/dsh-fs-local -The **local-filesystem implementation** of the `ctx.fs` seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the four `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`. +The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the six `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-tool-fs to expose read/write/edit to the model. +// ctx.fs is now the local backend; load @deepseek-ai/dsh-file-context for policy +// and @deepseek-ai/dsh-tool-fs to expose read/write/edit to the model. ``` ## Behavior - **`resolve(path)`** — relative paths resolve from `config.cwd` (default `process.cwd()`). 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. -- **`readPage`** — UTF-8 only. A fast path (`readFile`) handles files under `FAST_PATH_MAX_SIZE` (10 MB); larger files stream with a capped line buffer so a newline-free giant file can't exhaust memory. Invalid UTF-8 and NUL-byte samples are rejected (`FS_NOT_TEXT`). Output is bounded to `READ_LIMIT` (2000) lines, `READ_MAX_BYTES` (50 KB), and `READ_MAX_LINE_LENGTH` (2000) chars per line; hitting any bound records a `partial` view. The `version` is `mtimeMs:size`. -- **`createOrReplace`** — 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`. Honors the `FsExpectation`: an `observed` write must match the recorded version (else `FS_STALE_VERSION`); a `partial` write onto an existing file is rejected (`FS_PARTIAL_OBSERVATION`); an `unobserved` write onto an existing file is rejected (`FS_NOT_OBSERVED`). -- **`applyEdit`** — atomic literal read-modify-write over the same primitive. Verifies the expected version, 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`). +- **`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 policy layer (`ctx.fileContext`) decides which to call by size and owns the line windowing. +- **`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`. Honors the `FsWriteExpectation`: `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. Verifies the expected version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content), 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 diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index a7e124db15..6a2b5a28cc 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -1,13 +1,13 @@ /** * 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 read/write/edit mechanics can be unit-tested without a Context. + * the raw stat/read/write/edit mechanics can be unit-tested without a Context. * - * The reader uses two code paths so a single huge line can never balloon - * memory: a **fast path** (`readFile` + in-memory split) for files under - * {@link FAST_PATH_MAX_SIZE}, and a **streaming path** (manual newline scan - * with a capped line buffer) for larger files. Both reject invalid UTF-8 and - * NUL-byte binary samples, and keep only the requested page in memory. + * 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-file-context`. 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 @@ -24,56 +24,12 @@ import { chmod, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:f import type { Stats } from 'node:fs' import { basename, dirname, join, resolve } from 'node:path' import { TextDecoder } from 'node:util' -import { FsError } from '@deepseek-ai/dsh-fs' -import type { FsReadRequest, FsTextLine, FsView } from '@deepseek-ai/dsh-fs' +import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' -/** Default and maximum number of lines returned by one read. */ -export const READ_LIMIT = 2000 +/** Files at or above this size stream their text; smaller files read whole. */ +export const STREAM_MIN_SIZE = 10 * 1024 * 1024 -/** Maximum characters returned for a single line. */ -export const READ_MAX_LINE_LENGTH = 2000 - -/** Maximum bytes returned for selected file lines. */ -export const READ_MAX_BYTES = 50 * 1024 - -/** Files smaller than this use the in-memory fast path; larger files stream. */ -export const FAST_PATH_MAX_SIZE = 10 * 1024 * 1024 - -const READ_MAX_BYTES_LABEL = `${READ_MAX_BYTES / 1024} KB` -const READ_MAX_LINE_SUFFIX = `... (line truncated to ${READ_MAX_LINE_LENGTH} chars)` const BINARY_SAMPLE_BYTES = 8192 -const LINE_BUFFER_CAP = READ_MAX_LINE_LENGTH + 1 - -/** - * Test seam: lets specs force the streaming path (via a small - * `fastPathMaxSize`) and pin the temp-file name (to prove exclusive-open - * behavior) without a 10 MB fixture or a name race. - */ -export interface FsIoInternals { - /** Override {@link FAST_PATH_MAX_SIZE} for routing. */ - fastPathMaxSize?: number - /** Override the generated private staging-dir name (relative to the target dir). */ - tempDirName?: (writePath: string) => string - /** Override the generated temp-file name (relative to the private staging dir). */ - 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 -} - -/** 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: string -} - -/** Result of probing a path: null when it does not exist. */ -export interface PathInfo { - version: string - mode: number - isFile: boolean -} function isENOENT(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT' @@ -94,8 +50,40 @@ function throwIfAborted(signal: AbortSignal | undefined, verb: string): void { } /** Opaque version token from a stat: mtime (ns precision) + size. */ -function versionOf(info: Stats): string { - return `${info.mtimeMs}:${info.size}` +function versionOf(info: Stats): FsVersion { + return FsVersion(`${info.mtimeMs}:${info.size}`) +} + +/** + * Test seam: lets specs force the streaming read path (via a small + * `streamMinSize`) and pin the temp-file name (to prove exclusive-open + * behavior) without a 10 MB fixture or a name race. + */ +export interface FsIoInternals { + /** Override {@link STREAM_MIN_SIZE} for read routing. */ + streamMinSize?: number + /** Override the generated private staging-dir name (relative to the target dir). */ + tempDirName?: (writePath: string) => string + /** Override the generated temp-file name (relative to the private staging dir). */ + 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 +} + +/** 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 } /** @@ -111,26 +99,27 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise { try { const info = await stat(absolutePath) - return { version: versionOf(info), mode: info.mode & 0o777, isFile: info.isFile() } + const type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other' + return { version: versionOf(info), mode: info.mode & 0o777, type, size: info.size } } catch (error: unknown) { /* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; surface it. */ if (!isENOENT(error)) throw error @@ -140,67 +129,6 @@ export async function probe(absolutePath: string): Promise { // --- Reading --- -interface PageAccumulator { - lines: FsTextLine[] - totalLines: number - outputBytes: number - truncatedByBytes: boolean - truncatedByLine: boolean - done: boolean -} - -function newAccumulator(): PageAccumulator { - return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, truncatedByLine: false, done: false } -} - -function truncateReadLine(line: string): { text: string; truncated: boolean } { - return line.length > READ_MAX_LINE_LENGTH - ? { text: `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}`, truncated: true } - : { text: line, truncated: false } -} - -function lineByteSize(line: string, currentLineCount: number): number { - return Buffer.byteLength(line, 'utf8') + (currentLineCount > 0 ? 1 : 0) -} - -function consumeLine(acc: PageAccumulator, rawLine: string, request: FsReadRequest): void { - acc.totalLines += 1 - if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return - - const { text, truncated } = truncateReadLine(rawLine) - if (truncated) acc.truncatedByLine = true - const bytes = lineByteSize(text, acc.lines.length) - if (acc.outputBytes + bytes > READ_MAX_BYTES) { - 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 -} - -/** The outcome shape `readTextPage` returns (minus the offset/limit echo, which the caller adds). */ -export interface ReadPageResult { - lines: FsTextLine[] - totalLines: number - truncatedByBytes: boolean - view: FsView - version: string -} - -function buildResult(acc: PageAccumulator, request: FsReadRequest, version: string, displayPath: string): ReadPageResult { - 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') - } - const endLine = acc.lines.at(-1)?.number ?? Math.max(0, request.offset - 1) - const view: FsView = request.offset === 1 && !acc.truncatedByBytes && !acc.truncatedByLine && endLine >= acc.totalLines ? 'full' : 'partial' - return { lines: acc.lines, totalLines: acc.totalLines, truncatedByBytes: acc.truncatedByBytes, view, version } -} - function notTextError(verb: 'read' | 'edit', displayPath: string): FsError { return new FsError(`cannot ${verb} "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT') } @@ -209,8 +137,9 @@ function decodeUtf8(buffer: Uint8Array, verb: 'read' | 'edit', displayPath: stri try { return new TextDecoder('utf-8', { fatal: true }).decode(buffer) } catch (error: unknown) { - if (error instanceof TypeError) throw notTextError(verb, displayPath) - throw error + /* 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) } } @@ -223,90 +152,50 @@ function decodeUtf8Stream( try { return chunk ? decoder.decode(chunk, { stream: true }) : decoder.decode() } catch (error: unknown) { - if (error instanceof TypeError) throw notTextError(verb, displayPath) - throw error + /* 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) } } -/** - * Read a bounded UTF-8 text-file page. Rejects non-regular files, invalid - * UTF-8, and NUL-byte binary samples; dispatches to the fast or streaming path - * by file size. - */ -export async function readTextPage( - target: LocalTarget, - request: FsReadRequest, - signal?: AbortSignal, - internals: FsIoInternals = {}, -): Promise { - throwIfAborted(signal, 'read') - const absolutePath = target.targetKey +async function statRegularFile(target: LocalTarget, verb: 'read', signal?: AbortSignal): Promise { + throwIfAborted(signal, verb) let info: Stats try { - info = await stat(absolutePath) + 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 read "${target.displayPath}": not found`, 'FS_NOT_FOUND') + throw new FsError(`cannot ${verb} "${target.displayPath}": not found`, 'FS_NOT_FOUND') } - if (!info.isFile()) throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') - - const version = versionOf(info) - const fastPathMax = internals.fastPathMaxSize ?? FAST_PATH_MAX_SIZE - return info.size < fastPathMax - ? readTextPageFast(target, request, version, signal) - : readTextPageStreaming(target, request, version, signal) + if (!info.isFile()) throw new FsError(`cannot ${verb} "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + return info } -async function readTextPageFast( - target: LocalTarget, - request: FsReadRequest, - version: string, - signal?: AbortSignal, -): Promise { +/** + * 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 { + await statRegularFile(target, 'read', signal) const raw = await readFile(target.targetKey, signal ? { 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') } - - const text = decodeUtf8(raw, 'read', target.displayPath) - const acc = newAccumulator() - let startPos = 0 - let newlinePos: number - while ((newlinePos = text.indexOf('\n', startPos)) !== -1) { - consumeLine(acc, stripCarriageReturn(text.slice(startPos, newlinePos)), request) - if (acc.done) break - startPos = newlinePos + 1 - } - if (!acc.done && startPos < text.length) { - consumeLine(acc, stripCarriageReturn(text.slice(startPos)), request) - } - return buildResult(acc, request, version, target.displayPath) + return decodeUtf8(raw, 'read', target.displayPath) } -async function readTextPageStreaming( - target: LocalTarget, - request: FsReadRequest, - version: string, - signal?: AbortSignal, -): Promise { +/** + * 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 { + await statRegularFile(target, 'read', signal) const stream = createReadStream(target.targetKey, signal ? { signal } : {}) - const acc = newAccumulator() - let lineBuffer = '' - let sampledBytes = 0 const decoder = new TextDecoder('utf-8', { fatal: true }) - - function appendToLineBuffer(segment: string): void { - if (lineBuffer.length >= LINE_BUFFER_CAP) return - lineBuffer += segment - if (lineBuffer.length > LINE_BUFFER_CAP) lineBuffer = lineBuffer.slice(0, LINE_BUFFER_CAP) - } - - function flushLine(): void { - consumeLine(acc, stripCarriageReturn(lineBuffer), request) - lineBuffer = '' - } + let sampledBytes = 0 function scanBinarySample(chunk: Buffer): void { if (sampledBytes >= BINARY_SAMPLE_BYTES) return @@ -317,51 +206,17 @@ async function readTextPageStreaming( sampledBytes += sample.length } - function consumeChunk(chunk: string): ReadPageResult | undefined { - 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 buildResult(acc, request, version, target.displayPath) - } - appendToLineBuffer(chunk.slice(startPos)) - return undefined - } - try { for await (const chunk of stream as AsyncIterable) { scanBinarySample(chunk) - const result = consumeChunk(decodeUtf8Stream(decoder, chunk, 'read', target.displayPath)) - if (result) return result + yield decodeUtf8Stream(decoder, chunk, 'read', target.displayPath) } - const finalResult = consumeChunk(decodeUtf8Stream(decoder, undefined, 'read', target.displayPath)) - if (finalResult) return finalResult + 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 } - - if (lineBuffer.length > 0) flushLine() - return buildResult(acc, request, version, target.displayPath) -} - -/** Format the line-numbered body + pagination footer for a read page. */ -export function formatReadBody(result: ReadPageResult, offset: number): string { - const endLine = result.lines.at(-1)?.number ?? Math.max(0, offset - 1) - let footer: string - if (result.truncatedByBytes) { - footer = `(Output capped at ${READ_MAX_BYTES_LABEL}. Showing lines ${offset}-${endLine}. Use offset=${endLine + 1} to continue.)` - } else if (endLine < result.totalLines) { - footer = `(Showing lines ${offset}-${endLine} of ${result.totalLines}. Use offset=${endLine + 1} to continue.)` - } else { - footer = `(End of file - total ${result.totalLines} lines)` - } - return result.lines.length > 0 - ? `${result.lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n\n${footer}` - : footer } // --- Writing --- diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 0184a8a323..3c8ba61f78 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -1,10 +1,11 @@ /** - * Local-filesystem implementation of the `ctx.fs` seam. {@link LocalFileSystem} - * subclasses {@link FileSystem} and backs the four 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). + * Local-filesystem implementation of the `ctx.fs` provider seam. + * {@link LocalFileSystem} subclasses {@link FileSystem} and backs the six + * 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`. @@ -14,43 +15,39 @@ import { Context } from 'cordis' import z from 'schemastery' -import { FileSystem, FsError } from '@deepseek-ai/dsh-fs' +import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsEditOutcome, FsEditRequest, - FsExpectation, - FsReadOutcome, - FsReadRequest, + FsInfo, FsTarget, - FsVersion, + FsWriteExpectation, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' import { applyLiteralEdit, probe, readForEdit, - readTextPage, + readWholeText, resolveLocalTarget, restoreLineEndings, + streamWholeText, writeFileAtomic, } from './fsio.ts' import type { FsIoInternals } from './fsio.ts' export { - FAST_PATH_MAX_SIZE, - READ_LIMIT, - READ_MAX_BYTES, - READ_MAX_LINE_LENGTH, + STREAM_MIN_SIZE, applyLiteralEdit, - formatReadBody, probe, readForEdit, - readTextPage, + readWholeText, resolveLocalTarget, restoreLineEndings, + streamWholeText, writeFileAtomic, } from './fsio.ts' -export type { FsIoInternals, LineEndings, LocalTarget, PathInfo, ReadPageResult } from './fsio.ts' +export type { FsIoInternals, LineEndings, LocalTarget, PathInfo } from './fsio.ts' /** Configuration for the local filesystem backend. */ export interface Config { @@ -105,47 +102,41 @@ export class LocalFileSystem extends FileSystem { return { inputPath: path, targetKey: local.targetKey, displayPath: local.displayPath } } - override async readPage(target: FsTarget, request: FsReadRequest, signal?: AbortSignal): Promise { - const result = await readTextPage( - { displayPath: target.displayPath, targetKey: target.targetKey }, - request, - signal, - this.internals, - ) - return { - offset: request.offset, - limit: request.limit, - lines: result.lines, - totalLines: result.totalLines, - version: result.version, - view: result.view, - ...result.truncatedByBytes ? { truncatedByBytes: true } : {}, - } + override async stat(target: FsTarget, signal?: AbortSignal): Promise { + 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 createOrReplace( + override async readText(target: FsTarget, signal?: AbortSignal): Promise { + return readWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal) + } + + override streamText(target: FsTarget, signal?: AbortSignal): Promise> { + return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)) + } + + override async writeText( target: FsTarget, content: string, - expected: FsExpectation, + expected: FsWriteExpectation, signal?: AbortSignal, ): Promise { return this.withLock(target.targetKey, async () => { const existing = await probe(target.targetKey) - if (existing && !existing.isFile) { + if (existing && existing.type !== 'file') { throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') } - if (expected.kind === 'observed') { - // Stale guard: the file must still be at the version the owner observed. + 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 === 'partial') { - if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, 'FS_STALE_VERSION') - throw new FsError(`cannot overwrite existing "${target.displayPath}" after only a partial read`, 'FS_PARTIAL_OBSERVATION') } else if (existing) { - // Unobserved write onto an existing file: a blind overwrite — require a read first. + // 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') } @@ -158,7 +149,7 @@ export class LocalFileSystem extends FileSystem { }) } - override async applyEdit( + override async editText( target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, @@ -166,8 +157,10 @@ export class LocalFileSystem extends FileSystem { ): Promise { return this.withLock(target.targetKey, async () => { const existing = await probe(target.targetKey) - if (!existing) throw new FsError(`cannot edit "${target.displayPath}": not found`, 'FS_NOT_FOUND') - if (!existing.isFile) throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + // 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. + 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') if (existing.version !== expected.version) { throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION') } @@ -188,9 +181,9 @@ export class LocalFileSystem extends FileSystem { /* 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: string } | null, target: FsTarget): string { + private versionAfterWrite(after: { version: FsVersion } | null, target: FsTarget): FsVersion { if (after) return after.version - return `missing:${target.targetKey}` + return FsVersion(`missing:${target.targetKey}`) } } diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index eb675472af..20c5cfa21f 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -1,7 +1,9 @@ /** - * Tests for the local backend through the `ctx.fs` service: the full - * read→write→edit lifecycle with the read-before-write policy, stale-version - * guards, concurrency races, symlink identity, and HMR/disposal. + * 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-file-context`, so it is not exercised here. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -9,8 +11,9 @@ import { mkdtemp, readFile, rm, stat, symlink, writeFile, unlink } from 'node:fs import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' -import { LocalFileSystem, probe } from '@deepseek-ai/dsh-fs-local' -import type { FsExecContext } from '@deepseek-ai/dsh-fs' +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 @@ -28,12 +31,17 @@ afterEach(async () => { await rm(dir, { recursive: true, force: true }) }) -const READ_ALL = { offset: 1, limit: 2000 } -const exec = (): FsExecContext => ({ agent: { session: {} } }) function lockCount(localFs: LocalFileSystem): number { return (localFs as unknown as { locks: Map> }).locks.size } +/** The version the backend currently reports for a resolved target. */ +async function versionOf(target: FsTarget): Promise { + 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() @@ -43,255 +51,219 @@ describe('registration', () => { }) }) -describe('read → write → edit lifecycle', () => { - it('creates a new file without a prior read', async () => { +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('writeText', () => { + it('createIfAbsent creates a new file', async () => { const target = await fs.resolve('new.txt') - const outcome = await fs.write(target, 'fresh', exec()) + 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('updates an existing file after reading it', async () => { + it('createIfAbsent rejects an existing file as FS_NOT_OBSERVED', async () => { await writeFile(join(dir, 'a.txt'), 'old') - const owner = exec() const target = await fs.resolve('a.txt') - await fs.read(target, READ_ALL, owner) - const outcome = await fs.write(target, 'new', owner) + 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('edits an existing file after reading it', async () => { - await writeFile(join(dir, 'a.txt'), 'hello world') - const owner = exec() + it('replaceIfVersion rejects a stale version', async () => { + await writeFile(join(dir, 'a.txt'), 'v1') const target = await fs.resolve('a.txt') - await fs.read(target, READ_ALL, owner) - const outcome = await fs.edit(target, { oldString: 'world', newString: 'there', replaceAll: false }, owner) - expect(outcome.replacements).toBe(1) - expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there') + 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('rejects an empty edit oldString through ctx.fs without hanging or changing the file', async () => { - await writeFile(join(dir, 'a.txt'), 'hello world') - const owner = exec() + 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') - await fs.read(target, READ_ALL, owner) - - await expect(fs.edit(target, { oldString: '', newString: 'boom', replaceAll: false }, owner)) - .rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' }) - expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world') + 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('propagates truncatedByBytes from a byte-capped read', async () => { - await writeFile(join(dir, 'big.txt'), Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')) - const outcome = await fs.read(await fs.resolve('big.txt'), READ_ALL, exec()) - expect(outcome.truncatedByBytes).toBe(true) - expect(outcome.view).toBe('partial') - }) - - it('records an over-long-line read as partial, so write/edit stay blocked', async () => { - await writeFile(join(dir, 'long.txt'), 'x'.repeat(3000)) - const owner = exec() - const target = await fs.resolve('long.txt') - const outcome = await fs.read(target, READ_ALL, owner) - - expect(outcome.view).toBe('partial') - await expect(fs.write(target, 'new', owner)).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) - await expect( - fs.edit(target, { oldString: 'x', newString: 'y', replaceAll: false }, owner), - ).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) - }) - - it('allows a follow-up edit without re-reading (write/edit refresh state)', async () => { - await writeFile(join(dir, 'a.txt'), 'a b') - const owner = exec() - const target = await fs.resolve('a.txt') - await fs.read(target, READ_ALL, owner) - await fs.edit(target, { oldString: 'a', newString: 'X', replaceAll: false }, owner) - await fs.edit(target, { oldString: 'b', newString: 'Y', replaceAll: false }, owner) - expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('X Y') + 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('releases per-target mutation locks after success and failure', async () => { const target = await fs.resolve('a.txt') - await fs.write(target, 'created', exec()) + await fs.writeText(target, 'created', { kind: 'createIfAbsent' }) expect(lockCount(fs)).toBe(0) - - await expect(fs.write(target, 'blind overwrite', exec())).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + await expect(fs.writeText(target, 'again', { kind: 'createIfAbsent' })) + .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) expect(lockCount(fs)).toBe(0) }) }) -describe('read-before-write policy', () => { - it('rejects a blind overwrite of an existing file (no prior read)', async () => { - await writeFile(join(dir, 'a.txt'), 'old') +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') - await expect(fs.write(target, 'new', exec())).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }, { version: await versionOf(target) }) + expect(outcome.replacements).toBe(1) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there') }) - it('rejects a write after only a partial read', async () => { - await writeFile(join(dir, 'a.txt'), 'one\ntwo') - const owner = exec() + it('checks the stale version BEFORE literal matching', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') const target = await fs.resolve('a.txt') - await fs.read(target, { offset: 1, limit: 1 }, owner) - await expect(fs.write(target, 'new', owner)).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) + 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('rejects a write after a partial read when the file was deleted, without recreating it', async () => { - const path = join(dir, 'a.txt') - await writeFile(path, 'one\ntwo') - const owner = exec() + it('rejects a deleted target as stale (before matching)', async () => { + await writeFile(join(dir, 'a.txt'), 'hello') const target = await fs.resolve('a.txt') - await fs.read(target, { offset: 1, limit: 1 }, owner) - await unlink(path) - - await expect(fs.write(target, 'new', owner)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) - await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }) + 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 an edit with no prior read (FS_NOT_OBSERVED)', async () => { - await writeFile(join(dir, 'a.txt'), 'old') - const target = await fs.resolve('a.txt') - await expect(fs.edit(target, { oldString: 'old', newString: 'new', replaceAll: false }, exec())) - .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + 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 invalid UTF-8 reads and edits without rewriting the file', async () => { - const path = join(dir, 'invalid-utf8.txt') + 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.replacements).toBe(3) + 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 owner = exec() - const target = await fs.resolve('invalid-utf8.txt') - - await expect(fs.read(target, READ_ALL, owner)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) - const existing = await probe(target.targetKey) - if (!existing) throw new Error('expected invalid UTF-8 fixture to exist') - await expect( - fs.applyEdit(target, { oldString: 'h', newString: 'H', replaceAll: false }, { version: existing.version }), - ).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + 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) }) -}) - -describe('stale-version guard + concurrency (defensive class B)', () => { - it('rejects a write when the file changed since it was read', async () => { - await writeFile(join(dir, 'a.txt'), 'v1') - const owner = exec() - const target = await fs.resolve('a.txt') - await fs.read(target, READ_ALL, owner) - // An out-of-band change after the read. - await writeFile(join(dir, 'a.txt'), 'changed-externally') - await expect(fs.write(target, 'v2', owner)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) - }) - - it('rejects an observed write when the file was deleted after the read', async () => { - await writeFile(join(dir, 'a.txt'), 'v1') - const owner = exec() - const target = await fs.resolve('a.txt') - await fs.read(target, READ_ALL, owner) - await unlink(join(dir, 'a.txt')) // file vanishes; observed write must fail (not silently create) - await expect(fs.write(target, 'v2', owner)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) - }) it('two concurrent edits: one wins, the other is rejected as stale', async () => { await writeFile(join(dir, 'a.txt'), 'base') - const owner = exec() const target = await fs.resolve('a.txt') - await fs.read(target, READ_ALL, owner) - // Both edits captured the same recorded version; only one rename can match it. + const version = await versionOf(target) const results = await Promise.allSettled([ - fs.edit(target, { oldString: 'base', newString: 'one', replaceAll: false }, owner), - fs.edit(target, { oldString: 'base', newString: 'two', replaceAll: false }, owner), + fs.editText(target, { oldString: 'base', newString: 'one', replaceAll: false }, { version }), + fs.editText(target, { oldString: 'base', newString: 'two', replaceAll: false }, { version }), ]) - const fulfilled = results.filter(r => r.status === 'fulfilled') + expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1) const rejected = results.filter(r => r.status === 'rejected') - expect(fulfilled).toHaveLength(1) expect(rejected).toHaveLength(1) expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' }) expect(lockCount(fs)).toBe(0) }) }) -describe('symlink targetKey identity (defensive class F)', () => { - it('a read via the real path authorizes an edit via the symlink path', async () => { +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 owner = exec() - await fs.read(await fs.resolve('real.txt'), READ_ALL, owner) - // Edit through the link: same realpath → same targetKey → prior read counts. - const linkTarget = await fs.resolve('link.txt') - const outcome = await fs.edit(linkTarget, { oldString: 'hello', newString: 'bye', replaceAll: false }, owner) - expect(outcome.replacements).toBe(1) - expect(await readFile(join(dir, 'real.txt'), 'utf8')).toBe('bye') // link preserved, target written - }) + const viaReal = await fs.resolve('real.txt') + const viaLink = await fs.resolve('link.txt') + expect(viaLink.targetKey).toBe(viaReal.targetKey) - it('write through a symlink preserves the link and writes the real target', async () => { - await writeFile(join(dir, 'real.txt'), 'hello') - await symlink(join(dir, 'real.txt'), join(dir, 'link.txt')) - const owner = exec() - const linkTarget = await fs.resolve('link.txt') - await fs.read(linkTarget, READ_ALL, owner) - await fs.write(linkTarget, 'replaced', owner) - expect(await readFile(join(dir, 'real.txt'), 'utf8')).toBe('replaced') + 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 owner = exec() - await fs.read(await fs.resolve('real.txt'), READ_ALL, owner) - await writeFile(join(dir, 'real.txt'), 'changed') // out-of-band via real path - const linkTarget = await fs.resolve('link.txt') - await expect(fs.edit(linkTarget, { oldString: 'hello', newString: 'bye', replaceAll: false }, owner)) + 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('non-regular targets', () => { - it('rejects writing onto a directory', async () => { - const target = await fs.resolve('.') // the cwd dir - await expect(fs.write(target, 'x', exec())).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) - }) - - it('applyEdit rejects a target that vanished after the read', async () => { - await writeFile(join(dir, 'a.txt'), 'hello') - const owner = exec() - const target = await fs.resolve('a.txt') - const version = (await fs.read(target, READ_ALL, owner)).version - await unlink(join(dir, 'a.txt')) - await expect(fs.applyEdit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version })) - .rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) - }) - - it('applyEdit rejects a non-regular target', async () => { - const target = await fs.resolve('.') - await expect(fs.applyEdit(target, { oldString: 'a', newString: 'b', replaceAll: false }, { version: 'v' })) - .rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) - }) -}) - -describe('HMR / disposal (defensive class D)', () => { +describe('HMR / disposal', () => { it('disposing the fiber withdraws ctx.fs', async () => { const local = new Context() - const fiber = await local.plugin(LocalFileSystem, { cwd: dir }) + const localFiber = await local.plugin(LocalFileSystem, { cwd: dir }) expect(local.fs).toBeDefined() - await fiber.dispose() + await localFiber.dispose() expect(local.fs).toBeUndefined() }) - - it('a fresh provider does not inherit recorded file state', async () => { - await writeFile(join(dir, 'a.txt'), 'hello') - const local = new Context() - const owner = exec() - const fiber = await local.plugin(LocalFileSystem, { cwd: dir }) - await (local.fs as LocalFileSystem).read(await local.fs.resolve('a.txt'), READ_ALL, owner) - await fiber.dispose() - - await local.plugin(LocalFileSystem, { cwd: dir }) - const fs2 = local.fs as LocalFileSystem - const target = await fs2.resolve('a.txt') - // Same owner object, but state was released on disposal. - await expect(fs2.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, owner)) - .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) - }) }) diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index f25f9e9a0b..13ab9ed860 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -1,24 +1,27 @@ /** - * Cordis-free tests for the raw local-filesystem I/O: path resolution, - * fast/streaming reads, pagination/caps, binary rejection, atomic-write temp - * safety, literal edit matching, and line-ending handling. + * 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-file-context`, so it is not tested here. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtemp, readFile, rm, stat, symlink, writeFile, mkdir, readdir } from 'node:fs/promises' +import { mkdtemp, readFile, rm, stat, symlink, 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, - formatReadBody, probe, readForEdit, - readTextPage, + readWholeText, resolveLocalTarget, restoreLineEndings, + streamWholeText, writeFileAtomic, } from '@deepseek-ai/dsh-fs-local' import type { LocalTarget } from '@deepseek-ai/dsh-fs-local' +import { FsTargetKey } from '@deepseek-ai/dsh-fs' let dir: string beforeEach(async () => { @@ -28,8 +31,13 @@ afterEach(async () => { await rm(dir, { recursive: true, force: true }) }) -const READ_ALL = { offset: 1, limit: 2000 } -const localTarget = (path: string): LocalTarget => ({ displayPath: path, targetKey: path }) +const localTarget = (path: string): LocalTarget => ({ displayPath: path, targetKey: FsTargetKey(path) }) + +async function collect(chunks: AsyncIterable): Promise { + 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 () => { @@ -37,11 +45,10 @@ describe('resolveLocalTarget', () => { await writeFile(file, 'hi') const target = await resolveLocalTarget(dir, 'a.txt') expect(target.displayPath).toBe(file) - expect(target.targetKey).toBe(await (await import('node:fs/promises')).realpath(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 { realpath } = await import('node:fs/promises') const target = await resolveLocalTarget(dir, 'missing.txt') expect(target.targetKey).toBe(join(await realpath(dir), 'missing.txt')) }) @@ -67,186 +74,104 @@ describe('resolveLocalTarget', () => { }) }) -describe('readTextPage', () => { - it('reads a small file with line numbers and full view', async () => { +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() + await new Promise((resolve) => { server.listen(sockPath, () => { resolve() }) }) + try { + expect((await probe(sockPath))?.type).toBe('other') + } finally { + await new Promise((resolve) => { server.close(() => { resolve() }) }) + } + }) +}) + +describe('readWholeText', () => { + it('reads a small file', async () => { const file = join(dir, 'a.txt') await writeFile(file, 'one\ntwo\nthree') - const result = await readTextPage(localTarget(file), READ_ALL) - expect(result.lines).toEqual([ - { number: 1, text: 'one' }, - { number: 2, text: 'two' }, - { number: 3, text: 'three' }, - ]) - expect(result.totalLines).toBe(3) - expect(result.view).toBe('full') - }) - - it('paginates with offset/limit and reports a partial view', async () => { - const file = join(dir, 'a.txt') - await writeFile(file, 'one\ntwo\nthree\nfour') - const result = await readTextPage(localTarget(file), { offset: 2, limit: 2 }) - expect(result.lines.map(l => l.number)).toEqual([2, 3]) - expect(result.view).toBe('partial') - expect(formatReadBody(result, 2)).toContain('(Showing lines 2-3 of 4. Use offset=4 to continue.)') - }) - - it('a whole-file read from offset 1 is a full view; offset>1 is partial', async () => { - const file = join(dir, 'a.txt') - await writeFile(file, 'one\ntwo') - expect((await readTextPage(localTarget(file), { offset: 1, limit: 10 })).view).toBe('full') - expect((await readTextPage(localTarget(file), { offset: 2, limit: 10 })).view).toBe('partial') - }) - - it('truncates an over-long line', async () => { - const file = join(dir, 'long.txt') - await writeFile(file, 'x'.repeat(3000)) - const result = await readTextPage(localTarget(file), READ_ALL) - expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)') - expect(result.view).toBe('partial') - }) - - it('caps output bytes and reports truncatedByBytes', async () => { - const file = join(dir, 'big.txt') - const lines = Array.from({ length: 2000 }, () => 'y'.repeat(100)) - await writeFile(file, lines.join('\n')) - const result = await readTextPage(localTarget(file), READ_ALL) - expect(result.truncatedByBytes).toBe(true) - expect(formatReadBody(result, 1)).toContain('Output capped at 50 KB') - }) - - it('strips CRLF so a Windows file reads like LF', async () => { - const file = join(dir, 'crlf.txt') - await writeFile(file, 'one\r\ntwo\r\n') - const result = await readTextPage(localTarget(file), READ_ALL) - expect(result.lines.map(l => l.text)).toEqual(['one', 'two']) - }) - - it('reads an empty file at offset 1', async () => { - const file = join(dir, 'empty.txt') - await writeFile(file, '') - const result = await readTextPage(localTarget(file), READ_ALL) - expect(result.lines).toEqual([]) - expect(result.totalLines).toBe(0) - expect(formatReadBody(result, 1)).toBe('(End of file - total 0 lines)') - }) - - it('rejects an offset past EOF', async () => { - const file = join(dir, 'a.txt') - await writeFile(file, 'one\ntwo') - await expect(readTextPage(localTarget(file), { offset: 9, limit: 1 })).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) - }) - - it('rejects a binary file (fast path)', async () => { - const file = join(dir, 'bin') - await writeFile(file, Buffer.from([0x68, 0x00, 0x69])) - await expect(readTextPage(localTarget(file), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) - }) - - it('rejects invalid UTF-8 bytes (fast path)', async () => { - const file = join(dir, 'invalid-utf8.txt') - await writeFile(file, Buffer.from([0x68, 0xff, 0x69])) - await expect(readTextPage(localTarget(file), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + expect(await readWholeText(localTarget(file))).toBe('one\ntwo\nthree') }) it('rejects a missing file and a directory', async () => { - await expect(readTextPage(localTarget(join(dir, 'nope')), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) - await expect(readTextPage(localTarget(dir), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + 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(readTextPage(localTarget(file), READ_ALL, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) + await expect(readWholeText(localTarget(file), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) }) - it('passes a live (non-aborted) signal through the fast path', async () => { + it('passes a live (non-aborted) signal through', async () => { const file = join(dir, 'a.txt') await writeFile(file, 'one\ntwo') - const result = await readTextPage(localTarget(file), READ_ALL, new AbortController().signal) - expect(result.totalLines).toBe(2) - }) - - describe('streaming path (forced via a tiny fastPathMaxSize)', () => { - const stream = { fastPathMaxSize: 1 } - - it('reads and paginates large files the same way', async () => { - const file = join(dir, 'a.txt') - await writeFile(file, 'one\ntwo\nthree') - const result = await readTextPage(localTarget(file), { offset: 2, limit: 1 }, undefined, stream) - expect(result.lines).toEqual([{ number: 2, text: 'two' }]) - expect(result.totalLines).toBe(3) - }) - - it('rejects a binary file on the streaming path', async () => { - const file = join(dir, 'bin') - await writeFile(file, Buffer.from([0x68, 0x00, 0x69])) - await expect(readTextPage(localTarget(file), READ_ALL, undefined, stream)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) - }) - - it('caps a newline-free giant line without unbounded buffering', async () => { - const file = join(dir, 'one-line.txt') - await writeFile(file, 'z'.repeat(5000)) - const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) - expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)') - expect(result.view).toBe('partial') - }) - - it('rejects invalid UTF-8 bytes on the streaming path', async () => { - const file = join(dir, 'invalid-utf8.txt') - await writeFile(file, Buffer.from([0x68, 0xff, 0x69])) - await expect(readTextPage(localTarget(file), READ_ALL, undefined, stream)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) - }) - - it('honors abort on the streaming path', async () => { - const file = join(dir, 'a.txt') - await writeFile(file, 'one\ntwo') - await expect(readTextPage(localTarget(file), READ_ALL, AbortSignal.abort(), stream)).rejects.toMatchObject({ code: 'FS_ABORTED' }) - }) - - it('caps output bytes mid-stream', async () => { - const file = join(dir, 'big.txt') - await writeFile(file, Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')) - const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) - expect(result.truncatedByBytes).toBe(true) - }) - - it('flushes a final line with no trailing newline', async () => { - const file = join(dir, 'no-nl.txt') - await writeFile(file, 'one\ntwo') // no trailing \n - const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) - expect(result.lines.map(l => l.text)).toEqual(['one', 'two']) - }) - - it('handles a trailing newline (no dangling buffer at EOF)', async () => { - const file = join(dir, 'nl.txt') - await writeFile(file, 'one\ntwo\n') // trailing \n → empty buffer at end - const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) - expect(result.lines.map(l => l.text)).toEqual(['one', 'two']) - expect(result.totalLines).toBe(2) - }) - - it('passes a live (non-aborted) signal through to the stream', async () => { - const file = join(dir, 'a.txt') - await writeFile(file, 'one\ntwo') - const result = await readTextPage(localTarget(file), READ_ALL, new AbortController().signal, stream) - expect(result.totalLines).toBe(2) - }) - - it('scans across multiple stream chunks', async () => { - // A file well past the default 64 KB stream highWaterMark yields multiple chunks, - // exercising the non-first-chunk branch and the line-buffer cap across appends. - const file = join(dir, 'multi.txt') - const lines = Array.from({ length: 50 }, (_, i) => `line ${i}: ${'x'.repeat(3000)}`) - await writeFile(file, lines.join('\n')) - const result = await readTextPage(localTarget(file), { offset: 1, limit: 3 }, undefined, stream) - expect(result.lines[0]?.text.startsWith('line 0:')).toBe(true) - expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)') - expect(result.totalLines).toBeGreaterThanOrEqual(3) - }) + expect(await readWholeText(localTarget(file), new AbortController().signal)).toBe('one\ntwo') }) }) -describe('writeFileAtomic — temp-file safety (defensive class A)', () => { +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') + }) +}) + +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 @@ -259,8 +184,7 @@ describe('writeFileAtomic — temp-file safety (defensive class A)', () => { }) expect(inspected).toBe(true) expect(await readFile(file, 'utf8')).toBe('hello') - const info = await stat(file) - expect(info.mode & 0o777).toBe(0o640) + expect((await stat(file)).mode & 0o777).toBe(0o640) expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([]) }) @@ -278,7 +202,6 @@ describe('writeFileAtomic — temp-file safety (defensive class A)', () => { await expect( writeFileAtomic(file, 'hello', undefined, undefined, { tempDirName: () => tempDirName }), ).rejects.toMatchObject({ code: 'EEXIST' }) - // The pre-existing staging dir is intact and the target was not created. expect(await readFile(join(dir, tempDirName, 'PRECIOUS'), 'utf8')).toBe('keep') await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' }) }) @@ -303,9 +226,8 @@ describe('writeFileAtomic — temp-file safety (defensive class A)', () => { it('cleans up the temp file when the final rename fails', async () => { const sub = join(dir, 'occupied') - await mkdir(sub) // rename(temp, sub) fails because sub is a non-empty/dir target + await mkdir(sub) await expect(writeFileAtomic(sub, 'hi', undefined, undefined)).rejects.toBeInstanceOf(Error) - // No leftover staging dirs in the directory. expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([]) }) }) @@ -346,16 +268,11 @@ describe('readForEdit + restoreLineEndings', () => { expect(restoreLineEndings(edited.content, original.lineEndings)).toBe('one\r\nTWO\r\n') }) - it('rejects a binary file', async () => { - const file = join(dir, 'bin') - await writeFile(file, Buffer.from([0x00, 0x01])) - await expect(readForEdit(file, file)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) - }) - - it('rejects invalid UTF-8 bytes', async () => { - const file = join(dir, 'invalid-utf8.txt') - await writeFile(file, Buffer.from([0x68, 0xff, 0x69])) - await expect(readForEdit(file, file)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + 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 () => { @@ -365,20 +282,3 @@ describe('readForEdit + restoreLineEndings', () => { expect(original.content).toBe('one\ntwo') }) }) - -describe('probe', () => { - it('returns null for a missing path and info 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?.isFile).toBe(true) - expect(typeof info?.version).toBe('string') - }) - - it('marks a directory as not a regular file', async () => { - const sub = join(dir, 'sub') - await mkdir(sub) - expect((await probe(sub))?.isFile).toBe(false) - }) -}) diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 856ec95076..1599ac25cc 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -1,38 +1,37 @@ # @deepseek-ai/dsh-fs -The **filesystem seam**: an abstract `FileSystem` service (`ctx.fs`) defining WHAT a filesystem backend does — resolve paths, read bounded text pages, create/replace files, apply literal edits — without saying HOW. +The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the text-storage primitives a backend provides — resolve a path, stat metadata, read/stream text, write atomically, and apply a guarded literal edit — without saying HOW. -This package is one third of the filesystem capability, 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) and [the filesystem capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md)): +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), and [the split-the-filesystem-seam RFC](../../../docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)): -| Package | Role | -|---|---| -| `@deepseek-ai/dsh-fs` (this) | the interface: abstract service + vocabulary types + read-before-write/edit policy | -| `@deepseek-ai/dsh-fs-local` | an implementation: the host filesystem | -| `@deepseek-ai/dsh-tool-fs` | the model-facing `read`/`write`/`edit` tool schemas over `ctx.fs` | +| Layer | Package | Role | +|---|---|---| +| tool | `@deepseek-ai/dsh-tool-fs` | model-facing `read`/`write`/`edit` schemas + text rendering | +| policy | `@deepseek-ai/dsh-file-context` | `ctx.fileContext`: observed-state, read windowing, write/edit freshness | +| provider seam | `@deepseek-ai/dsh-fs` (this) | `ctx.fs`: text IO + guarded mutation primitives | +| provider | `@deepseek-ai/dsh-fs-local` | the host-filesystem implementation | -A future sandboxed, virtual, or remote backend implements this interface and the tool schemas don't change. +A future sandboxed, virtual, or remote backend implements this interface and the policy/tool layers don't change. ## Service API (`ctx.fs`) -Consumers call the concrete public API; backends implement the four primitives. +A backend subclasses `FileSystem` and implements six primitives. -| Member | Kind | Semantics | -|---|---|---| -| `resolve(path)` | primitive | Resolve a path into a stable `FsTarget` (`inputPath`, opaque `targetKey`, `displayPath`). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. | -| `readPage(target, request, signal?)` | primitive | Read a bounded UTF-8 text page. Returns line-numbered content, `totalLines`, an opaque `version`, and a `view` (`full` only when the page covered the whole file). | -| `createOrReplace(target, content, expected, signal?)` | primitive | Create/replace a file honoring the `FsExpectation` stale guard. | -| `applyEdit(target, edit, expected, signal?)` | primitive | Atomic literal read-modify-write, verifying the expected version. `oldString` must be non-empty. | -| `read(target, request, exec?, signal?)` | public | Calls `readPage`, then records observed state for the derived owner. | -| `write(target, content, exec?, signal?)` | public | Builds the `FsExpectation` from recorded state, calls `createOrReplace`, refreshes state to `full`. Updating an existing file needs a prior `full` read; a create does not. | -| `edit(target, edit, exec?, signal?)` | public | Requires a prior `full` read by this owner (else `FS_NOT_OBSERVED` / `FS_PARTIAL_OBSERVATION`), rejects empty `oldString`, calls `applyEdit`, refreshes state. | -| `owner(exec?)` | helper | Derives the file-state owner (`exec.agent.session`) — `undefined` when there is none. | +| Member | Semantics | +|---|---| +| `resolve(path)` | Resolve a path into a stable `FsTarget` (`inputPath`, opaque `targetKey`, `displayPath`). 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). | +| `writeText(target, content, expected, signal?)` | Atomic create/replace honoring the `FsWriteExpectation` (`createIfAbsent` or `replaceIfVersion`). | +| `editText(target, edit, expected, signal?)` | Version-guarded literal edit. Verifies `expected.version` BEFORE matching, then applies the replacement and writes atomically — one mutation critical section. | -## Read-before-write/edit lives in the seam +## A provider seam, not the policy layer -Write/edit safety depends on backend-defined target identity and version tokens, so `ctx.fs` — not the tool layer — records what each owner has observed (keyed by an opaque owner object, normally the agent session, then by `targetKey`) and enforces the policy. The base class owns owner derivation, the file-state store, and *which* `FsExpectation` to hand the backend; the backend owns version comparison and I/O. Only a `full` view authorizes write/edit; a `partial` view (paged/truncated read) records context but does not. +`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 version-guarded literal-edit critical section. It does **not** own line windows, numbered lines, rendered footers, or observed-state — those model-facing read-windowing and read-before-write/edit policies live one layer up in `ctx.fileContext` ([`@deepseek-ai/dsh-file-context`](../file-context)), so a sandboxed/remote backend inherits no model-facing observation policy. -State is held in a `WeakMap` keyed by the owner object and dropped on disposal (HMR safety). Persistence across sessions is deferred — a resumed session must read files again before write/edit. +`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 -`FsTarget` / `FsVersion` are opaque — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. 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_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_PARTIAL_OBSERVATION`, `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. +`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. `FsWriteExpectation` is the explicit 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`). 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_TEXT`, `FS_NOT_REGULAR_FILE`, `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. diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index a0bce4940a..7520956310 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -20,10 +20,12 @@ ], "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" } diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index b23d7dd90a..70675b01c2 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -1,62 +1,62 @@ /** - * The filesystem seam (`ctx.fs`): an abstract service defining WHAT a - * filesystem backend does — resolve paths into stable targets, read bounded - * text pages, create/replace files, and apply literal edits — 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 tool schemas that consume them + * 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 expectation, 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 packages. + * The split mirrors the bash seam (`BashExecutor`/`LocalBashExecutor`). See the + * capability-seam RFC for why a swappable capability is three (here four) + * packages. * - * ## Read-before-write/edit lives here, not in the tools + * ## This is a provider seam, not the policy layer * - * Write/edit safety depends on backend-defined target identity and version - * tokens, so the seam — not the consumer — records what each owner has observed - * and enforces the policy. The base class owns owner derivation, the file-state - * store, and the decision of *which* {@link FsExpectation} to hand a backend; - * the backend owns version comparison and the actual I/O. A consumer passes its - * execution context through {@link read}/{@link write}/{@link edit} and never - * touches the cache, owner key, or version tokens. + * `ctx.fs` is deliberately close to fsspec-style storage primitives. It owns + * UTF-8 decoding, binary/NUL rejection, atomic full-file writes, and the + * version-guarded literal-edit critical section — but NOT line windows, + * numbered lines, rendered footers, or observed-state. Those model-facing + * read-windowing and read-before-write/edit policies live one layer up in the + * concrete `ctx.fileContext` service (`@deepseek-ai/dsh-file-context`), 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. * * @module @deepseek-ai/dsh-fs */ import { Context, Service } from 'cordis' -import { FsError } from './types.ts' import type { FsEditOutcome, FsEditRequest, - FsExecContext, - FsExpectation, - FsReadOutcome, - FsReadRequest, + FsInfo, FsTarget, FsVersion, + FsWriteExpectation, FsWriteOutcome, - FileState, } from './types.ts' export { FsError, + FsTargetKey, + FsVersion, } from './types.ts' export type { FsEditOutcome, FsEditRequest, FsErrorCode, - FsExecContext, - FsExpectation, - FsReadOutcome, - FsReadRequest, - FsStateSource, + FsInfo, FsTarget, - FsTextLine, - FsVersion, - FsView, + FsWriteExpectation, FsWriteOutcome, - FileState, } from './types.ts' declare module 'cordis' { @@ -66,50 +66,32 @@ declare module 'cordis' { } /** - * Abstract filesystem service. Subclass, implement the four backend primitives - * ({@link resolve}, {@link readPage}, {@link createOrReplace}, - * {@link applyEdit}), 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). - * - * Consumers call the concrete public API ({@link read}/{@link write}/ - * {@link edit}), which derives the file-state owner, enforces the - * read-before-write/edit policy, and refreshes recorded state — then delegates - * the actual I/O to the backend primitives. + * Abstract filesystem provider service. Subclass, implement the six text-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 file-state lookup agree across paths (e.g. through symlinks). - * - {@link readPage} returns line-numbered UTF-8 content with a `version` and a - * `view` (`full` only when the page covered the whole file). - * - {@link createOrReplace} honors the {@link FsExpectation}: `observed` - * rejects with `FS_STALE_VERSION` if the file changed since `version`; - * `partial` rejects existing targets because the owner saw only a - * non-editable view; `unobserved` creates iff the target is absent and - * otherwise rejects. - * - {@link applyEdit} verifies the expected version (stale guard) and is atomic - * (read-modify-write must not interleave with a concurrent edit). + * 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 writeText} is atomic temp-file + rename honoring the + * {@link FsWriteExpectation}. + * - {@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. */ export abstract class FileSystem extends Service { - /** - * Observed-file state, keyed first by the owner object (weakly held, so a - * collected session frees its state), then by {@link FsTarget.targetKey}. - */ - private fileStates = new WeakMap>() - constructor(ctx: Context) { super(ctx, 'fs') - ctx.effect(() => () => { - // Drop all recorded state on disposal so a reloaded backend starts clean - // (HMR safety). The WeakMap itself would be GC'd, but replacing it makes - // the release observable and immediate for tests. - this.fileStates = new WeakMap() - }, 'fs file-state teardown') } - // --- Backend primitives (subclass implements; all backend I/O lives here) --- - /** * 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 @@ -118,139 +100,32 @@ export abstract class FileSystem extends Service { */ abstract resolve(path: string): Promise - /** Read a bounded UTF-8 text page from a target. */ - abstract readPage(target: FsTarget, request: FsReadRequest, signal?: AbortSignal): Promise + /** Return target metadata, or `undefined` when the target does not exist. */ + abstract stat(target: FsTarget, signal?: AbortSignal): Promise + + /** Read the whole regular text file as a single decoded string. */ + abstract readText(target: FsTarget, signal?: AbortSignal): Promise /** - * Create or fully replace a UTF-8 text file, honoring `expected` as the - * stale guard / create-vs-update decision. + * 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. */ - abstract createOrReplace(target: FsTarget, content: string, expected: FsExpectation, signal?: AbortSignal): Promise + abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> /** - * Apply a literal edit to an existing UTF-8 text file, verifying - * `expected.version` as the stale guard. Atomic read-modify-write. + * Create or fully replace a UTF-8 text file atomically, honoring `expected` + * as the create-vs-replace decision and stale guard. */ - abstract applyEdit(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise - - // --- Owner + file-state machinery (shared by all backends) --- + abstract writeText(target: FsTarget, content: string, expected: FsWriteExpectation, signal?: AbortSignal): Promise /** - * Derive the file-state owner from an execution context — normally the active - * agent session. Returns `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. + * Apply a literal edit to an existing UTF-8 text file. Verifies + * `expected.version` as the stale guard BEFORE literal matching, then applies + * the replacement and writes atomically — one mutation critical section. */ - owner(exec?: FsExecContext): object | undefined { - return exec?.agent?.session - } - - /** Look up recorded state for an owner+target, if any. */ - protected getState(owner: object, targetKey: string): FileState | undefined { - return this.fileStates.get(owner)?.get(targetKey) - } - - /** Record (or replace) one owner's observed state for a target. */ - protected recordState(owner: object, state: FileState): void { - let byTarget = this.fileStates.get(owner) - if (!byTarget) { - byTarget = new Map() - this.fileStates.set(owner, byTarget) - } - byTarget.set(state.targetKey, state) - } - - // --- Concrete public API (orchestration; consumers call these) --- - - /** - * Read a bounded text page and, when an owner is derivable, record the - * observed state (a `full` view authorizes later write/edit; a `partial` view - * does not). - */ - async read(target: FsTarget, request: FsReadRequest, exec?: FsExecContext, signal?: AbortSignal): Promise { - const outcome = await this.readPage(target, request, signal) - const owner = this.owner(exec) - if (owner) { - this.recordState(owner, { - targetKey: target.targetKey, - displayPath: target.displayPath, - version: outcome.version, - view: outcome.view, - updatedAt: this.now(), - source: 'read', - }) - } - return outcome - } - - /** - * Create or fully replace a file. Updating an existing file requires a `full` - * prior observation by this owner; a create (no prior state, target absent) - * does not. After a successful write the recorded state refreshes to `full` - * at the new version so a follow-up modification needs no re-read. - */ - async write(target: FsTarget, content: string, exec?: FsExecContext, signal?: AbortSignal): Promise { - const owner = this.owner(exec) - const prior = owner ? this.getState(owner, target.targetKey) : undefined - const expected: FsExpectation = prior - ? prior.view === 'full' - ? { kind: 'observed', version: prior.version } - : { kind: 'partial', version: prior.version } - : { kind: 'unobserved' } - - const outcome = await this.createOrReplace(target, content, expected, signal) - if (owner) { - this.recordState(owner, { - targetKey: target.targetKey, - displayPath: target.displayPath, - version: outcome.version, - view: 'full', - updatedAt: this.now(), - source: 'write', - }) - } - return outcome - } - - /** - * Apply a literal edit. Always requires a `full` prior observation by this - * owner. No owner or absent state rejects with `FS_NOT_OBSERVED`; a partial - * view rejects with `FS_PARTIAL_OBSERVATION`; an empty `oldString` rejects - * before backend I/O. There is no "create via edit". Refreshes recorded - * state to `full` at the new version on success. - */ - async edit(target: FsTarget, edit: FsEditRequest, exec?: FsExecContext, signal?: AbortSignal): Promise { - if (edit.oldString.length === 0) { - throw new FsError('old_string must be a non-empty string', 'FS_EDIT_NOT_FOUND') - } - const owner = this.owner(exec) - const prior = owner ? this.getState(owner, target.targetKey) : undefined - if (!owner || !prior) { - throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED') - } - if (prior.view !== 'full') { - throw new FsError(`edit requires a full read of "${target.displayPath}" first`, 'FS_PARTIAL_OBSERVATION') - } - - const outcome = await this.applyEdit(target, edit, { version: prior.version }, signal) - this.recordState(owner, { - targetKey: target.targetKey, - displayPath: target.displayPath, - version: outcome.version, - view: 'full', - updatedAt: this.now(), - source: 'edit', - }) - return outcome - } - - /** - * Wall-clock now (ms). A protected seam so tests can use deterministic - * timestamps; production uses `Date.now()`. - */ - protected now(): number { - return Date.now() - } + abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise } export default FileSystem diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index f08723731e..62ba52b52f 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -1,34 +1,49 @@ /** - * Vocabulary for the filesystem capability seam (`ctx.fs`): the request/outcome - * shapes backends produce and consumers format, the opaque target/version - * identities, the per-owner file-state record, and the typed error taxonomy. + * Vocabulary for the filesystem provider seam (`ctx.fs`): the opaque + * target/version identities, the metadata `stat` returns, the write-expectation + * 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 model-facing consumer - * (`@deepseek-ai/dsh-tool-fs`). They deliberately avoid host-path assumptions: - * `targetKey` and `version` are opaque tokens, and `displayPath` is the only - * field a consumer may show. + * future sandboxed/remote backends) and by the policy layer + * (`@deepseek-ai/dsh-file-context`). 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 policy layer (`ctx.fileContext`). * * @module @deepseek-ai/dsh-fs/types */ import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { Branded } from '@deepseek-ai/dsh-brand' /** - * Minimal structural view of a tool execution the filesystem seam needs to - * derive a file-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` - * satisfies this shape, so the consumer passes its `exec` straight through - * without `dsh-fs` 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); `dsh-fs` never reads any of its fields. + * 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 interface FsExecContext { - /** 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 - } +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 } /** @@ -38,12 +53,8 @@ export interface FsExecContext { export interface FsTarget { /** The original model/plugin-supplied path, for diagnostics only. */ inputPath: string - /** - * Opaque key for stale guards and file-state 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. - */ - targetKey: string + /** 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. @@ -52,64 +63,30 @@ export interface FsTarget { } /** - * Opaque file-version token. The local backend derives it from mtime+size; a - * remote backend might use a revision id. `ctx.fs` records it for stale checks; - * consumers may display related metadata but MUST NOT interpret this token. + * 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 type FsVersion = string - -/** Resolved read window. The consumer applies its defaults/caps before calling. */ -export interface FsReadRequest { - /** 1-based first line to return. */ - offset: number - /** Maximum number of lines to return. */ - limit: number -} - -/** One line returned from a text file. */ -export interface FsTextLine { - /** 1-based line number in the file. */ - number: number - /** Line text without its trailing newline. */ - text: string -} - -/** Whether a recorded/returned view covers the whole file or only part of it. */ -export type FsView = 'full' | 'partial' - -/** Outcome of a bounded text read. */ -export interface FsReadOutcome { - /** 1-based first line requested. */ - offset: number - /** Maximum number of lines requested. */ - limit: number - /** Returned lines, already numbered. */ - lines: FsTextLine[] - /** 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 - /** Opaque version of the file at read time. */ +export interface FsInfo { + /** Opaque freshness token of the target right now. */ version: FsVersion - /** - * Whether this read saw the whole file (`full`) or only part of it - * (`partial`). Only a `full` view authorizes a later write/edit. - */ - view: FsView + /** 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 } /** - * The read-before-write decision the base service hands to a backend for a - * full-file write. `observed` means the owner has a `full` view recorded at - * `version` (the backend rejects if the file has since changed); `partial` - * means the owner saw only a non-editable view of that target; `unobserved` - * means there is no prior view (the backend may create iff the target is - * absent, else rejects as not observed). + * The explicit intent of a {@link FileSystem.writeText} call. `createIfAbsent` + * creates a missing target and rejects an existing one with `FS_NOT_OBSERVED` + * (the path used 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`. */ -export type FsExpectation = - | { kind: 'observed'; version: FsVersion } - | { kind: 'partial'; version: FsVersion } - | { kind: 'unobserved' } +export type FsWriteExpectation = + | { kind: 'createIfAbsent' } + | { kind: 'replaceIfVersion'; version: FsVersion } /** Outcome of a full-file write. */ export interface FsWriteOutcome { @@ -139,29 +116,6 @@ export interface FsEditOutcome { version: FsVersion } -/** Source that last touched a recorded {@link FileState}. */ -export type FsStateSource = 'read' | 'write' | 'edit' - -/** - * What an owner has observed about one target. Keyed (inside the service) first - * by the owner object, then by {@link FsTarget.targetKey}. Only a `full` view - * authorizes write/edit. - */ -export interface FileState { - /** Backend target identity this state describes. */ - targetKey: string - /** Display path captured when the state was recorded. */ - displayPath: string - /** Opaque version the owner last saw. */ - version: FsVersion - /** Whether the owner saw the whole file or only part of it. */ - view: FsView - /** Wall-clock time the state was last updated (ms since epoch). */ - updatedAt: number - /** Operation that produced this state. */ - source: FsStateSource -} - /** * Stable, machine-routable codes for filesystem failures. Carried on * {@link FsError}; the tool registry surfaces `{ name, code }` on `isError` @@ -173,7 +127,6 @@ export type FsErrorCode = | 'FS_NOT_REGULAR_FILE' | 'FS_STALE_VERSION' | 'FS_NOT_OBSERVED' - | 'FS_PARTIAL_OBSERVATION' | 'FS_AMBIGUOUS_EDIT' | 'FS_EDIT_NOT_FOUND' | 'FS_ABORTED' diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index 84f84cbe0b..e06cfa9ee6 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -1,98 +1,69 @@ /** - * Tests for the filesystem service seam itself: registration/disposal, owner - * derivation, and the read-before-write/edit policy the base class enforces - * (which `FsExpectation` it hands the backend, multi-owner isolation, and - * state refresh) — all exercised through a fake in-memory backend that records - * the expectations it received. + * 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-file-context`; 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 } from '@deepseek-ai/dsh-fs' +import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsEditOutcome, FsEditRequest, - FsExpectation, - FsReadOutcome, - FsReadRequest, + FsInfo, FsTarget, - FsView, + FsWriteExpectation, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' -/** A fake backend: an in-memory file table, recording every expectation it is handed. */ +/** A minimal in-memory fake implementing the six provider primitives. */ class FakeFileSystem extends FileSystem { files = new Map() - versions = new Map() - /** View the next `readPage` should report (tests flip this for partial reads). */ - nextReadView: FsView = 'full' - /** Expectations handed to `createOrReplace`, in call order. */ - writeExpectations: FsExpectation[] = [] - /** Versions handed to `applyEdit`, in call order. */ - editExpectedVersions: string[] = [] - - private bump(key: string): string { - const next = (this.versions.get(key) ?? 0) + 1 - this.versions.set(key, next) - return `v${next}` - } override async resolve(path: string): Promise { - return { inputPath: path, targetKey: path, displayPath: path } + return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path } } - - override async readPage(target: FsTarget, request: FsReadRequest): Promise { + override async stat(target: FsTarget): Promise { + 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 { const content = this.files.get(target.targetKey) if (content === undefined) throw new FsError(`not found: ${target.displayPath}`, 'FS_NOT_FOUND') - const allLines = content.split('\n') - const lines = allLines - .slice(request.offset - 1, request.offset - 1 + request.limit) - .map((text, i) => ({ number: request.offset + i, text })) - return { - offset: request.offset, - limit: request.limit, - lines, - totalLines: allLines.length, - version: `v${this.versions.get(target.targetKey) ?? 0}`, - view: this.nextReadView, - } + return content } - - override async createOrReplace(target: FsTarget, content: string, expected: FsExpectation): Promise { - this.writeExpectations.push(expected) + override async streamText(target: FsTarget): Promise> { + const content = await this.readText(target) + return (async function* () { yield content })() + } + override async writeText(target: FsTarget, content: string, _expected: FsWriteExpectation): Promise { const existed = this.files.has(target.targetKey) this.files.set(target.targetKey, content) - return { operation: existed ? 'update' : 'create', version: this.bump(target.targetKey) } + return { operation: existed ? 'update' : 'create', version: FsVersion('v2') } } - - override async applyEdit(target: FsTarget, edit: FsEditRequest, expected: { version: string }): Promise { - this.editExpectedVersions.push(expected.version) + override async editText(target: FsTarget, edit: FsEditRequest): Promise { const content = this.files.get(target.targetKey) ?? '' this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString)) - return { replacements: 1, replaceAll: edit.replaceAll, version: this.bump(target.targetKey) } + return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3') } } } -async function setup() { - const ctx = new Context() - await ctx.plugin(FakeFileSystem) - const fs = ctx.fs as FakeFileSystem - return { ctx, fs } -} - -const READ_ALL: FsReadRequest = { offset: 1, limit: 2000 } -const ownerExec = (session: object) => ({ agent: { session } }) - -describe('FileSystem service seam', () => { - it('registers as ctx.fs and serves the API', async () => { - const { fs } = await setup() +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 outcome = await fs.read(await fs.resolve('a.txt'), READ_ALL) - expect(outcome.lines).toEqual([{ number: 1, text: '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 } = await setup() + const ctx = new Context() + await ctx.plugin(FakeFileSystem) await expect(ctx.plugin(FakeFileSystem)).rejects.toThrow() }) @@ -103,203 +74,30 @@ describe('FileSystem service seam', () => { await fiber.dispose() expect(ctx.fs).toBeUndefined() }) -}) -describe('owner derivation', () => { - it('derives the owner from exec.agent.session', async () => { - const { fs } = await setup() - const session = {} - expect(fs.owner(ownerExec(session))).toBe(session) - }) - - it('returns undefined with no exec, no agent, or no session', async () => { - const { fs } = await setup() - expect(fs.owner()).toBeUndefined() - expect(fs.owner({})).toBeUndefined() - expect(fs.owner({ agent: {} })).toBeUndefined() - }) -}) - -describe('read records observed state', () => { - it('a full read authorizes a later in-place write (observed expectation)', async () => { - const { fs } = await setup() - const exec = ownerExec({}) - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - - await fs.read(target, READ_ALL, exec) - await fs.write(target, 'goodbye', exec) - - expect(fs.writeExpectations).toEqual([{ kind: 'observed', version: 'v0' }]) - }) - - it('a partial read does NOT authorize a write (passes a partial expectation)', async () => { - const { fs } = await setup() - const exec = ownerExec({}) - fs.files.set('a.txt', 'hello') - fs.nextReadView = 'partial' - const target = await fs.resolve('a.txt') - - await fs.read(target, { offset: 1, limit: 1 }, exec) - await fs.write(target, 'goodbye', exec) - - expect(fs.writeExpectations).toEqual([{ kind: 'partial', version: 'v0' }]) - }) - - it('skips recording when there is no owner', async () => { - const { fs } = await setup() - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - - await fs.read(target, READ_ALL) // no exec - await fs.write(target, 'goodbye') // no exec → cannot be observed - - expect(fs.writeExpectations).toEqual([{ kind: 'unobserved' }]) - }) -}) - -describe('write policy', () => { - it('a create (no prior state) is unobserved', async () => { - const { fs } = await setup() - const exec = ownerExec({}) - const target = await fs.resolve('new.txt') - - const outcome = await fs.write(target, 'fresh', exec) - - expect(outcome.operation).toBe('create') - expect(fs.writeExpectations).toEqual([{ kind: 'unobserved' }]) - }) - - it('refreshes state to full after a write, so a follow-up edit needs no re-read', async () => { - const { fs } = await setup() - const exec = ownerExec({}) - const target = await fs.resolve('a.txt') - - await fs.write(target, 'one', exec) // create → state now full at v1 - await fs.edit(target, { oldString: 'one', newString: 'two', replaceAll: false }, exec) - - expect(fs.editExpectedVersions).toEqual(['v1']) - }) -}) - -describe('edit policy', () => { - it('rejects with FS_NOT_OBSERVED when the file was never read', async () => { - const { fs } = await setup() - const exec = ownerExec({}) - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - - await expect( - fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec), - ).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) - }) - - it('rejects with FS_PARTIAL_OBSERVATION when only a partial view was recorded', async () => { - const { fs } = await setup() - const exec = ownerExec({}) - fs.files.set('a.txt', 'hello') - fs.nextReadView = 'partial' - const target = await fs.resolve('a.txt') - await fs.read(target, { offset: 1, limit: 1 }, exec) - - await expect( - fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec), - ).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) - }) - - it('rejects an empty oldString before calling the backend primitive', async () => { - const { fs } = await setup() - const exec = ownerExec({}) - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - await fs.read(target, READ_ALL, exec) - - await expect( - fs.edit(target, { oldString: '', newString: 'bye', replaceAll: false }, exec), - ).rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' }) - expect(fs.editExpectedVersions).toEqual([]) - }) - - it('rejects when there is no owner (cannot prove prior observation)', async () => { - const { fs } = await setup() - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - - await expect( - fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }), - ).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) - }) - - it('proceeds after a full read, passing the recorded version as the stale guard', async () => { - const { fs } = await setup() - const exec = ownerExec({}) - fs.files.set('a.txt', 'hello') - fs.versions.set('a.txt', 7) // distinguishable version - const target = await fs.resolve('a.txt') - await fs.read(target, READ_ALL, exec) - - await fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec) - - expect(fs.editExpectedVersions).toEqual(['v7']) - }) -}) - -describe('multi-owner isolation', () => { - it('owner A reading does not grant owner B edit authority', async () => { - const { fs } = await setup() - const a = ownerExec({}) - const b = ownerExec({}) - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - - await fs.read(target, READ_ALL, a) - - // B never read it → B's edit must be rejected. - await expect( - fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, b), - ).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) - // A still may edit. - await expect( - fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, a), - ).resolves.toMatchObject({ replacements: 1 }) - }) - - it('each owner records its own observed version independently', async () => { - const { fs } = await setup() - const a = ownerExec({}) - const b = ownerExec({}) - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - - await fs.read(target, READ_ALL, a) // A sees v0 - await fs.write(target, 'mid', b) // B writes unobserved → file now v1 - await fs.write(target, 'late', a) // A still holds its v0 observation - - expect(fs.writeExpectations).toEqual([ - { kind: 'unobserved' }, - { kind: 'observed', version: 'v0' }, - ]) - }) -}) - -describe('disposal releases recorded state', () => { - it('a fresh provider after disposal starts with no inherited state', async () => { + it('streamText yields the same text readText returns', async () => { const ctx = new Context() - const fiber = await ctx.plugin(FakeFileSystem) - const fs1 = ctx.fs as FakeFileSystem - const exec = ownerExec({}) - fs1.files.set('a.txt', 'hello') - await fs1.read(await fs1.resolve('a.txt'), READ_ALL, exec) - await fiber.dispose() - await ctx.plugin(FakeFileSystem) - const fs2 = ctx.fs as FakeFileSystem - fs2.files.set('a.txt', 'hello') - const target = await fs2.resolve('a.txt') - // Reusing the same exec/owner object: state must NOT carry over. - await expect( - fs2.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec), - ).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + 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('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') }) }) diff --git a/packages/fs/fs/tsconfig.json b/packages/fs/fs/tsconfig.json index 7b250a29c4..1ed5f54447 100644 --- a/packages/fs/fs/tsconfig.json +++ b/packages/fs/fs/tsconfig.json @@ -8,6 +8,7 @@ "references": [ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, + { "path": "../../util/brand" }, { "path": "../../llm/llm" } ] } diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 2beb45be9c..f751ecb051 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -1,11 +1,12 @@ # @deepseek-ai/dsh-tool-fs -The **model-facing filesystem tools** — `read`, `write`, `edit` — over the `ctx.fs` seam ([`@deepseek-ai/dsh-fs`](../fs)). This is the consumer third of the filesystem capability; it owns tool names, JSON schemas, argument validation, prompt sections, and result formatting, and **never** touches filesystem I/O (no `node:fs`/`node:path`, no implementation import). +The **model-facing filesystem tools** — `read`, `write`, `edit` — over the `ctx.fileContext` policy layer ([`@deepseek-ai/dsh-file-context`](../file-context)). This is the consumer layer of the filesystem stack; it owns tool names, JSON schemas, argument validation, prompt sections, and result formatting, and **never** touches filesystem I/O (no `node:fs`/`node:path`, no implementation import) or reaches around the policy layer to `ctx.fs`. ```ts ignore-check -// Load a ctx.fs provider first, then the tools. +// Load a ctx.fs provider, the policy layer, then the tools. await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local -await ctx.plugin(ToolFs) // this package — registers read/write/edit +await ctx.plugin(FileContext) // @deepseek-ai/dsh-file-context +await ctx.plugin(ToolFs) // this package — registers read/write/edit ``` Each tool also ships as a subpath plugin for focused deployments: @@ -21,13 +22,17 @@ import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' | Tool | Arguments | Behavior | |---|---|---| | `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at 2000 lines. | -| `write` | `file_path`, `content` | Create or fully replace a file. Overwriting an existing file requires a prior `read` (the backend enforces it); creating a new file does not. | -| `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. Requires a prior `read`. | +| `write` | `file_path`, `content` | Create or fully replace a file. Overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. | +| `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. Requires a prior `read` (any window) and the file unchanged since. | Field names are snake_case to match Claude Code and existing harness tool schemas. -## How the read-before-write policy is enforced +## How the read-before-write/edit policy is enforced -The tools do **not** check whether a `read` ran or inspect any cache. Each tool resolves the path via `ctx.fs.resolve()`, then calls `ctx.fs.read/write/edit(target, …, exec)` — passing the current tool execution context straight through. `ctx.fs` derives the file-state owner (normally the agent session) from that context and owns the prior-observation and stale-version policy. Backend errors (`FsError`) flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached. +The tools do **not** check whether a `read` ran or inspect any cache. Each tool resolves the path via `ctx.fileContext.resolve()`, then calls `ctx.fileContext.read/write/edit(target, …, exec)` — passing the current tool execution context straight through. `ctx.fileContext` derives the observed-state owner (normally the agent session) from that context and owns the freshness policy: a recorded read at the file's current version authorizes a write/edit, and any windowed read counts (authorization is freshness, not a full-view requirement). Backend errors (`FsError`) flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached. + +## The no-bypass contract + +A model-facing read MUST go through `ctx.fileContext.read`, never `ctx.fs.readText`/`streamText`, so every successful read records observed-state before rendering — which is why the tools inject `fileContext`, not `fs`. Direct `ctx.fs` calls remain an explicit escape hatch for non-tool consumers: a direct `ctx.fs.readText` records nothing, so a later `edit` rejects with `FS_NOT_OBSERVED` until the file is read through `ctx.fileContext`. Tool schemas reach the system prompt automatically via the tool registry; this package additionally registers short prose guidance through `ctx.systemPrompt.section(...)`. diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 744a41736d..f158142fca 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -32,6 +32,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-file-context": "^0.0.1", "@deepseek-ai/dsh-fs": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -40,6 +41,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-file-context": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 3f65f5660d..d54fe3045b 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -1,8 +1,8 @@ /** * The model-facing `edit` tool: update an existing UTF-8 text file by replacing * literal text, requiring a unique match by default. Execution goes through - * `ctx.fs`, which enforces prior observation and the stale-version guard and - * owns the literal-match semantics. + * `ctx.fileContext`, which enforces prior observation (the freshness policy) + * and delegates the literal-match + stale-guard critical section to `ctx.fs`. * * @module @deepseek-ai/dsh-tool-fs/edit */ @@ -60,8 +60,8 @@ export function apply(ctx: Context): void { }, async execute(args, exec): Promise { const input = parseEditArgs(args) - const target = await ctx.fs.resolve(input.filePath) - const outcome = await ctx.fs.edit( + const target = await ctx.fileContext.resolve(input.filePath) + const outcome = await ctx.fileContext.edit( target, { oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll }, exec, @@ -76,7 +76,7 @@ export function apply(ctx: Context): void { export const name = 'fs-edit' /** Services required by the `edit` tool plugin. */ -export const inject = ['tools', 'fs', 'systemPrompt'] +export const inject = ['tools', 'fileContext', 'systemPrompt'] /** Named helper for direct registration in the root plugin and tests. */ export const applyEditTool = apply diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index 437c16b5dd..5509c7980b 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -1,13 +1,16 @@ /** * The model-facing filesystem tool suite (`read`, `write`, `edit`) over the - * `ctx.fs` seam. This root plugin registers all three tools by composing the - * per-tool registration helpers; each tool is also exposed as a subpath plugin - * (`@deepseek-ai/dsh-tool-fs/read`, `/write`, `/edit`) for focused deployments. + * `ctx.fileContext` policy layer. This root plugin registers all three tools by + * composing the per-tool registration helpers; each tool is also exposed as a + * subpath plugin (`@deepseek-ai/dsh-tool-fs/read`, `/write`, `/edit`) for focused + * deployments. * * The package owns model-facing concerns only — tool names, JSON schemas, * argument validation, prompt sections, result formatting. All filesystem - * execution goes through `ctx.fs`; this package never imports `node:fs`, - * `node:path`, or an `@deepseek-ai/dsh-fs-local` implementation. + * execution goes through `ctx.fileContext` (never directly around it to + * `ctx.fs`), so every model read records observed-state before rendering; this + * package never imports `node:fs`, `node:path`, or an + * `@deepseek-ai/dsh-fs-local` implementation. * * @module @deepseek-ai/dsh-tool-fs */ @@ -25,7 +28,7 @@ export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.ts' export const name = 'tool-fs' /** Services required by the filesystem tool suite. */ -export const inject = ['tools', 'fs', 'systemPrompt'] +export const inject = ['tools', 'fileContext', 'systemPrompt'] /** Register the full `read`/`write`/`edit` filesystem tool suite. */ export function apply(ctx: Context): void { diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index bfa67a588f..8a6ae8d609 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -1,8 +1,9 @@ /** * The model-facing `read` tool: inspect a UTF-8 text file and return * line-numbered content with pagination guidance. Execution goes through - * `ctx.fs` — this module owns only the model-facing schema, argument - * validation, and result formatting, never filesystem I/O. + * `ctx.fileContext` (which records observed state and owns read windowing) — + * this module owns only the model-facing schema, argument validation, and + * result formatting, never filesystem I/O. * * @module @deepseek-ai/dsh-tool-fs/read */ @@ -10,7 +11,7 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { FsReadOutcome } from '@deepseek-ai/dsh-fs' +import type { FileReadOutcome } from '@deepseek-ai/dsh-file-context' import type {} from '@deepseek-ai/dsh-system-prompt' /** Default and maximum number of lines returned by one `read` call. */ @@ -40,7 +41,7 @@ export function parseReadArgs(args: { file_path: string; offset?: number; limit? } /** Format a read outcome as one OpenCode-style line-numbered text block body. */ -export function formatReadOutput(displayPath: string, outcome: FsReadOutcome): string { +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) { @@ -78,8 +79,8 @@ export function apply(ctx: Context): void { }, async execute(args, exec): Promise { const input = parseReadArgs(args) - const target = await ctx.fs.resolve(input.filePath) - const outcome = await ctx.fs.read(target, { offset: input.offset, limit: input.limit }, exec, exec.signal) + const target = await ctx.fileContext.resolve(input.filePath) + const outcome = await ctx.fileContext.read(target, { offset: input.offset, limit: input.limit }, exec, exec.signal) return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }] }, })) @@ -89,7 +90,7 @@ export function apply(ctx: Context): void { export const name = 'fs-read' /** Services required by the `read` tool plugin. */ -export const inject = ['tools', 'fs', 'systemPrompt'] +export const inject = ['tools', 'fileContext', 'systemPrompt'] /** Named helper for direct registration in the root plugin and tests. */ export const applyReadTool = apply diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index ff66d10127..8c242c5256 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -1,8 +1,8 @@ /** * The model-facing `write` tool: create or fully replace a UTF-8 text file. - * Execution goes through `ctx.fs`, which enforces the read-before-overwrite - * policy (updating an existing file requires a prior read in the same - * execution context; creating a new file does not). + * Execution goes through `ctx.fileContext`, which enforces the freshness policy + * (creating a new file needs no prior read; replacing an existing file requires + * a prior read in the same execution context at the unchanged version). * * @module @deepseek-ai/dsh-tool-fs/write */ @@ -46,8 +46,8 @@ export function apply(ctx: Context): void { }, async execute(args, exec): Promise { const input = parseWriteArgs(args) - const target = await ctx.fs.resolve(input.filePath) - const outcome = await ctx.fs.write(target, input.content, exec, exec.signal) + const target = await ctx.fileContext.resolve(input.filePath) + const outcome = await ctx.fileContext.write(target, input.content, exec, exec.signal) return [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }] }, })) @@ -57,7 +57,7 @@ export function apply(ctx: Context): void { export const name = 'fs-write' /** Services required by the `write` tool plugin. */ -export const inject = ['tools', 'fs', 'systemPrompt'] +export const inject = ['tools', 'fileContext', 'systemPrompt'] /** Named helper for direct registration in the root plugin and tests. */ export const applyWriteTool = apply diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 6f81763241..d61bdb5cac 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -1,8 +1,9 @@ /** - * Integration tests: the real local backend (`dsh-fs-local`) plus the model - * tools (`dsh-tool-fs`), exercised through `ctx.tools.execute()` so nothing - * bypasses the tool registry. These verify the WORLD — files are read back from - * disk and asserted byte-for-byte — not the tool's self-report. + * Integration tests: the real local backend (`dsh-fs-local`) plus the real + * policy layer (`dsh-file-context`) plus the model tools (`dsh-tool-fs`), + * exercised through `ctx.tools.execute()` so nothing bypasses the tool registry. + * 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 } from 'vitest' @@ -14,6 +15,7 @@ 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 FileContext from '@deepseek-ai/dsh-file-context' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' let dir: string @@ -28,6 +30,7 @@ beforeEach(async () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(LocalFileSystem, { cwd: dir }) + await ctx.plugin(FileContext) fiber = await ctx.plugin(ToolFs) }) afterEach(async () => { @@ -61,7 +64,6 @@ describe('write → disk', () => { const result = await call('write', { file_path: 'a.txt', content: 'clobber' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) - // The world is unchanged. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original') }) @@ -72,6 +74,15 @@ describe('write → disk', () => { 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', () => { @@ -89,6 +100,14 @@ describe('read', () => { 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', () => { @@ -108,13 +127,27 @@ describe('edit → disk', () => { expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world') }) - it('rejects an edit after only a partial read, leaving the file untouched', async () => { - await writeFile(join(dir, 'a.txt'), 'hello\nworld') + 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_PARTIAL_OBSERVATION' }) - expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello\nworld') + expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' }) }) it('rejects an ambiguous match without replace_all', async () => { @@ -141,3 +174,15 @@ describe('edit → disk', () => { expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('one three') }) }) + +describe('no-bypass / escape-hatch contract', () => { + 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 policy layer — 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 was not through ctx.fileContext. + 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' }) + }) +}) diff --git a/packages/fs/tool-fs/tests/subpaths.spec.ts b/packages/fs/tool-fs/tests/subpaths.spec.ts index ac35babe04..7243955969 100644 --- a/packages/fs/tool-fs/tests/subpaths.spec.ts +++ b/packages/fs/tool-fs/tests/subpaths.spec.ts @@ -1,36 +1,43 @@ /** * Tests for the per-tool subpath plugins (`@deepseek-ai/dsh-tool-fs/read`, * `/write`, `/edit`): each registers exactly one tool, injects the same - * services, and cleans up on disposal. + * services (`tools`, `fileContext`, `systemPrompt`), and cleans up on disposal. */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import { FileSystem } from '@deepseek-ai/dsh-fs' +import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsEditOutcome, - FsReadOutcome, + FsInfo, FsTarget, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' +import FileContext from '@deepseek-ai/dsh-file-context' import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read' import * as writePlugin from '@deepseek-ai/dsh-tool-fs/write' import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' class StubFs extends FileSystem { override async resolve(path: string): Promise { - return { inputPath: path, targetKey: path, displayPath: path } + return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path } } - override async readPage(): Promise { - return { offset: 1, limit: 1, lines: [], totalLines: 0, version: 'v', view: 'full' } + override async stat(): Promise { + return { version: FsVersion('v'), type: 'file', size: 0 } } - override async createOrReplace(): Promise { - return { operation: 'create', version: 'v' } + override async readText(): Promise { + return '' } - override async applyEdit(): Promise { - return { replacements: 1, replaceAll: false, version: 'v' } + override async streamText(): Promise> { + return (async function* () { yield '' })() + } + override async writeText(): Promise { + return { operation: 'create', version: FsVersion('v') } + } + override async editText(): Promise { + return { replacements: 1, replaceAll: false, version: FsVersion('v') } } } @@ -39,6 +46,7 @@ async function base() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(StubFs) + await ctx.plugin(FileContext) return ctx } @@ -64,7 +72,7 @@ describe('subpath plugins', () => { expect(ctx.tools.schemas()).toHaveLength(0) }) - it('stays pending without a ctx.fs provider', async () => { + it('stays pending without a ctx.fileContext provider', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 594a07dbbd..ef1490b5ce 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -1,8 +1,10 @@ /** - * Consumer-surface tests for the filesystem tools using a fake `ctx.fs` that - * records the execution context it received and returns canned outcomes. These - * verify schemas, argument validation, result formatting, FsError→isError - * propagation, and that each tool passes `exec` straight through to `ctx.fs`. + * Consumer-surface tests for the filesystem tools. They run the REAL + * `ctx.fileContext` policy service over a fake `ctx.fs` provider (the genuine + * collaborator, per the prefer-the-real-implementation rule), so they verify + * schemas, argument validation, result formatting, FsError→isError propagation, + * and that each tool records observed-state through `ctx.fileContext` (the + * no-bypass contract) — not just that it moved bytes. */ import { describe, expect, it } from 'vitest' @@ -10,65 +12,56 @@ 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 } from '@deepseek-ai/dsh-fs' +import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsEditOutcome, FsEditRequest, - FsExecContext, - FsReadOutcome, - FsReadRequest, + FsInfo, FsTarget, + FsWriteExpectation, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' +import FileContext from '@deepseek-ai/dsh-file-context' +import type { FileReadOutcome } from '@deepseek-ai/dsh-file-context' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { formatReadOutput } from '@deepseek-ai/dsh-tool-fs' -/** - * Records the public-API calls (and the exec each received) and returns canned - * outcomes; lets a test arm a rejection. Overrides the public methods directly - * (not the primitives) so we observe exactly what the tool passed. - */ +/** An in-memory fake provider; a test can arm a rejection on any primitive. */ class FakeFs extends FileSystem { - calls: Array<{ op: string; exec: FsExecContext | undefined; target: FsTarget }> = [] + files = new Map() rejectWith?: FsError + private throwIfArmed(): void { + if (this.rejectWith) throw this.rejectWith + } + override async resolve(path: string): Promise { - return { inputPath: path, targetKey: `key:${path}`, displayPath: `/abs/${path}` } + return { inputPath: path, targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` } } - - override async readPage(): Promise { - throw new Error('not used: tool tests override read()') + override async stat(target: FsTarget): Promise { + 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 createOrReplace(): Promise { - throw new Error('not used') + override async readText(target: FsTarget): Promise { + return this.files.get(target.targetKey) ?? '' } - override async applyEdit(): Promise { - throw new Error('not used') + override async streamText(target: FsTarget): Promise> { + const content = this.files.get(target.targetKey) ?? '' + return (async function* () { yield content })() } - - override async read(target: FsTarget, _request: FsReadRequest, exec?: FsExecContext): Promise { - this.calls.push({ op: 'read', exec, target }) - if (this.rejectWith) throw this.rejectWith - return { - offset: 1, - limit: 2000, - lines: [{ number: 1, text: 'hello' }, { number: 2, text: 'world' }], - totalLines: 2, - version: 'v1', - view: 'full', - } + override async writeText(target: FsTarget, content: string, _expected: FsWriteExpectation): Promise { + this.throwIfArmed() + const existed = this.files.has(target.targetKey) + this.files.set(target.targetKey, content) + return { operation: existed ? 'update' : 'create', version: FsVersion('v2') } } - - override async write(target: FsTarget, _content: string, exec?: FsExecContext): Promise { - this.calls.push({ op: 'write', exec, target }) - if (this.rejectWith) throw this.rejectWith - return { operation: 'create', version: 'v1' } - } - - override async edit(target: FsTarget, _edit: FsEditRequest, exec?: FsExecContext): Promise { - this.calls.push({ op: 'edit', exec, target }) - if (this.rejectWith) throw this.rejectWith - return { replacements: 1, replaceAll: false, version: 'v1' } + override async editText(target: FsTarget, edit: FsEditRequest): Promise { + this.throwIfArmed() + const content = this.files.get(target.targetKey) ?? '' + this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString)) + return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3') } } } @@ -77,6 +70,7 @@ async function setup() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(FakeFs) + await ctx.plugin(FileContext) await ctx.plugin(ToolFs) const fs = ctx.fs as FakeFs return { ctx, fs } @@ -110,11 +104,11 @@ describe('registration', () => { expect(prompt).toContain('Use the edit tool') }) - it('stays pending until ctx.fs exists (inject)', async () => { + it('stays pending until ctx.fileContext exists (inject)', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(ToolFs) // no fs provider + await ctx.plugin(ToolFs) // no fileContext provider expect(ctx.tools.schemas()).toHaveLength(0) }) @@ -123,6 +117,7 @@ describe('registration', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(FakeFs) + await ctx.plugin(FileContext) const fiber = await ctx.plugin(ToolFs) expect(ctx.tools.schemas()).toHaveLength(3) await fiber.dispose() @@ -132,7 +127,8 @@ describe('registration', () => { describe('read tool', () => { it('formats line-numbered content with a footer', async () => { - const { ctx } = await setup() + 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(`/abs/a.txt @@ -166,18 +162,25 @@ describe('read tool', () => { expect(text(result)).toContain('file_path must be a non-empty string') }) - it('passes the execution context through to ctx.fs', async () => { + it('records observed state so a follow-up edit by the same session is authorized', async () => { const { ctx, fs } = await setup() const session = {} - await call(ctx, 'read', { file_path: 'a.txt' }, { session }) - expect(fs.calls).toHaveLength(1) - expect(fs.calls[0]?.op).toBe('read') - expect(fs.calls[0]?.exec?.agent?.session).toBe(session) + 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) + }) + + 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' }) }) }) describe('formatReadOutput footer variants', () => { - const base = { offset: 1, limit: 2000, lines: [{ number: 1, text: 'x' }], totalLines: 1, version: 'v', view: 'full' as const } + const base: FileReadOutcome = { offset: 1, limit: 2000, lines: [{ number: 1, text: 'x' }], totalLines: 1, version: FsVersion('v') } it('reports a byte-capped read', () => { const out = formatReadOutput('/f', { ...base, totalLines: 99, truncatedByBytes: true }) @@ -225,9 +228,12 @@ describe('write tool', () => { }) describe('edit tool', () => { - it('formats a single-replacement success', async () => { - const { ctx } = await setup() - const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) + it('formats a single-replacement success after a read', async () => { + const { ctx, fs } = await setup() + const session = {} + 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.') }) @@ -252,19 +258,11 @@ describe('edit tool', () => { expect(text(result)).toContain('file_path must be a non-empty string') }) - it('propagates FS_NOT_OBSERVED from the backend', async () => { + it('propagates FS_NOT_OBSERVED when the file was never read', async () => { const { ctx, fs } = await setup() - fs.rejectWith = new FsError('read first', 'FS_NOT_OBSERVED') - const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) + fs.files.set('key:a.txt', 'hello') + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session: {} }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) }) - - it('propagates FS_PARTIAL_OBSERVATION from the backend', async () => { - const { ctx, fs } = await setup() - fs.rejectWith = new FsError('read fully first', 'FS_PARTIAL_OBSERVATION') - const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) - expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) - }) }) diff --git a/packages/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json index ee5a853c91..b8bd0b2148 100644 --- a/packages/fs/tool-fs/tsconfig.json +++ b/packages/fs/tool-fs/tsconfig.json @@ -11,6 +11,7 @@ { "path": "../../llm/llm" }, { "path": "../../core/tools" }, { "path": "../../core/system-prompt" }, - { "path": "../fs" } + { "path": "../fs" }, + { "path": "../file-context" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cae3a42202..1095777517 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,8 +236,23 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/fs/file-context: + devDependencies: + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../fs + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/fs/fs: devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -266,6 +281,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-file-context': + specifier: workspace:^ + version: link:../file-context '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index d9df0302ab..1c5ec2430e 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -37,19 +37,17 @@ { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" }, { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsExecContext", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTarget", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTargetKey", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsReadRequest", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTextLine", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsView", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsReadOutcome", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsExpectation", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsInfo", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteExpectation", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteOutcome", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditRequest", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditOutcome", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsStateSource", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileState", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsErrorCode", "source": "packages/fs/fs/src/types.ts" } + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsErrorCode", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileContextExec", "source": "packages/fs/file-context/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadRequest", "source": "packages/fs/file-context/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/file-context/src/types.ts" } ] } diff --git a/tsconfig.build.json b/tsconfig.build.json index ea3882b873..d32b663b9a 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -28,6 +28,7 @@ { "path": "./packages/bash/tool-bash" }, { "path": "./packages/fs/fs" }, { "path": "./packages/fs/fs-local" }, + { "path": "./packages/fs/file-context" }, { "path": "./packages/fs/tool-fs" }, { "path": "./packages/support/invariants" }, { "path": "./packages/ui/acp" }, From 1409e2ed154de0f22fe9666e7dfa83bb3f58648e Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 26 Jun 2026 17:45:54 +0800 Subject: [PATCH 15/46] fix: address codex review round 1 Translate a mid-read AbortError from readFile into the seam's structured FsError('FS_ABORTED') in readWholeText and readForEdit (the streaming/write paths already did), and make the socket-type probe test reject on a listen error instead of hanging where unix-domain sockets are unavailable. --- packages/fs/fs-local/src/fsio.ts | 20 +++++++++++++++++-- packages/fs/fs-local/tests/fsio.spec.ts | 26 ++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 6a2b5a28cc..a22097699a 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -49,6 +49,22 @@ 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 { + 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}`) @@ -178,7 +194,7 @@ async function statRegularFile(target: LocalTarget, verb: 'read', signal?: Abort */ export async function readWholeText(target: LocalTarget, signal?: AbortSignal): Promise { await statRegularFile(target, 'read', signal) - const raw = await readFile(target.targetKey, signal ? { 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') @@ -330,7 +346,7 @@ export async function readForEdit( signal?: AbortSignal, ): Promise<{ content: string; lineEndings: LineEndings }> { throwIfAborted(signal, 'edit') - const buffer = await readFile(absolutePath, signal ? { signal } : {}) + 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) diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 13ab9ed860..cb469c7466 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -94,7 +94,10 @@ describe('probe', () => { it('reports a socket/special file as type "other"', async () => { const sockPath = join(dir, 'sock') const server = createServer() - await new Promise((resolve) => { server.listen(sockPath, () => { resolve() }) }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(sockPath, () => { resolve() }) + }) try { expect((await probe(sockPath))?.type).toBe('other') } finally { @@ -133,6 +136,17 @@ describe('readWholeText', () => { 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', () => { @@ -281,4 +295,14 @@ describe('readForEdit + restoreLineEndings', () => { 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' }) + }) }) From b80291206710e0fbd2165ea24f33972168579df3 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 26 Jun 2026 17:59:40 +0800 Subject: [PATCH 16/46] fix: address codex review round 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve targetKey by realpathing the nearest EXISTING ancestor and re-appending the missing suffix, so a not-yet-created file under a symlinked ancestor with missing intermediate dirs gets the same key before and after creation — keeping observed-state intact across a write→edit cycle. Make the socket-type probe test skip (not fail) when a sandbox forbids unix-domain sockets. --- packages/fs/fs-local/src/fsio.ts | 39 ++++++++++++++++--------- packages/fs/fs-local/tests/fsio.spec.ts | 36 +++++++++++++++++++---- 2 files changed, 56 insertions(+), 19 deletions(-) diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index a22097699a..61cc3fee1a 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -104,11 +104,13 @@ export interface PathInfo { /** * Resolve a path to its absolute display path and realpath identity. Relative - * paths are based on `cwd`. The `targetKey` realpaths the parent directory and - * re-appends the basename, so a not-yet-created file gets the same stable key - * it will have after creation (the directory exists even when the file does - * not). Two input paths reaching the same file via symlinks share one key. - * Falls back to the absolute path when even the parent cannot be resolved. + * 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 { if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND') @@ -117,16 +119,27 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise { expect(viaLink.displayPath).toBe(link) }) - it('falls back to the absolute path when even the parent dir is absent', async () => { + 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(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 () => { @@ -94,10 +110,18 @@ describe('probe', () => { it('reports a socket/special file as type "other"', async () => { const sockPath = join(dir, 'sock') const server = createServer() - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(sockPath, () => { resolve() }) - }) + try { + await new Promise((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 { From d612ebaef15cf2ac1e5182489262d16b3ddfc6ee Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 26 Jun 2026 18:14:30 +0800 Subject: [PATCH 17/46] fix: address codex review round 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the freshness token observed AFTER the read (re-stat post-read, falling back to the routing stat if the file vanished) so the version returned/recorded matches the bytes returned — a writer racing between the routing stat and the read can no longer make a follow-up edit spuriously stale. Stream reads when the backend reports no size, so a size-less backend never buffers a large file whole. Update the cordis-catalog link map to the current filesystem API symbols (FileContextExec/FileReadRequest/FileReadOutcome/FsInfo/FsWriteExpectation). --- docs/cordis-catalog/events-and-services.md | 4 +- packages/fs/file-context/src/index.ts | 19 ++++++-- packages/fs/file-context/tests/policy.spec.ts | 46 ++++++++++++++++++- scripts/gen-cordis-catalog.ts | 9 ++-- 4 files changed, 66 insertions(+), 12 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index c4896ec0ad..78f9f44324 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -351,7 +351,7 @@ async write(target: FsTarget, content: string, exec?: FileContextExec, signal?: async edit(target: FsTarget, edit: FsEditRequest, exec?: FileContextExec, signal?: AbortSignal): Promise ``` -Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) +Types: [FileContextExec](../core-data-structures/filesystem.md) · [FileReadOutcome](../core-data-structures/filesystem.md) · [FileReadRequest](../core-data-structures/filesystem.md) · [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) Source: [`packages/fs/file-context/src/index.ts:65`](../../packages/fs/file-context/src/index.ts) @@ -376,7 +376,7 @@ abstract writeText(target: FsTarget, content: string, expected: FsWriteExpectati abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise ``` -Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) +Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteExpectation](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) Source: [`packages/fs/fs/src/index.ts:90`](../../packages/fs/fs/src/index.ts) diff --git a/packages/fs/file-context/src/index.ts b/packages/fs/file-context/src/index.ts index 6dbb33d1d2..c8ed44dde1 100644 --- a/packages/fs/file-context/src/index.ts +++ b/packages/fs/file-context/src/index.ts @@ -118,27 +118,36 @@ export class FileContext extends Service { /** * Read a bounded line window from a target. Stats first (rejecting an absent * target with `FS_NOT_FOUND` and a non-regular one with `FS_NOT_REGULAR_FILE`), - * chooses `readText` vs `streamText` by size, builds the window, and — when an - * owner is derivable — records the version so a later write/edit is authorized. + * chooses `readText` vs `streamText` by size — streaming when the size is + * large OR unknown so a size-less backend never buffers an arbitrarily large + * file — builds the window, then records the version observed AFTER the read + * so the recorded freshness token corresponds to the bytes actually returned + * (a writer racing between the routing stat and the read can't make a + * follow-up edit spuriously stale against a pre-read version). */ async read(target: FsTarget, request: FileReadRequest, exec?: FileContextExec, signal?: AbortSignal): Promise { const info = await this.ctx.fs.stat(target, 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') - const chunks = info.size !== undefined && info.size >= STREAM_MIN_SIZE + const chunks = info.size === undefined || info.size >= STREAM_MIN_SIZE ? await this.ctx.fs.streamText(target, signal) : [await this.ctx.fs.readText(target, signal)] const window = await buildWindow(chunks, request, target.displayPath) + // The version that matches the bytes just read: a stat taken after the read + // (falling back to the routing stat if the file vanished in the interim). + const after = await this.ctx.fs.stat(target, signal) + const version = after?.version ?? info.version + const owner = this.owner(exec) - if (owner) this.record(owner, target.targetKey, info.version) + if (owner) this.record(owner, target.targetKey, version) return { offset: request.offset, limit: request.limit, lines: window.lines, totalLines: window.totalLines, - version: info.version, + version, ...window.truncatedByBytes ? { truncatedByBytes: true } : {}, } } diff --git a/packages/fs/file-context/tests/policy.spec.ts b/packages/fs/file-context/tests/policy.spec.ts index e15398eb54..317776d5b3 100644 --- a/packages/fs/file-context/tests/policy.spec.ts +++ b/packages/fs/file-context/tests/policy.spec.ts @@ -27,6 +27,8 @@ class FakeFs extends FileSystem { versions = new Map() /** Size to report from stat (lets a test push read onto the streaming path). */ reportSize?: number + /** When true, stat omits `size` entirely (a size-less backend). */ + omitSize = false /** Whether streamText was used for the last read (vs readText). */ lastReadStreamed = false writeExpectations: FsWriteExpectation[] = [] @@ -47,7 +49,7 @@ class FakeFs extends FileSystem { override async stat(target: FsTarget): Promise { const content = this.files.get(target.targetKey) if (content === undefined) return undefined - return { version: this.ver(target.targetKey), type: 'file', size: this.reportSize ?? content.length } + return { version: this.ver(target.targetKey), type: 'file', ...this.omitSize ? {} : { size: this.reportSize ?? content.length } } } override async readText(target: FsTarget): Promise { this.lastReadStreamed = false @@ -154,6 +156,48 @@ describe('read', () => { expect(fs.lastReadStreamed).toBe(true) }) + it('streams when the backend reports no size (never buffers a size-less file)', async () => { + const { fs, fileContext } = await setup() + fs.files.set('a.txt', 'one\ntwo') + fs.omitSize = true + await fileContext.read(await fs.resolve('a.txt'), READ_ALL) + expect(fs.lastReadStreamed).toBe(true) + }) + + it('records the version observed after the read, not the routing stat', async () => { + const { fs, fileContext } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + fs.versions.set('a.txt', 1) + const target = await fs.resolve('a.txt') + // A writer bumps the version after the routing stat but before the post-read stat. + const realReadText = fs.readText.bind(fs) + fs.readText = async (t) => { + const text = await realReadText(t) + fs.versions.set('a.txt', 5) // file changed during the read + return text + } + const outcome = await fileContext.read(target, READ_ALL, exec) + expect(outcome.version).toBe('v5') + // The recorded (post-read) version authorizes an edit without going stale. + await fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec) + expect(fs.editExpectedVersions).toEqual(['v5']) + }) + + it('falls back to the routing-stat version if the file vanishes after the read', async () => { + const { fs, fileContext } = await setup() + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + const realReadText = fs.readText.bind(fs) + fs.readText = async (t) => { + const text = await realReadText(t) + fs.files.delete('a.txt') // vanishes → post-read stat returns undefined + return text + } + const outcome = await fileContext.read(target, READ_ALL) + expect(outcome.version).toBe('v0') // the routing-stat version + }) + it('surfaces truncatedByBytes when the window hits the byte cap', async () => { const { fs, fileContext } = await setup() fs.files.set('big.txt', Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 41f8830335..4d2598155b 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -77,13 +77,14 @@ const LINK_MAP: Record = { BashTaskRead: 'bash.md', FsEditOutcome: 'filesystem.md', FsEditRequest: 'filesystem.md', - FsExecContext: 'filesystem.md', - FsExpectation: 'filesystem.md', - FsReadOutcome: 'filesystem.md', - FsReadRequest: 'filesystem.md', + FsInfo: 'filesystem.md', FsTarget: 'filesystem.md', FsVersion: 'filesystem.md', + FsWriteExpectation: 'filesystem.md', FsWriteOutcome: 'filesystem.md', + FileContextExec: 'filesystem.md', + FileReadRequest: 'filesystem.md', + FileReadOutcome: 'filesystem.md', } /** One harness event, extracted from an `interface Events` block. */ From 8d1f86fcca71aa2a89d33536eb93adffaf6cc6b6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:53:23 +0800 Subject: [PATCH 18/46] empty From 90dceea0e40ec8442413c425c033059c52b41108 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sun, 28 Jun 2026 13:49:02 +0800 Subject: [PATCH 19/46] refactor(fs): make dsh-file-context an event-gate plugin, not a method service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invert the tool↔policy control flow per the file-context event-gate RFC. dsh-tool-fs becomes the executor — it reads/writes/edits through ctx.fs directly, owns read windowing, and dispatches fs/write-expectation / fs/edit-expectation (single-slot waterfalls) plus a contained fs/observed emit. dsh-file-context drops its ctx.fileContext service and becomes a pure event-gate plugin (observed-state + read-before-edit + version-guarded write/edit, decided on those events). The provider's version guard becomes optional so ctx.fs alone is a complete unconstrained text-storage seam: removing the policy plugin gracefully loses the policy instead of breaking the tool at a service-injection boundary. --- docs/architecture.md | 9 +- docs/cordis-catalog/events-and-services.md | 68 ++- docs/core-data-structures/filesystem.md | 43 +- docs/module-graph.md | 3 +- docs/rfc/README.md | 1 + .../2026-06-26-file-context-as-event-gate.md | 175 ++++++++ .../2026-06-26-fsspec-style-fs-seam.md | 10 +- packages/README.md | 12 +- packages/fs/README.md | 10 +- packages/fs/file-context/README.md | 43 +- packages/fs/file-context/package.json | 2 +- packages/fs/file-context/src/index.ts | 252 +++++------ packages/fs/file-context/src/types.ts | 45 +- packages/fs/file-context/tests/policy.spec.ts | 421 ++++++------------ packages/fs/fs-local/README.md | 10 +- packages/fs/fs-local/src/index.ts | 18 +- packages/fs/fs-local/tests/filesystem.spec.ts | 42 ++ packages/fs/fs/README.md | 25 +- packages/fs/fs/src/index.ts | 104 ++++- packages/fs/fs/src/types.ts | 19 +- packages/fs/fs/tests/service.spec.ts | 2 +- packages/fs/tool-fs/README.md | 30 +- packages/fs/tool-fs/package.json | 1 - packages/fs/tool-fs/src/edit.ts | 27 +- packages/fs/tool-fs/src/index.ts | 29 +- packages/fs/tool-fs/src/observe.ts | 34 ++ packages/fs/tool-fs/src/read.ts | 50 ++- packages/fs/tool-fs/src/types.ts | 32 ++ .../{file-context => tool-fs}/src/window.ts | 12 +- packages/fs/tool-fs/src/write.ts | 26 +- packages/fs/tool-fs/tests/integration.spec.ts | 362 ++++++++++----- packages/fs/tool-fs/tests/subpaths.spec.ts | 13 +- packages/fs/tool-fs/tests/tools.spec.ts | 85 +++- .../tests/window.spec.ts | 4 +- scripts/type-equiv.manifest.json | 3 +- 35 files changed, 1229 insertions(+), 793 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md create mode 100644 packages/fs/tool-fs/src/observe.ts create mode 100644 packages/fs/tool-fs/src/types.ts rename packages/fs/{file-context => tool-fs}/src/window.ts (92%) rename packages/fs/{file-context => tool-fs}/tests/window.spec.ts (98%) diff --git a/docs/architecture.md b/docs/architecture.md index 796026bb10..1afa766f63 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,8 +25,8 @@ For a catalog of the **data structures** this architecture moves around — the │ @deepseek-ai/dsh-bash-local (bash impl) │ │ @deepseek-ai/dsh-tool-bash (bash tool schemas) │ │ @deepseek-ai/dsh-fs-local (filesystem impl) │ -│ @deepseek-ai/dsh-file-context (filesystem policy) │ -│ @deepseek-ai/dsh-tool-fs (filesystem tool schemas) │ +│ @deepseek-ai/dsh-file-context (filesystem policy gate) │ +│ @deepseek-ai/dsh-tool-fs (filesystem tools+executor)│ │ @deepseek-ai/dsh-session-persistence-jsonl (persistence impl)│ ├─────────────────────────────────────────────────────────────┤ │ @deepseek-ai/dsh-agent (vocabulary + registry) │ @@ -57,8 +57,7 @@ Dependency rule: **extension** plugins depend on interface packages, never on `d | `ctx.agents` | `AgentRegistry` | dsh-agent | live `Agent` handles + the create/resume factory seam (returns an `AgentHandle` = `{ agent, dispose() }` for owned per-agent teardown) | | `ctx.agentLoop` | `AgentLoop` | dsh-agent-loop | creates `ReactLoopAgent`s and drives their loops | | `ctx.bash` | `BashExecutor` (abstract) | dsh-bash | bash execution seam: foreground runs + background tasks | -| `ctx.fs` | `FileSystem` (abstract) | dsh-fs | filesystem provider seam: path resolution, stat, text read/stream, guarded writes/edits | -| `ctx.fileContext` | `FileContext` | dsh-file-context | filesystem policy: read windowing, observed-state, write/edit freshness over `ctx.fs` | +| `ctx.fs` | `FileSystem` (abstract) | dsh-fs | filesystem provider seam: path resolution, stat, text read/stream, atomic writes/edits (optional version guard); owns the `fs/*` policy events | All registrations (`registerAdapter`, `section`, `tools`, `register`, …) go through `ctx.effect()` and return disposers, so plugin hot-reload (vendored HMR) and fiber disposal clean up automatically. @@ -74,7 +73,7 @@ Swappable capabilities are split into **three packages** so each part evolves in The LLM seam has the same topology folded differently: `dsh-llm` carries the interface (`LlmAdapter`) AND the consumer surface (`ctx.llm.stream()`), with adapters as implementation packages — there the consumer is the loop itself, not a swappable schema surface. Use the full three-package split when the consumer is independently replaceable; keep interface + consumer together when they are one concern. Don't split preemptively: a capability with one conceivable implementation and one consumer stays one package until proven otherwise. -The filesystem capability follows the bash topology with a fourth layer: `dsh-fs` owns the abstract `ctx.fs` provider seam (text IO + guarded mutation primitives), `dsh-fs-local` provides the local backend, `dsh-file-context` is a concrete `ctx.fileContext` policy service (read windowing + observed-state + write/edit freshness, injecting `fs`), and `dsh-tool-fs` exposes the model-facing `read`/`write`/`edit` schemas over `ctx.fileContext`. The policy layer is a concrete service, not a second swappable seam — it owns the model-facing observation policy a sandboxed/remote backend has no business carrying. +The filesystem capability follows the bash topology with a fourth layer, but the policy is contributed through an **event gate**, not a method service: `dsh-fs` owns the abstract `ctx.fs` provider seam (text IO + atomic mutation primitives whose version guard is optional) and the `fs/*` policy event vocabulary, `dsh-fs-local` provides the local backend, `dsh-tool-fs` is the model-facing `read`/`write`/`edit` tools AND the executor (it reads/writes/edits through `ctx.fs` directly, owns read windowing, dispatches the `fs/*` events), and `dsh-file-context` is a policy PLUGIN (no service) that decides the `fs/write-expectation`/`fs/edit-expectation` waterfalls and records on `fs/observed` to add observed-state + read-before-edit + version-guarded write/edit. Because the tool is not method-coupled to the policy, dropping `dsh-file-context` gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool at a service-injection boundary. The default product config loads `dsh-file-context`, so the default behavior remains read-before-write/edit. See [the file-context event-gate RFC](rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md). > **"Capability" — two unrelated meanings.** (1) The *seam pattern* above ("one plugin provides a capability, another needs it") is realized by plain Cordis **services + `inject`**: a provider registers a service (`ctx.bash`, declared in `interface Context`); a consumer declares `inject: ['bash']` and its fiber stays pending until the service exists, tearing down via HMR if it later vanishes. No extra library is needed. (2) `@cordisjs/plugin-capability` is a different axis entirely — a **permission/capability-security** service (named permissions with inheritance/dependency, tested against a session via `ctx.capability.test`). It is a candidate for the deferred permissions/sandbox work (the `tools/execute` veto seam), NOT a mechanism for swapping implementations. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 78f9f44324..b1114ce4c8 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -11,7 +11,7 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary ## Events -Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 22 events across 5 scopes. +Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 25 events across 6 scopes. ### `agent/*` @@ -183,6 +183,44 @@ Types: [Agent](../core-data-structures/core.md) Source: [`packages/core/agent/src/types.ts:162`](../../packages/core/agent/src/types.ts) +### `fs/*` + +#### `fs/edit-expectation` — waterfall + +Single-slot decision: produce the optional version guard for the next 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-file-context` 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 Events.'fs/write-expectation'). + +```ts cordis-catalog +'fs/edit-expectation'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> +``` + +Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) + +Source: [`packages/fs/fs/src/index.ts:117`](../../packages/fs/fs/src/index.ts) + +#### `fs/observed` — emit + +Record that an actor observed a target at a version, after a successful read/write/edit. Fire-and-forget. A listener MUST be a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-file-context`'s is a `WeakMap.set`); the tool wraps the emit in a try/catch so a synchronous listener bug is logged and swallowed, never failing the already-completed mutation. cordis `emit` does not await listener promises, so this is not an async-error containment seam — async audit/telemetry does not belong here. No listener ⇒ nothing recorded. `actor` is the opaque tool-execution context. + +```ts cordis-catalog +'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void +``` + +Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) + +Source: [`packages/fs/fs/src/index.ts:129`](../../packages/fs/fs/src/index.ts) + +#### `fs/write-expectation` — waterfall + +Single-slot decision: produce the write expectation for the next 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-file-context` 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. + +```ts cordis-catalog +'fs/write-expectation'(target: FsTarget, actor: object | undefined, next: () => FsWriteExpectation | undefined | Promise): Promise +``` + +Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteExpectation](../core-data-structures/filesystem.md) + +Source: [`packages/fs/fs/src/index.ts:105`](../../packages/fs/fs/src/index.ts) + ### `llm/*` #### `llm/stream` — waterfall @@ -279,7 +317,7 @@ Source: [`packages/core/tools/src/index.ts:43`](../../packages/core/tools/src/in ## Services -The 10 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. +The 9 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. ### `ctx.agentLoop` — `AgentLoop` @@ -339,22 +377,6 @@ Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../c Source: [`packages/bash/bash/src/index.ts:59`](../../packages/bash/bash/src/index.ts) -### `ctx.fileContext` — `FileContext` - -The file-context policy service. Injects `fs`, registers as `ctx.fileContext`, and is the only read/write/edit path the model-facing tools use. - -```ts cordis-catalog -owner(exec?: FileContextExec): object | undefined -async resolve(path: string): Promise -async read(target: FsTarget, request: FileReadRequest, exec?: FileContextExec, signal?: AbortSignal): Promise -async write(target: FsTarget, content: string, exec?: FileContextExec, signal?: AbortSignal): Promise -async edit(target: FsTarget, edit: FsEditRequest, exec?: FileContextExec, signal?: AbortSignal): Promise -``` - -Types: [FileContextExec](../core-data-structures/filesystem.md) · [FileReadOutcome](../core-data-structures/filesystem.md) · [FileReadRequest](../core-data-structures/filesystem.md) · [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) - -Source: [`packages/fs/file-context/src/index.ts:65`](../../packages/fs/file-context/src/index.ts) - ### `ctx.fs` — `FileSystem` (abstract seam) Abstract filesystem provider service. Subclass, implement the six text-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). @@ -364,21 +386,21 @@ Semantics every backend must honor: - resolve returns a stable 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). - stat returns FsInfo metadata (never content) or `undefined` when the target is absent. - readText/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`. -- writeText is atomic temp-file + rename honoring the FsWriteExpectation. -- 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. +- writeText is atomic temp-file + rename. `expected` is OPTIONAL: omit it for an unconditional create-or-overwrite (the bare-provider default), or supply a FsWriteExpectation to guard the write. +- 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`). ```ts cordis-catalog abstract resolve(path: string): Promise abstract stat(target: FsTarget, signal?: AbortSignal): Promise abstract readText(target: FsTarget, signal?: AbortSignal): Promise abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> -abstract writeText(target: FsTarget, content: string, expected: FsWriteExpectation, signal?: AbortSignal): Promise -abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise +abstract writeText(target: FsTarget, content: string, expected?: FsWriteExpectation, signal?: AbortSignal): Promise +abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise ``` Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteExpectation](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:90`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:158`](../../packages/fs/fs/src/index.ts) ### `ctx.llm` — `LlmService` diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index 99d073c6d4..e114c409d8 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -1,8 +1,10 @@ # Filesystem -The filesystem stack is split across four packages: a provider seam ([dsh-fs](../../packages/fs/fs), `ctx.fs`, text IO + guarded mutation), a local implementation ([dsh-fs-local](../../packages/fs/fs-local), local disk), a policy layer ([dsh-file-context](../../packages/fs/file-context), `ctx.fileContext`, read windowing + write/edit freshness), and a consumer ([dsh-tool-fs](../../packages/fs/tool-fs), the model-facing `read`/`write`/`edit` tools). Filesystem access is an optional capability, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). A sandboxed, remote, virtual, or project-scoped backend can implement the same `FileSystem` service without changing the policy layer or the tool schemas. +The filesystem stack is split across four packages: a provider seam ([dsh-fs](../../packages/fs/fs), `ctx.fs`, text IO + atomic mutation primitives whose version guard is optional), a local implementation ([dsh-fs-local](../../packages/fs/fs-local), local disk), a policy plugin ([dsh-file-context](../../packages/fs/file-context), observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate — NO service), and a consumer ([dsh-tool-fs](../../packages/fs/tool-fs), the model-facing `read`/`write`/`edit` tools, which is also the EXECUTOR — it reads/writes/edits through `ctx.fs` directly and owns read windowing). Filesystem access is an optional capability, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). A sandboxed, remote, virtual, or project-scoped backend can implement the same `FileSystem` service without changing the policy plugin or the tool schemas. -Provider source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) and [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts). Policy source: [`packages/fs/file-context/src/types.ts`](../../packages/fs/file-context/src/types.ts). +The model is **additive, not subtractive**: `ctx.fs` alone is a complete, unconstrained text-storage seam (`write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text). `dsh-file-context` is a plugin that *adds* policy on top by deciding the `fs/*` waterfalls; removing it leaves the bare provider rather than breaking the tool, because the tool is not method-coupled to the policy. The default product config still loads it, so the default behavior remains read-before-write/edit. + +Provider source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) and [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts). Policy source: [`packages/fs/file-context/src/types.ts`](../../packages/fs/file-context/src/types.ts). Read-rendering source: [`packages/fs/tool-fs/src/types.ts`](../../packages/fs/tool-fs/src/types.ts). ## Target identity and metadata (provider seam) @@ -16,7 +18,7 @@ interface FsTarget { } ``` -The backend owns file-version tokens — the freshness token a write/edit guards against. The policy layer stores them for stale checks; consumers do not interpret them. Both ids are branded opaque strings. +The backend owns file-version tokens — the freshness token a write/edit guards against. The policy plugin stores them for stale checks; consumers do not interpret them. Both ids are branded opaque strings. ```ts type-equiv type FsTargetKey = Branded<'FsTargetKey'> @@ -26,7 +28,7 @@ type FsTargetKey = Branded<'FsTargetKey'> type FsVersion = Branded<'FsVersion'> ``` -`stat` returns metadata (never content), or `undefined` when the target is absent. `type` lets the policy layer reject directories/special files before reading, and `size` lets it choose `readText` vs `streamText` without probing by failure. +`stat` returns metadata (never content), or `undefined` when the target is absent. `type` lets the tool reject directories/special files before reading, and `size` lets it choose `readText` vs `streamText` without probing by failure. ```ts type-equiv interface FsInfo { @@ -38,7 +40,7 @@ interface FsInfo { ## Write and edit guards (provider seam) -`writeText` takes an explicit write expectation rather than inferring intent. `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. +Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteExpectation` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit share one symmetric `expected?` shape. ```ts type-equiv type FsWriteExpectation = @@ -53,7 +55,7 @@ interface FsWriteOutcome { } ``` -`editText` is a provider-level guarded mutation, not a `read` plus `write` composed in the policy layer. It verifies the expected version BEFORE literal matching (so a stale edit reports `FS_STALE_VERSION`, not a match failure against newer content), then applies the replacement and writes atomically — keeping matching, line-ending handling, stale checks, and atomic replacement inside one mutation critical section. +`editText` is a provider-level mutation, not a `read` plus `write` composed elsewhere. When guarded it verifies the expected version BEFORE literal matching (so a stale edit reports `FS_STALE_VERSION`, not a match failure against newer content); unguarded it edits the current content. Either way it applies the replacement and writes atomically — keeping matching, line-ending handling, the stale check, and atomic replacement inside one mutation critical section — and a missing target reports `FS_STALE_VERSION` on both paths. ```ts type-equiv interface FsEditRequest { @@ -71,9 +73,15 @@ interface FsEditOutcome { } ``` -## Execution context and read outcome (policy layer) +## The fs policy events (provider-seam vocabulary) -The policy layer needs just enough execution context to derive the observed-state owner. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through without making `dsh-file-context` import the tool, agent, or session packages. +`dsh-fs` owns three events the tool dispatches and the policy plugin listens for, so the emitter (`dsh-tool-fs`) and the listener (`dsh-file-context`) share a vocabulary without the emitter depending on the policy plugin. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure. + +`fs/write-expectation` and `fs/edit-expectation` are **single-slot decision waterfalls**: the tool dispatches each with a default thunk returning `undefined` (the bare provider), and a listener fully decides without calling `next()`. The slot is first-wins by registration order — the policy plugin owning it is a deployment convention, not an enforced invariant. `fs/observed` is a fire-and-forget recording event whose listener must be synchronous and side-effect-only; the tool contains a throw so a recording bug never fails the already-completed mutation. The generated catalog shows the exact signatures on [events-and-services.md](../cordis-catalog/events-and-services.md). + +## Execution context (policy plugin) + +The policy plugin needs just enough execution context to derive the observed-state owner by narrowing the opaque `object` actor the `fs/*` events carry. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through as the actor without making `dsh-file-context` import the tool, agent, or session packages. ```ts type-equiv interface FileContextExec { @@ -83,14 +91,9 @@ interface FileContextExec { } ``` -A text read is bounded by line window, byte cap, and backend limits. The outcome the model-facing `read` tool renders carries the file's version at read time; there is no `full`/`partial` view — authorization is freshness-based, so any windowed read can authorize a later write/edit when the file is unchanged. +## Read outcome (consumer / read rendering) -```ts type-equiv -interface FileReadRequest { - offset: number - limit: number -} -``` +A text read is bounded by line window, byte cap, and backend limits. The outcome the model-facing `read` tool renders carries the file's version at read time; there is no `full`/`partial` view — authorization is freshness-based, so any windowed read can authorize a later write/edit when the file is unchanged. Read windowing and this outcome shape live in `dsh-tool-fs` (the executor that owns the read), not in the policy plugin. ```ts type-equiv interface FileReadOutcome { @@ -103,9 +106,9 @@ interface FileReadOutcome { } ``` -## Observed-file state (policy layer) +## Observed-file state (policy plugin) -Observed state is a `WeakMap>` inside `ctx.fileContext`. An entry exists **iff** the owner has read that target through `ctx.fileContext.read`, so its presence *is* the read record — there is no separate `hasRead` flag and no view distinction. The owner is normally `exec.agent.session`, but the policy layer treats it as opaque and never reads its fields. A successful read/write/edit refreshes the recorded version for that owner; disposal drops everything (HMR safety). +Observed state is a `WeakMap>` held inside the `dsh-file-context` plugin. 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 separate `hasRead` flag and no view distinction. The owner is derived from the event actor (normally `exec.agent.session`), treated as opaque and never read. A successful read/write/edit refreshes the recorded version for that owner; disposal drops everything (HMR safety). ## Error taxonomy (provider seam) @@ -123,8 +126,8 @@ type FsErrorCode = | 'FS_ABORTED' ``` -`FS_NOT_OBSERVED` means no recorded read exists for this owner (or a `createIfAbsent` hit an existing file). `FS_STALE_VERSION` means the backend version no longer matches the observed one. Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`. +`FS_NOT_OBSERVED` means the policy plugin has no prior-observation record for this owner (or a `createIfAbsent` hit an existing file). `FS_STALE_VERSION` means the backend version no longer matches the observed one (or an edit hit a missing target). Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`. -## The services +## The service and the plugin -`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `stat`, `readText`, `streamText`, `writeText`, and `editText`. `FileContext` (`ctx.fileContext`, concrete) injects `fs` and owns the model-facing policy: `read` windows text and records observed state, `write`/`edit` derive the freshness expectation and refresh state. The generated wiring catalog shows the exact service signatures on [events-and-services.md](../cordis-catalog/events-and-services.md#ctxfs--filesystem-abstract-seam). +`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `stat`, `readText`, `streamText`, `writeText`, and `editText`. `dsh-file-context` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit expectation waterfalls (supplying `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED`) and records on `fs/observed`. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated wiring catalog shows the exact `ctx.fs` signatures on [events-and-services.md](../cordis-catalog/events-and-services.md#ctxfs--filesystem-abstract-seam). diff --git a/docs/module-graph.md b/docs/module-graph.md index ad3d7f7b89..dfa1c3a3ed 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -53,7 +53,6 @@ graph TD tool-bash --> bash tool-bash --> llm tool-bash --> tools - tool-fs --> file-context tool-fs --> fs tool-fs --> llm tool-fs --> system-prompt @@ -100,7 +99,7 @@ graph TD | `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` | | `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` | | `tool-bash` | `agent`, `bash`, `llm`, `tools` | -| `tool-fs` | `file-context`, `fs`, `llm`, `system-prompt`, `tools` | +| `tool-fs` | `fs`, `llm`, `system-prompt`, `tools` | | `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` | | `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` | | `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `ui-stdio` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index cf9c343206..64f9060acf 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -118,6 +118,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | | [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | | [Extract example apps into packages](implemented/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | +| [Make `dsh-file-context` an event-gate plugin, not a method interface](implemented/architecture/2026-06-26-file-context-as-event-gate.md) | 2026-06-26 | ### Process diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md new file mode 100644 index 0000000000..efb6667d4e --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -0,0 +1,175 @@ +# RFC: Make `dsh-file-context` an event-gate plugin, not a method interface + +Status: implemented + +## Problem + +[The split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) put `ctx.fileContext` between the model-facing tools and the `ctx.fs` provider: `dsh-tool-fs` injects `fileContext` and routes every `read`/`write`/`edit` through its methods. That makes `fileContext` **in-path and mandatory**. The tool cannot reach `ctx.fs` without it, the policy layer owns the fs I/O and the read windowing, and a deployment that does not want observed-state policy cannot simply drop the package — `dsh-tool-fs` would fail to resolve `ctx.fileContext`. + +This couples three things that should be separable: + +1. **What the tool does** — resolve a path, read a window, write/edit a file. This is the tool's job and needs only `ctx.fs`. +2. **The freshness/observation policy** — "edit requires a prior read", "write/edit must be based on the version you read". This is the `dsh-file-context` plugin's job. +3. **The recording of observed state** — a side effect that should never block the tool from functioning. + +Because the tool calls `fileContext` methods, removing the policy layer is a breaking change rather than a graceful loss of an *add-on*. The policy is load-bearing for the tool to even run, not an opt-in tightening. + +## Decision + +Invert the control flow. **`dsh-tool-fs` becomes the executor and calls `ctx.fs` directly**; **`dsh-file-context` becomes a gate + recorder plugin** that participates through events, never through a method the tool calls and never by registering a `ctx.fileContext` service. + +```text +tool dsh-tool-fs executor: resolves, reads windows, writes/edits via ctx.fs; + emits fs policy events; renders results +policy dsh-file-context plugin: listens to fs/write-expectation + + fs/edit-expectation (single-slot waterfall) and fs/observed + (emit) events; adds observed-state + freshness. +provider seam dsh-fs ctx.fs: text IO + ATOMIC mutation primitives whose version + guard is OPTIONAL; owns the fs policy event vocabulary +provider dsh-fs-local local implementation of ctx.fs +``` + +The model is **additive, 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. There is no "先读后写", no version check, nothing to remove; the bare provider just does the I/O atomically. `dsh-file-context` is a plugin that *adds* constraints on top: observed-state, read-before-edit, and "write/edit must be based on the version you read". So removing `dsh-file-context` does not break `dsh-tool-fs` at the service-injection boundary; it removes the policy gate and leaves the bare provider behavior. The product default still loads `dsh-file-context`, so the default user-facing behavior and prompt discipline remain read-before-write/edit. The bare-provider mode exists because the tool should not be method-coupled to the policy plugin, not because an unconstrained filesystem is the normal product stance. + +`dsh-tool-fs` no longer injects `fileContext`. It injects `fs` and `tools`/`systemPrompt`. + +## The policy is enforced by provider CAS, not by `dsh-file-context` stat + +`dsh-file-context` enforces "you must write/edit based on the version you read" **without ever calling `stat` or comparing versions itself**. It supplies the observed version as the CAS basis and lets the provider's mutation critical section detect staleness: + +- "Have you read this file?" is the one thing `dsh-file-context` decides locally — a `WeakMap` lookup, no I/O. 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 read-match-rename. `dsh-file-context` passes `vObserved` as the expectation; the provider raises `FS_STALE_VERSION` if the file has moved on. + +This is deliberate. If `dsh-file-context` stat-ed and compared versions in its waterfall handler, there would be a TOCTOU gap between that check and the tool's actual write — the file could change in between, so the check would be a false guarantee that the provider's lock has to back up anyway. Putting the version check in the provider's critical section is both race-free and zero extra `stat`. So `dsh-file-context` does **no** filesystem I/O; the "must be based on the latest read" guarantee is *realized* by CAS, and `dsh-file-context` only chooses the basis (`vObserved`) and gates on prior observation. + +## Provider contract change: the version guard is optional + +For the bare provider to be unconstrained, the version guard on its two mutations becomes **optional** — present ⇒ guarded, absent ⇒ unconditional: + +```ts ignore-check +// writeText: expected is now optional. The FsWriteExpectation union is UNCHANGED. +writeText(target: FsTarget, content: string, expected?: FsWriteExpectation, signal?: AbortSignal): Promise +// undefined → unconditionally create-or-overwrite (bare default) +// createIfAbsent → create only, reject an existing file (dsh-file-context, unobserved) [unchanged] +// replaceIfVersion → overwrite only at the observed version, else FS_STALE_VERSION [unchanged] + +// editText: expected becomes optional (was the required { version: FsVersion }). +editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise +// undefined → unconditionally replace literal text in the current content (bare default); +// a missing target still reports FS_STALE_VERSION +// { version } → edit only at that version, else FS_STALE_VERSION (the current behavior) +``` + +The `FsWriteExpectation` union itself does not change — the third "unconditional" state is expressed by *omitting* `expected`, so both mutations share one symmetric shape (`expected?`: omit = no guard, present = guarded). This keeps full backward compatibility for the guarded paths `dsh-file-context` uses; only the previously-impossible "no guard" case is new, and it is the bare-provider default. The mutation still runs inside the backend's per-target lock either way, so an unconditional write/edit is still atomic (no torn files); "unconditional" drops the *version* precondition, not the atomicity. `editText` reports a missing target as `FS_STALE_VERSION` on both guarded and unguarded paths, preserving one edit failure code for "the target cannot be edited at this moment". + +## Event vocabulary (owned by `dsh-fs`) + +The events live in `@deepseek-ai/dsh-fs`, not in `dsh-file-context`. This is forced by the decoupling contract: `dsh-tool-fs` is the emitter, so it must reference the event types, and it must keep compiling even though `dsh-file-context` no longer provides a method service. `dsh-fs` is the package both `dsh-tool-fs` and `dsh-file-context` already depend on, so it is the only home that lets the emitter and the policy listener share a vocabulary without the emitter depending on the policy plugin. + +These events carry existing `dsh-fs` vocabulary (`FsTarget`, `FsVersion`, `FsWriteExpectation`) plus an opaque actor — not model-facing concepts (no line windows, numbered lines, or rendered footers leak down). + +**The two `fs/*` decision events are single-slot decision points, NOT a composable interception chain.** A waterfall listener that does not call `next()` short-circuits the rest of the chain (verified in [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts) — `waterfall` runs listeners around the final `next` thunk, and a listener that returns without calling `next()` reaches neither later listeners nor the tool's default thunk). `dsh-file-context` fully decides the write/edit expectation and does not call `next()`, so it occupies that one decision slot in the default deployment. This is deliberate: "what version basis does this mutation guard against" is a single decision, not an accumulation. The names (`fs/write-expectation`, `fs/edit-expectation`) say "produce the value", not "authorize", so they do not imply a stackable authorization chain. Genuinely composable interception (permission, audit, sandbox) belongs on the existing `tools/execute` waterfall, which every tool call already flows through — not on this fs version-decision slot. + +**The occupant is decided by registration order — first-registered (or `prepend`ed) wins.** cordis dispatches waterfall listeners in registration order (`push`, or `unshift` for `prepend` — [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts)), and the first non-`next()` decider short-circuits the rest. So the slot is **first-wins**, and `dsh-file-context` owning it rests on the default deployment convention: it is the decider registered for these events. The event shape does NOT itself guarantee "an unread edit is rejected" — a plugin that registers a looser `fs/edit-expectation` decider BEFORE `dsh-file-context` (or with `prepend`) would decide first and bypass the `FS_NOT_OBSERVED` gate. That is the inherent property of a first-wins single slot, stated here so it is not mistaken for an enforced invariant. This RFC does not add a multi-policy composition mechanism; the implementation requirement is that the shipped `dsh-tool-fs` dispatches these waterfalls on every write/edit path and the shipped default config loads `dsh-file-context` as the policy decider. + +The actor is typed `object` in `dsh-fs` — a pure opaque carrier the provider seam never reads or narrows. The owner-derivation (`actor.agent?.session`) and the `{ agent?: { session? } }` structural shape stay entirely inside `dsh-file-context`, which narrows the `object` actor to that shape in its listeners. `dsh-fs` owns the event names and the fs vocabulary; it does NOT own the policy layer's runtime owner structure. + +```ts +import type { FsTarget, FsVersion, FsWriteExpectation } from '@deepseek-ai/dsh-fs' + +interface Events { + /** + * Single-slot decision: produce the write expectation for the next + * ctx.fs.writeText. The default returns undefined (unconditional create-or- + * overwrite — the bare provider). The policy listener returns createIfAbsent + * (unobserved) or { kind: 'replaceIfVersion', version: vObserved } (observed). + * The listener does NOT call next(): one decision, not a composable chain. @mode waterfall + */ + 'fs/write-expectation'(target: FsTarget, actor: object | undefined, next: () => FsWriteExpectation | undefined | Promise): Promise + /** + * Single-slot decision: produce the optional version guard for the next + * ctx.fs.editText. The default returns undefined (unconditional edit of the + * current content — the bare provider; no stat). The 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. @mode waterfall + */ + 'fs/edit-expectation'(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. Listeners MUST be synchronous, side-effect- + * only recorders (`dsh-file-context`'s is a WeakMap write); the tool wraps the + * emit in a try/catch so a synchronous listener bug is logged and swallowed, + * never failing the already-completed mutation. No listener ⇒ nothing recorded. + * @mode emit + */ + 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void +} +``` + +The `fs/*` decision events are **unbound waterfalls dispatched by the tool** (like `agent/request`, which the loop dispatches with no `this`), not service-bound waterfalls (like `llm/stream`). The dispatcher is the `dsh-tool-fs` plugin, which is not a service. + +## Tool contract (`dsh-tool-fs`) + +The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte unchanged) and prompt sections. The prompt guidance stays policy-first because the default product config loads `dsh-file-context`: the model is still told to read before overwriting or editing, and any wording that says the "backend" requires that should be corrected to say the default file-context policy requires it. The bare-provider fallback does not change the default prompt stance. + +`dsh-tool-fs` gains the executor responsibilities relocated from the old `fileContext` method service, including **read windowing** (`window.ts`, `READ_MAX_BYTES`, `READ_MAX_LINE_LENGTH`, `FileReadRequest`/`FileReadOutcome`/`FileTextLine`, `STREAM_MIN_SIZE`), which is the tool's rendering detail now that the tool owns the read. Those read-windowing types and helpers move into `dsh-tool-fs`; the policy plugin must not remain a type dependency for the tool. + +`dsh-tool-fs` exposes each tool as a first-class **subpath plugin** (`/read`, `/write`, `/edit`) for focused deployments, plus a root plugin that composes all three. The `inject` change applies to **all four**: each of `read.ts`, `write.ts`, `edit.ts`, and `index.ts` drops `fileContext` from `inject` and adds `fs` (keeping `tools`/`systemPrompt`). Updating only the root plugin would leave a focused deployment that loads just `@deepseek-ai/dsh-tool-fs/edit` still coupled to the old method service, silently breaking the decoupling contract for exactly the deployments subpaths exist to serve. + +`stat` budget is minimized by letting the waterfall produce the expectation lazily — the bare default returns `undefined` (no guard) and never stats: + +- **read** — one `stat` (type + size routing + version), then `readText`/`streamText`, then `buildWindow`, then a contained `emit('fs/observed', target, info.version, exec)`. The post-read confirming `stat` from the old `fileContext.read` is dropped; a writer racing between the routing stat and the read can at worst make a *later* guarded edit spuriously `FS_STALE_VERSION` (fail-closed: the model re-reads, never writes against the wrong version, since `editText` re-checks in its lock). +- **write** — `expectation = await ctx.waterfall('fs/write-expectation', target, exec, () => undefined)`, then `ctx.fs.writeText(target, content, expectation)`, then a contained `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** with or without `dsh-file-context`. +- **edit** — `expectation = await ctx.waterfall('fs/edit-expectation', target, exec, () => undefined)`, then `ctx.fs.editText(target, edit, expectation)`, then a contained `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** in both cases: the bare default is `undefined` (unconditional edit), so the tool never stats to manufacture a basis. If the target is absent, the provider reports `FS_STALE_VERSION` even on the unguarded path. + +The tool passes `exec` (the tool-execution context) as the `actor` argument on every dispatch, so `dsh-file-context` can derive its observed-state owner. The tool does not know whether the policy plugin is present: it always provides the bare default behavior in the `next` thunk, and `dsh-file-context` short-circuits the thunk before it runs in the default deployment. + +**`fs/observed` recording must never fail the tool, because it fires AFTER the mutation already succeeded** — a throw there becomes an `isError` result ([tools/index.ts](../../../../packages/core/tools/src/index.ts) — `ToolRegistry.execute` catches a tool throw into an error result), reporting failure for a write/edit that actually happened. The tool therefore wraps the dispatch in a try/catch that logs and swallows synchronous listener bugs (the established fire-and-forget pattern in [agent.ts](../../../../packages/core/agent-loop/src/agent.ts)). The event contract is intentionally narrower than "arbitrary observers": an `fs/observed` listener MUST be synchronous and side-effect-only — `dsh-file-context`'s listener is a `WeakMap.set`, which cannot throw under normal operation and returns no promise. Cordis `emit` does not await listener promises, so the try/catch is NOT an async-error containment mechanism; async audit/telemetry/listener work does not belong on this event. If layered or async observation is ever wanted, that is a new event with its own dispatch story. + +## Policy plugin contract (`dsh-file-context`) + +`dsh-file-context` is a plugin, not a service. It does not register `ctx.fileContext`, has no public method surface, and exposes no `read`/`write`/`edit`/`resolve` methods. It attaches three listeners via `ctx.on()` registrations (each returning a disposer for HMR). It keeps the observed-state `WeakMap>` and the structural owner derivation (narrowing the event's opaque `object` actor to its own `{ agent?: { session? } }` shape), but does not inject `fs` — every handler operates only on its own `WeakMap`, never on `ctx.fs`. + +- `fs/write-expectation` listener: `prior = getObserved(owner, key)`; return `prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }`. It does NOT call `next()`: it fully owns the single decision slot. +- `fs/edit-expectation` listener: `prior = getObserved(owner, key)`; if no `owner` or no `prior`, throw `FS_NOT_OBSERVED`; else return `{ version: prior.version }`. Also does not call `next()`. +- `fs/observed` listener: `record(owner, key, version)`. + +An observed-state entry is the **prior-observation record**: a successful `read`, `write`, OR `edit` all emit `fs/observed` and record `{ version }`, so the entry's presence means "this owner has observed this target at this version", not narrowly "has read it". 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, so the next edit's basis is the version it just produced. `FS_NOT_OBSERVED` rejects only an edit with NO prior observation of any kind. The owner is derived structurally from `{ agent?: { session? } }`; disposal drops all state (HMR safety). + +`dsh-file-context` is now a pure policy/recording plugin with no service surface — it influences the world only through the event seam. That is what removes the method coupling from `dsh-tool-fs`. + +## Bare-provider behavior (no `dsh-file-context`) + +This is not the default product mode — the default product config loads `dsh-file-context`. It is the unconstrained provider floor that exists once the tool is no longer coupled to a policy method service. With `dsh-file-context` absent, every `fs/*` waterfall falls through to its `undefined` default and `fs/observed` has no listener: + +- **read** is identical (it never needed policy; it only emits a now-unheard `fs/observed`). +- **write** unconditionally creates-or-overwrites: `expected` is `undefined`, so `writeText` writes whether or not the file exists and whatever its current version. No read-first requirement, no version check. +- **edit** unconditionally replaces literal text in the file's current content: `expected` is `undefined`, so `editText` matches and rewrites without a version guard or a read-first requirement (`FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` still apply — those are about the literal match, not freshness). A missing target still reports `FS_STALE_VERSION`, matching the guarded edit path's "cannot edit this target now" code. + +Both mutations are still atomic (the backend's per-target lock is unconditional). What is simply *absent*, not lost, is the policy `dsh-file-context` would add: observed-state, read-before-edit, and version-guarded write/edit. Loading `dsh-file-context` layers those constraints on by having its listeners return guarded `expected` values instead of `undefined`; nothing in the bare provider changes. + +## Supersedes + +This amends — does not reverse — [the split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md). The four-layer split, the provider contract, and the freshness *policy* are all kept. What changes is the **coupling between the tool and the policy layer**: a mandatory method service became a plugin-owned event gate, and the fs I/O + read windowing moved from `fileContext` up into `dsh-tool-fs`. The split-fs-seam RFC's description of `dsh-tool-fs` injecting `fileContext` and of `fileContext` owning `read`/`write`/`edit` was updated to match in the same change. + +## Acceptance Criteria + +- All four `dsh-tool-fs` injection points — the root plugin AND the `/read`, `/write`, `/edit` subpath plugins — inject `fs` (+ `tools`/`systemPrompt`), not `fileContext`; each calls `ctx.fs` directly and dispatches the `fs/write-expectation`/`fs/edit-expectation` waterfalls (passing `exec` as the actor) and the contained `fs/observed` emit. Read windowing lives in `dsh-tool-fs`. +- `dsh-fs` declares the three events with `@mode` tags and an opaque `object` actor argument (no agent/session structure leaks into the provider vocabulary); the generated cordis catalog is regenerated. +- `dsh-file-context` is a plugin, not a service: it does not register `ctx.fileContext`, has no public `read`/`write`/`edit`/`resolve` methods, and does not inject `fs`; it registers the three listeners, keeps observed-state, and has HMR/disposal coverage (dispose the fiber, assert the gate no longer rewrites). +- **Bare-provider test**: a config WITHOUT `dsh-file-context` that loads a **subpath plugin** (e.g. just `@deepseek-ai/dsh-tool-fs/edit`, plus `/read`/`/write` as the scenario needs) boots, and `read`/`write`(create AND overwrite)/`edit` work through `dsh-tool-fs` against the real `dsh-fs-local`; an `edit` of an unread existing file and an overwrite of an existing unread file both succeed (unconditional bare-provider behavior), proving the subpath plugins — not just the root — carry no `fileContext` dependency. A bare-provider edit of a missing target reports `FS_STALE_VERSION`. With `dsh-file-context` present, the same unread `edit` is rejected `FS_NOT_OBSERVED` and the same unread overwrite uses `createIfAbsent` (rejected on an existing file). +- **Single-slot semantics**: a test registers a second `fs/edit-expectation` listener AFTER `dsh-file-context` and asserts it is NOT reached (first-wins short-circuit), and documents in a comment that a decider registered before/`prepend`ed would instead win — the slot is first-wins by convention, not an enforced invariant. +- **Contained observed recording**: a test with a synchronously throwing `fs/observed` listener performs a write/edit and asserts the tool result is still success (the completed mutation is not turned into an `isError`). The event contract requires synchronous side-effect-only listeners; the try/catch is the synchronous backstop, not async rejection handling. +- `dsh-fs` `writeText`/`editText` make `expected` optional (omit ⇒ unconditional); the `FsWriteExpectation` union is unchanged, and `dsh-file-context`'s guarded paths (`createIfAbsent`/`replaceIfVersion`/`{ version }`) behave exactly as today. A bare-provider test exercises an unconditional overwrite, an unconditional edit, and a missing-target edit reporting `FS_STALE_VERSION`. +- Freshness is enforced by provider CAS when guarded: an edit after a stale read reports `FS_STALE_VERSION` (regression test); `dsh-file-context` performs no `stat`. +- `stat` budget: read = 1, write = 0, edit = 0 — in the tool, with or without `dsh-file-context` (the bare default returns `undefined`, never stats). A test asserts neither write nor edit stats in the tool on either path. +- Model-facing schemas stay byte-for-byte unchanged; snapshot transcript goldens are unaffected (or the diff is reviewed and re-recorded with justification). +- Docs/artifacts updated in the same change: `docs/architecture.md`, fs package READMEs, `docs/core-data-structures/filesystem.md`, the split-fs-seam RFC's now-amended description, type-equiv blocks + manifest, cordis catalog, module graph. Gates green: `doc-sync`, `knip`, `test:coverage` (100% per-file). + +## Risks + +- **Event indirection over a method call.** A waterfall + emit is less direct than `await ctx.fileContext.edit(...)`. The payoff is removing the tool-to-policy method dependency while keeping the default policy plugin; the cost is one more event vocabulary to learn. Mitigated by keeping the three events narrow and documenting the default-thunk semantics on each. +- **Policy events in the storage seam.** `dsh-fs` gains two version-decision events plus a recording event though it is "just storage". This is the price of decoupling (the emitter cannot depend on the policy plugin). The events carry only `dsh-fs` vocabulary plus an opaque `object` actor and no model-facing concepts, so the seam stays free of line-window/observation policy types and of the agent/session owner structure. +- **Single policy occupant, first-wins by convention.** The `fs/write-expectation`/`fs/edit-expectation` slots hold exactly one decider; the first-registered (or `prepend`ed) listener wins and the rest are short-circuited. `dsh-file-context` owning the slot is a deployment convention, not an event-enforced invariant — a second decider registered first would bypass it. This is acceptable because a second fs-version-policy decider is a misconfiguration, not a feature. If a future need for *layered* fs version policy appears, it is a new RFC (a composable value-passing seam), not a silent second listener on these events. Layered permission/audit/sandbox interception already has its home on `tools/execute`. +- **Dropping the post-read confirming stat** makes a follow-up *guarded* edit occasionally fail-closed (`FS_STALE_VERSION` → re-read) under a read/write race. This is a UX nicety lost, never a correctness hole; the provider lock still prevents wrong-version writes. +- **The bare provider does no read-before-write/edit and no version check.** A deployment without `dsh-file-context` lets the model overwrite or edit any existing file unconditionally. This is the deliberate meaning of keeping the tool independent of a policy service: the safety disciplines live in the default `dsh-file-context` plugin. A deployment that omits it is opting into an unconstrained filesystem on purpose; that is not the default product stance. diff --git a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md index 25ff66816f..7f2e5d7091 100644 --- a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md +++ b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md @@ -20,13 +20,15 @@ The old RFC already deferred a separate `@deepseek-ai/dsh-file-context` package. Split the stack into four layers: ```text -tool dsh-tool-fs model-facing schemas + text rendering -policy dsh-file-context ctx.fileContext (concrete service): observed-state, read windowing, write/edit freshness -provider seam dsh-fs ctx.fs: text IO + guarded mutation primitives +tool dsh-tool-fs model-facing schemas + read windowing + text rendering; the EXECUTOR (reads/writes/edits via ctx.fs, dispatches the fs/* events) +policy dsh-file-context observed-state + read-before-edit + write/edit freshness, contributed through the fs/* event gate (no service) +provider seam dsh-fs ctx.fs: text IO + atomic mutation primitives (optional version guard) provider dsh-fs-local local implementation of ctx.fs ``` -`dsh-tool-fs` keeps the same model-facing `read`/`write`/`edit` schemas. It injects `fileContext`, not `fs`, and never reaches around the policy layer for model reads/writes/edits. +`dsh-tool-fs` keeps the same model-facing `read`/`write`/`edit` schemas. It injects `fs` (not a policy service) and reaches `ctx.fs` directly, dispatching the `fs/*` policy events so `dsh-file-context` can gate and record. + +The tool↔policy COUPLING below was reworked by [the file-context event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md): `dsh-file-context` is now a gate PLUGIN that participates through the `fs/*` events (no `ctx.fileContext` service), and read windowing + the fs I/O moved up into `dsh-tool-fs`. The four-layer split, the provider contract, and the freshness *policy* this RFC decided are unchanged. Read the "`ctx.fileContext.read`/`write`/`edit`" method descriptions below as the policy DECISIONS the gate plugin now makes on the `fs/*` events, and the provider's version guard as optional (omit = unconditional bare provider). ## Provider Contract diff --git a/packages/README.md b/packages/README.md index 265fdd8676..85e802a5ab 100644 --- a/packages/README.md +++ b/packages/README.md @@ -31,10 +31,10 @@ dsh-agent ← dsh-llm, dsh-session, dsh-brand dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent dsh-bash-local ← dsh-bash (BashExecutor impl) dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas) -dsh-fs ← dsh-llm, dsh-brand (filesystem provider seam) +dsh-fs ← dsh-llm, dsh-brand (filesystem provider seam + fs/* events) dsh-fs-local ← dsh-fs (FileSystem impl) -dsh-file-context ← dsh-fs (read windowing + write/edit freshness policy) -dsh-tool-fs ← dsh-file-context, dsh-fs, dsh-tools (file tool schemas) +dsh-file-context ← dsh-fs (observed-state + freshness policy gate, no service) +dsh-tool-fs ← dsh-fs, dsh-tools (file tools + executor) 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-system-prompt, dsh-tools, dsh-agent @@ -63,10 +63,10 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop | `bash/` | `bash` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` | | `bash-local/` | `bash` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) | | `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) | -| `fs/` | `fs` | Filesystem provider seam: text IO + guarded mutation primitives | `ctx.fs` | +| `fs/` | `fs` | Filesystem provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` events | `ctx.fs` | | `fs-local/` | `fs` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | -| `file-context/` | `fs` | Policy layer: read windowing, observed-state, write/edit freshness | `ctx.fileContext` | -| `tool-fs/` | `fs` | Model-facing `read`/`write`/`edit` tool schemas | (registers on `ctx.tools`) | +| `file-context/` | `fs` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit via the `fs/*` event gate | (no service — `fs/*` listeners) | +| `tool-fs/` | `fs` | Model-facing `read`/`write`/`edit` tools + executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) | | `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` | diff --git a/packages/fs/README.md b/packages/fs/README.md index a793a94b4b..79e1d5c0f6 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -1,12 +1,12 @@ # fs/ - filesystem capability family -The filesystem stack: a provider seam (text IO + guarded mutation), a local implementation, a policy layer (read windowing + write/edit freshness), and the model-facing file tools. All **product** packages. +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 + guarded mutation primitives | `ctx.fs` | +| `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`) | -| `file-context/` | Policy layer: observed-state, read windowing, write/edit freshness | `ctx.fileContext` | -| `tool-fs/` | Model-facing `read`/`write`/`edit` tool schemas | (registers on `ctx.tools`) | +| `file-context/` | 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 layer, or the model-facing tool schemas. The policy layer (`file-context/`) is a concrete service, not a swappable seam — it owns the model-facing observation policy that does not belong on a provider backend. +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 (`file-context/`) 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. The default product config loads it. diff --git a/packages/fs/file-context/README.md b/packages/fs/file-context/README.md index 373fb2759b..b543722055 100644 --- a/packages/fs/file-context/README.md +++ b/packages/fs/file-context/README.md @@ -1,16 +1,18 @@ # @deepseek-ai/dsh-file-context -The **file-context policy layer**: a concrete `ctx.fileContext` service that owns model-facing read windowing and write/edit freshness on top of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). This is the policy third of the filesystem stack — it is **not** a swappable seam, but the deferred policy layer that does not belong on the `FileSystem` provider base class. +The **file-context 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.fileContext` 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 FileContext from '@deepseek-ai/dsh-file-context' +import * as FileContext from '@deepseek-ai/dsh-file-context' declare const ctx: Context -// A ctx.fs provider must already be loaded (e.g. @deepseek-ai/dsh-fs-local); -// FileContext injects `fs` and registers ctx.fileContext. Load -// @deepseek-ai/dsh-tool-fs afterwards to expose read/write/edit to the model. +// 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/*-expectation slots. await ctx.plugin(FileContext) ``` @@ -18,26 +20,29 @@ await ctx.plugin(FileContext) | Layer | Package | Role | |---|---|---| -| tool | `@deepseek-ai/dsh-tool-fs` | model-facing schemas + text rendering | -| policy | `@deepseek-ai/dsh-file-context` (this) | `ctx.fileContext`: observed-state, read windowing, write/edit freshness | -| provider seam | `@deepseek-ai/dsh-fs` | `ctx.fs`: text IO + guarded mutation primitives | +| 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-file-context` (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` | -## Service API (`ctx.fileContext`) +## How the gate participates -| Member | Semantics | +Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek-ai/dsh-tool-fs`): + +| Event | This plugin's listener | |---|---| -| `read(target, request, exec?, signal?)` | Stats the target, rejects absent/non-regular targets, chooses `readText`/`streamText` by size, builds the requested line window, records the version, and returns the `FileReadOutcome` the tool renders. | -| `write(target, content, exec?, signal?)` | No recorded read → `writeText({ kind: 'createIfAbsent' })` (only new files create blindly); a recorded read → `writeText({ kind: 'replaceIfVersion', version })`. Refreshes recorded state on success. | -| `edit(target, edit, exec?, signal?)` | Requires a recorded read by this owner (else `FS_NOT_OBSERVED`); passes the observed version to `ctx.fs.editText` as the stale guard and refreshes recorded state. | -| `owner(exec?)` | Derives the observed-state owner (`exec.agent.session`) — `undefined` when there is none. | +| `fs/write-expectation` | No prior observation → `{ kind: 'createIfAbsent' }`; a prior observation → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. | +| `fs/edit-expectation` | 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 read record, freshness is the authorization +## Observed state is the prior-observation record; freshness is provider CAS -Observed state is a `WeakMap>`. An entry exists **iff** the owner has read that target through `read`, so its presence *is* the read record — there is no `hasRead` flag and no `full`/`partial` view. Authorization is based on version freshness only: 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 (the provider's stale guard enforces it). State is held weakly and dropped on disposal (HMR safety); persistence across sessions is deferred. +Observed state is a `WeakMap>`. 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. -## The no-bypass contract +## Single-slot, first-wins -A model-facing read MUST go through `ctx.fileContext.read`, never `ctx.fs.readText`/`streamText`, so every successful read records observed state before the tool renders. Direct `ctx.fs` calls remain an explicit escape hatch for non-tool consumers: a direct `ctx.fs.readText` records nothing, so a later `ctx.fileContext.edit` rejects with `FS_NOT_OBSERVED` until the file is read through `ctx.fileContext`. +The `fs/write-expectation`/`fs/edit-expectation` 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`. -The line-windowing mechanics live in `src/window.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the service wiring and policy. +## 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. diff --git a/packages/fs/file-context/package.json b/packages/fs/file-context/package.json index 77c905703b..577b650aac 100644 --- a/packages/fs/file-context/package.json +++ b/packages/fs/file-context/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-file-context", - "description": "File-context policy layer (ctx.fileContext) for the DeepSeek Harness — read windowing and write/edit freshness over the ctx.fs provider seam", + "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", diff --git a/packages/fs/file-context/src/index.ts b/packages/fs/file-context/src/index.ts index c8ed44dde1..5e0488ea0e 100644 --- a/packages/fs/file-context/src/index.ts +++ b/packages/fs/file-context/src/index.ts @@ -1,193 +1,159 @@ /** - * The file-context policy layer (`ctx.fileContext`): a concrete service that - * owns model-facing read windowing and write/edit freshness on top of the - * `ctx.fs` provider seam. It is NOT a swappable seam — it is the previously - * deferred policy layer that does not belong on the `FileSystem` provider base - * class (where a sandboxed/remote backend would otherwise inherit model-facing - * observation policy it has no business carrying). + * The file-context 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.fileContext` service and exposes no + * `read`/`write`/`edit`/`resolve` methods; it influences the world only by + * deciding the `fs/write-expectation`/`fs/edit-expectation` 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 read record + * ## Observed state IS the prior-observation record * - * Observed state lives here as `WeakMap>`. An - * entry exists iff the owner has read that target through {@link read}, so its - * presence *is* the read record — there is no separate `hasRead` flag. The owner - * is derived structurally from `{ agent?: { session? } }` and held weakly, so a - * collected session frees its state; disposal drops everything (HMR safety). + * State lives here as `WeakMap>`. 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, not full/partial views + * ## Freshness via provider CAS, not stat * - * Authorization is based on version freshness only. A windowed read records the - * file's version, and any later write/edit at that version is authorized — a - * model that read lines 100-150 of a large file can still edit line 120 as long - * as the file is unchanged. There is no `full`/`partial` distinction: the bytes - * the edit matches must merely come from the version the model read, which the - * provider's stale guard enforces. + * 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. * - * ## The no-bypass contract + * ## Single-slot, first-wins * - * A model-facing read MUST go through {@link read} (never `ctx.fs.readText`/ - * `streamText` directly), so every successful read records observed state before - * the tool renders. Direct `ctx.fs` calls are allowed for non-tool consumers but - * record nothing, so a later {@link edit} rejects with `FS_NOT_OBSERVED` until - * the file is read through `ctx.fileContext`. + * The `fs/write-expectation`/`fs/edit-expectation` 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-file-context */ -import { Context, Service } from 'cordis' +import type { Context } from 'cordis' import { FsError } from '@deepseek-ai/dsh-fs' -import type { FsTarget, FsVersion, FsEditRequest, FsEditOutcome, FsWriteOutcome } from '@deepseek-ai/dsh-fs' -import { buildWindow } from './window.ts' -import type { FileContextExec, FileReadRequest, FileReadOutcome } from './types.ts' +import type { FsTarget, FsVersion, FsWriteExpectation } from '@deepseek-ai/dsh-fs' +import type { FileContextExec } from './types.ts' -export type { FileTextLine, ReadWindow, WindowResult } from './window.ts' -export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow } from './window.ts' -export type { FileContextExec, FileReadRequest, FileReadOutcome } from './types.ts' - -/** Files at or above this size stream; smaller files read whole into memory. */ -export const STREAM_MIN_SIZE = 10 * 1024 * 1024 - -declare module 'cordis' { - interface Context { - fileContext: FileContext - } -} - -/** What an owner has observed about one target: just the version it last saw. */ -interface ObservedState { - version: FsVersion -} +export type { FileContextExec } from './types.ts' /** - * The file-context policy service. Injects `fs`, registers as `ctx.fileContext`, - * and is the only read/write/edit path the model-facing tools use. + * 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. */ -export class FileContext extends Service { - static inject = ['fs'] - +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 read record. + * entry's PRESENCE is the prior-observation record. */ - private observed = new WeakMap>() - - constructor(ctx: Context) { - super(ctx, 'fileContext') - ctx.effect(() => () => { - // Drop all recorded state on disposal so a reloaded service starts clean - // (HMR safety). The WeakMap itself would be GC'd, but replacing it makes - // the release observable and immediate for tests. - this.observed = new WeakMap() - }, 'fileContext observed-state teardown') - } + private observed = new WeakMap>() /** - * Derive the observed-state owner from an execution context — normally the + * 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. */ - owner(exec?: FileContextExec): object | undefined { - return exec?.agent?.session + private owner(actor: object | undefined): object | undefined { + return (actor as FileContextExec | undefined)?.agent?.session } - private getObserved(owner: object, targetKey: string): ObservedState | undefined { + private get(owner: object, targetKey: string): FsVersion | undefined { return this.observed.get(owner)?.get(targetKey) } - private record(owner: object, targetKey: string, version: FsVersion): void { + 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 }) + byTarget.set(targetKey, version) + } + + /** Drop all recorded state (HMR safety / disposal). */ + clear(): void { + this.observed = new WeakMap() } /** - * Resolve a path into a stable {@link FsTarget}, delegating to the provider. - * Exposed here so the model-facing tools never need to inject `ctx.fs` - * directly — they resolve and then read/write/edit entirely through - * `ctx.fileContext`. + * Decide the write expectation: 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). */ - async resolve(path: string): Promise { - return this.ctx.fs.resolve(path) + writeExpectation(target: FsTarget, actor: object | undefined): FsWriteExpectation { + const owner = this.owner(actor) + const prior = owner ? this.get(owner, target.targetKey) : undefined + return prior ? { kind: 'replaceIfVersion', version: prior } : { kind: 'createIfAbsent' } } /** - * Read a bounded line window from a target. Stats first (rejecting an absent - * target with `FS_NOT_FOUND` and a non-regular one with `FS_NOT_REGULAR_FILE`), - * chooses `readText` vs `streamText` by size — streaming when the size is - * large OR unknown so a size-less backend never buffers an arbitrarily large - * file — builds the window, then records the version observed AFTER the read - * so the recorded freshness token corresponds to the bytes actually returned - * (a writer racing between the routing stat and the read can't make a - * follow-up edit spuriously stale against a pre-read version). + * Decide the edit version guard: requires a prior observation by this owner + * (else `FS_NOT_OBSERVED`); returns the observed version as the CAS basis. */ - async read(target: FsTarget, request: FileReadRequest, exec?: FileContextExec, signal?: AbortSignal): Promise { - const info = await this.ctx.fs.stat(target, 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') - - const chunks = info.size === undefined || info.size >= STREAM_MIN_SIZE - ? await this.ctx.fs.streamText(target, signal) - : [await this.ctx.fs.readText(target, signal)] - const window = await buildWindow(chunks, request, target.displayPath) - - // The version that matches the bytes just read: a stat taken after the read - // (falling back to the routing stat if the file vanished in the interim). - const after = await this.ctx.fs.stat(target, signal) - const version = after?.version ?? info.version - - const owner = this.owner(exec) - if (owner) this.record(owner, target.targetKey, version) - return { - offset: request.offset, - limit: request.limit, - lines: window.lines, - totalLines: window.totalLines, - version, - ...window.truncatedByBytes ? { truncatedByBytes: true } : {}, - } - } - - /** - * Create or fully replace a file. With no recorded read, writes - * `createIfAbsent` (only new files can be created blindly); with a recorded - * read, writes `replaceIfVersion` at the observed version (existing files are - * replaced only if unchanged since the read). Refreshes recorded state from - * the returned version on success. - */ - async write(target: FsTarget, content: string, exec?: FileContextExec, signal?: AbortSignal): Promise { - const owner = this.owner(exec) - const prior = owner ? this.getObserved(owner, target.targetKey) : undefined - const outcome = await this.ctx.fs.writeText( - target, - content, - prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }, - signal, - ) - if (owner) this.record(owner, target.targetKey, outcome.version) - return outcome - } - - /** - * Apply a literal edit. Requires a recorded read by this owner (else - * `FS_NOT_OBSERVED`); passes the observed version to `ctx.fs.editText` as the - * stale guard and refreshes recorded state from the returned version. The - * provider owns the mutation critical section and the literal match. - */ - async edit(target: FsTarget, edit: FsEditRequest, exec?: FileContextExec, signal?: AbortSignal): Promise { - const owner = this.owner(exec) - const prior = owner ? this.getObserved(owner, target.targetKey) : undefined + editExpectation(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') } - const outcome = await this.ctx.fs.editText(target, edit, { version: prior.version }, signal) - this.record(owner, target.targetKey, outcome.version) - return outcome + 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) } } -export default FileContext +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'file-context' + +/** + * 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() + }, 'file-context observed-state teardown') + + // fs/write-expectation: 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-expectation', (target, actor) => Promise.resolve().then(() => gate.writeExpectation(target, actor))) + + // fs/edit-expectation: 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-expectation', (target, actor) => Promise.resolve().then(() => gate.editExpectation(target, actor))) + + // fs/observed: synchronous, side-effect-only WeakMap write (cannot throw under + // normal operation); the tool contains any throw so a record bug never fails + // the already-completed mutation. + ctx.on('fs/observed', (target, version, actor) => { + gate.observe(target, version, actor) + }) +} diff --git a/packages/fs/file-context/src/types.ts b/packages/fs/file-context/src/types.ts index 842d9a08c7..b3157cc2e9 100644 --- a/packages/fs/file-context/src/types.ts +++ b/packages/fs/file-context/src/types.ts @@ -1,24 +1,21 @@ /** - * Vocabulary for the file-context policy layer (`ctx.fileContext`): the - * minimal execution-context shape used to derive an observed-state owner, the - * resolved read window, and the structured read outcome the model-facing `read` - * tool renders. + * Vocabulary for the file-context 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 model-facing - * read-windowing and observation policy on top of it. + * re-used from `@deepseek-ai/dsh-fs`; this package owns only the observed-state + * owner structure on top of it. * * @module @deepseek-ai/dsh-file-context/types */ -import type { FsVersion } from '@deepseek-ai/dsh-fs' -import type { FileTextLine } from './window.ts' - /** - * Minimal structural view of a tool execution the policy layer needs to derive + * 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 consumer passes its `exec` straight through without - * `dsh-file-context` importing `dsh-tools`, `dsh-agent`, or `dsh-session`. + * 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. @@ -30,27 +27,3 @@ export interface FileContextExec { session?: object } } - -/** Resolved read window. The consumer applies its defaults/caps before calling. */ -export interface FileReadRequest { - /** 1-based first line to return. */ - offset: number - /** Maximum number of lines to return. */ - limit: number -} - -/** Outcome of a bounded text read — what the model-facing `read` tool renders. */ -export interface FileReadOutcome { - /** 1-based first line requested. */ - offset: number - /** Maximum number of lines requested. */ - limit: 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 - /** Opaque version of the file at read time. */ - version: FsVersion -} diff --git a/packages/fs/file-context/tests/policy.spec.ts b/packages/fs/file-context/tests/policy.spec.ts index 317776d5b3..63808f1610 100644 --- a/packages/fs/file-context/tests/policy.spec.ts +++ b/packages/fs/file-context/tests/policy.spec.ts @@ -1,349 +1,192 @@ /** - * Tests for the file-context policy layer: registration/disposal/HMR, owner - * derivation, observed-state-as-read-record, read windowing over a fake - * provider, freshness-based write/edit authorization (including the key - * windowed-read-authorizes-edit behavior), the read→streamText size routing, - * and multi-owner isolation. The provider is a fake `ctx.fs` recording the - * expectations it was handed. + * Tests for the file-context 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 expectations and records versions on its own WeakMap. */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' -import type { - FsEditOutcome, - FsEditRequest, - FsInfo, - FsTarget, - FsWriteExpectation, - FsWriteOutcome, -} from '@deepseek-ai/dsh-fs' -import FileContext, { STREAM_MIN_SIZE } from '@deepseek-ai/dsh-file-context' -import type { FileContextExec, FileReadRequest } from '@deepseek-ai/dsh-file-context' +import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' +import type { FsTarget, FsWriteExpectation } from '@deepseek-ai/dsh-fs' +import * as FileContext from '@deepseek-ai/dsh-file-context' +import type { FileContextExec } from '@deepseek-ai/dsh-file-context' -/** A fake provider: in-memory files, recording every expectation/version it is handed. */ -class FakeFs extends FileSystem { - files = new Map() - versions = new Map() - /** Size to report from stat (lets a test push read onto the streaming path). */ - reportSize?: number - /** When true, stat omits `size` entirely (a size-less backend). */ - omitSize = false - /** Whether streamText was used for the last read (vs readText). */ - lastReadStreamed = false - writeExpectations: FsWriteExpectation[] = [] - editExpectedVersions: string[] = [] +function target(path: string): FsTarget { + return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path } +} +const ownerExec = (session: object): FileContextExec => ({ agent: { session } }) - private ver(key: string): FsVersion { - return FsVersion(`v${this.versions.get(key) ?? 0}`) - } - private bump(key: string): FsVersion { - const next = (this.versions.get(key) ?? 0) + 1 - this.versions.set(key, next) - return FsVersion(`v${next}`) - } - - override async resolve(path: string): Promise { - return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path } - } - override async stat(target: FsTarget): Promise { - const content = this.files.get(target.targetKey) - if (content === undefined) return undefined - return { version: this.ver(target.targetKey), type: 'file', ...this.omitSize ? {} : { size: this.reportSize ?? content.length } } - } - override async readText(target: FsTarget): Promise { - this.lastReadStreamed = false - return this.files.get(target.targetKey) ?? '' - } - override async streamText(target: FsTarget): Promise> { - this.lastReadStreamed = true - const content = this.files.get(target.targetKey) ?? '' - return (async function* () { yield content })() - } - override async writeText(target: FsTarget, content: string, expected: FsWriteExpectation): Promise { - this.writeExpectations.push(expected) - const existed = this.files.has(target.targetKey) - this.files.set(target.targetKey, content) - return { operation: existed ? 'update' : 'create', version: this.bump(target.targetKey) } - } - override async editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }): Promise { - this.editExpectedVersions.push(expected.version) - const content = this.files.get(target.targetKey) ?? '' - this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString)) - return { replacements: 1, replaceAll: edit.replaceAll, version: this.bump(target.targetKey) } - } +/** Dispatch the write-expectation waterfall with the bare default thunk. */ +function writeExpectation(ctx: Context, t: FsTarget, actor: object | undefined): Promise { + return ctx.waterfall('fs/write-expectation', t, actor, () => undefined) +} +/** Dispatch the edit-expectation waterfall with the bare default thunk. */ +function editExpectation(ctx: Context, t: FsTarget, actor: object | undefined): Promise<{ version: FsVersion } | undefined> { + return ctx.waterfall('fs/edit-expectation', t, actor, () => undefined) } async function setup() { const ctx = new Context() - await ctx.plugin(FakeFs) - await ctx.plugin(FileContext) - const fs = ctx.fs as FakeFs - const fileContext = ctx.fileContext - return { ctx, fs, fileContext } + const fiber = await ctx.plugin(FileContext) + return { ctx, fiber } } -const READ_ALL: FileReadRequest = { offset: 1, limit: 2000 } -const ownerExec = (session: object): FileContextExec => ({ agent: { session } }) - describe('registration / disposal', () => { - it('registers as ctx.fileContext and injects fs', async () => { - const { fileContext } = await setup() - expect(fileContext).toBeDefined() + it('registers no service surface (it is a plugin, not ctx.fileContext)', async () => { + const { ctx } = await setup() + expect((ctx as Context & { fileContext?: unknown }).fileContext).toBeUndefined() }) - it('stays pending until ctx.fs exists', async () => { + 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(FileContext) // no fs provider - expect(ctx.fileContext).toBeUndefined() - }) - - it('withdraws ctx.fileContext when its fiber is disposed (HMR safety)', async () => { - const ctx = new Context() - await ctx.plugin(FakeFs) - const fiber = await ctx.plugin(FileContext) - expect(ctx.fileContext).toBeDefined() - await fiber.dispose() - expect(ctx.fileContext).toBeUndefined() + await ctx.plugin(FileContext) + // The listener is live: an unobserved write decides createIfAbsent. + expect(await writeExpectation(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' }) }) }) -describe('owner derivation', () => { - it('derives the owner from exec.agent.session', async () => { - const { fileContext } = await setup() - const session = {} - expect(fileContext.owner(ownerExec(session))).toBe(session) +describe('write-expectation decision', () => { + it('an unobserved target decides createIfAbsent', async () => { + const { ctx } = await setup() + expect(await writeExpectation(ctx, target('a.txt'), ownerExec({}))).toEqual({ kind: 'createIfAbsent' }) }) - it('returns undefined with no exec, no agent, or no session', async () => { - const { fileContext } = await setup() - expect(fileContext.owner()).toBeUndefined() - expect(fileContext.owner({})).toBeUndefined() - expect(fileContext.owner({ agent: {} })).toBeUndefined() + it('a no-owner actor decides createIfAbsent', async () => { + const { ctx } = await setup() + expect(await writeExpectation(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' }) + expect(await writeExpectation(ctx, target('a.txt'), {})).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 writeExpectation(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' }) }) }) -describe('read', () => { - it('returns a windowed outcome and rejects an absent target', async () => { - const { fs, fileContext } = await setup() - fs.files.set('a.txt', 'one\ntwo') - const outcome = await fileContext.read(await fs.resolve('a.txt'), READ_ALL) - expect(outcome.lines).toEqual([{ number: 1, text: 'one' }, { number: 2, text: 'two' }]) - expect(outcome.version).toBe('v0') - - await expect(fileContext.read(await fs.resolve('missing.txt'), READ_ALL)) - .rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) +describe('edit-expectation decision', () => { + it('rejects an unread edit with FS_NOT_OBSERVED', async () => { + const { ctx } = await setup() + await expect(editExpectation(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) }) - it('rejects a non-regular target', async () => { - const { fs, fileContext } = await setup() - fs.files.set('d', '') - const target = await fs.resolve('d') - // Force stat to report a directory. - fs.stat = async () => ({ version: FsVersion('v0'), type: 'directory' }) - await expect(fileContext.read(target, READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + it('rejects an edit with no owner (cannot prove prior observation)', async () => { + const { ctx } = await setup() + await expect(editExpectation(ctx, target('a.txt'), undefined)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) }) - it('reads small files whole and large files via streamText', async () => { - const { fs, fileContext } = await setup() - fs.files.set('a.txt', 'one\ntwo') - - await fileContext.read(await fs.resolve('a.txt'), READ_ALL) - expect(fs.lastReadStreamed).toBe(false) - - fs.reportSize = STREAM_MIN_SIZE - await fileContext.read(await fs.resolve('a.txt'), READ_ALL) - expect(fs.lastReadStreamed).toBe(true) - }) - - it('streams when the backend reports no size (never buffers a size-less file)', async () => { - const { fs, fileContext } = await setup() - fs.files.set('a.txt', 'one\ntwo') - fs.omitSize = true - await fileContext.read(await fs.resolve('a.txt'), READ_ALL) - expect(fs.lastReadStreamed).toBe(true) - }) - - it('records the version observed after the read, not the routing stat', async () => { - const { fs, fileContext } = await setup() + it('returns the observed version as the CAS basis after an observation', async () => { + const { ctx } = await setup() const exec = ownerExec({}) - fs.files.set('a.txt', 'hello') - fs.versions.set('a.txt', 1) - const target = await fs.resolve('a.txt') - // A writer bumps the version after the routing stat but before the post-read stat. - const realReadText = fs.readText.bind(fs) - fs.readText = async (t) => { - const text = await realReadText(t) - fs.versions.set('a.txt', 5) // file changed during the read - return text - } - const outcome = await fileContext.read(target, READ_ALL, exec) - expect(outcome.version).toBe('v5') - // The recorded (post-read) version authorizes an edit without going stale. - await fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec) - expect(fs.editExpectedVersions).toEqual(['v5']) - }) - - it('falls back to the routing-stat version if the file vanishes after the read', async () => { - const { fs, fileContext } = await setup() - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - const realReadText = fs.readText.bind(fs) - fs.readText = async (t) => { - const text = await realReadText(t) - fs.files.delete('a.txt') // vanishes → post-read stat returns undefined - return text - } - const outcome = await fileContext.read(target, READ_ALL) - expect(outcome.version).toBe('v0') // the routing-stat version - }) - - it('surfaces truncatedByBytes when the window hits the byte cap', async () => { - const { fs, fileContext } = await setup() - fs.files.set('big.txt', Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')) - const outcome = await fileContext.read(await fs.resolve('big.txt'), READ_ALL) - expect(outcome.truncatedByBytes).toBe(true) + ctx.emit('fs/observed', target('a.txt'), FsVersion('v3'), exec) + expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' }) }) }) -describe('observed-state is the read record', () => { - it('a read authorizes a later in-place write at the observed version', async () => { - const { fs, fileContext } = await setup() +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({}) - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - - await fileContext.read(target, READ_ALL, exec) - await fileContext.write(target, 'goodbye', exec) - - expect(fs.writeExpectations).toEqual([{ kind: 'replaceIfVersion', version: 'v0' }]) + ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) // a read + expect(await writeExpectation(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v0' }) }) - it('a windowed (partial) read still authorizes edit — freshness, not full/partial', async () => { - const { fs, fileContext } = await setup() + it('a write/edit observation refreshes the basis, so the next edit needs no re-read', async () => { + const { ctx } = await setup() const exec = ownerExec({}) - fs.files.set('a.txt', 'one\ntwo\nthree\nfour') - const target = await fs.resolve('a.txt') - - // Read only lines 2-3 — a partial window. - const outcome = await fileContext.read(target, { offset: 2, limit: 2 }, exec) - expect(outcome.lines.map(l => l.number)).toEqual([2, 3]) - - // Edit is authorized anyway: the file is unchanged since the read. - await fileContext.edit(target, { oldString: 'one', newString: 'X', replaceAll: false }, exec) - expect(fs.editExpectedVersions).toEqual(['v0']) + // 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 editExpectation(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 editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v2' }) }) - it('skips recording when there is no owner, so write is createIfAbsent', async () => { - const { fs, fileContext } = await setup() - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - - await fileContext.read(target, READ_ALL) // no exec - // No recorded read → createIfAbsent → the provider rejects an existing target. - fs.writeText = async () => { throw new FsError('exists', 'FS_NOT_OBSERVED') } - await expect(fileContext.write(target, 'x')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) - }) -}) - -describe('write policy', () => { - it('a create (no prior read) uses createIfAbsent', async () => { - const { fs, fileContext } = await setup() - const exec = ownerExec({}) - const target = await fs.resolve('new.txt') - const outcome = await fileContext.write(target, 'fresh', exec) - expect(outcome.operation).toBe('create') - expect(fs.writeExpectations).toEqual([{ kind: 'createIfAbsent' }]) - }) - - it('refreshes state after a write, so a follow-up edit needs no re-read', async () => { - const { fs, fileContext } = await setup() - const exec = ownerExec({}) - const target = await fs.resolve('a.txt') - await fileContext.write(target, 'one', exec) // create → state now at v1 - await fileContext.edit(target, { oldString: 'one', newString: 'two', replaceAll: false }, exec) - expect(fs.editExpectedVersions).toEqual(['v1']) - }) -}) - -describe('edit policy', () => { - it('rejects with FS_NOT_OBSERVED when the file was never read', async () => { - const { fs, fileContext } = await setup() - const exec = ownerExec({}) - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec)) - .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) - }) - - it('rejects when there is no owner (cannot prove prior observation)', async () => { - const { fs, fileContext } = await setup() - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false })) - .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) - }) - - it('passes the recorded version as the stale guard after a read', async () => { - const { fs, fileContext } = await setup() - const exec = ownerExec({}) - fs.files.set('a.txt', 'hello') - fs.versions.set('a.txt', 7) - const target = await fs.resolve('a.txt') - await fileContext.read(target, READ_ALL, exec) - await fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec) - expect(fs.editExpectedVersions).toEqual(['v7']) + 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(editExpectation(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) }) }) describe('multi-owner isolation', () => { - it('owner A reading does not grant owner B edit authority', async () => { - const { fs, fileContext } = await setup() + it('owner A observing does not grant owner B edit authority', async () => { + const { ctx } = await setup() const a = ownerExec({}) const b = ownerExec({}) - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - - await fileContext.read(target, READ_ALL, a) - await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, b)) - .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) - await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, a)) - .resolves.toMatchObject({ replacements: 1 }) + ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) + await expect(editExpectation(ctx, target('a.txt'), b)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(await editExpectation(ctx, target('a.txt'), a)).toEqual({ version: 'v0' }) }) it('each owner records its own observed version independently', async () => { - const { fs, fileContext } = await setup() + const { ctx } = await setup() const a = ownerExec({}) const b = ownerExec({}) - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - - await fileContext.read(target, READ_ALL, a) // A sees v0 - await fileContext.write(target, 'mid', b) // B has no read → createIfAbsent - await fileContext.write(target, 'late', a) // A still holds its v0 observation - - expect(fs.writeExpectations).toEqual([ - { kind: 'createIfAbsent' }, - { kind: 'replaceIfVersion', version: 'v0' }, - ]) + ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) // A observed v0 + // B never observed → createIfAbsent; A still holds v0 → replaceIfVersion. + expect(await writeExpectation(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' }) + expect(await writeExpectation(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' }) }) }) -describe('disposal releases recorded state', () => { - it('a fresh service after disposal starts with no inherited state', async () => { - const ctx = new Context() - await ctx.plugin(FakeFs) - const fs = ctx.fs as FakeFs - const fiber = await ctx.plugin(FileContext) +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 expectation = await ctx.waterfall('fs/write-expectation', target('a.txt'), ownerExec({}), () => { + defaultRan = true + return undefined + }) + expect(expectation).toEqual({ kind: 'createIfAbsent' }) + expect(defaultRan).toBe(false) + }) + + it('a SECOND decider registered AFTER file-context is not reached (first-wins short-circuit)', async () => { + const { ctx } = await setup() + let secondRan = false + // Registered after file-context, so it dispatches second; file-context 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-expectation', () => { + secondRan = true + return Promise.resolve(undefined) + }) const exec = ownerExec({}) - fs.files.set('a.txt', 'hello') - await ctx.fileContext.read(await fs.resolve('a.txt'), READ_ALL, exec) + ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) + await editExpectation(ctx, target('a.txt'), exec) + 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(FileContext) + ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) + expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' }) await fiber.dispose() await ctx.plugin(FileContext) - const target = await fs.resolve('a.txt') // Same owner object, but state was released on disposal. - await expect(ctx.fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec)) - .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + await expect(editExpectation(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(FileContext) + await fiber.dispose() + // With no listener, the waterfall falls through to the bare default. + expect(await writeExpectation(ctx, target('a.txt'), ownerExec({}))).toBeUndefined() }) }) diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index a4bb087aea..60ad805938 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -6,17 +6,17 @@ The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepse 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-file-context for policy -// and @deepseek-ai/dsh-tool-fs to expose read/write/edit to the model. +// ctx.fs is now the local backend; load @deepseek-ai/dsh-file-context for the +// freshness policy gate and @deepseek-ai/dsh-tool-fs to expose read/write/edit. ``` ## Behavior - **`resolve(path)`** — relative paths resolve from `config.cwd` (default `process.cwd()`). 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 policy layer (`ctx.fileContext`) decides which to call by size and owns the line windowing. -- **`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`. Honors the `FsWriteExpectation`: `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. Verifies the expected version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content), 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`). +- **`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. +- **`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 diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 3c8ba61f78..9f769bc7cf 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -120,7 +120,7 @@ export class LocalFileSystem extends FileSystem { override async writeText( target: FsTarget, content: string, - expected: FsWriteExpectation, + expected?: FsWriteExpectation, signal?: AbortSignal, ): Promise { return this.withLock(target.targetKey, async () => { @@ -129,16 +129,19 @@ export class LocalFileSystem extends FileSystem { throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') } - if (expected.kind === 'replaceIfVersion') { + 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 (existing) { + } 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. await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals) const after = await probe(target.targetKey) @@ -152,16 +155,21 @@ export class LocalFileSystem extends FileSystem { override async editText( target: FsTarget, edit: FsEditRequest, - expected: { version: FsVersion }, + expected?: { version: FsVersion }, signal?: AbortSignal, ): Promise { 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') - if (existing.version !== expected.version) { + // 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') } diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 20c5cfa21f..4119188f1b 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -144,6 +144,26 @@ describe('writeText', () => { .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('releases per-target mutation locks after success and failure', async () => { const target = await fs.resolve('a.txt') await fs.writeText(target, 'created', { kind: 'createIfAbsent' }) @@ -173,6 +193,28 @@ describe('editText', () => { .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.replacements).toBe(1) + 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') diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 1599ac25cc..fd2307dec5 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -1,14 +1,14 @@ # @deepseek-ai/dsh-fs -The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the text-storage primitives a backend provides — resolve a path, stat metadata, read/stream text, write atomically, and apply a guarded literal edit — without saying HOW. +The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the text-storage primitives a backend provides — resolve a path, stat metadata, read/stream text, 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), and [the split-the-filesystem-seam RFC](../../../docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)): +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 | `@deepseek-ai/dsh-tool-fs` | model-facing `read`/`write`/`edit` schemas + text rendering | -| policy | `@deepseek-ai/dsh-file-context` | `ctx.fileContext`: observed-state, read windowing, write/edit freshness | -| provider seam | `@deepseek-ai/dsh-fs` (this) | `ctx.fs`: text IO + guarded mutation primitives | +| 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-file-context` | 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. @@ -23,15 +23,22 @@ A backend subclasses `FileSystem` and implements six primitives. | `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). | -| `writeText(target, content, expected, signal?)` | Atomic create/replace honoring the `FsWriteExpectation` (`createIfAbsent` or `replaceIfVersion`). | -| `editText(target, edit, expected, signal?)` | Version-guarded literal edit. Verifies `expected.version` BEFORE matching, then applies the replacement and writes atomically — one mutation critical section. | +| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteExpectation` (`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 [catalog](../../../docs/cordis-catalog/events-and-services.md)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-file-context`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-expectation` and `fs/edit-expectation` 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 version-guarded literal-edit critical section. It does **not** own line windows, numbered lines, rendered footers, or observed-state — those model-facing read-windowing and read-before-write/edit policies live one layer up in `ctx.fileContext` ([`@deepseek-ai/dsh-file-context`](../file-context)), so a sandboxed/remote backend inherits no model-facing observation policy. +`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-file-context`) 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. `FsWriteExpectation` is the explicit 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`). 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_TEXT`, `FS_NOT_REGULAR_FILE`, `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. +`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. `FsWriteExpectation` 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_TEXT`, `FS_NOT_REGULAR_FILE`, `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. + diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index 70675b01c2..f23eae98fb 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -17,12 +17,12 @@ * * `ctx.fs` is deliberately close to fsspec-style storage primitives. It owns * UTF-8 decoding, binary/NUL rejection, atomic full-file writes, and the - * version-guarded literal-edit critical section — but NOT line windows, - * numbered lines, rendered footers, or observed-state. Those model-facing - * read-windowing and read-before-write/edit policies live one layer up in the - * concrete `ctx.fileContext` service (`@deepseek-ai/dsh-file-context`), so a - * sandboxed/remote backend inherits no model-facing observation policy it has - * no business carrying. + * 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-file-context`) 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 @@ -30,6 +30,30 @@ * 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-file-context`) adds on top by supplying the guard. + * + * ## The fs policy events live here, not in the policy plugin + * + * This package owns the `fs/write-expectation`, `fs/edit-expectation`, and + * `fs/observed` event vocabulary (see {@link Events}). The emitter is + * `@deepseek-ai/dsh-tool-fs` and the default listener is + * `@deepseek-ai/dsh-file-context`; 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 */ @@ -63,6 +87,47 @@ declare module 'cordis' { interface Context { fs: FileSystem } + + interface Events { + /** + * Single-slot decision: produce the write expectation 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-file-context` 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. + * @mode waterfall + */ + 'fs/write-expectation'(target: FsTarget, actor: object | undefined, next: () => FsWriteExpectation | undefined | Promise): Promise + /** + * 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-file-context` 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-expectation'}). + * @mode waterfall + */ + 'fs/edit-expectation'(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. A listener MUST be a synchronous, + * side-effect-only recorder (`@deepseek-ai/dsh-file-context`'s is a + * `WeakMap.set`); the tool wraps the emit in a try/catch so a synchronous + * listener bug is logged and swallowed, never failing the already-completed + * mutation. cordis `emit` does not await listener promises, so this is not an + * async-error containment seam — async audit/telemetry does not belong here. + * No listener ⇒ nothing recorded. `actor` is the opaque tool-execution context. + * @mode emit + */ + 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void + } } /** @@ -80,12 +145,15 @@ declare module 'cordis' { * - {@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 writeText} is atomic temp-file + rename honoring the - * {@link FsWriteExpectation}. + * - {@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 FsWriteExpectation} 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. + * 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) { @@ -115,17 +183,21 @@ export abstract class FileSystem extends Service { abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> /** - * Create or fully replace a UTF-8 text file atomically, honoring `expected` - * as the create-vs-replace decision and stale guard. + * 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. */ - abstract writeText(target: FsTarget, content: string, expected: FsWriteExpectation, signal?: AbortSignal): Promise + abstract writeText(target: FsTarget, content: string, expected?: FsWriteExpectation, signal?: AbortSignal): Promise /** - * Apply a literal edit to an existing UTF-8 text file. Verifies - * `expected.version` as the stale guard BEFORE literal matching, then applies - * the replacement and writes atomically — one mutation critical section. + * 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`. */ - abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise + abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise } export default FileSystem diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index 62ba52b52f..258c7a1e8b 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -13,7 +13,8 @@ * consumer may show. * * Model-facing concepts (line windows, numbered lines, observed-state) do NOT - * live here; they belong to the policy layer (`ctx.fileContext`). + * live here; they belong to the consumer tool and the policy plugin + * (`@deepseek-ai/dsh-tool-fs` / `@deepseek-ai/dsh-file-context`). * * @module @deepseek-ai/dsh-fs/types */ @@ -78,11 +79,17 @@ export interface FsInfo { } /** - * The explicit intent of a {@link FileSystem.writeText} call. `createIfAbsent` - * creates a missing target and rejects an existing one with `FS_NOT_OBSERVED` - * (the path used 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`. + * 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 FsWriteExpectation = | { kind: 'createIfAbsent' } diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index e06cfa9ee6..7091746dc3 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -38,7 +38,7 @@ class FakeFileSystem extends FileSystem { const content = await this.readText(target) return (async function* () { yield content })() } - override async writeText(target: FsTarget, content: string, _expected: FsWriteExpectation): Promise { + override async writeText(target: FsTarget, content: string, _expected?: FsWriteExpectation): Promise { const existed = this.files.has(target.targetKey) this.files.set(target.targetKey, content) return { operation: existed ? 'update' : 'create', version: FsVersion('v2') } diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index f751ecb051..e3e8a1cdff 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -1,15 +1,17 @@ # @deepseek-ai/dsh-tool-fs -The **model-facing filesystem tools** — `read`, `write`, `edit` — over the `ctx.fileContext` policy layer ([`@deepseek-ai/dsh-file-context`](../file-context)). This is the consumer layer of the filesystem stack; it owns tool names, JSON schemas, argument validation, prompt sections, and result formatting, and **never** touches filesystem I/O (no `node:fs`/`node:path`, no implementation import) or reaches around the policy layer to `ctx.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-file-context`](../file-context)) through the `fs/*` event gate; the tool is not method-coupled to it. ```ts ignore-check -// Load a ctx.fs provider, the policy layer, then the tools. +// 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(FileContext) // @deepseek-ai/dsh-file-context +await ctx.plugin(FileContext) // @deepseek-ai/dsh-file-context (policy gate) await ctx.plugin(ToolFs) // this package — registers read/write/edit ``` -Each tool also ships as a subpath plugin for focused deployments: +`@deepseek-ai/dsh-file-context` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). The default product config loads it, so the default behavior stays read-before-write/edit. + +Each tool also ships as a subpath plugin for focused deployments (each injects `fs`, not a policy service): ```ts ignore-check import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read' @@ -22,17 +24,23 @@ import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' | Tool | Arguments | Behavior | |---|---|---| | `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at 2000 lines. | -| `write` | `file_path`, `content` | Create or fully replace a file. Overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. | -| `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. Requires a prior `read` (any window) and the file unchanged since. | +| `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. -## How the read-before-write/edit policy is enforced +## The tool is the executor; policy is an event gate -The tools do **not** check whether a `read` ran or inspect any cache. Each tool resolves the path via `ctx.fileContext.resolve()`, then calls `ctx.fileContext.read/write/edit(target, …, exec)` — passing the current tool execution context straight through. `ctx.fileContext` derives the observed-state owner (normally the agent session) from that context and owns the freshness policy: a recorded read at the file's current version authorizes a write/edit, and any windowed read counts (authorization is freshness, not a full-view requirement). Backend errors (`FsError`) flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached. +The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve()`, then: -## The no-bypass contract +- **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits a contained `fs/observed`. (1 stat.) +- **write** — `ctx.waterfall('fs/write-expectation', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, expectation)`, then `fs/observed`. (0 stat.) +- **edit** — `ctx.waterfall('fs/edit-expectation', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, expectation)`, then `fs/observed`. (0 stat.) -A model-facing read MUST go through `ctx.fileContext.read`, never `ctx.fs.readText`/`streamText`, so every successful read records observed-state before rendering — which is why the tools inject `fileContext`, not `fs`. Direct `ctx.fs` calls remain an explicit escape hatch for non-tool consumers: a direct `ctx.fs.readText` records nothing, so a later `edit` rejects with `FS_NOT_OBSERVED` until the file is read through `ctx.fileContext`. +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-file-context` 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. -Tool schemas reach the system prompt automatically via the tool registry; this package additionally registers short prose guidance through `ctx.systemPrompt.section(...)`. +## `fs/observed` never fails the tool + +`fs/observed` fires AFTER the read/write/edit already succeeded, so the tool wraps the emit in a try/catch (`src/observe.ts`) that logs and swallows a synchronous listener bug — otherwise a recording failure would turn a completed mutation into an `isError`. The event contract requires synchronous, side-effect-only listeners; this is the synchronous backstop, not async-error handling. + +The line-windowing mechanics live in `src/window.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them. diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index f158142fca..801712bf1c 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -32,7 +32,6 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-file-context": "^0.0.1", "@deepseek-ai/dsh-fs": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index d54fe3045b..5d70310502 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -1,8 +1,14 @@ /** * The model-facing `edit` tool: update an existing UTF-8 text file by replacing - * literal text, requiring a unique match by default. Execution goes through - * `ctx.fileContext`, which enforces prior observation (the freshness policy) - * and delegates the literal-match + stale-guard critical section to `ctx.fs`. + * literal text, requiring a unique match by default. The tool is the executor: + * it dispatches the `fs/edit-expectation` waterfall to obtain the optional + * version guard, calls `ctx.fs.editText` directly, and emits a contained + * `fs/observed`. The default thunk returns `undefined` (unconditional edit of + * the current content — the bare provider); a policy plugin + * (`@deepseek-ai/dsh-file-context`) 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/edit */ @@ -11,7 +17,9 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { FsEditOutcome } from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' +import { emitObserved } from './observe.ts' /** Validated `edit` arguments after defaulting. */ interface EditInput { @@ -60,13 +68,18 @@ export function apply(ctx: Context): void { }, async execute(args, exec): Promise { const input = parseEditArgs(args) - const target = await ctx.fileContext.resolve(input.filePath) - const outcome = await ctx.fileContext.edit( + const target = await ctx.fs.resolve(input.filePath) + // 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 expectation = await ctx.waterfall('fs/edit-expectation', target, exec, () => undefined) + const outcome = await ctx.fs.editText( target, { oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll }, - exec, + expectation, exec.signal, ) + emitObserved(ctx, target, outcome.version, exec) return [{ type: 'text', text: formatEditOutput(target.displayPath, outcome) }] }, })) @@ -76,7 +89,7 @@ export function apply(ctx: Context): void { export const name = 'fs-edit' /** Services required by the `edit` tool plugin. */ -export const inject = ['tools', 'fileContext', 'systemPrompt'] +export const inject = ['tools', 'fs', 'systemPrompt'] /** Named helper for direct registration in the root plugin and tests. */ export const applyEditTool = apply diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index 5509c7980b..b57810185e 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -1,15 +1,24 @@ /** * The model-facing filesystem tool suite (`read`, `write`, `edit`) over the - * `ctx.fileContext` policy layer. This root plugin registers all three tools by + * `ctx.fs` provider seam. This root plugin registers all three tools by * composing the per-tool registration helpers; each tool is also exposed as a * subpath plugin (`@deepseek-ai/dsh-tool-fs/read`, `/write`, `/edit`) for focused * deployments. * - * The package owns model-facing concerns only — tool names, JSON schemas, - * argument validation, prompt sections, result formatting. All filesystem - * execution goes through `ctx.fileContext` (never directly around it to - * `ctx.fs`), so every model read records observed-state before rendering; this - * package never imports `node:fs`, `node:path`, or an + * ## 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-expectation`/`fs/edit-expectation`) to obtain the OPTIONAL version + * guard, and after every read/write/edit it emits a contained `fs/observed`. A + * policy plugin (`@deepseek-ai/dsh-file-context`, loaded by the default product + * config) occupies the decision slot and listens for `fs/observed` to add + * observed-state + read-before-edit + version-guarded write/edit. 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 @@ -20,15 +29,19 @@ import { applyReadTool } from './read.ts' import { applyWriteTool } from './write.ts' import { applyEditTool } from './edit.ts' -export { READ_LIMIT, applyReadTool, formatReadOutput, parseReadArgs } from './read.ts' +export { READ_LIMIT, STREAM_MIN_SIZE, applyReadTool, formatReadOutput, parseReadArgs } from './read.ts' export { applyWriteTool, formatWriteOutput, parseWriteArgs } from './write.ts' export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.ts' +export { emitObserved } from './observe.ts' +export type { FileTextLine, ReadWindow, WindowResult } from './window.ts' +export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow } from './window.ts' +export type { FileReadOutcome } from './types.ts' /** Cordis plugin name used by loader diagnostics. */ export const name = 'tool-fs' /** Services required by the filesystem tool suite. */ -export const inject = ['tools', 'fileContext', 'systemPrompt'] +export const inject = ['tools', 'fs', 'systemPrompt'] /** Register the full `read`/`write`/`edit` filesystem tool suite. */ export function apply(ctx: Context): void { diff --git a/packages/fs/tool-fs/src/observe.ts b/packages/fs/tool-fs/src/observe.ts new file mode 100644 index 0000000000..407dc66fbb --- /dev/null +++ b/packages/fs/tool-fs/src/observe.ts @@ -0,0 +1,34 @@ +/** + * The contained `fs/observed` emit shared by the `read`/`write`/`edit` tools. + * + * `fs/observed` fires AFTER a mutation/read already succeeded, so a throwing + * listener must never turn the completed operation into an `isError` result + * (the tool registry catches a tool throw into an error result). The event + * contract requires a synchronous, side-effect-only listener (the policy + * plugin's is a `WeakMap.set`); this try/catch is the synchronous backstop — + * it logs and swallows a listener bug, mirroring the fire-and-forget pattern in + * the agent loop. It is NOT async-error containment: cordis `emit` does not + * await listener promises, so async observation does not belong on this event. + * + * @module @deepseek-ai/dsh-tool-fs/observe + */ + +import type { Context } from 'cordis' +import type { FsTarget, FsVersion } from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-fs' + +/** + * Emit `fs/observed` for a just-completed read/write/edit, containing any + * synchronous listener throw so the already-successful operation still reports + * success. + */ +export function emitObserved(ctx: Context, target: FsTarget, version: FsVersion, actor: object | undefined): void { + try { + ctx.emit('fs/observed', target, version, actor) + } catch (error: unknown) { + // Contained: the read/write/edit already succeeded. An `fs/observed` listener + // MUST be synchronous and side-effect-only; a synchronous bug is logged and + // swallowed so a recording failure never fails the completed operation. + ctx.logger.warn(`fs/observed listener threw for "${target.displayPath}": ${String(error)}`) + } +} diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 8a6ae8d609..bc12068553 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -1,9 +1,12 @@ /** * The model-facing `read` tool: inspect a UTF-8 text file and return - * line-numbered content with pagination guidance. Execution goes through - * `ctx.fileContext` (which records observed state and owns read windowing) — - * this module owns only the model-facing schema, argument validation, and - * result formatting, never filesystem I/O. + * 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/window}), and emits a contained + * `fs/observed` so a policy plugin (`@deepseek-ai/dsh-file-context`) can record + * the read. With no policy plugin the emit is simply unheard. This module owns + * the model-facing schema, argument validation, read windowing, and result + * formatting; the freshness/observation policy is not its concern. * * @module @deepseek-ai/dsh-tool-fs/read */ @@ -11,12 +14,19 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { FileReadOutcome } from '@deepseek-ai/dsh-file-context' +import { FsError } from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' +import { buildWindow } from './window.ts' +import { emitObserved } from './observe.ts' +import type { FileReadOutcome } from './types.ts' /** Default and maximum number of lines returned by one `read` call. */ export const READ_LIMIT = 2000 +/** Files at or above this size stream; smaller files read whole into memory. */ +export const STREAM_MIN_SIZE = 10 * 1024 * 1024 + /** Validated `read` arguments after defaulting. */ interface ReadInput { filePath: string @@ -79,8 +89,32 @@ export function apply(ctx: Context): void { }, async execute(args, exec): Promise { const input = parseReadArgs(args) - const target = await ctx.fileContext.resolve(input.filePath) - const outcome = await ctx.fileContext.read(target, { offset: input.offset, limit: input.limit }, exec, exec.signal) + const target = await ctx.fs.resolve(input.filePath) + + // 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 >= STREAM_MIN_SIZE + ? await ctx.fs.streamText(target, exec.signal) + : [await ctx.fs.readText(target, exec.signal)] + const window = await buildWindow(chunks, { offset: input.offset, limit: input.limit }, target.displayPath) + + const outcome: FileReadOutcome = { + offset: input.offset, + limit: input.limit, + lines: window.lines, + totalLines: window.totalLines, + version: info.version, + ...window.truncatedByBytes ? { truncatedByBytes: true } : {}, + } + emitObserved(ctx, target, info.version, exec) return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }] }, })) @@ -90,7 +124,7 @@ export function apply(ctx: Context): void { export const name = 'fs-read' /** Services required by the `read` tool plugin. */ -export const inject = ['tools', 'fileContext', 'systemPrompt'] +export const inject = ['tools', 'fs', 'systemPrompt'] /** Named helper for direct registration in the root plugin and tests. */ export const applyReadTool = apply diff --git a/packages/fs/tool-fs/src/types.ts b/packages/fs/tool-fs/src/types.ts new file mode 100644 index 0000000000..48a0592abb --- /dev/null +++ b/packages/fs/tool-fs/src/types.ts @@ -0,0 +1,32 @@ +/** + * Vocabulary for the model-facing filesystem tools (`@deepseek-ai/dsh-tool-fs`): + * the structured read outcome the `read` tool renders. The read window + * (`offset`/`limit`) and per-line shape live in + * {@link module:@deepseek-ai/dsh-tool-fs/window}; this file owns the assembled + * outcome the tool formats. + * + * The provider vocabulary (`FsTarget`, `FsVersion`, write/edit shapes) is + * re-used from `@deepseek-ai/dsh-fs` — this package owns only the model-facing + * read-rendering shape on top of it. + * + * @module @deepseek-ai/dsh-tool-fs/types + */ + +import type { FsVersion } from '@deepseek-ai/dsh-fs' +import type { FileTextLine } from './window.ts' + +/** Outcome of a bounded text read — what the model-facing `read` tool renders. */ +export interface FileReadOutcome { + /** 1-based first line requested. */ + offset: number + /** Maximum number of lines requested. */ + limit: 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 + /** Opaque version of the file at read time. */ + version: FsVersion +} diff --git a/packages/fs/file-context/src/window.ts b/packages/fs/tool-fs/src/window.ts similarity index 92% rename from packages/fs/file-context/src/window.ts rename to packages/fs/tool-fs/src/window.ts index 97e51e2ee4..fb33907710 100644 --- a/packages/fs/file-context/src/window.ts +++ b/packages/fs/tool-fs/src/window.ts @@ -1,16 +1,16 @@ /** - * Cordis-free line-windowing for `@deepseek-ai/dsh-file-context`. Relocated - * from the local backend: turning a file's decoded text into a bounded, - * line-numbered window (offset/limit, byte cap, per-line truncation) is - * model-facing READ POLICY, not a storage primitive, so it lives in the policy - * layer rather than in every `ctx.fs` backend. + * Cordis-free line-windowing for `@deepseek-ai/dsh-tool-fs`. Turning a file's + * decoded text into a bounded, line-numbered window (offset/limit, byte cap, + * per-line truncation) is the model-facing READ-RENDERING detail the tool owns + * now that the tool reads through `ctx.fs` directly — it is not a storage + * primitive and not freshness policy. * * The provider (`ctx.fs.readText`/`streamText`) hands back already-decoded text * (UTF-8 validated, binary rejected); this module 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. * - * @module @deepseek-ai/dsh-file-context/window + * @module @deepseek-ai/dsh-tool-fs/window */ import { FsError } from '@deepseek-ai/dsh-fs' diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 8c242c5256..e2b44ee78a 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -1,8 +1,12 @@ /** - * The model-facing `write` tool: create or fully replace a UTF-8 text file. - * Execution goes through `ctx.fileContext`, which enforces the freshness policy - * (creating a new file needs no prior read; replacing an existing file requires - * a prior read in the same execution context at the unchanged version). + * The model-facing `write` tool: create or fully replace a UTF-8 text file. The + * tool is the executor: it dispatches the `fs/write-expectation` waterfall to + * obtain the optional version guard, calls `ctx.fs.writeText` directly, and + * emits a contained `fs/observed`. The default thunk returns `undefined` + * (unconditional create-or-overwrite — the bare provider); a policy plugin + * (`@deepseek-ai/dsh-file-context`) occupies the single decision slot and + * returns `createIfAbsent`/`replaceIfVersion` instead. The tool stats ZERO + * times either way. * * @module @deepseek-ai/dsh-tool-fs/write */ @@ -11,7 +15,9 @@ import type { Context } from 'cordis' import { defineTool } 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 { emitObserved } from './observe.ts' /** Validate value constraints the schema DSL can't express. */ export function parseWriteArgs(args: { file_path: string; content: string }): { filePath: string; content: string } { @@ -34,7 +40,7 @@ export function apply(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 backend requires it) and prefer edit for targeted changes.', + 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 file-context policy requires it) and prefer edit for targeted changes.', }) ctx.tools.register(defineTool({ @@ -46,8 +52,12 @@ export function apply(ctx: Context): void { }, async execute(args, exec): Promise { const input = parseWriteArgs(args) - const target = await ctx.fileContext.resolve(input.filePath) - const outcome = await ctx.fileContext.write(target, input.content, exec, exec.signal) + const target = await ctx.fs.resolve(input.filePath) + // Single-slot decision: the policy plugin produces createIfAbsent/ + // replaceIfVersion; the bare default is undefined (unconditional). No stat. + const expectation = await ctx.waterfall('fs/write-expectation', target, exec, () => undefined) + const outcome = await ctx.fs.writeText(target, input.content, expectation, exec.signal) + emitObserved(ctx, target, outcome.version, exec) return [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }] }, })) @@ -57,7 +67,7 @@ export function apply(ctx: Context): void { export const name = 'fs-write' /** Services required by the `write` tool plugin. */ -export const inject = ['tools', 'fileContext', 'systemPrompt'] +export const inject = ['tools', 'fs', 'systemPrompt'] /** Named helper for direct registration in the root plugin and tests. */ export const applyWriteTool = apply diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index d61bdb5cac..696741c334 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -1,12 +1,20 @@ /** - * Integration tests: the real local backend (`dsh-fs-local`) plus the real - * policy layer (`dsh-file-context`) plus the model tools (`dsh-tool-fs`), - * exercised through `ctx.tools.execute()` so nothing bypasses the tool registry. + * 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-file-context` policy gate plugin: read-before- + * write/edit, version-guarded mutation, FS_NOT_OBSERVED for unread edits. + * - BARE — WITHOUT the policy plugin, loading only SUBPATH plugins: every + * `fs/*` waterfall falls through to its undefined default, so write/edit are + * unconditional. This proves the subpaths (not just the root) carry no policy + * dependency. + * * 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 } from 'vitest' +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' @@ -15,8 +23,11 @@ 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 FileContext from '@deepseek-ai/dsh-file-context' +import * as FileContext from '@deepseek-ai/dsh-file-context' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read' +import * as writePlugin from '@deepseek-ai/dsh-tool-fs/write' +import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' let dir: string let ctx: Context @@ -24,20 +35,6 @@ let fiber: Awaited> // A stable session object stands in for an agent session (the file-state owner). const session = {} -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(FileContext) - fiber = await ctx.plugin(ToolFs) -}) -afterEach(async () => { - await fiber.dispose() - await rm(dir, { recursive: true, force: true }) -}) - let callCounter = 0 function call(name: string, args: unknown) { return ctx.tools.execute({ @@ -52,137 +49,260 @@ function text(result: { content: { type: string; text?: string }[] }): string { return result.content.filter(b => b.type === 'text').map(b => b.text).join('') } -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') +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-file-context)', () => { + 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(FileContext) + fiber = await ctx.plugin(ToolFs) }) - 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') + 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' }) + }) }) - 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') + 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.)') + }) }) - 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('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() + }) + }) + + describe('contained fs/observed recording', () => { + it('a synchronously throwing fs/observed listener does not fail the completed write', async () => { + ctx.on('fs/observed', () => { throw new Error('listener boom') }) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const result = await call('write', { file_path: 'a.txt', content: 'hi' }) + // The write succeeded on disk; the listener throw was logged and swallowed. + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hi') + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) }) }) -describe('read', () => { - it('returns line-numbered content', async () => { +// -------------------------------------------------------------------------- +// BARE deployment: SUBPATH plugins only, NO policy gate. +// -------------------------------------------------------------------------- +describe('bare provider (subpath plugins, no dsh-file-context)', () => { + 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 }) + await ctx.plugin(readPlugin) + await ctx.plugin(writePlugin) + fiber = await ctx.plugin(editPlugin) + }) + + 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') - 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('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('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.)') + 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') }) -}) -describe('edit → disk', () => { - it('applies a unique literal replacement after a read', async () => { + it('edit unconditionally edits an UNREAD existing file', 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' }) + 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('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('no-bypass / escape-hatch contract', () => { - it('a direct ctx.fs.readText records no observed-state, so a later edit rejects', async () => { + it('edit still enforces literal-match codes (FS_EDIT_NOT_FOUND), unrelated to freshness', async () => { await writeFile(join(dir, 'a.txt'), 'hello world') - // Reach AROUND the policy layer — 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 was not through ctx.fileContext. - const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + 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_NOT_OBSERVED' }) + 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() }) }) diff --git a/packages/fs/tool-fs/tests/subpaths.spec.ts b/packages/fs/tool-fs/tests/subpaths.spec.ts index 7243955969..32a276ca25 100644 --- a/packages/fs/tool-fs/tests/subpaths.spec.ts +++ b/packages/fs/tool-fs/tests/subpaths.spec.ts @@ -1,7 +1,10 @@ /** * Tests for the per-tool subpath plugins (`@deepseek-ai/dsh-tool-fs/read`, - * `/write`, `/edit`): each registers exactly one tool, injects the same - * services (`tools`, `fileContext`, `systemPrompt`), and cleans up on disposal. + * `/write`, `/edit`): each registers exactly one tool, injects the same services + * (`tools`, `fs`, `systemPrompt`) — NOT a policy service — and cleans up on + * disposal. They boot over the bare `ctx.fs` provider with NO + * `@deepseek-ai/dsh-file-context`, proving each subpath carries no policy-plugin + * dependency. */ import { describe, expect, it } from 'vitest' @@ -15,7 +18,6 @@ import type { FsTarget, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' -import FileContext from '@deepseek-ai/dsh-file-context' import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read' import * as writePlugin from '@deepseek-ai/dsh-tool-fs/write' import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' @@ -46,12 +48,11 @@ async function base() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(StubFs) - await ctx.plugin(FileContext) return ctx } describe('subpath plugins', () => { - it('each registers exactly its one tool', async () => { + it('each registers exactly its one tool (over the bare provider, no policy plugin)', async () => { const cases: Array<[unknown, string]> = [ [readPlugin, 'read'], [writePlugin, 'write'], @@ -72,7 +73,7 @@ describe('subpath plugins', () => { expect(ctx.tools.schemas()).toHaveLength(0) }) - it('stays pending without a ctx.fileContext provider', async () => { + it('stays pending without a ctx.fs provider', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index ef1490b5ce..5ca6947354 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -1,13 +1,14 @@ /** - * Consumer-surface tests for the filesystem tools. They run the REAL - * `ctx.fileContext` policy service over a fake `ctx.fs` provider (the genuine - * collaborator, per the prefer-the-real-implementation rule), so they verify - * schemas, argument validation, result formatting, FsError→isError propagation, - * and that each tool records observed-state through `ctx.fileContext` (the - * no-bypass contract) — not just that it moved bytes. + * Consumer-surface tests for the filesystem tools as the EXECUTOR. They run the + * REAL `@deepseek-ai/dsh-file-context` 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 } from 'vitest' +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' @@ -21,15 +22,17 @@ import type { FsWriteExpectation, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' -import FileContext from '@deepseek-ai/dsh-file-context' -import type { FileReadOutcome } from '@deepseek-ai/dsh-file-context' +import * as FileContext from '@deepseek-ai/dsh-file-context' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' -import { formatReadOutput } 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() rejectWith?: FsError + writeExpectations: (FsWriteExpectation | undefined)[] = [] + editExpectations: ({ version: FsVersion } | undefined)[] = [] private throwIfArmed(): void { if (this.rejectWith) throw this.rejectWith @@ -51,14 +54,16 @@ class FakeFs extends FileSystem { const content = this.files.get(target.targetKey) ?? '' return (async function* () { yield content })() } - override async writeText(target: FsTarget, content: string, _expected: FsWriteExpectation): Promise { + override async writeText(target: FsTarget, content: string, expected?: FsWriteExpectation): Promise { this.throwIfArmed() + this.writeExpectations.push(expected) const existed = this.files.has(target.targetKey) this.files.set(target.targetKey, content) return { operation: existed ? 'update' : 'create', version: FsVersion('v2') } } - override async editText(target: FsTarget, edit: FsEditRequest): Promise { + override async editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }): Promise { this.throwIfArmed() + this.editExpectations.push(expected) const content = this.files.get(target.targetKey) ?? '' this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString)) return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3') } @@ -104,11 +109,11 @@ describe('registration', () => { expect(prompt).toContain('Use the edit tool') }) - it('stays pending until ctx.fileContext exists (inject)', async () => { + 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 fileContext provider + await ctx.plugin(ToolFs) // no fs provider expect(ctx.tools.schemas()).toHaveLength(0) }) @@ -169,6 +174,7 @@ describe('read tool', () => { 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.editExpectations).toEqual([{ version: 'v1' }]) }) it('propagates FS_NOT_FOUND for an absent file', async () => { @@ -177,6 +183,48 @@ describe('read tool', () => { 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', () => { @@ -204,11 +252,12 @@ describe('formatReadOutput footer variants', () => { }) describe('write tool', () => { - it('formats a create result', async () => { - const { ctx } = await setup() - const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }) + 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: {} }) expect(result.isError).toBe(false) expect(text(result)).toContain('Created file') + expect(fs.writeExpectations).toEqual([{ kind: 'createIfAbsent' }]) }) it('rejects a blank file_path', async () => { @@ -258,7 +307,7 @@ describe('edit tool', () => { expect(text(result)).toContain('file_path must be a non-empty string') }) - it('propagates FS_NOT_OBSERVED when the file was never read', async () => { + 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: {} }) diff --git a/packages/fs/file-context/tests/window.spec.ts b/packages/fs/tool-fs/tests/window.spec.ts similarity index 98% rename from packages/fs/file-context/tests/window.spec.ts rename to packages/fs/tool-fs/tests/window.spec.ts index 6b1a8b5b93..b596a47465 100644 --- a/packages/fs/file-context/tests/window.spec.ts +++ b/packages/fs/tool-fs/tests/window.spec.ts @@ -6,8 +6,8 @@ */ import { describe, expect, it } from 'vitest' -import { buildWindow, READ_MAX_LINE_LENGTH } from '@deepseek-ai/dsh-file-context' -import type { ReadWindow } from '@deepseek-ai/dsh-file-context' +import { buildWindow, READ_MAX_LINE_LENGTH } from '@deepseek-ai/dsh-tool-fs' +import type { ReadWindow } from '@deepseek-ai/dsh-tool-fs' const READ_ALL: ReadWindow = { offset: 1, limit: 2000 } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 1c5ec2430e..f471723679 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -47,7 +47,6 @@ { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditOutcome", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsErrorCode", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileContextExec", "source": "packages/fs/file-context/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadRequest", "source": "packages/fs/file-context/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/file-context/src/types.ts" } + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/tool-fs/src/types.ts" } ] } From f9f475cbea682b0120225bfa463411e2a6a01097 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sun, 28 Jun 2026 14:02:51 +0800 Subject: [PATCH 20/46] fix: address codex review round 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correct the filesystem-wiring claims flagged by codex: no default/example config wires the fs tools yet (the demo agents do file ops through bash), so the docs and RFC no longer assert that "the default product config loads dsh-file-context". They now state the intended stance — a deployment that loads the fs tools is expected to also load dsh-file-context for read-before-write/edit. --- docs/architecture.md | 2 +- docs/core-data-structures/filesystem.md | 2 +- .../2026-06-26-file-context-as-event-gate.md | 10 +++++----- packages/fs/README.md | 2 +- packages/fs/tool-fs/README.md | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 1afa766f63..32737adbc7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,7 +73,7 @@ Swappable capabilities are split into **three packages** so each part evolves in The LLM seam has the same topology folded differently: `dsh-llm` carries the interface (`LlmAdapter`) AND the consumer surface (`ctx.llm.stream()`), with adapters as implementation packages — there the consumer is the loop itself, not a swappable schema surface. Use the full three-package split when the consumer is independently replaceable; keep interface + consumer together when they are one concern. Don't split preemptively: a capability with one conceivable implementation and one consumer stays one package until proven otherwise. -The filesystem capability follows the bash topology with a fourth layer, but the policy is contributed through an **event gate**, not a method service: `dsh-fs` owns the abstract `ctx.fs` provider seam (text IO + atomic mutation primitives whose version guard is optional) and the `fs/*` policy event vocabulary, `dsh-fs-local` provides the local backend, `dsh-tool-fs` is the model-facing `read`/`write`/`edit` tools AND the executor (it reads/writes/edits through `ctx.fs` directly, owns read windowing, dispatches the `fs/*` events), and `dsh-file-context` is a policy PLUGIN (no service) that decides the `fs/write-expectation`/`fs/edit-expectation` waterfalls and records on `fs/observed` to add observed-state + read-before-edit + version-guarded write/edit. Because the tool is not method-coupled to the policy, dropping `dsh-file-context` gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool at a service-injection boundary. The default product config loads `dsh-file-context`, so the default behavior remains read-before-write/edit. See [the file-context event-gate RFC](rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md). +The filesystem capability follows the bash topology with a fourth layer, but the policy is contributed through an **event gate**, not a method service: `dsh-fs` owns the abstract `ctx.fs` provider seam (text IO + atomic mutation primitives whose version guard is optional) and the `fs/*` policy event vocabulary, `dsh-fs-local` provides the local backend, `dsh-tool-fs` is the model-facing `read`/`write`/`edit` tools AND the executor (it reads/writes/edits through `ctx.fs` directly, owns read windowing, dispatches the `fs/*` events), and `dsh-file-context` is a policy PLUGIN (no service) that decides the `fs/write-expectation`/`fs/edit-expectation` waterfalls and records on `fs/observed` to add observed-state + read-before-edit + version-guarded write/edit. Because the tool is not method-coupled to the policy, dropping `dsh-file-context` gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool at a service-injection boundary. The fs tools are not wired into any default/example config yet (the demo agents do file ops through bash); a deployment that loads `dsh-tool-fs` is expected to also load `dsh-file-context` so the default behavior is read-before-write/edit. See [the file-context event-gate RFC](rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md). > **"Capability" — two unrelated meanings.** (1) The *seam pattern* above ("one plugin provides a capability, another needs it") is realized by plain Cordis **services + `inject`**: a provider registers a service (`ctx.bash`, declared in `interface Context`); a consumer declares `inject: ['bash']` and its fiber stays pending until the service exists, tearing down via HMR if it later vanishes. No extra library is needed. (2) `@cordisjs/plugin-capability` is a different axis entirely — a **permission/capability-security** service (named permissions with inheritance/dependency, tested against a session via `ctx.capability.test`). It is a candidate for the deferred permissions/sandbox work (the `tools/execute` veto seam), NOT a mechanism for swapping implementations. diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index e114c409d8..54ea3e05d0 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -2,7 +2,7 @@ The filesystem stack is split across four packages: a provider seam ([dsh-fs](../../packages/fs/fs), `ctx.fs`, text IO + atomic mutation primitives whose version guard is optional), a local implementation ([dsh-fs-local](../../packages/fs/fs-local), local disk), a policy plugin ([dsh-file-context](../../packages/fs/file-context), observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate — NO service), and a consumer ([dsh-tool-fs](../../packages/fs/tool-fs), the model-facing `read`/`write`/`edit` tools, which is also the EXECUTOR — it reads/writes/edits through `ctx.fs` directly and owns read windowing). Filesystem access is an optional capability, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). A sandboxed, remote, virtual, or project-scoped backend can implement the same `FileSystem` service without changing the policy plugin or the tool schemas. -The model is **additive, not subtractive**: `ctx.fs` alone is a complete, unconstrained text-storage seam (`write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text). `dsh-file-context` is a plugin that *adds* policy on top by deciding the `fs/*` waterfalls; removing it leaves the bare provider rather than breaking the tool, because the tool is not method-coupled to the policy. The default product config still loads it, so the default behavior remains read-before-write/edit. +The model is **additive, not subtractive**: `ctx.fs` alone is a complete, unconstrained text-storage seam (`write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text). `dsh-file-context` is a plugin that *adds* policy on top by deciding the `fs/*` waterfalls; removing it leaves the bare provider rather than breaking the tool, because the tool is not method-coupled to the policy. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-file-context` so the default behavior is read-before-write/edit. Provider source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) and [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts). Policy source: [`packages/fs/file-context/src/types.ts`](../../packages/fs/file-context/src/types.ts). Read-rendering source: [`packages/fs/tool-fs/src/types.ts`](../../packages/fs/tool-fs/src/types.ts). diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md index efb6667d4e..999f795a08 100644 --- a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -29,7 +29,7 @@ provider seam dsh-fs ctx.fs: text IO + ATOMIC mutation primitives who provider dsh-fs-local local implementation of ctx.fs ``` -The model is **additive, 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. There is no "先读后写", no version check, nothing to remove; the bare provider just does the I/O atomically. `dsh-file-context` is a plugin that *adds* constraints on top: observed-state, read-before-edit, and "write/edit must be based on the version you read". So removing `dsh-file-context` does not break `dsh-tool-fs` at the service-injection boundary; it removes the policy gate and leaves the bare provider behavior. The product default still loads `dsh-file-context`, so the default user-facing behavior and prompt discipline remain read-before-write/edit. The bare-provider mode exists because the tool should not be method-coupled to the policy plugin, not because an unconstrained filesystem is the normal product stance. +The model is **additive, 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. There is no "先读后写", no version check, nothing to remove; the bare provider just does the I/O atomically. `dsh-file-context` is a plugin that *adds* constraints on top: observed-state, read-before-edit, and "write/edit must be based on the version you read". So removing `dsh-file-context` does not break `dsh-tool-fs` at the service-injection boundary; it removes the policy gate and leaves the bare provider behavior. The intended deployment stance is that a config loading the fs tools also loads `dsh-file-context`, so the user-facing behavior and prompt discipline are read-before-write/edit (no default/example config wires the fs tools yet — the demo agents do file ops through bash). The bare-provider mode exists because the tool should not be method-coupled to the policy plugin, not because an unconstrained filesystem is the normal product stance. `dsh-tool-fs` no longer injects `fileContext`. It injects `fs` and `tools`/`systemPrompt`. @@ -70,7 +70,7 @@ These events carry existing `dsh-fs` vocabulary (`FsTarget`, `FsVersion`, `FsWri **The two `fs/*` decision events are single-slot decision points, NOT a composable interception chain.** A waterfall listener that does not call `next()` short-circuits the rest of the chain (verified in [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts) — `waterfall` runs listeners around the final `next` thunk, and a listener that returns without calling `next()` reaches neither later listeners nor the tool's default thunk). `dsh-file-context` fully decides the write/edit expectation and does not call `next()`, so it occupies that one decision slot in the default deployment. This is deliberate: "what version basis does this mutation guard against" is a single decision, not an accumulation. The names (`fs/write-expectation`, `fs/edit-expectation`) say "produce the value", not "authorize", so they do not imply a stackable authorization chain. Genuinely composable interception (permission, audit, sandbox) belongs on the existing `tools/execute` waterfall, which every tool call already flows through — not on this fs version-decision slot. -**The occupant is decided by registration order — first-registered (or `prepend`ed) wins.** cordis dispatches waterfall listeners in registration order (`push`, or `unshift` for `prepend` — [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts)), and the first non-`next()` decider short-circuits the rest. So the slot is **first-wins**, and `dsh-file-context` owning it rests on the default deployment convention: it is the decider registered for these events. The event shape does NOT itself guarantee "an unread edit is rejected" — a plugin that registers a looser `fs/edit-expectation` decider BEFORE `dsh-file-context` (or with `prepend`) would decide first and bypass the `FS_NOT_OBSERVED` gate. That is the inherent property of a first-wins single slot, stated here so it is not mistaken for an enforced invariant. This RFC does not add a multi-policy composition mechanism; the implementation requirement is that the shipped `dsh-tool-fs` dispatches these waterfalls on every write/edit path and the shipped default config loads `dsh-file-context` as the policy decider. +**The occupant is decided by registration order — first-registered (or `prepend`ed) wins.** cordis dispatches waterfall listeners in registration order (`push`, or `unshift` for `prepend` — [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts)), and the first non-`next()` decider short-circuits the rest. So the slot is **first-wins**, and `dsh-file-context` owning it rests on the default deployment convention: it is the decider registered for these events. The event shape does NOT itself guarantee "an unread edit is rejected" — a plugin that registers a looser `fs/edit-expectation` decider BEFORE `dsh-file-context` (or with `prepend`) would decide first and bypass the `FS_NOT_OBSERVED` gate. That is the inherent property of a first-wins single slot, stated here so it is not mistaken for an enforced invariant. This RFC does not add a multi-policy composition mechanism; the implementation requirement is that `dsh-tool-fs` dispatches these waterfalls on every write/edit path and that a config wiring the fs tools loads `dsh-file-context` as the policy decider. The actor is typed `object` in `dsh-fs` — a pure opaque carrier the provider seam never reads or narrows. The owner-derivation (`actor.agent?.session`) and the `{ agent?: { session? } }` structural shape stay entirely inside `dsh-file-context`, which narrows the `object` actor to that shape in its listeners. `dsh-fs` owns the event names and the fs vocabulary; it does NOT own the policy layer's runtime owner structure. @@ -110,7 +110,7 @@ The `fs/*` decision events are **unbound waterfalls dispatched by the tool** (li ## Tool contract (`dsh-tool-fs`) -The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte unchanged) and prompt sections. The prompt guidance stays policy-first because the default product config loads `dsh-file-context`: the model is still told to read before overwriting or editing, and any wording that says the "backend" requires that should be corrected to say the default file-context policy requires it. The bare-provider fallback does not change the default prompt stance. +The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte unchanged) and prompt sections. The prompt guidance stays policy-first because a deployment loading the fs tools is expected to also load `dsh-file-context`: the model is still told to read before overwriting or editing, and any wording that says the "backend" requires that should be corrected to say the file-context policy requires it. The bare-provider fallback does not change the prompt stance. `dsh-tool-fs` gains the executor responsibilities relocated from the old `fileContext` method service, including **read windowing** (`window.ts`, `READ_MAX_BYTES`, `READ_MAX_LINE_LENGTH`, `FileReadRequest`/`FileReadOutcome`/`FileTextLine`, `STREAM_MIN_SIZE`), which is the tool's rendering detail now that the tool owns the read. Those read-windowing types and helpers move into `dsh-tool-fs`; the policy plugin must not remain a type dependency for the tool. @@ -140,7 +140,7 @@ An observed-state entry is the **prior-observation record**: a successful `read` ## Bare-provider behavior (no `dsh-file-context`) -This is not the default product mode — the default product config loads `dsh-file-context`. It is the unconstrained provider floor that exists once the tool is no longer coupled to a policy method service. With `dsh-file-context` absent, every `fs/*` waterfall falls through to its `undefined` default and `fs/observed` has no listener: +This is not the intended deployment stance — a config loading the fs tools is expected to also load `dsh-file-context`. It is the unconstrained provider floor that exists once the tool is no longer coupled to a policy method service. With `dsh-file-context` absent, every `fs/*` waterfall falls through to its `undefined` default and `fs/observed` has no listener: - **read** is identical (it never needed policy; it only emits a now-unheard `fs/observed`). - **write** unconditionally creates-or-overwrites: `expected` is `undefined`, so `writeText` writes whether or not the file exists and whatever its current version. No read-first requirement, no version check. @@ -172,4 +172,4 @@ This amends — does not reverse — [the split-fs-seam RFC](../simplification/2 - **Policy events in the storage seam.** `dsh-fs` gains two version-decision events plus a recording event though it is "just storage". This is the price of decoupling (the emitter cannot depend on the policy plugin). The events carry only `dsh-fs` vocabulary plus an opaque `object` actor and no model-facing concepts, so the seam stays free of line-window/observation policy types and of the agent/session owner structure. - **Single policy occupant, first-wins by convention.** The `fs/write-expectation`/`fs/edit-expectation` slots hold exactly one decider; the first-registered (or `prepend`ed) listener wins and the rest are short-circuited. `dsh-file-context` owning the slot is a deployment convention, not an event-enforced invariant — a second decider registered first would bypass it. This is acceptable because a second fs-version-policy decider is a misconfiguration, not a feature. If a future need for *layered* fs version policy appears, it is a new RFC (a composable value-passing seam), not a silent second listener on these events. Layered permission/audit/sandbox interception already has its home on `tools/execute`. - **Dropping the post-read confirming stat** makes a follow-up *guarded* edit occasionally fail-closed (`FS_STALE_VERSION` → re-read) under a read/write race. This is a UX nicety lost, never a correctness hole; the provider lock still prevents wrong-version writes. -- **The bare provider does no read-before-write/edit and no version check.** A deployment without `dsh-file-context` lets the model overwrite or edit any existing file unconditionally. This is the deliberate meaning of keeping the tool independent of a policy service: the safety disciplines live in the default `dsh-file-context` plugin. A deployment that omits it is opting into an unconstrained filesystem on purpose; that is not the default product stance. +- **The bare provider does no read-before-write/edit and no version check.** A deployment without `dsh-file-context` lets the model overwrite or edit any existing file unconditionally. This is the deliberate meaning of keeping the tool independent of a policy service: the safety disciplines live in the `dsh-file-context` plugin. A deployment that omits it is opting into an unconstrained filesystem on purpose; that is not the intended stance for a config that ships the fs tools. diff --git a/packages/fs/README.md b/packages/fs/README.md index 79e1d5c0f6..0fbce830e3 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -9,4 +9,4 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona | `file-context/` | 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 (`file-context/`) 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. The default product config loads it. +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 (`file-context/`) 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. diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index e3e8a1cdff..787a2cca9a 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -9,7 +9,7 @@ await ctx.plugin(FileContext) // @deepseek-ai/dsh-fi await ctx.plugin(ToolFs) // this package — registers read/write/edit ``` -`@deepseek-ai/dsh-file-context` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). The default product config loads it, so the default behavior stays read-before-write/edit. +`@deepseek-ai/dsh-file-context` 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. Each tool also ships as a subpath plugin for focused deployments (each injects `fs`, not a policy service): From 1059166cb19aa4264142e62b748b7e62d2407481 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sun, 28 Jun 2026 14:15:04 +0800 Subject: [PATCH 21/46] fix: address codex review round 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - edit tool: add the read-before-edit requirement to the model-facing prompt (with the just-created/edited-this-session exception), matching write's guidance so the model doesn't only learn it via a failed FS_NOT_OBSERVED call. - fsspec-style-fs-seam RFC: correct the acceptance criteria that still claimed a ctx.fileContext service and a fileContext inject — the landed design is the fs/* event gate with the tool injecting fs. - filesystem-tool-schemas RFC: replace the stale "prior full file state" edit requirement with version-freshness wording (any windowed read authorizes a fresh edit; no partial-view flag). --- .../implemented/feature/2026-06-17-filesystem-tool-schemas.md | 2 +- .../simplification/2026-06-26-fsspec-style-fs-seam.md | 4 ++-- packages/fs/tool-fs/src/edit.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md index 45928e9871..fa572d4a0b 100644 --- a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md +++ b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md @@ -62,7 +62,7 @@ Arguments: - `new_string: string` — required. Literal replacement text; an empty string deletes the match. - `replace_all?: boolean` — optional. Defaults to false. When false, `old_string` must identify exactly one match. -`edit` requires prior full file state derived from a previous read in the same execution context. `ctx.fs` derives the file-state owner and uses the recorded version as the stale guard. +`edit` requires a prior observation of the file in the same execution context (any windowed read counts — authorization is version freshness, not a full-view requirement), or a prior write/edit by that context. The `dsh-file-context` policy plugin derives the owner and supplies the recorded version as the stale guard; the provider's mutation lock enforces it. The first pass rejects Codex-style patch grammars and multi-mode edit APIs. It uses one strict literal replacement mode so the model-facing contract stays simple and the backend can own exact-match, duplicate-match, line-ending, and stale-version semantics. diff --git a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md index 7f2e5d7091..26bbb65056 100644 --- a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md +++ b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md @@ -106,8 +106,8 @@ It keeps the interface/implementation/consumer discipline, consumer-never-import ## Acceptance Criteria - `dsh-fs` exposes exactly `resolve`/`stat`/`readText`/`streamText`/`writeText`/`editText`; `stat` returns `FsInfo | undefined`; `writeText` uses `FsWriteExpectation` (`createIfAbsent` or `replaceIfVersion`); removed types/primitives are gone, and the old `applyEdit` API is replaced by `editText`. -- `dsh-file-context` registers `ctx.fileContext`, owns observed-state plus `read`/`write`/`edit` policy, injects `fs`, and has HMR/disposal coverage. -- `dsh-tool-fs` injects `fileContext`; model-facing schemas stay byte-for-byte unchanged; the no-bypass contract and escape-hatch contract are documented and tested. +- `dsh-file-context` adds the observed-state + `read`/`write`/`edit` freshness policy and has HMR/disposal coverage. (It does so as a gate PLUGIN on the `fs/*` events with no `ctx.fileContext` service, per [the event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md) — the original service form this RFC proposed was reworked.) +- `dsh-tool-fs` reaches the policy decisions and model-facing schemas stay byte-for-byte unchanged; the observation contract (a read records observed-state; a direct `ctx.fs` read does not) is documented and tested. (The tool injects `fs` and dispatches the `fs/*` events rather than injecting a `fileContext` service, per the event-gate RFC.) - Windowed read authorizing edit is shown to fail on the pre-refit code and pass after the refit. Existing version-CAS behavior is preserved with a regression test; it is not claimed as a pre-refit failure. An edit based on a stale read must report `FS_STALE_VERSION` before attempting literal matching. - `dsh-fs-local` carries no line, view, or `formatReadBody` logic; it does carry provider-level `editText` logic. - Docs and generated artifacts are updated: `docs/architecture.md`, `packages/README.md`, fs package READMEs, `docs/core-data-structures/filesystem.md`, affected `type-equiv` blocks and `scripts/type-equiv.manifest.json`, Cordis catalog, module graph, and doc references. diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 5d70310502..d3725db1af 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -54,7 +54,7 @@ export function apply(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.', + 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 file-context policy requires it), unless you just created or edited it in this session.', }) ctx.tools.register(defineTool({ From 4a1177093a0057cf46b9339e337601b6f1e25a08 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sun, 28 Jun 2026 17:43:29 +0800 Subject: [PATCH 22/46] refactor(tool-fs): drop per-tool subpath plugins; adopt single-tsconfig build Adapt the four fs packages to master's single-tsconfig build convention (lib/types outDir + types path + files allowlist), brought in by the merge. While doing so, drop dsh-tool-fs's /read//write//edit subpath plugins. They were the only subpath-export package in the tree and forced bespoke tsdown, tsconfig path, package.json files, and workspace-constraint handling that no sibling tool package (e.g. dsh-tool-bash) carries, for a focused-deployment use case no consumer needed. dsh-tool-fs is now a single root plugin that registers read/write/edit, mirroring dsh-tool-bash; the per-tool registration helpers stay internal modules the root composes. The file-context event-gate RFC is amended to record the narrowed scope. --- .../2026-06-17-filesystem-capability-seam.md | 8 +- .../2026-06-26-file-context-as-event-gate.md | 8 +- .../2026-06-17-filesystem-tool-schemas.md | 2 +- packages/fs/file-context/package.json | 4 +- packages/fs/fs-local/package.json | 4 +- packages/fs/fs/package.json | 4 +- packages/fs/tool-fs/README.md | 8 -- packages/fs/tool-fs/package.json | 16 +--- packages/fs/tool-fs/src/edit.ts | 13 +-- packages/fs/tool-fs/src/index.ts | 5 +- packages/fs/tool-fs/src/read.ts | 13 +-- packages/fs/tool-fs/src/write.ts | 13 +-- packages/fs/tool-fs/tests/integration.spec.ts | 18 ++-- packages/fs/tool-fs/tests/subpaths.spec.ts | 83 ------------------- packages/fs/tool-fs/tsdown.config.ts | 21 ----- tsconfig.base.json | 3 - 16 files changed, 34 insertions(+), 189 deletions(-) delete mode 100644 packages/fs/tool-fs/tests/subpaths.spec.ts delete mode 100644 packages/fs/tool-fs/tsdown.config.ts diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md index 4faae84d83..0a4365bc82 100644 --- a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -49,7 +49,7 @@ The filesystem seam uses the same dependency direction as the bash trio: `@deepseek-ai/dsh-tool-fs` depends on `@deepseek-ai/dsh-fs`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and `cordis`. It registers model-facing tools and prompt sections. It must not import `node:fs`, `node:path`, or `@deepseek-ai/dsh-fs-local`; filesystem execution always goes through `ctx.fs`. If the implementation needs concrete agent or session helper types, those dependencies belong in `tool-fs`; they must not leak back into `dsh-fs`. -The root `tool-fs` plugin registers the full filesystem tool suite by composing the per-tool registration helpers (`read`, `write`, and `edit`). The same helpers are exposed as subpath plugins such as `@deepseek-ai/dsh-tool-fs/read`, `@deepseek-ai/dsh-tool-fs/write`, and `@deepseek-ai/dsh-tool-fs/edit` for focused deployments. Root and subpath plugins follow the same rule: they inject `fs` and never import an implementation package. +The root `tool-fs` plugin registers the full filesystem tool suite (`read`, `write`, and `edit`) by composing the per-tool registration helpers. It injects `fs` and never imports an implementation package. ## `ctx.fs` contract @@ -117,7 +117,7 @@ The tool package must keep model-facing contracts stable when backends change. A The first implementation requires a prior full `read` before updating an existing file with `write` or `edit`. `tool-fs` does not implement this by checking whether a tool named `read` ran or by reading the file-state cache. It passes the current execution context to `ctx.fs`, and `ctx.fs` derives the file-state owner and enforces file-state/stale-version policy. Creating a new file with `write` does not require prior state or an owner. -The root plugin registers the full suite by composing the per-tool registration helpers. The subpath plugins register one tool each for focused deployments and tests. Both forms inject `fs`, `tools`, and `systemPrompt`. +The root plugin registers the full suite by composing the per-tool registration helpers. It injects `fs`, `tools`, and `systemPrompt`. ## Migration plan @@ -128,7 +128,7 @@ This RFC starts from `origin/master`, where no filesystem tool package exists ye 3. Add `packages/fs/tool-fs` with the model-facing `read`, `write`, and `edit` tools over `ctx.fs`. 4. Update `docs/architecture.md`, `packages/README.md`, package READMEs, build/typecheck config, and aggregate maintenance scripts such as `scripts/publint-all.ts`. -This first pass does not add a separate `@deepseek-ai/dsh-file-context` package. The file-state store lives behind `ctx.fs` so root and subpath `tool-fs` plugins share the same read-before-write/edit policy automatically. +This first pass does not add a separate `@deepseek-ai/dsh-file-context` package. The file-state store lives behind `ctx.fs` so the `tool-fs` plugin gets the read-before-write/edit policy automatically. Example leaf configs stay bash-only in this landing. Wiring `examples/coding-agent` or `examples/acp-agent` to `dsh-fs-local` + `dsh-tool-fs` changes the model prompt, visible tool schemas, and ACP snapshot transcript, so it should land as a follow-up UX/example change with prompt and snapshot updates in the same PR. @@ -156,7 +156,7 @@ Beyond the happy/sad paths above, `dsh-fs-local` tests must cover the defensive- - **Concurrency / stale races.** The RFC names edit as race-prone (see Risks). Test that two concurrent write/edit operations against the same target settle deterministically: one succeeds and the other is rejected with `FS_STALE_VERSION` rather than silently overwriting, and that a successful edit refreshes recorded state so an immediately-following edit by the same owner proceeds. - **HMR safety and disposal.** `dsh-fs-local` registers `ctx.fs` and owns the in-memory file-state store, so it needs its own HMR-safety test (register the backend on a fiber, dispose it, assert the `ctx.fs` provider is withdrawn and the file-state store is released — a later provider starts with no inherited state). -`dsh-tool-fs` tests cover the consumer surface with a fake `ctx.fs` implementation. They should verify tool schemas, argument validation, prompt-section registration, formatting of successful results, propagation of backend `FsError` codes into `isError` tool results through `ctx.tools.execute()`, that read/write/edit pass the current execution context or structural projection through to `ctx.fs`, root-plugin suite registration, subpath plugin registration, and HMR cleanup. +`dsh-tool-fs` tests cover the consumer surface with a fake `ctx.fs` implementation. They should verify tool schemas, argument validation, prompt-section registration, formatting of successful results, propagation of backend `FsError` codes into `isError` tool results through `ctx.tools.execute()`, that read/write/edit pass the current execution context or structural projection through to `ctx.fs`, root-plugin suite registration, and HMR cleanup. Integration tests should load `dsh-fs-local` plus `dsh-tool-fs` and execute `read`, `write`, and `edit` through `ctx.tools.execute()` to prove the three packages work together without bypassing the tool registry. They must verify the world, not the tool's self-report: after a `write`/`edit`, read the file back from disk and assert byte-identical content (and that untouched files are unchanged), rather than trusting the returned `ContentBlock[]`. Each integration/e2e test owns its resources — create the harness in the test, run against a per-test temporary directory, and dispose the harness and remove the directory in `afterEach` even on failure or timeout. diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md index 999f795a08..23460b211f 100644 --- a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -112,9 +112,9 @@ The `fs/*` decision events are **unbound waterfalls dispatched by the tool** (li The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte unchanged) and prompt sections. The prompt guidance stays policy-first because a deployment loading the fs tools is expected to also load `dsh-file-context`: the model is still told to read before overwriting or editing, and any wording that says the "backend" requires that should be corrected to say the file-context policy requires it. The bare-provider fallback does not change the prompt stance. -`dsh-tool-fs` gains the executor responsibilities relocated from the old `fileContext` method service, including **read windowing** (`window.ts`, `READ_MAX_BYTES`, `READ_MAX_LINE_LENGTH`, `FileReadRequest`/`FileReadOutcome`/`FileTextLine`, `STREAM_MIN_SIZE`), which is the tool's rendering detail now that the tool owns the read. Those read-windowing types and helpers move into `dsh-tool-fs`; the policy plugin must not remain a type dependency for the tool. +`dsh-tool-fs` gains the executor responsibilities relocated from the old `fileContext` method service, including **read windowing** (`window.ts`, `READ_MAX_BYTES`, `READ_MAX_LINE_LENGTH`, `FileReadOutcome`/`FileTextLine`, `STREAM_MIN_SIZE`), which is the tool's rendering detail now that the tool owns the read. Those read-windowing types and helpers move into `dsh-tool-fs`; the policy plugin must not remain a type dependency for the tool. -`dsh-tool-fs` exposes each tool as a first-class **subpath plugin** (`/read`, `/write`, `/edit`) for focused deployments, plus a root plugin that composes all three. The `inject` change applies to **all four**: each of `read.ts`, `write.ts`, `edit.ts`, and `index.ts` drops `fileContext` from `inject` and adds `fs` (keeping `tools`/`systemPrompt`). Updating only the root plugin would leave a focused deployment that loads just `@deepseek-ai/dsh-tool-fs/edit` still coupled to the old method service, silently breaking the decoupling contract for exactly the deployments subpaths exist to serve. +`dsh-tool-fs` is a single root plugin that registers all three tools (`read`/`write`/`edit`), mirroring `dsh-tool-bash`. It injects `fs` (plus `tools`/`systemPrompt`), never `fileContext`. (The original proposal also exposed each tool as a `/read`/`/write`/`/edit` subpath plugin for focused deployments; that was dropped on implementation — no consumer needed a single-tool deployment, and the subpath publishing forced bespoke `tsdown`/`tsconfig`/`files`/workspace-constraint handling no sibling tool package carries. The per-tool registration helpers (`applyReadTool`/`applyWriteTool`/`applyEditTool`) remain internal modules the root plugin composes.) `stat` budget is minimized by letting the waterfall produce the expectation lazily — the bare default returns `undefined` (no guard) and never stats: @@ -154,10 +154,10 @@ This amends — does not reverse — [the split-fs-seam RFC](../simplification/2 ## Acceptance Criteria -- All four `dsh-tool-fs` injection points — the root plugin AND the `/read`, `/write`, `/edit` subpath plugins — inject `fs` (+ `tools`/`systemPrompt`), not `fileContext`; each calls `ctx.fs` directly and dispatches the `fs/write-expectation`/`fs/edit-expectation` waterfalls (passing `exec` as the actor) and the contained `fs/observed` emit. Read windowing lives in `dsh-tool-fs`. +- The `dsh-tool-fs` root plugin injects `fs` (+ `tools`/`systemPrompt`), not `fileContext`; it calls `ctx.fs` directly and dispatches the `fs/write-expectation`/`fs/edit-expectation` waterfalls (passing `exec` as the actor) and the contained `fs/observed` emit. Read windowing lives in `dsh-tool-fs`. (No subpath plugins — see the Tool contract above.) - `dsh-fs` declares the three events with `@mode` tags and an opaque `object` actor argument (no agent/session structure leaks into the provider vocabulary); the generated cordis catalog is regenerated. - `dsh-file-context` is a plugin, not a service: it does not register `ctx.fileContext`, has no public `read`/`write`/`edit`/`resolve` methods, and does not inject `fs`; it registers the three listeners, keeps observed-state, and has HMR/disposal coverage (dispose the fiber, assert the gate no longer rewrites). -- **Bare-provider test**: a config WITHOUT `dsh-file-context` that loads a **subpath plugin** (e.g. just `@deepseek-ai/dsh-tool-fs/edit`, plus `/read`/`/write` as the scenario needs) boots, and `read`/`write`(create AND overwrite)/`edit` work through `dsh-tool-fs` against the real `dsh-fs-local`; an `edit` of an unread existing file and an overwrite of an existing unread file both succeed (unconditional bare-provider behavior), proving the subpath plugins — not just the root — carry no `fileContext` dependency. A bare-provider edit of a missing target reports `FS_STALE_VERSION`. With `dsh-file-context` present, the same unread `edit` is rejected `FS_NOT_OBSERVED` and the same unread overwrite uses `createIfAbsent` (rejected on an existing file). +- **Bare-provider test**: a config WITHOUT `dsh-file-context` boots the `dsh-tool-fs` root plugin, and `read`/`write`(create AND overwrite)/`edit` work against the real `dsh-fs-local`; an `edit` of an unread existing file and an overwrite of an existing unread file both succeed (unconditional bare-provider behavior), proving the tool carries no `fileContext` dependency. A bare-provider edit of a missing target reports `FS_STALE_VERSION`. With `dsh-file-context` present, the same unread `edit` is rejected `FS_NOT_OBSERVED` and the same unread overwrite uses `createIfAbsent` (rejected on an existing file). - **Single-slot semantics**: a test registers a second `fs/edit-expectation` listener AFTER `dsh-file-context` and asserts it is NOT reached (first-wins short-circuit), and documents in a comment that a decider registered before/`prepend`ed would instead win — the slot is first-wins by convention, not an enforced invariant. - **Contained observed recording**: a test with a synchronously throwing `fs/observed` listener performs a write/edit and asserts the tool result is still success (the completed mutation is not turned into an `isError`). The event contract requires synchronous side-effect-only listeners; the try/catch is the synchronous backstop, not async rejection handling. - `dsh-fs` `writeText`/`editText` make `expected` optional (omit ⇒ unconditional); the `FsWriteExpectation` union is unchanged, and `dsh-file-context`'s guarded paths (`createIfAbsent`/`replaceIfVersion`/`{ version }`) behave exactly as today. A bare-provider test exercises an unconditional overwrite, an unconditional edit, and a missing-target edit reporting `FS_STALE_VERSION`. diff --git a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md index fa572d4a0b..6e510c69b1 100644 --- a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md +++ b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md @@ -100,7 +100,7 @@ The following are deliberately out of scope for the first filesystem schema pass - `edit` requires `file_path`, `old_string`, and `new_string`, accepts optional boolean `replace_all`, rejects empty `old_string`, and defaults `replace_all` to false. - The registered JSON schemas use the snake_case field names in this RFC. - The tool descriptions accurately describe that existing-file `write` and `edit` require a prior full read in the same execution context, while new-file `write` does not. -- The root plugin and subpath plugins register the same schemas. +- The `tool-fs` root plugin registers all three schemas. Integration tests should execute `read`, `write`, and `edit` through `ctx.tools.execute()` with a fake or local `ctx.fs` provider and verify that model arguments are translated into the expected `ctx.fs` calls. diff --git a/packages/fs/file-context/package.json b/packages/fs/file-context/package.json index 5d05d09f13..16ee567305 100644 --- a/packages/fs/file-context/package.json +++ b/packages/fs/file-context/package.json @@ -15,7 +15,9 @@ "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index f1073981ff..4945684713 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -15,7 +15,9 @@ "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 65e71108a1..395816dbf9 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -15,7 +15,9 @@ "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 787a2cca9a..86298031de 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -11,14 +11,6 @@ await ctx.plugin(ToolFs) // this package — re `@deepseek-ai/dsh-file-context` 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. -Each tool also ships as a subpath plugin for focused deployments (each injects `fs`, not a policy service): - -```ts ignore-check -import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read' -import * as writePlugin from '@deepseek-ai/dsh-tool-fs/write' -import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' -``` - ## Tools (schemas per [the filesystem tool schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md)) | Tool | Arguments | Behavior | diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index a726bde6d4..5323bbadcf 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -11,23 +11,13 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./read": { - "types": "./lib/types/read.d.ts", - "default": "./lib/read.js" - }, - "./write": { - "types": "./lib/types/write.d.ts", - "default": "./lib/write.js" - }, - "./edit": { - "types": "./lib/types/edit.d.ts", - "default": "./lib/edit.js" - }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index d3725db1af..53029808ec 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -10,7 +10,7 @@ * tool stats ZERO times either way; a missing target is reported by the provider * as `FS_STALE_VERSION`. * - * @module @deepseek-ai/dsh-tool-fs/edit + * @module @deepseek-ai/dsh-tool-fs/src/edit */ import type { Context } from 'cordis' @@ -50,7 +50,7 @@ export function formatEditOutput(displayPath: string, outcome: FsEditOutcome): s } /** Register the `edit` tool and its system-prompt guidance. */ -export function apply(ctx: Context): void { +export function applyEditTool(ctx: Context): void { ctx.systemPrompt.section({ name: 'tool:edit', order: 102, @@ -84,12 +84,3 @@ export function apply(ctx: Context): void { }, })) } - -/** Cordis plugin name used by loader diagnostics. */ -export const name = 'fs-edit' - -/** Services required by the `edit` tool plugin. */ -export const inject = ['tools', 'fs', 'systemPrompt'] - -/** Named helper for direct registration in the root plugin and tests. */ -export const applyEditTool = apply diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index b57810185e..8c2123c3d4 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -1,9 +1,6 @@ /** * The model-facing filesystem tool suite (`read`, `write`, `edit`) over the - * `ctx.fs` provider seam. This root plugin registers all three tools by - * composing the per-tool registration helpers; each tool is also exposed as a - * subpath plugin (`@deepseek-ai/dsh-tool-fs/read`, `/write`, `/edit`) for focused - * deployments. + * `ctx.fs` provider seam. This single plugin registers all three tools. * * ## The tool is the executor; policy is an event gate * diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index bc12068553..54fd553011 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -8,7 +8,7 @@ * the model-facing schema, argument validation, read windowing, and result * formatting; the freshness/observation policy is not its concern. * - * @module @deepseek-ai/dsh-tool-fs/read + * @module @deepseek-ai/dsh-tool-fs/src/read */ import type { Context } from 'cordis' @@ -72,7 +72,7 @@ ${body} } /** Register the `read` tool and its system-prompt guidance. */ -export function apply(ctx: Context): void { +export function applyReadTool(ctx: Context): void { ctx.systemPrompt.section({ name: 'tool:read', order: 100, @@ -119,12 +119,3 @@ export function apply(ctx: Context): void { }, })) } - -/** Cordis plugin name used by loader diagnostics. */ -export const name = 'fs-read' - -/** Services required by the `read` tool plugin. */ -export const inject = ['tools', 'fs', 'systemPrompt'] - -/** Named helper for direct registration in the root plugin and tests. */ -export const applyReadTool = apply diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index e2b44ee78a..407744ec03 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -8,7 +8,7 @@ * returns `createIfAbsent`/`replaceIfVersion` instead. The tool stats ZERO * times either way. * - * @module @deepseek-ai/dsh-tool-fs/write + * @module @deepseek-ai/dsh-tool-fs/src/write */ import type { Context } from 'cordis' @@ -36,7 +36,7 @@ ${verb} file } /** Register the `write` tool and its system-prompt guidance. */ -export function apply(ctx: Context): void { +export function applyWriteTool(ctx: Context): void { ctx.systemPrompt.section({ name: 'tool:write', order: 101, @@ -62,12 +62,3 @@ export function apply(ctx: Context): void { }, })) } - -/** Cordis plugin name used by loader diagnostics. */ -export const name = 'fs-write' - -/** Services required by the `write` tool plugin. */ -export const inject = ['tools', 'fs', 'systemPrompt'] - -/** Named helper for direct registration in the root plugin and tests. */ -export const applyWriteTool = apply diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 696741c334..cc99d52b80 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -5,10 +5,9 @@ * * - DEFAULT — with the real `dsh-file-context` policy gate plugin: read-before- * write/edit, version-guarded mutation, FS_NOT_OBSERVED for unread edits. - * - BARE — WITHOUT the policy plugin, loading only SUBPATH plugins: every - * `fs/*` waterfall falls through to its undefined default, so write/edit are - * unconditional. This proves the subpaths (not just the root) carry no policy - * dependency. + * - 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. @@ -25,9 +24,6 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' import * as FileContext from '@deepseek-ai/dsh-file-context' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' -import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read' -import * as writePlugin from '@deepseek-ai/dsh-tool-fs/write' -import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' let dir: string let ctx: Context @@ -243,18 +239,16 @@ describe('default deployment (with dsh-file-context)', () => { }) // -------------------------------------------------------------------------- -// BARE deployment: SUBPATH plugins only, NO policy gate. +// BARE deployment: the tool suite WITHOUT the policy gate. // -------------------------------------------------------------------------- -describe('bare provider (subpath plugins, no dsh-file-context)', () => { +describe('bare provider (no dsh-file-context)', () => { 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 }) - await ctx.plugin(readPlugin) - await ctx.plugin(writePlugin) - fiber = await ctx.plugin(editPlugin) + fiber = await ctx.plugin(ToolFs) }) it('read works (it never needed policy)', async () => { diff --git a/packages/fs/tool-fs/tests/subpaths.spec.ts b/packages/fs/tool-fs/tests/subpaths.spec.ts deleted file mode 100644 index 32a276ca25..0000000000 --- a/packages/fs/tool-fs/tests/subpaths.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Tests for the per-tool subpath plugins (`@deepseek-ai/dsh-tool-fs/read`, - * `/write`, `/edit`): each registers exactly one tool, injects the same services - * (`tools`, `fs`, `systemPrompt`) — NOT a policy service — and cleans up on - * disposal. They boot over the bare `ctx.fs` provider with NO - * `@deepseek-ai/dsh-file-context`, proving each subpath carries no policy-plugin - * dependency. - */ - -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' -import type { - FsEditOutcome, - FsInfo, - FsTarget, - FsWriteOutcome, -} from '@deepseek-ai/dsh-fs' -import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read' -import * as writePlugin from '@deepseek-ai/dsh-tool-fs/write' -import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' - -class StubFs extends FileSystem { - override async resolve(path: string): Promise { - return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path } - } - override async stat(): Promise { - return { version: FsVersion('v'), type: 'file', size: 0 } - } - override async readText(): Promise { - return '' - } - override async streamText(): Promise> { - return (async function* () { yield '' })() - } - override async writeText(): Promise { - return { operation: 'create', version: FsVersion('v') } - } - override async editText(): Promise { - return { replacements: 1, replaceAll: false, version: FsVersion('v') } - } -} - -async function base() { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(StubFs) - return ctx -} - -describe('subpath plugins', () => { - it('each registers exactly its one tool (over the bare provider, no policy plugin)', async () => { - const cases: Array<[unknown, string]> = [ - [readPlugin, 'read'], - [writePlugin, 'write'], - [editPlugin, 'edit'], - ] - for (const [plugin, toolName] of cases) { - const ctx = await base() - await ctx.plugin(plugin as Parameters[0]) - expect(ctx.tools.schemas().map(s => s.name)).toEqual([toolName]) - } - }) - - it('cleans up on disposal (HMR safety)', async () => { - const ctx = await base() - const fiber = await ctx.plugin(readPlugin as Parameters[0]) - expect(ctx.tools.schemas()).toHaveLength(1) - await fiber.dispose() - expect(ctx.tools.schemas()).toHaveLength(0) - }) - - it('stays pending without a ctx.fs provider', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(writePlugin as Parameters[0]) - expect(ctx.tools.schemas()).toHaveLength(0) - }) -}) diff --git a/packages/fs/tool-fs/tsdown.config.ts b/packages/fs/tool-fs/tsdown.config.ts deleted file mode 100644 index ef07bcf108..0000000000 --- a/packages/fs/tool-fs/tsdown.config.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { defineConfig } from 'tsdown' - -/** - * tool-fs exposes one package root plus one entry per tool plugin, so each tool - * can be loaded or replaced independently as a subpath plugin - * (`@deepseek-ai/dsh-tool-fs/read`, `/write`, `/edit`). The root tsdown builds - * only `lib/types/index.js`, so this override adds the per-tool entries. tsdown - * reads the emitted JS under `lib/types` (from `tsc -b`); declarations come from - * `tsc -b` too (dts: false), matching every package. - */ -export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/read.js', 'lib/types/write.js', 'lib/types/edit.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, -}) - diff --git a/tsconfig.base.json b/tsconfig.base.json index 309c36c33f..7ac09c1725 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -34,9 +34,6 @@ "@cordisjs/plugin-timer": ["./vendor/timer/src"], "@cordisjs/plugin-hmr": ["./vendor/hmr/src"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], - "@deepseek-ai/dsh-tool-fs/read": ["./packages/fs/tool-fs/src/read.ts"], - "@deepseek-ai/dsh-tool-fs/write": ["./packages/fs/tool-fs/src/write.ts"], - "@deepseek-ai/dsh-tool-fs/edit": ["./packages/fs/tool-fs/src/edit.ts"], // One wildcard maps every @deepseek-ai/dsh- to its source. Package // dir names are unique across groups, so first-on-disk-wins resolution is // unambiguous; adding a package under an existing group needs no edit From f8e99b87404271fe13606f02dd792456bb3649c1 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 29 Jun 2026 10:34:08 +0800 Subject: [PATCH 23/46] refactor(tool-fs): consolidate read rendering; drop the fs/observed try-catch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cohesion cleanups on the filesystem tool package: - Fold window.ts + types.ts + formatReadOutput into one cordis-free read-render.ts. Line windowing, the FileReadOutcome shape, and output formatting are one concern (the read tool's rendering); splitting them across three files added no value. read.ts is now just the tool (schema + I/O). - Drop observe.ts and emit fs/observed with a plain ctx.emit in read/write/edit. The event is contractually a synchronous, side-effect-only recorder (file-context's listener is a WeakMap.set), so the per-call try/catch guarded against a contract violation that cannot happen under the shipped listener — defensive code for an impossible case. The event contract (dsh-fs JSDoc, README, RFC) is updated to state the fire-and-forget semantics plainly. --- docs/cordis-catalog/events-and-services.md | 2 +- docs/core-data-structures/filesystem.md | 2 +- .../2026-06-26-file-context-as-event-gate.md | 22 +++---- packages/fs/fs/src/index.ts | 14 ++--- packages/fs/tool-fs/README.md | 6 +- packages/fs/tool-fs/src/edit.ts | 17 +++--- packages/fs/tool-fs/src/index.ts | 8 +-- packages/fs/tool-fs/src/observe.ts | 34 ----------- .../tool-fs/src/{window.ts => read-render.ts} | 57 ++++++++++++++++--- packages/fs/tool-fs/src/read.ts | 42 ++++---------- packages/fs/tool-fs/src/types.ts | 32 ----------- packages/fs/tool-fs/src/write.ts | 8 +-- packages/fs/tool-fs/tests/integration.spec.ts | 13 ----- .../{window.spec.ts => read-render.spec.ts} | 0 scripts/type-equiv.manifest.json | 2 +- 15 files changed, 100 insertions(+), 159 deletions(-) delete mode 100644 packages/fs/tool-fs/src/observe.ts rename packages/fs/tool-fs/src/{window.ts => read-render.ts} (65%) delete mode 100644 packages/fs/tool-fs/src/types.ts rename packages/fs/tool-fs/tests/{window.spec.ts => read-render.spec.ts} (100%) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 37437c9262..e95687d78c 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -199,7 +199,7 @@ Source: [`packages/fs/fs/src/index.ts:117`](../../packages/fs/fs/src/index.ts) #### `fs/observed` — emit -Record that an actor observed a target at a version, after a successful read/write/edit. Fire-and-forget. A listener MUST be a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-file-context`'s is a `WeakMap.set`); the tool wraps the emit in a try/catch so a synchronous listener bug is logged and swallowed, never failing the already-completed mutation. cordis `emit` does not await listener promises, so this is not an async-error containment seam — async audit/telemetry does not belong here. No listener ⇒ nothing recorded. `actor` is the opaque tool-execution context. +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-file-context`'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. ```ts cordis-catalog 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index 54ea3e05d0..22cda4672d 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -4,7 +4,7 @@ The filesystem stack is split across four packages: a provider seam ([dsh-fs](.. The model is **additive, not subtractive**: `ctx.fs` alone is a complete, unconstrained text-storage seam (`write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text). `dsh-file-context` is a plugin that *adds* policy on top by deciding the `fs/*` waterfalls; removing it leaves the bare provider rather than breaking the tool, because the tool is not method-coupled to the policy. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-file-context` so the default behavior is read-before-write/edit. -Provider source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) and [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts). Policy source: [`packages/fs/file-context/src/types.ts`](../../packages/fs/file-context/src/types.ts). Read-rendering source: [`packages/fs/tool-fs/src/types.ts`](../../packages/fs/tool-fs/src/types.ts). +Provider source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) and [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts). Policy source: [`packages/fs/file-context/src/types.ts`](../../packages/fs/file-context/src/types.ts). Read-rendering source: [`packages/fs/tool-fs/src/read-render.ts`](../../packages/fs/tool-fs/src/read-render.ts). ## Target identity and metadata (provider seam) diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md index 23460b211f..a51cde555d 100644 --- a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -96,10 +96,10 @@ interface Events { 'fs/edit-expectation'(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. Listeners MUST be synchronous, side-effect- - * only recorders (`dsh-file-context`'s is a WeakMap write); the tool wraps the - * emit in a try/catch so a synchronous listener bug is logged and swallowed, - * never failing the already-completed mutation. No listener ⇒ nothing recorded. + * read/write/edit. Fire-and-forget (plain emit). Listeners MUST be + * synchronous, side-effect-only recorders (`dsh-file-context`'s is a WeakMap + * write); the tool does not guard the emit, so a throwing listener surfaces as + * the tool's isError result. No listener ⇒ nothing recorded. * @mode emit */ 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void @@ -112,19 +112,19 @@ The `fs/*` decision events are **unbound waterfalls dispatched by the tool** (li The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte unchanged) and prompt sections. The prompt guidance stays policy-first because a deployment loading the fs tools is expected to also load `dsh-file-context`: the model is still told to read before overwriting or editing, and any wording that says the "backend" requires that should be corrected to say the file-context policy requires it. The bare-provider fallback does not change the prompt stance. -`dsh-tool-fs` gains the executor responsibilities relocated from the old `fileContext` method service, including **read windowing** (`window.ts`, `READ_MAX_BYTES`, `READ_MAX_LINE_LENGTH`, `FileReadOutcome`/`FileTextLine`, `STREAM_MIN_SIZE`), which is the tool's rendering detail now that the tool owns the read. Those read-windowing types and helpers move into `dsh-tool-fs`; the policy plugin must not remain a type dependency for the tool. +`dsh-tool-fs` gains the executor responsibilities relocated from the old `fileContext` method service, including **read rendering** (`read-render.ts`: `buildWindow` + `formatReadOutput`, `READ_MAX_BYTES`, `READ_MAX_LINE_LENGTH`, `FileReadOutcome`/`FileTextLine`, plus `STREAM_MIN_SIZE` in `read.ts`), which is the tool's rendering detail now that the tool owns the read. Those read-rendering types and helpers move into `dsh-tool-fs`; the policy plugin must not remain a type dependency for the tool. `dsh-tool-fs` is a single root plugin that registers all three tools (`read`/`write`/`edit`), mirroring `dsh-tool-bash`. It injects `fs` (plus `tools`/`systemPrompt`), never `fileContext`. (The original proposal also exposed each tool as a `/read`/`/write`/`/edit` subpath plugin for focused deployments; that was dropped on implementation — no consumer needed a single-tool deployment, and the subpath publishing forced bespoke `tsdown`/`tsconfig`/`files`/workspace-constraint handling no sibling tool package carries. The per-tool registration helpers (`applyReadTool`/`applyWriteTool`/`applyEditTool`) remain internal modules the root plugin composes.) `stat` budget is minimized by letting the waterfall produce the expectation lazily — the bare default returns `undefined` (no guard) and never stats: -- **read** — one `stat` (type + size routing + version), then `readText`/`streamText`, then `buildWindow`, then a contained `emit('fs/observed', target, info.version, exec)`. The post-read confirming `stat` from the old `fileContext.read` is dropped; a writer racing between the routing stat and the read can at worst make a *later* guarded edit spuriously `FS_STALE_VERSION` (fail-closed: the model re-reads, never writes against the wrong version, since `editText` re-checks in its lock). -- **write** — `expectation = await ctx.waterfall('fs/write-expectation', target, exec, () => undefined)`, then `ctx.fs.writeText(target, content, expectation)`, then a contained `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** with or without `dsh-file-context`. -- **edit** — `expectation = await ctx.waterfall('fs/edit-expectation', target, exec, () => undefined)`, then `ctx.fs.editText(target, edit, expectation)`, then a contained `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** in both cases: the bare default is `undefined` (unconditional edit), so the tool never stats to manufacture a basis. If the target is absent, the provider reports `FS_STALE_VERSION` even on the unguarded path. +- **read** — one `stat` (type + size routing + version), then `readText`/`streamText`, then `buildWindow`, then an `emit('fs/observed', target, info.version, exec)`. The post-read confirming `stat` from the old `fileContext.read` is dropped; a writer racing between the routing stat and the read can at worst make a *later* guarded edit spuriously `FS_STALE_VERSION` (fail-closed: the model re-reads, never writes against the wrong version, since `editText` re-checks in its lock). +- **write** — `expectation = await ctx.waterfall('fs/write-expectation', target, exec, () => undefined)`, then `ctx.fs.writeText(target, content, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** with or without `dsh-file-context`. +- **edit** — `expectation = await ctx.waterfall('fs/edit-expectation', target, exec, () => undefined)`, then `ctx.fs.editText(target, edit, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** in both cases: the bare default is `undefined` (unconditional edit), so the tool never stats to manufacture a basis. If the target is absent, the provider reports `FS_STALE_VERSION` even on the unguarded path. The tool passes `exec` (the tool-execution context) as the `actor` argument on every dispatch, so `dsh-file-context` can derive its observed-state owner. The tool does not know whether the policy plugin is present: it always provides the bare default behavior in the `next` thunk, and `dsh-file-context` short-circuits the thunk before it runs in the default deployment. -**`fs/observed` recording must never fail the tool, because it fires AFTER the mutation already succeeded** — a throw there becomes an `isError` result ([tools/index.ts](../../../../packages/core/tools/src/index.ts) — `ToolRegistry.execute` catches a tool throw into an error result), reporting failure for a write/edit that actually happened. The tool therefore wraps the dispatch in a try/catch that logs and swallows synchronous listener bugs (the established fire-and-forget pattern in [agent.ts](../../../../packages/core/agent-loop/src/agent.ts)). The event contract is intentionally narrower than "arbitrary observers": an `fs/observed` listener MUST be synchronous and side-effect-only — `dsh-file-context`'s listener is a `WeakMap.set`, which cannot throw under normal operation and returns no promise. Cordis `emit` does not await listener promises, so the try/catch is NOT an async-error containment mechanism; async audit/telemetry/listener work does not belong on this event. If layered or async observation is ever wanted, that is a new event with its own dispatch story. +**`fs/observed` fires AFTER the mutation already succeeded**, via a plain `ctx.emit`. The event contract is intentionally narrow: an `fs/observed` listener MUST be synchronous and side-effect-only — `dsh-file-context`'s listener is a `WeakMap.set`, which cannot throw under normal operation and returns no promise. The tool does not guard the emit, so a listener that violates the contract by throwing would surface as the tool's `isError` result ([tools/index.ts](../../../../packages/core/tools/src/index.ts) — `ToolRegistry.execute` catches a tool throw into an error result) — reporting failure for a write/edit that actually happened. That is the price of keeping the event a plain fire-and-forget recorder: cordis `emit` does not await listener promises, so async or fallible audit/telemetry/listener work does not belong on this event. If layered or async observation is ever wanted, that is a new event with its own dispatch story. ## Policy plugin contract (`dsh-file-context`) @@ -154,12 +154,12 @@ This amends — does not reverse — [the split-fs-seam RFC](../simplification/2 ## Acceptance Criteria -- The `dsh-tool-fs` root plugin injects `fs` (+ `tools`/`systemPrompt`), not `fileContext`; it calls `ctx.fs` directly and dispatches the `fs/write-expectation`/`fs/edit-expectation` waterfalls (passing `exec` as the actor) and the contained `fs/observed` emit. Read windowing lives in `dsh-tool-fs`. (No subpath plugins — see the Tool contract above.) +- The `dsh-tool-fs` root plugin injects `fs` (+ `tools`/`systemPrompt`), not `fileContext`; it calls `ctx.fs` directly and dispatches the `fs/write-expectation`/`fs/edit-expectation` waterfalls (passing `exec` as the actor) and the `fs/observed` emit. Read rendering lives in `dsh-tool-fs`. (No subpath plugins — see the Tool contract above.) - `dsh-fs` declares the three events with `@mode` tags and an opaque `object` actor argument (no agent/session structure leaks into the provider vocabulary); the generated cordis catalog is regenerated. - `dsh-file-context` is a plugin, not a service: it does not register `ctx.fileContext`, has no public `read`/`write`/`edit`/`resolve` methods, and does not inject `fs`; it registers the three listeners, keeps observed-state, and has HMR/disposal coverage (dispose the fiber, assert the gate no longer rewrites). - **Bare-provider test**: a config WITHOUT `dsh-file-context` boots the `dsh-tool-fs` root plugin, and `read`/`write`(create AND overwrite)/`edit` work against the real `dsh-fs-local`; an `edit` of an unread existing file and an overwrite of an existing unread file both succeed (unconditional bare-provider behavior), proving the tool carries no `fileContext` dependency. A bare-provider edit of a missing target reports `FS_STALE_VERSION`. With `dsh-file-context` present, the same unread `edit` is rejected `FS_NOT_OBSERVED` and the same unread overwrite uses `createIfAbsent` (rejected on an existing file). - **Single-slot semantics**: a test registers a second `fs/edit-expectation` listener AFTER `dsh-file-context` and asserts it is NOT reached (first-wins short-circuit), and documents in a comment that a decider registered before/`prepend`ed would instead win — the slot is first-wins by convention, not an enforced invariant. -- **Contained observed recording**: a test with a synchronously throwing `fs/observed` listener performs a write/edit and asserts the tool result is still success (the completed mutation is not turned into an `isError`). The event contract requires synchronous side-effect-only listeners; the try/catch is the synchronous backstop, not async rejection handling. +- **Fire-and-forget recording**: `fs/observed` is emitted via a plain `ctx.emit` after the mutation succeeds; a listener is contractually synchronous and side-effect-only, so the tool does not guard it. - `dsh-fs` `writeText`/`editText` make `expected` optional (omit ⇒ unconditional); the `FsWriteExpectation` union is unchanged, and `dsh-file-context`'s guarded paths (`createIfAbsent`/`replaceIfVersion`/`{ version }`) behave exactly as today. A bare-provider test exercises an unconditional overwrite, an unconditional edit, and a missing-target edit reporting `FS_STALE_VERSION`. - Freshness is enforced by provider CAS when guarded: an edit after a stale read reports `FS_STALE_VERSION` (regression test); `dsh-file-context` performs no `stat`. - `stat` budget: read = 1, write = 0, edit = 0 — in the tool, with or without `dsh-file-context` (the bare default returns `undefined`, never stats). A test asserts neither write nor edit stats in the tool on either path. diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index f23eae98fb..f25a521301 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -117,13 +117,13 @@ declare module 'cordis' { 'fs/edit-expectation'(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. A listener MUST be a synchronous, - * side-effect-only recorder (`@deepseek-ai/dsh-file-context`'s is a - * `WeakMap.set`); the tool wraps the emit in a try/catch so a synchronous - * listener bug is logged and swallowed, never failing the already-completed - * mutation. cordis `emit` does not await listener promises, so this is not an - * async-error containment seam — async audit/telemetry does not belong here. - * No listener ⇒ nothing recorded. `actor` is the opaque tool-execution context. + * read/write/edit. Fire-and-forget (plain `emit`). A listener MUST be a + * synchronous, side-effect-only recorder (`@deepseek-ai/dsh-file-context`'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. * @mode emit */ 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 86298031de..bcc5c5cdf1 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -31,8 +31,8 @@ The tools do **not** inject a policy service or inspect any cache. Each tool res 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-file-context` 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` never fails the tool +## `fs/observed` is fire-and-forget -`fs/observed` fires AFTER the read/write/edit already succeeded, so the tool wraps the emit in a try/catch (`src/observe.ts`) that logs and swallows a synchronous listener bug — otherwise a recording failure would turn a completed mutation into an `isError`. The event contract requires synchronous, side-effect-only listeners; this is the synchronous backstop, not async-error handling. +`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-file-context`'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 line-windowing mechanics live in `src/window.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them. +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. diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 53029808ec..f1eab5e319 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -2,13 +2,12 @@ * 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-expectation` waterfall to obtain the optional - * version guard, calls `ctx.fs.editText` directly, and emits a contained - * `fs/observed`. The default thunk returns `undefined` (unconditional edit of - * the current content — the bare provider); a policy plugin - * (`@deepseek-ai/dsh-file-context`) 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`. + * 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-file-context`) + * 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 */ @@ -19,7 +18,6 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { FsEditOutcome } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' -import { emitObserved } from './observe.ts' /** Validated `edit` arguments after defaulting. */ interface EditInput { @@ -79,7 +77,8 @@ export function applyEditTool(ctx: Context): void { expectation, exec.signal, ) - emitObserved(ctx, target, outcome.version, exec) + // Record the observed version (a no-op when no policy plugin listens). + ctx.emit('fs/observed', target, outcome.version, exec) return [{ type: 'text', text: formatEditOutput(target.displayPath, outcome) }] }, })) diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index 8c2123c3d4..285299e352 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -26,13 +26,11 @@ import { applyReadTool } from './read.ts' import { applyWriteTool } from './write.ts' import { applyEditTool } from './edit.ts' -export { READ_LIMIT, STREAM_MIN_SIZE, applyReadTool, formatReadOutput, parseReadArgs } from './read.ts' +export { READ_LIMIT, STREAM_MIN_SIZE, applyReadTool, parseReadArgs } from './read.ts' export { applyWriteTool, formatWriteOutput, parseWriteArgs } from './write.ts' export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.ts' -export { emitObserved } from './observe.ts' -export type { FileTextLine, ReadWindow, WindowResult } from './window.ts' -export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow } from './window.ts' -export type { FileReadOutcome } from './types.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' /** Cordis plugin name used by loader diagnostics. */ export const name = 'tool-fs' diff --git a/packages/fs/tool-fs/src/observe.ts b/packages/fs/tool-fs/src/observe.ts deleted file mode 100644 index 407dc66fbb..0000000000 --- a/packages/fs/tool-fs/src/observe.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * The contained `fs/observed` emit shared by the `read`/`write`/`edit` tools. - * - * `fs/observed` fires AFTER a mutation/read already succeeded, so a throwing - * listener must never turn the completed operation into an `isError` result - * (the tool registry catches a tool throw into an error result). The event - * contract requires a synchronous, side-effect-only listener (the policy - * plugin's is a `WeakMap.set`); this try/catch is the synchronous backstop — - * it logs and swallows a listener bug, mirroring the fire-and-forget pattern in - * the agent loop. It is NOT async-error containment: cordis `emit` does not - * await listener promises, so async observation does not belong on this event. - * - * @module @deepseek-ai/dsh-tool-fs/observe - */ - -import type { Context } from 'cordis' -import type { FsTarget, FsVersion } from '@deepseek-ai/dsh-fs' -import type {} from '@deepseek-ai/dsh-fs' - -/** - * Emit `fs/observed` for a just-completed read/write/edit, containing any - * synchronous listener throw so the already-successful operation still reports - * success. - */ -export function emitObserved(ctx: Context, target: FsTarget, version: FsVersion, actor: object | undefined): void { - try { - ctx.emit('fs/observed', target, version, actor) - } catch (error: unknown) { - // Contained: the read/write/edit already succeeded. An `fs/observed` listener - // MUST be synchronous and side-effect-only; a synchronous bug is logged and - // swallowed so a recording failure never fails the completed operation. - ctx.logger.warn(`fs/observed listener threw for "${target.displayPath}": ${String(error)}`) - } -} diff --git a/packages/fs/tool-fs/src/window.ts b/packages/fs/tool-fs/src/read-render.ts similarity index 65% rename from packages/fs/tool-fs/src/window.ts rename to packages/fs/tool-fs/src/read-render.ts index fb33907710..97a1384792 100644 --- a/packages/fs/tool-fs/src/window.ts +++ b/packages/fs/tool-fs/src/read-render.ts @@ -1,19 +1,23 @@ /** - * Cordis-free line-windowing for `@deepseek-ai/dsh-tool-fs`. Turning a file's + * 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) is the model-facing READ-RENDERING detail the tool owns - * now that the tool reads through `ctx.fs` directly — it is not a storage - * primitive and not freshness policy. + * 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); this module only scans that text for - * newlines and builds the requested window. A capped line buffer means a + * (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 + * `/` envelope the model sees. * - * @module @deepseek-ai/dsh-tool-fs/window + * @module @deepseek-ai/dsh-tool-fs/read-render */ import { FsError } from '@deepseek-ai/dsh-fs' +import type { FsVersion } from '@deepseek-ai/dsh-fs' /** Maximum characters returned for a single line. */ export const READ_MAX_LINE_LENGTH = 2000 @@ -40,7 +44,7 @@ export interface FileTextLine { text: string } -/** The windowed result this module builds from a file's decoded text. */ +/** The windowed result {@link buildWindow} produces from a file's decoded text. */ export interface WindowResult { /** Returned lines, already numbered. */ lines: FileTextLine[] @@ -50,6 +54,22 @@ export interface WindowResult { truncatedByBytes: boolean } +/** Outcome of a bounded text read — what {@link formatReadOutput} renders. */ +export interface FileReadOutcome { + /** 1-based first line requested. */ + offset: number + /** Maximum number of lines requested. */ + limit: 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 + /** Opaque version of the file at read time. */ + version: FsVersion +} + interface WindowAccumulator { lines: FileTextLine[] totalLines: number @@ -137,3 +157,24 @@ export async function buildWindow( 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 `${displayPath} +file + +${body} +` +} diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 54fd553011..31d31424cb 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -2,11 +2,12 @@ * 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/window}), and emits a contained - * `fs/observed` so a policy plugin (`@deepseek-ai/dsh-file-context`) can record - * the read. With no policy plugin the emit is simply unheard. This module owns - * the model-facing schema, argument validation, read windowing, and result - * formatting; the freshness/observation policy is not its concern. + * ({@link module:@deepseek-ai/dsh-tool-fs/read-render}), and emits `fs/observed` + * so a policy plugin (`@deepseek-ai/dsh-file-context`) 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 */ @@ -17,9 +18,8 @@ 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 } from './window.ts' -import { emitObserved } from './observe.ts' -import type { FileReadOutcome } from './types.ts' +import { buildWindow, formatReadOutput } from './read-render.ts' +import type { FileReadOutcome } from './read-render.ts' /** Default and maximum number of lines returned by one `read` call. */ export const READ_LIMIT = 2000 @@ -50,27 +50,6 @@ export function parseReadArgs(args: { file_path: string; offset?: number; limit? return { filePath: args.file_path, offset, limit } } -/** 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 `${displayPath} -file - -${body} -` -} - /** Register the `read` tool and its system-prompt guidance. */ export function applyReadTool(ctx: Context): void { ctx.systemPrompt.section({ @@ -114,7 +93,10 @@ export function applyReadTool(ctx: Context): void { version: info.version, ...window.truncatedByBytes ? { truncatedByBytes: true } : {}, } - emitObserved(ctx, target, info.version, exec) + // 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) }] }, })) diff --git a/packages/fs/tool-fs/src/types.ts b/packages/fs/tool-fs/src/types.ts deleted file mode 100644 index 48a0592abb..0000000000 --- a/packages/fs/tool-fs/src/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Vocabulary for the model-facing filesystem tools (`@deepseek-ai/dsh-tool-fs`): - * the structured read outcome the `read` tool renders. The read window - * (`offset`/`limit`) and per-line shape live in - * {@link module:@deepseek-ai/dsh-tool-fs/window}; this file owns the assembled - * outcome the tool formats. - * - * The provider vocabulary (`FsTarget`, `FsVersion`, write/edit shapes) is - * re-used from `@deepseek-ai/dsh-fs` — this package owns only the model-facing - * read-rendering shape on top of it. - * - * @module @deepseek-ai/dsh-tool-fs/types - */ - -import type { FsVersion } from '@deepseek-ai/dsh-fs' -import type { FileTextLine } from './window.ts' - -/** Outcome of a bounded text read — what the model-facing `read` tool renders. */ -export interface FileReadOutcome { - /** 1-based first line requested. */ - offset: number - /** Maximum number of lines requested. */ - limit: 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 - /** Opaque version of the file at read time. */ - version: FsVersion -} diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 407744ec03..564d99ddd6 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -2,8 +2,8 @@ * The model-facing `write` tool: create or fully replace a UTF-8 text file. The * tool is the executor: it dispatches the `fs/write-expectation` waterfall to * obtain the optional version guard, calls `ctx.fs.writeText` directly, and - * emits a contained `fs/observed`. The default thunk returns `undefined` - * (unconditional create-or-overwrite — the bare provider); a policy plugin + * emits `fs/observed`. The default thunk returns `undefined` (unconditional + * create-or-overwrite — the bare provider); a policy plugin * (`@deepseek-ai/dsh-file-context`) occupies the single decision slot and * returns `createIfAbsent`/`replaceIfVersion` instead. The tool stats ZERO * times either way. @@ -17,7 +17,6 @@ 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 { emitObserved } from './observe.ts' /** Validate value constraints the schema DSL can't express. */ export function parseWriteArgs(args: { file_path: string; content: string }): { filePath: string; content: string } { @@ -57,7 +56,8 @@ export function applyWriteTool(ctx: Context): void { // replaceIfVersion; the bare default is undefined (unconditional). No stat. const expectation = await ctx.waterfall('fs/write-expectation', target, exec, () => undefined) const outcome = await ctx.fs.writeText(target, input.content, expectation, exec.signal) - emitObserved(ctx, target, outcome.version, exec) + // Record the observed version (a no-op when no policy plugin listens). + ctx.emit('fs/observed', target, outcome.version, exec) return [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }] }, })) diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index cc99d52b80..087227f790 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -223,19 +223,6 @@ describe('default deployment (with dsh-file-context)', () => { statSpy.mockRestore() }) }) - - describe('contained fs/observed recording', () => { - it('a synchronously throwing fs/observed listener does not fail the completed write', async () => { - ctx.on('fs/observed', () => { throw new Error('listener boom') }) - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const result = await call('write', { file_path: 'a.txt', content: 'hi' }) - // The write succeeded on disk; the listener throw was logged and swallowed. - expect(result.isError).toBe(false) - expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hi') - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - }) }) // -------------------------------------------------------------------------- diff --git a/packages/fs/tool-fs/tests/window.spec.ts b/packages/fs/tool-fs/tests/read-render.spec.ts similarity index 100% rename from packages/fs/tool-fs/tests/window.spec.ts rename to packages/fs/tool-fs/tests/read-render.spec.ts diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5b9b4b4673..68352a111b 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -51,7 +51,7 @@ { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditOutcome", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsErrorCode", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileContextExec", "source": "packages/fs/file-context/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/tool-fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/tool-fs/src/read-render.ts" }, { "doc": "docs/core-data-structures/compaction.md", "symbol": "CompactionResult", "source": "packages/compact/compact/src/types.ts" }, From f05717e1aca336011b2b3baee7cf760dfa87863f Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:47:29 -0700 Subject: [PATCH 24/46] docs: address terminology review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarity-first revisions from review: - keep English for ambiguous terms (Cordis, fork, harness, schema, spawn, manifest, transcript, compaction, dispose) - first-use glosses for CLI, Function Calling, HMR, fiber - inference/reasoning carry the English in parens to disambiguate - memory distinguishes 记忆 (agent memory) vs 内存 (resource usage) - plugin -> 插件; add a separate mod -> 模组 row - 中英搭配 note on translated terms (wire format, adapter contract) --- docs/i18n/terminology.md | 53 +++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index a0b145117a..6031f3f70a 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -1,14 +1,16 @@ # Terminology +本表约定本仓库的中英术语统一译法。 + | English | 中文 | 备注 | |---|---|---| | ACP | ACP | | | AI | AI | 首次出现可写:人工智能(AI) | | API | API | | -| CLI | CLI | | -| Cordis | Cordis | | -| Function Calling | Function Calling | | -| HMR | HMR | | +| CLI | CLI | 首次出现可写:命令行界面(CLI) | +| Cordis | Cordis | 保留英文 | +| Function Calling | Function Calling | 首次出现可写:Function Calling(函数调用) | +| HMR | HMR | 首次出现可写:热模块替换(HMR) | | JSON Schema | JSON Schema | | | JSONL | JSONL | | | lint | lint | | @@ -18,24 +20,24 @@ | RAG | RAG | 首次出现可写:检索增强生成(RAG) | | SDK | SDK | | | SSE | SSE | | -| agent | agent | 首次出现可写:agent(智能体);不要译作:代理 | +| agent | agent | 首次出现可写:agent(智能体) | | agent loop | agent loop | | -| fiber | fiber | | -| fixture | fixture | 首次出现可写:fixture(测试样例) | -| fork | fork | 首次出现可写:fork(派生) | -| harness | harness | 不要译作:测试框架、脚手架 | -| manifest | 清单 | 指文件名或字段名时保留 `manifest` | +| fiber | fiber | 首次出现可写:fiber(插件运行时) | +| fixture | fixture | 首次出现可写:fixture(测试夹具);指测试前置数据或环境 | +| fork | fork | 保留英文 | +| harness | harness | 保留英文 | +| manifest | manifest | 首次出现可写:manifest(描述模块或工具元数据的文件) | | schema DSL | schema DSL | | -| schema | schema | API/类型名保留 `schema`;一般 prose 可译为“模式” | -| seam | seam | 首次出现可写:seam(扩展点);不要译作:接缝 | +| schema | schema | 保留英文 | +| seam | seam | 首次出现可写:seam(扩展点) | | skill | skill | 首次出现可写:skill(技能) | -| spawn | spawn | 首次出现可写:spawn(新建) | +| spawn | spawn | 保留英文 | | steering | steering | 首次出现可写:steering(中途引导) | -| subagent | subagent | 首次出现可写:subagent(子 agent);不要译作:子代理 | -| transcript | 交互记录 | | -| waterfall | waterfall | 首次出现可写:waterfall(瀑布式事件);不要译作:瀑布流 | -| wire format | 协议格式 | | -| adapter contract | 适配器契约 | | +| subagent | subagent | 首次出现可写:subagent(子 agent) | +| transcript | transcript | 首次出现可写:transcript(文本记录);指会话渲染给用户或编辑器的完整文本,区别于事件日志(event log) | +| waterfall | waterfall | 首次出现可写:waterfall(瀑布式事件) | +| wire format | 协议格式 | 首次出现可写:协议格式(wire format) | +| adapter contract | 适配器契约 | 首次出现可写:适配器契约(adapter contract) | | adapter | 适配器 | | | append-only | 仅追加 | | | artifact | 产物 | | @@ -46,15 +48,15 @@ | cancel | 取消 | | | checkpoint | 检查点 | | | chunk | 分片 | | -| compaction | 压缩 | | +| compaction | compaction | 首次出现可写:compaction(上下文压缩);正文优先保留英文 | | consumer | 消费方 | | | content block | 内容块 | | | config | 配置 | | | context | 上下文 | | -| context compaction | 上下文压缩 | | +| context compaction | 上下文压缩 | 首次出现可写:上下文压缩(context compaction) | | coverage | 覆盖率 | | | crash recovery | 崩溃恢复 | | -| dispose | 释放 | | +| dispose | dispose | 首次出现可写:dispose(释放资源);正文优先保留英文 | | durability | 持久性 | | | event log | 事件日志 | | | event | 事件 | | @@ -65,24 +67,25 @@ | foreground run | 前台运行 | | | hook | 钩子 | | | implementation | 实现 | | -| inference | 推理 | | +| inference | 推理(inference) | 每次提及时保留英文括注,避免与 reasoning 混淆 | | injection | 注入 | | | interface | 接口 | | | integration | 集成 | | -| memory | 记忆 | 指 agent memory;不要译作:内存 | +| memory | memory / 记忆 / 内存 | 按上下文区分:agent memory 译为“记忆”;resource/memory usage 译为“内存” | | message | 消息 | | +| mod | 模组 | 区别于 module(模块);plugin 译作「插件」 | | model provider | 模型提供方 | | | module | 模块 | | | permission | 权限 | | | persistence | 持久化 | | | pipeline | 流水线 | | -| plugin | 模组 | 不要译作:插件 | +| plugin | 插件 | mod 对应“模组” | | prompt | 提示词 | | | provider | 提供方 | | | provider-neutral | 提供方无关 | | | quality gate | 质量门禁 | | | registry | 注册表 | | -| reasoning | 推理 | `reasoning_content` 译为“思考内容” | +| reasoning | 推理(reasoning) | 需要和 inference 区分时保留英文括注;`reasoning_content` 译为“思考内容” | | replay | 回放 | | | resume | 恢复 | | | runtime | 运行时 | | From 1f35a4446d28dbd67030ade4bd26d65443956a47 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 29 Jun 2026 15:59:52 +0800 Subject: [PATCH 25/46] fix(compact): address PR 110 review findings Honor cancellation and disposal around async pre-step setup before the loop can open a step or call the model. Route compaction summarization through agent/request so router agents can select the model, and remove the stale model argument from agent/pre-step. Document serial events and the approximate convergence bound, regenerate the Cordis catalog, and add regression coverage for router compaction, HMR cleanup, and assembly/pre-step interruption. --- AGENTS.md | 2 +- docs/cordis-catalog/events-and-services.md | 24 +- docs/core-data-structures/compaction.md | 4 +- docs/core-data-structures/core.md | 2 +- .../2026-06-18-compaction-capability-seam.md | 8 +- .../2026-06-20-generated-cordis-catalog.md | 2 +- examples/coding-agent/tests/compaction.e2e.ts | 7 +- packages/compact/compact-basic/README.md | 4 +- packages/compact/compact-basic/src/index.ts | 60 ++-- packages/compact/compact-basic/src/types.ts | 23 +- .../compact-basic/tests/compact-basic.spec.ts | 192 ++++++++----- packages/compact/compact/README.md | 6 +- packages/compact/compact/src/index.ts | 28 +- .../compact/compact/tests/compact.spec.ts | 27 +- packages/core/agent-loop/src/loop.ts | 58 ++-- packages/core/agent-loop/tests/loop.spec.ts | 18 +- .../agent-loop/tests/review-fixes.spec.ts | 270 ++++++++++++++++++ packages/core/agent/src/types.ts | 9 +- 18 files changed, 551 insertions(+), 193 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e08dd02e5f..f02940b5ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -236,7 +236,7 @@ In the **core** packages (`packages/llm/llm`, `packages/core/tools`, `packages/c Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-cordis-catalog` + `verify-md-wrap` + `verify-md-links` + `verify-doc-refs` + `verify-package-paths` + `verify-rfc-classification` + `verify-type-equiv`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, regenerates the cordis events/services catalog from source and fails if the committed copy is stale, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, checks that every `docs/*.md` path cited in a source comment resolves, checks that every `packages/` reference naming a real package resolves, checks that every RFC is filed under a valid class folder and listed in its index, and checks that every ` ```ts type-equiv ` doc block still matches its source type — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. -**Tag every new event with `@mode`.** The cordis events/services catalog ([docs/cordis-catalog/events-and-services.md](docs/cordis-catalog/events-and-services.md)) is GENERATED from source by `scripts/gen-cordis-catalog.ts` — never hand-edit it; run `pnpm run gen-cordis-catalog` and commit the result. When you add an event to an `interface Events` block, its JSDoc MUST carry a `@mode emit|waterfall|parallel` tag (the generator hard-errors without it): use `waterfall` when the signature ends with a `next: () => …` parameter (the listener transforms or vetoes via `next()`), `parallel` when the loop awaits a fan-out with no veto (e.g. an awaited `Promise | void` checkpoint like `session/flush`), and `emit` for plain fire-and-forget notifications. The generator also cross-checks the tag against the signature where the shape is conclusive (a trailing `next` ⇒ waterfall) and hard-errors on a contradiction. Write the rest of the event's JSDoc to stand alone — it is the catalog entry's prose. +**Tag every new event with `@mode`.** The cordis events/services catalog ([docs/cordis-catalog/events-and-services.md](docs/cordis-catalog/events-and-services.md)) is GENERATED from source by `scripts/gen-cordis-catalog.ts` — never hand-edit it; run `pnpm run gen-cordis-catalog` and commit the result. When you add an event to an `interface Events` block, its JSDoc MUST carry a `@mode emit|waterfall|parallel|serial` tag (the generator hard-errors without it): use `waterfall` when the signature ends with a `next: () => …` parameter (the listener transforms or vetoes via `next()`), `parallel` when the loop awaits a fan-out with no veto (e.g. an awaited `Promise | void` checkpoint like `session/flush`), `serial` when the loop awaits listeners in registration order with no veto (e.g. an ordered surface-mutation checkpoint like `agent/pre-step`), and `emit` for plain fire-and-forget notifications. The generator also cross-checks the tag against the signature where the shape is conclusive (a trailing `next` ⇒ waterfall) and hard-errors on a contradiction. Write the rest of the event's JSDoc to stand alone — it is the catalog entry's prose. **The core-data-structures catalog is a maintained surface, not a write-once artifact.** [docs/core-data-structures/](docs/core-data-structures/core.md) catalogs the spine vocabulary (core.md) and the per-seam types (sub-pages). When a change adds, removes, or reshapes a type the catalog documents — a new `…Map` variant, a new content-block or session-event type, a field on `GenerateOptions`/`Agent`/`ToolDefinition`/a bash type, or a whole new core/seam type — update the catalog in the SAME change: edit the prose, and for a pasted ` ```ts type-equiv ` block, re-copy it verbatim and keep `scripts/type-equiv.manifest.json` 1:1 with the blocks. The `verify-type-equiv` gate catches a *drifted paste* of an already-documented type, but it canNOT tell you a brand-new core type was never documented — that judgment is on the author and the reviewer. The definition of "core" (the spine-vs-seam line) is in [core.md § What counts as "core"](docs/core-data-structures/core.md#what-counts-as-core); a genuinely spine-level new type belongs in core.md, a new capability's vocabulary on a sub-page. See [development.md](docs/development.md#documenting-types-verbatim-ts-type-equiv) for the `ts type-equiv` mechanics. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 5ace95c782..3823cf8144 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -49,21 +49,21 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:249`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts) #### `agent/pre-step` — serial Awaited pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step's `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`. `step` is the number of the step about to start. The loop awaits `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then opens the step and derives the request history ONCE from whatever the surface now holds. This is where compaction belongs: it mutates the session surface in place (shadowing an older range with a summary node) with its log-only `compact/*` records cleanly outside any step, and the single subsequent derive reflects the mutation — so there is no double-derive and no listener can see (or be expected to act on) an assembled `messages` array that does not exist yet. -Serial (awaited, in registration order, no veto), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform or veto, but the loop must wait for the mutation to complete before opening the step and deriving, and serial isolates listeners from each other (one finishes its surface append before the next runs). `system`/`model` are the assembled values a listener needs to measure pressure (system counts toward the budget) and to summarize (the model). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call). +Serial (awaited, in registration order, no veto), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform or veto, but the loop must wait for the mutation to complete before opening the step and deriving, and serial isolates listeners from each other (one finishes its surface append before the next runs). `fullSystemPrompt` is the assembled prompt a 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). ```ts cordis-catalog -'agent/pre-step'(agent: Agent, turn: number, step: number, system: string, model: string, signal: AbortSignal): Promise | void +'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise | void ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -87,7 +87,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -111,7 +111,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:242`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -135,7 +135,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:223`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -159,7 +159,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:238`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:237`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -171,7 +171,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:231`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -387,11 +387,11 @@ Implementations MUST honor: - **Blocking**: no compaction begins while another is in progress for the same session. The recommended mechanism is the log-recorded lock — append `compact/start` before the slow work and `compact/end` after (even on failure) — so the lock is visible to replay and crash recovery. ```ts cordis-catalog -abstract compactIfNeeded( session: Session, system: string, model: string, signal: AbortSignal, ): Promise -abstract compactRegion( session: Session, start: number, end: number, model: string, signal?: AbortSignal, ): Promise +abstract compactIfNeeded( agent: CompactAgentContext, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal, ): Promise +abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, turn: number, step: number, signal?: AbortSignal, ): Promise ``` -Source: [`packages/compact/compact/src/index.ts:57`](../../packages/compact/compact/src/index.ts) +Source: [`packages/compact/compact/src/index.ts:63`](../../packages/compact/compact/src/index.ts) ### `ctx.llm` — `LlmService` diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index d8a05dc8cf..a1ca8978a6 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -50,6 +50,6 @@ interface CompactionResult { ## The service -`CompactService` (`ctx.compact`, abstract — defined in [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts)) declares two abstract methods: `compactIfNeeded(session, system, model, signal)` checks token pressure and compacts an older range if the history is too large (returning `null` when nothing needs it), and `compactRegion(session, start, end, model, signal?)` forcibly summarizes surface nodes `[start, end]` into a single replacement node. `compactIfNeeded`'s parameters are all required — the loop's `agent/pre-step` checkpoint always supplies the assembled `system`, the `model`, and the turn `signal`. A backend summarizing via `ctx.llm.stream()` must forward `signal` into the call's `GenerateOptions.signal`, so an abort or dispose tears down the in-flight summarization. The entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. +`CompactService` (`ctx.compact`, abstract — defined in [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts)) declares two abstract methods: `compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)` checks token pressure and compacts an older range if the history is too large (returning `null` when nothing needs it), and `compactRegion(session, start, end, agent, turn, step, signal?)` forcibly summarizes surface nodes `[start, end]` into a single replacement node. `compactIfNeeded`'s parameters are all required — the loop's `agent/pre-step` checkpoint supplies the agent, lifecycle context, assembled `fullSystemPrompt`, and turn `signal`. A backend summarizing via `ctx.llm.stream()` must forward `signal` into the call's `GenerateOptions.signal`, so an abort or dispose tears down the in-flight summarization. The entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. -Auto-compaction runs on the serial `agent/pre-step` loop seam (fired once per step, after `turn/start` and BEFORE the step opens and its request history is derived), not the `agent/request` waterfall: compaction mutates the session surface in place — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives the request from the already-compacted surface. Retention is turn-agnostic — the only structural guard is tool-pairing balance (a compacted region's edges are balanced cuts on the surface, so it never splits a step's tool-calls from their results), so a single runaway turn that alone exceeds the window compacts its own early closed steps rather than being retained verbatim. The backend that ships this (`dsh-compact-basic`) documents the retention walk, the single-pass convergence invariant, and the crash/recoverable failure taxonomy. +Auto-compaction runs on the serial `agent/pre-step` loop seam (fired once per step, after `turn/start` and BEFORE the step opens and its request history is derived), not the `agent/request` waterfall: compaction mutates the session surface in place — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives the request from the already-compacted surface. Retention is turn-agnostic — the only structural guard is tool-pairing balance (a compacted region's edges are balanced cuts on the surface, so it never splits a step's tool-calls from their results), so a single runaway turn that alone exceeds the window compacts its own early closed steps rather than being retained verbatim. The backend that ships this (`dsh-compact-basic`) documents the retention walk, the approximate convergence invariant, and the crash/recoverable failure taxonomy. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index ee4f06e14a..0b9c8452e3 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -306,7 +306,7 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`, `systemPrompt?`) is merge-extensible — plugins add creation options by declaration merging. The `agent/*` event taxonomy (lifecycle, turn/step boundaries, the `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy). +`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`, `systemPrompt?`) is merge-extensible — plugins add creation options by declaration merging. The `agent/*` event taxonomy (lifecycle, turn/step boundaries, the serial `agent/pre-step` surface-mutation seam, and the `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy). ## `ToolDefinition` diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index f1ca9dad2f..ba6f6c6ae6 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -30,7 +30,7 @@ This is not a coupling smell — it is the contract's domain. The "only cordis" An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface, with only `estimateContentTokens()` and `summarize()` abstract. That recouples the contract to one strategy: a backend that wants a different retention policy or a different event-sequencing would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend, where it belongs, and keeps the interface a pure statement of *what*. The backend remains internally factored — `estimateContentTokens()` and `summarize()` are `protected` hooks a sub-backend can override without reimplementing the walk — but that factoring is the backend's private concern, not the contract's. -`compactIfNeeded(session, system, model, signal)` takes **required** parameters (not the original all-optional shape). The auto-compaction seam (below) always supplies all four — the assembled system prompt (counted toward the estimate), the model (summarization fallback), and the turn's abort signal — so optionality would only invite a hidden default at the seam. `compactRegion(session, start, end, model, signal?)` keeps an optional signal (a manual caller may omit it). +`compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)` takes **required** parameters (not the original all-optional shape). The auto-compaction seam (below) always supplies the agent, lifecycle context, assembled system prompt (counted toward the estimate), and the turn's abort signal, so optionality would only invite a hidden default at the seam. The session being compacted comes from the agent context. `compactRegion(session, start, end, agent, turn, step, signal?)` keeps an optional signal (a manual caller may omit it). Passing lifecycle context rather than a concrete model keeps router agents honest: the backend's summarization request can run through `agent/request`, where model-routing plugins already choose the actual model. ### Auto-compaction runs on `agent/pre-step`, a dedicated surface-mutation seam @@ -40,7 +40,7 @@ The fix is a dedicated loop seam, **`agent/pre-step`** (`@mode serial`), fired b ``` assembly = ctx.systemPrompt.assemble() -await ctx.serial('agent/pre-step', agent, turn, step, system, model, signal) ⟵ compaction mutates the surface here +await ctx.serial('agent/pre-step', agent, turn, step, system, signal) ⟵ compaction mutates the surface here session('step/start') ⟵ the step opens AFTER the seam messages = session.deriveMessages() ⟵ single derive, reflects the compaction request = waterfall agent/request ⟵ pure request transform (hooks, model switch) @@ -64,9 +64,9 @@ A runaway turn thus compacts exactly like any other history: its early *closed* `compactIfNeeded` always anchors the compacted range at the surface **head** (`nodes[0]`). After a first compaction lands a summary node at the head, the *second* compaction's range starts at that summary node and re-summarizes it together with the steps accumulated since — so the surface holds **at most one** auto-generated checkpoint, always at the head, re-consolidated each cycle (the backend's checkpoint-merge prompt makes this a cheap incremental merge — see below). This is *why* `CompactionResult.shadowedRange` is a **surface-position span, not a numeric seq interval**: after a replace lands a fresh high-seq summary node at an older range's position, `start` can be numerically **greater** than `end`. The range is resolved positionally (index into the ordered node list and slice), and `shadowedSeqs` is the authoritative set in surface order. (Manual `compactRegion` may target any aligned mid-range and so *can* leave several checkpoints; the checkpoint framing does not claim everything after it is recent.) -### Single-pass convergence invariant +### Approximate convergence invariant -`resolveConfig` **rejects** (throws at construction) any config where `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. The invariant guarantees the post-compaction history — the bounded summary plus the retained recent tail — is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction, with no thrash throttle needed. The bound is **strict** (`>=` rejects, not `>`): the token-pressure gate declines only when the estimate is `< threshold`, so a post-compaction history sitting *exactly* at the threshold would re-trigger on the very next check — equality is a leak, not a safe boundary. `summarizationMaxTokens` stays an explicit *quality* knob (terse summaries); the invariant only forbids setting it so high it breaks convergence. The sole residual is the single-unit-overflow case above (a backward-rounded oversized step can push the retained tail over budget) — which is exactly the out-of-scope concern, not a thrash bug. Per the pre-release reject-don't-migrate stance, a config that cannot guarantee convergence is a bug at the call site, not something to silently clamp. +`resolveConfig` **rejects** (throws at construction) any config where `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. The invariant bounds the two variable parts of post-compaction history — the bounded summary plus the retained recent tail — but it is intentionally approximate: checkpoint framing, per-message role overhead, system-prompt size, and the char/4 estimator's error can still leave a narrow accepted config near the threshold. The bound is **strict** (`>=` rejects, not `>`): the token-pressure gate declines only when the estimate is `< threshold`, so a post-compaction history sitting *exactly* at the threshold would re-trigger on the very next check — equality is a leak, not a safe boundary. `summarizationMaxTokens` stays an explicit *quality* knob (terse summaries); the invariant only forbids setting it so high it breaks the structural budget. The sole residual is the single-unit-overflow case above (a backward-rounded oversized step can push the retained tail over budget) — which is exactly the out-of-scope concern, not a thrash bug. Per the pre-release reject-don't-migrate stance, a config that cannot satisfy the structural bound is a bug at the call site, not something to silently clamp. ### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary diff --git a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md index 2801ae209c..ae07f4b14c 100644 --- a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md +++ b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md @@ -20,7 +20,7 @@ Pure generation is correct here because the codebase is disciplined enough that Specific choices: -- **`@mode` tag, cross-checked.** Each harness event's JSDoc carries an explicit `@mode emit|waterfall|parallel` tag; the generator hard-errors on a missing tag. Where the signature shape is conclusive — a trailing `next: () => …` parameter is structurally a waterfall — it asserts the tag agrees and hard-errors on a contradiction. The emit-vs-parallel distinction is not structurally visible (`session/flush` returns `Promise | void` with no `next`), so it is trusted from the tag. The authoring rule lives in [AGENTS.md](../../../../AGENTS.md). +- **`@mode` tag, cross-checked.** Each harness event's JSDoc carries an explicit `@mode emit|waterfall|parallel|serial` tag; the generator hard-errors on a missing tag. Where the signature shape is conclusive — a trailing `next: () => …` parameter is structurally a waterfall — it asserts the tag agrees and hard-errors on a contradiction. The emit/parallel/serial distinction is not structurally visible (`session/flush` returns `Promise | void` with no `next`, as does the ordered `agent/pre-step` checkpoint), so it is trusted from the tag. The authoring rule lives in [AGENTS.md](../../../../AGENTS.md). - **Tiered scope.** The harness tier (the 8 `@deepseek-ai/dsh-*` services + their events) is rendered in full from source. The inherited tier (cordis-core `ctx.on/emit/effect/provide/…` + the `internal/*` events + loader/hmr/timer) is pinned vendor source a plugin also sees; it is rendered tersely (name + one-line + source pointer) from a curated table in the generator, NOT walked from the vendor AST — the cordis-core `Context` mixes true ctx members with non-service fields (`root`, `baseUrl`, `logger`), and the vendor surface changes only on a deliberate vendor sync. - **Cross-links to the data-structure catalog.** A type name in a signature (`GenerateOptions`, `StreamChunk`, `ToolDefinition`, …) links to the core-data-structures page that documents it. The map is a small hand-curated const in the generator — NOT `type-equiv.manifest.json`, which documents the `…Map` symbols while signatures reference the derived union names, and lists a few symbols on two pages. - **A dedicated fence.** Signature blocks use a ` ```ts cordis-catalog ` info string that `doc-typecheck` recognizes and skips (a bare signature fragment is not standalone-compilable), excluded from the opt-out ratio — the same treatment `type-equiv` blocks get. diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index 2c9814c79d..17186055b1 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -43,14 +43,17 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa // Tiny window so a couple of steps crosses the threshold. The convergence // invariant requires summarizationMaxTokens + retainTokens to be strictly // BELOW the threshold = floor(contextWindow * thresholdRatio) = - // floor(2400 * 0.5) = 1200; 300 + 500 = 800 < 1200. + // floor(2400 * 0.5) = 1200; 600 + 500 = 1100 < 1200. The summary cap + // stays high enough for the live model to emit the required checkpoint + // sections; a truncated checkpoint fails closed and leaves no summary. ctx = await codingHarness(workdir, { compact: { contextWindow: 2400, thresholdRatio: 0.5, retainTokens: 500, - summarizationMaxTokens: 300, + summarizationMaxTokens: 600, }, + persistenceRoot: './.sessions', }) const agent = ctx.agentLoop.create(AgentId('e2e-compaction'), { model: 'deepseek-v4-flash', diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index cc171f62b4..2b13666033 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -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 `ctx.llm.stream()` summarization. +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. 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. @@ -11,7 +11,7 @@ The abstract contract states only WHAT compaction does; this backend owns every - **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). - **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. - **Single-pass convergence** — `resolveConfig()` rejects (throws) any config where `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. The invariant guarantees the post-compaction history (the bounded summary plus the retained recent tail) is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction. The bound is strict (`>=` rejects) because the token-pressure gate declines only when the estimate is `< threshold` — a post-compaction history sitting exactly at the threshold would re-trigger. -- **Summarization** — `summarize()`: a `ctx.llm.stream()` call assembled via `BlockAssembler` (the single model-call surface) 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 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. 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. - **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 `` 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, no-veto) 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()`). diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 479553a17c..e474be7cd9 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -10,7 +10,7 @@ * compaction declines and retries once it closes). * - **Summarization** — `ctx.llm.stream()` assembled via `BlockAssembler` * (the single model-call surface; same path the loop uses) with a fixed - * condense-the-history system prompt. + * condense-the-history system prompt routed through `agent/request`. * - **Surface mutation** — a single `user/message` replace node carries the * summary; `compact/*` events are log-only lock + provenance records. * - **Auto-compaction** — an `agent/pre-step` listener delegates to @@ -153,12 +153,6 @@ function finishError(finish: FinishReason): Error | undefined { * context. */ export class BasicCompactService extends CompactService { - /** - * `summarize()` reads `ctx.llm.stream()`. Declaring `llm` here lets the cordis - * context proxy resolve it when this service loads as a sibling of LlmService: - * without the inject, `this.ctx.llm` cannot be resolved from this fiber and - * compaction throws at runtime (see postmortem 0001). - */ static inject = ['llm'] /** Resolved configuration (defaults applied). */ @@ -188,11 +182,11 @@ export class BasicCompactService extends CompactService { // log-only `compact/*` records and the replacement node cleanly outside a // step, so a crash mid-compaction leaves an inert orphan the turn-repair // closes — never a half-open step. - ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, system: string, model: string, signal: AbortSignal) => { + ctx.on('agent/pre-step', async (agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal) => { try { - const result = await this.compactIfNeeded(agent.session, system, model, signal) + const result = await this.compactIfNeeded(agent, turn, step, fullSystemPrompt, signal) if (result) { - const after = this.estimateTokens(agent.session.deriveMessages(), system) + const after = this.estimateTokens(agent.session.deriveMessages(), fullSystemPrompt) ctx.logger.info( `compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` + `(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` + @@ -274,8 +268,9 @@ export class BasicCompactService extends CompactService { } /** - * Summarize conversation text into content blocks via `ctx.llm.stream()` - * assembled through a `BlockAssembler` (the single model-call surface). + * Summarize conversation text into content blocks via `agent/request` plus + * `ctx.llm.stream()` assembled through a `BlockAssembler` (the single + * model-call surface). * Override in a subclass for a template or remote summarizer. * * Honors the adapter failure contract: an adapter may report a model failure @@ -286,12 +281,10 @@ export class BasicCompactService extends CompactService { * Forwards `signal` into `GenerateOptions.signal` so an abort/dispose tears * down the in-flight summarization rather than orphaning the model call. */ - async summarize(text: string, model: string, signal?: AbortSignal): Promise { - if (!model) throw new Error('no model available for summarization') - + async summarize(text: string, agent: Agent, turn: number, step: number, signal?: AbortSignal): Promise { const assembler = new BlockAssembler() const options: GenerateOptions = { - model, + model: this.config.summarizationModel || agent.options.model || '', messages: [{ role: 'user', content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }], @@ -302,7 +295,11 @@ export class BasicCompactService extends CompactService { // exactOptionalPropertyTypes: only set `signal` when present — assigning // `undefined` to an optional `signal?: AbortSignal` is a type error. if (signal) options.signal = signal - for await (const chunk of this.ctx.llm.stream(options)) { + const request = await this.ctx.waterfall('agent/request', agent, turn, step, options, () => Promise.resolve(options)) + if (!request.model) { + throw new Error('no model available for summarization: set BasicCompactConfig.summarizationModel, AgentOptions.model, or supply one via the agent/request waterfall') + } + for await (const chunk of this.ctx.llm.stream(request)) { assembler.push(chunk) } @@ -341,13 +338,15 @@ export class BasicCompactService extends CompactService { * closes). */ override async compactIfNeeded( - session: Session, - system: string, - model: string, + agent: Agent, + turn: number, + step: number, + fullSystemPrompt: string, signal: AbortSignal, ): Promise { + const session = agent.session const messages = session.deriveMessages() - const totalTokens = this.estimateTokens(messages, system) + const totalTokens = this.estimateTokens(messages, fullSystemPrompt) const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio) if (totalTokens < threshold) return null @@ -401,14 +400,16 @@ export class BasicCompactService extends CompactService { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const cutoffSeq = nodes[keepFromIdx - 1]!.seq - return this.compactRegion(session, firstSeq, cutoffSeq, model, signal) + return this.compactRegion(session, firstSeq, cutoffSeq, agent, turn, step, signal) } override async compactRegion( session: Session, start: number, end: number, - model: string, + agent: Agent, + turn: number, + step: number, signal?: AbortSignal, ): Promise { // Resolve the range by surface POSITION, not numeric seq interval. A prior @@ -458,8 +459,8 @@ export class BasicCompactService extends CompactService { // strictly inside the open turn (but outside any step). A manual call on a // fully-closed session has no turn to enclose the events, so reject rather // than emit an un-enclosed run. - const turn = this._openTurn(session) - if (turn === null) { + const openTurn = this._openTurn(session) + if (openTurn === null) { throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn') } // Slice the ordered surface nodes [startIdx, endIdx] inclusive — the @@ -467,13 +468,12 @@ export class BasicCompactService extends CompactService { const shadowedSeqs = nodes.slice(startIdx, endIdx + 1).map(n => n.seq) // --- Acquire lock --- - const startEvent = session.append('compact/start', { turn }) + const startEvent = session.append('compact/start', { turn: openTurn }) try { // --- Extract text and summarize --- const text = this._extractText(session, shadowedSeqs) - const summaryModel = this.config.summarizationModel || model - const summary = await this.summarize(text, summaryModel, signal) + const summary = await this.summarize(text, agent, turn, step, signal) // Estimate token count of the shadowed content for provenance. let shadowedTokenCount = 0 @@ -511,7 +511,7 @@ export class BasicCompactService extends CompactService { // compact/start and here leaves a detectable orphaned lock (a compact/start // with no matching compact/end) rather than a compact/end that falsely // claims compaction finished before the surface replacement landed. - const endEvent = session.append('compact/end', { turn }) + const endEvent = session.append('compact/end', { turn: openTurn }) return { startSeq: startEvent.seq, @@ -526,7 +526,7 @@ export class BasicCompactService extends CompactService { // Always release the lock — append compact/end with the error so a // wedged lock is impossible. const msg = error instanceof Error ? error.message : String(error) - session.append('compact/end', { turn, error: msg }) + session.append('compact/end', { turn: openTurn, error: msg }) throw error } } diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index b7261eb093..13365b7ed1 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -39,21 +39,20 @@ export const DEFAULTS: ResolvedConfig = { } /** - * Apply defaults to a partial config and enforce the single-pass convergence + * Apply defaults to a partial config and enforce the approximate convergence * invariant. * * `summarizationMaxTokens + retainTokens` must be strictly BELOW the compaction - * threshold (`contextWindow * thresholdRatio`). The invariant guarantees that - * after a compaction the derived history — the (bounded) summary plus the - * retained recent tail — is structurally below the threshold, so the very next - * pre-step check passes and a second compaction cannot fire on the same - * content. The bound is strict (`>=` rejects) because `compactIfNeeded` declines - * only when the estimate is `< threshold`: a post-compaction history sitting - * EXACTLY at the threshold would re-trigger on the next check. Without the - * invariant, a too-large summary or retain budget would leave the - * post-compaction history at/over threshold, triggering compaction again and - * again. Pre-release we reject rather than clamp: a config that cannot guarantee - * convergence is a bug at the call site, not something to silently paper over. + * threshold (`contextWindow * thresholdRatio`). The invariant bounds the two + * variable pieces of post-compaction history — the summary and the retained + * recent tail — but it is intentionally approximate: checkpoint framing, + * per-message role overhead, system-prompt size, and the char/4 estimator's + * error can still leave a narrow accepted config near the threshold. The bound + * is strict (`>=` rejects) because `compactIfNeeded` declines only when the + * estimate is `< threshold`: a post-compaction history sitting EXACTLY at the + * threshold would re-trigger on the next check. Pre-release we reject rather + * than clamp: a config that cannot satisfy even this structural bound is a bug + * at the call site, not something to silently paper over. * * @throws if `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. */ diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 971aadb9dd..fbbb8258c5 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -29,7 +29,8 @@ class TestCompactService extends BasicCompactService { return blocks.length * 10 } - override async summarize(text: string, model: string): Promise { + override async summarize(text: string, agent: Agent): Promise { + const model = this.config.summarizationModel || agent.options.model || '' this.summarizeCalls.push({ text, model }) if (this.summarizeError) throw this.summarizeError return this.mockSummary @@ -184,7 +185,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai const svc = createTestService({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 55 }) const session = toolTurnSession(3) - const result = await svc.compactIfNeeded(session, '', 'm', SIGNAL) + const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL) expect(result).not.toBeNull() expect(result!.shadowedSeqs.length).toBeGreaterThan(0) // No dangling tool-result: every compacted/retained step stayed whole. @@ -214,7 +215,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai // Turn stays open. const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5 }) - const result = await svc.compactIfNeeded(s, '', 'm', SIGNAL) + const result = await compactIfNeeded(svc, s, '', 'm', SIGNAL) expect(result).toBeNull() expect(s.events.some(e => e.type === 'compact/start')).toBe(false) }) @@ -227,7 +228,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai const resultSeq = nodes[2]!.seq // start = the tool/result: its issuing assistant precedes it IN THE SAME STEP, // so starting here would orphan that assistant's tool-call. end is fine (user). - await expect(svc.compactRegion(session, resultSeq, resultSeq, 'm')) + await expect(compactRegion(svc, session, resultSeq, resultSeq, 'm')) .rejects.toThrow(/start seq .* is not a balanced boundary/) expect(userSeq).toBeLessThan(resultSeq) // sanity: ordering as expected }) @@ -240,7 +241,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai const asstSeq = nodes[1]!.seq // end = the assistant/message: its tool/result follows IN THE SAME STEP, so // ending here would strand that result. start is fine (the pre-step user). - await expect(svc.compactRegion(session, userSeq, asstSeq, 'm')) + await expect(compactRegion(svc, session, userSeq, asstSeq, 'm')) .rejects.toThrow(/end seq .* is not a balanced boundary/) }) @@ -257,7 +258,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai const nodes = s.surface.nodes // [user, asst] const userSeq = nodes[0]!.seq const asstSeq = nodes[1]!.seq - await expect(svc.compactRegion(s, userSeq, asstSeq, 'm')) + await expect(compactRegion(svc, s, userSeq, asstSeq, 'm')) .rejects.toThrow(/end seq .* is not a balanced boundary/) }) @@ -267,7 +268,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai const nodes = session.surface.nodes // [user1, asst1, res1, user2, asst2, res2] const startSeq = nodes[0]!.seq // pre-step user1 (free boundary) const endSeq = nodes[2]!.seq // res1 = last node of turn 1's closed step - const result = await svc.compactRegion(session, startSeq, endSeq, 'm') + const result = await compactRegion(svc, session, startSeq, endSeq, 'm') expect(result.shadowedRange).toEqual({ start: startSeq, end: endSeq }) expectNoOrphanToolResults(session.deriveMessages()) }) @@ -277,7 +278,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai const session = toolTurnSession(1) const nodes = session.surface.nodes const userSeq = nodes[0]!.seq // pre-step user: free boundary both ways - const result = await svc.compactRegion(session, userSeq, userSeq, 'm') + const result = await compactRegion(svc, session, userSeq, userSeq, 'm') expect(result.shadowedRange).toEqual({ start: userSeq, end: userSeq }) }) @@ -292,7 +293,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const nodes = s.surface.nodes const ctxSeq = nodes[0]!.seq - const result = await svc.compactRegion(s, ctxSeq, ctxSeq, 'm') + const result = await compactRegion(svc, s, ctxSeq, ctxSeq, 'm') expect(result.shadowedRange).toEqual({ start: ctxSeq, end: ctxSeq }) }) }) @@ -352,7 +353,7 @@ describe('BasicCompactService.compactRegion', () => { const firstSeq = nodes[0]!.seq const secondSeq = nodes[1]!.seq - const result = await svc.compactRegion(session, firstSeq, secondSeq, 'test-model') + const result = await compactRegion(svc, session, firstSeq, secondSeq, 'test-model') expect(result.shadowedSeqs).toEqual([firstSeq, secondSeq]) expect(result.shadowedRange.start).toBe(firstSeq) @@ -405,7 +406,7 @@ describe('BasicCompactService.compactRegion', () => { it('throws when start or end are not surface nodes', async () => { const svc = createTestService() const session = multiTurnSession(1, 1) - await expect(svc.compactRegion(session, 999, 1000, 'm')) + await expect(compactRegion(svc, session, 999, 1000, 'm')) .rejects.toThrow(/start seq 999 not found in surface/) }) @@ -413,7 +414,7 @@ describe('BasicCompactService.compactRegion', () => { const svc = createTestService() const session = multiTurnSession(2, 1) const nodes = session.surface.nodes - await expect(svc.compactRegion(session, nodes[1]!.seq, nodes[0]!.seq, 'm')) + await expect(compactRegion(svc, session, nodes[1]!.seq, nodes[0]!.seq, 'm')) .rejects.toThrow(/is after end seq .* on the surface/) }) @@ -422,7 +423,7 @@ describe('BasicCompactService.compactRegion', () => { const session = multiTurnSession(2, 1) const nodes = session.surface.nodes session.append('compact/start', { turn: 2 }) - await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm')) + await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')) .rejects.toThrow(/compaction already in progress/) }) @@ -432,7 +433,7 @@ describe('BasicCompactService.compactRegion', () => { const session = multiTurnSession(2, 1) const nodes = session.surface.nodes - await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm')) + await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')) .rejects.toThrow('model unavailable') const endEvent = session.events.findLast(e => e.type === 'compact/end') @@ -455,7 +456,7 @@ describe('BasicCompactService.compactRegion', () => { const session = multiTurnSession(1, 2) const nodes = session.surface.nodes - await svc.compactRegion(session, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + await compactRegion(svc, session, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') expect(svc.summarizeCalls.length).toBe(1) const { text, model } = svc.summarizeCalls[0]! @@ -470,7 +471,7 @@ describe('BasicCompactService.compactRegion', () => { const session = multiTurnSession(3, 1) const nodes = session.surface.nodes - const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm') + const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm') // Provenance (compact/summary) carries the RAW, unframed summary. expect(result.summary).toEqual([{ type: 'text', text: 'STRUCTURED SUMMARY' }]) @@ -492,7 +493,7 @@ describe('BasicCompactService.compactRegion', () => { const firstSeq = nodes[0]!.seq const lastSeq = nodes[nodes.length - 1]!.seq - await svc.compactRegion(session, firstSeq, lastSeq, 'm') + await compactRegion(svc, session, firstSeq, lastSeq, 'm') expect(svc.summarizeCalls.length).toBe(1) const { text } = svc.summarizeCalls[0]! @@ -506,14 +507,14 @@ describe('BasicCompactService.compactIfNeeded', () => { it('returns null when tokens are under threshold', async () => { const svc = createTestService({ contextWindow: 128000, thresholdRatio: 0.8 }) const session = multiTurnSession(1, 1) - expect(await svc.compactIfNeeded(session, '', 'm', SIGNAL)).toBeNull() + expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull() }) it('compacts when tokens exceed threshold', async () => { const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) const session = multiTurnSession(3, 1) // 6 surface nodes, 10 tokens each = 60 - const result = await svc.compactIfNeeded(session, '', 'm', SIGNAL) + const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL) expect(result).not.toBeNull() expect(result!.shadowedSeqs.length).toBeGreaterThan(0) }) @@ -522,7 +523,7 @@ describe('BasicCompactService.compactIfNeeded', () => { const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.2, retainTokens: 15 }) const session = multiTurnSession(5, 1) // 10 surface nodes = ~100 tokens - const result = await svc.compactIfNeeded(session, '', 'm', SIGNAL) + const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL) expect(result).not.toBeNull() const nodes = session.surface.nodes expect(result!.shadowedSeqs.length).toBeGreaterThan(0) @@ -538,7 +539,7 @@ describe('BasicCompactService.compactIfNeeded', () => { // summarizationMaxTokens (1) + retainTokens (45) = 46 < threshold 47. const svc = createTestService({ contextWindow: 470, thresholdRatio: 0.1, retainTokens: 45 }) const session = multiTurnSession(2, 1) - expect(await svc.compactIfNeeded(session, '', 'm', SIGNAL)).toBeNull() + expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull() }) it('compacts a runaway turn: its early CLOSED steps summarize while recent steps stay verbatim', async () => { @@ -572,7 +573,7 @@ describe('BasicCompactService.compactIfNeeded', () => { const nodesBefore = s.surface.nodes.length expect(nodesBefore).toBe(11) - const result = await svc.compactIfNeeded(s, '', 'm', SIGNAL) + const result = await compactIfNeeded(svc, s, '', 'm', SIGNAL) expect(result).not.toBeNull() // Early steps of the SAME open turn were shadowed (impossible under layer 2). expect(result!.shadowedSeqs.length).toBeGreaterThan(0) @@ -587,7 +588,7 @@ describe('BasicCompactService.compactIfNeeded', () => { it('returns null for an empty surface', async () => { const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) const session = new Session(SessionId('empty')) - expect(await svc.compactIfNeeded(session, '', 'm', SIGNAL)).toBeNull() + expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull() }) it('compacts again after a prior summary node heads the surface (the summary stays eligible)', async () => { @@ -600,7 +601,7 @@ describe('BasicCompactService.compactIfNeeded', () => { const svc = createTestService({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 25 }) const s = multiTurnSession(4, 1) // turns 1-4 closed, turn 5 open (no surface yet) - const first = await svc.compactIfNeeded(s, '', 'm', SIGNAL) + const first = await compactIfNeeded(svc, s, '', 'm', SIGNAL) expect(first).not.toBeNull() // The summary node now heads the surface with a fresh high seq. const summaryHeadSeq = s.surface.nodes[0]!.seq @@ -615,7 +616,7 @@ describe('BasicCompactService.compactIfNeeded', () => { s.append('assistant/message', { turn: 5, step: 1, content: [{ type: 'text', text: 'reply 5' }] }, { surfaceOp: 'append' }) s.append('step/end', { turn: 5, step: 1 }) - const second = await svc.compactIfNeeded(s, '', 'm', SIGNAL) + const second = await compactIfNeeded(svc, s, '', 'm', SIGNAL) expect(second).not.toBeNull() expect(second!.shadowedSeqs.length).toBeGreaterThan(0) // The fresh open-turn nodes were NOT compacted. @@ -630,7 +631,7 @@ describe('BasicCompactService replay equivalence', () => { const session = multiTurnSession(3, 1) const nodes = session.surface.nodes - await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm') + await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm') const derived = session.deriveMessages() const replayed = new Session(SessionId('replay'), [...session.events]) @@ -646,7 +647,7 @@ describe('BasicCompactService blocking (compaction in progress)', () => { const nodes = session.surface.nodes // Whole step (user → assistant) is a step-aligned region, so the call reaches // the in-progress check rather than being rejected for splitting a step. - await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm')) + await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')) .rejects.toThrow(/compaction already in progress/) }) @@ -656,7 +657,7 @@ describe('BasicCompactService blocking (compaction in progress)', () => { const nodes = session.surface.nodes session.append('compact/start', { turn: 1 }) session.append('compact/end', { turn: 1 }) - const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm') + const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm') expect(result).toBeDefined() }) @@ -679,7 +680,7 @@ describe('BasicCompactService blocking (compaction in progress)', () => { const nodes = s.surface.nodes // The stale start is before the turn/end, so it is NOT seen as in-progress. - const result = await svc.compactRegion(s, nodes[0]!.seq, nodes[1]!.seq, 'm') + const result = await compactRegion(svc, s, nodes[0]!.seq, nodes[1]!.seq, 'm') expect(result).toBeDefined() }) }) @@ -829,12 +830,37 @@ function stubAgent(session: Session, model?: string): Agent { return { session, options: { model } } as unknown as Agent } +function compactIfNeeded( + svc: BasicCompactService, + session: Session, + fullSystemPrompt: string, + model: string, + signal: AbortSignal, +) { + return svc.compactIfNeeded(stubAgent(session, model), 1, 1, fullSystemPrompt, signal) +} + +function compactRegion( + svc: BasicCompactService, + session: Session, + start: number, + end: number, + model: string, + signal?: AbortSignal, +) { + return svc.compactRegion(session, start, end, stubAgent(session, model), 1, 1, signal) +} + +function summarize(svc: BasicCompactService, text: string, model: string) { + return svc.summarize(text, stubAgent(new Session(SessionId('summary')), model), 1, 1) +} + describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('summarizes via the registered adapter and returns its content', async () => { const { ctx, adapter } = await ctxWithModel('SUMMARY TEXT') const svc = new BasicCompactService(ctx, { auto: false, summarizationMaxTokens: 512 }) - const summary = await svc.summarize('User: hi\n\nAssistant: hello', 'test-model') + const summary = await summarize(svc, 'User: hi\n\nAssistant: hello', 'test-model') expect(summary).toEqual([{ type: 'text', text: 'SUMMARY TEXT' }]) // The fixed system prompt and maxTokens flow through. expect(adapter.lastOptions!.system).toContain('compaction engine') @@ -846,19 +872,19 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('throws when no model is provided', async () => { const { ctx } = await ctxWithModel('x') const svc = new BasicCompactService(ctx, { auto: false }) - await expect(svc.summarize('text', '')).rejects.toThrow(/no model available/) + await expect(summarize(svc, 'text', '')).rejects.toThrow(/no model available/) }) it('rethrows when the stream ends with a finish-error chunk', async () => { const ctx = await ctxWithFinish({ kind: 'error', message: 'provider 401', code: 'UNAUTHORIZED' }) const svc = new BasicCompactService(ctx, { auto: false }) - await expect(svc.summarize('text', 'test-model')).rejects.toMatchObject({ message: 'provider 401', code: 'UNAUTHORIZED' }) + await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ message: 'provider 401', code: 'UNAUTHORIZED' }) }) it('rethrows a finish-error chunk without a code (code stays undefined)', async () => { const ctx = await ctxWithFinish({ kind: 'error', message: 'opaque failure' }) const svc = new BasicCompactService(ctx, { auto: false }) - const error = await svc.summarize('text', 'test-model').then(() => null, (e: unknown) => e as Error & { code?: string }) + const error = await summarize(svc, 'text', 'test-model').then(() => null, (e: unknown) => e as Error & { code?: string }) expect(error?.message).toBe('opaque failure') expect(error?.code).toBeUndefined() }) @@ -866,13 +892,13 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('rethrows when the stream ends with a finish-aborted chunk', async () => { const ctx = await ctxWithFinish({ kind: 'aborted' }) const svc = new BasicCompactService(ctx, { auto: false }) - await expect(svc.summarize('text', 'test-model')).rejects.toMatchObject({ message: 'summarization stream aborted', code: 'ABORTED' }) + await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ message: 'summarization stream aborted', code: 'ABORTED' }) }) it('fails closed on a max-tokens finish (an incomplete checkpoint must not commit)', async () => { const ctx = await ctxWithFinish({ kind: 'max-tokens' }) const svc = new BasicCompactService(ctx, { auto: false }) - await expect(svc.summarize('text', 'test-model')).rejects.toMatchObject({ code: 'MAX_TOKENS' }) + await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ code: 'MAX_TOKENS' }) }) it('compactRegion leaves the surface intact when summarization hits max-tokens', async () => { @@ -882,7 +908,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { const before = [...session.surface.nodes] const nodes = session.surface.nodes - await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model')) + await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'test-model')) .rejects.toMatchObject({ code: 'MAX_TOKENS' }) // No replacement landed — the surface is byte-identical, and the lock was @@ -899,7 +925,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { const session = multiTurnSession(2, 1) const nodes = session.surface.nodes - const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') + const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') expect(result.summary).toEqual([{ type: 'text', text: 'CONDENSED' }]) // The raw summary is wrapped in the checkpoint framing on the surface. expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'CONDENSED' }) @@ -908,8 +934,8 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => { /** Fire the agent/pre-step serial checkpoint as the loop does. */ - function firePreStep(ctx: Context, agent: Agent, step: number, system: string, model: string): Promise { - return ctx.serial('agent/pre-step', agent, 1, step, system, model, SIGNAL) + function firePreStep(ctx: Context, agent: Agent, step: number, fullSystemPrompt: string): Promise { + return ctx.serial('agent/pre-step', agent, 1, step, fullSystemPrompt, SIGNAL) } it('compacts (mutating the surface) when over threshold', async () => { @@ -919,7 +945,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => const agent = stubAgent(session, 'test-model') const before = session.surface.nodes.length - await firePreStep(ctx, agent, 1, '', 'test-model') + await firePreStep(ctx, agent, 1, '') // The surface shrank in place, and a summary checkpoint landed. expect(session.surface.nodes.length).toBeLessThan(before) @@ -936,7 +962,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => // A step-2 checkpoint (a tool-heavy turn's later step) must still compact — // the surface accumulated assistant/message + tool/result nodes since step 1. - await firePreStep(ctx, agent, 2, '', 'test-model') + await firePreStep(ctx, agent, 2, '') expect(session.events.some(e => e.type === 'compact/start')).toBe(true) }) @@ -946,7 +972,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => const session = multiTurnSession(1, 1) const agent = stubAgent(session, 'test-model') - await firePreStep(ctx, agent, 1, '', 'test-model') + await firePreStep(ctx, agent, 1, '') expect(session.events.some(e => e.type === 'compact/start')).toBe(false) }) @@ -960,7 +986,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => const agent = stubAgent(session, 'missing-model') const before = session.surface.nodes.length - await firePreStep(ctx, agent, 1, '', 'missing-model') + await firePreStep(ctx, agent, 1, '') // No summary landed; the surface is unchanged. expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) expect(session.surface.nodes.length).toBe(before) @@ -972,9 +998,44 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'test-model') - await firePreStep(ctx, agent, 1, '', 'test-model') + await firePreStep(ctx, agent, 1, '') expect(session.events.some(e => e.type === 'compact/start')).toBe(false) }) + + it('routes summarization through agent/request so router agents can choose the model', async () => { + const { ctx, adapter } = await ctxWithModel('ROUTED SUMMARY', 'routed-model') + ctx.on('agent/request', async (_agent, _turn, _step, options, next) => { + options.model = 'routed-model' + return next() + }) + void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20, summarizationMaxTokens: 50 }) + const session = multiTurnSession(5, 1) + const agent = stubAgent(session) + + await ctx.serial('agent/pre-step', agent, 1, 1, '', SIGNAL) + + expect(adapter.lastOptions?.model).toBe('routed-model') + expect(session.events.some(e => e.type === 'compact/summary')).toBe(true) + expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'ROUTED SUMMARY' }) + }) + + it('removes the auto pre-step listener when the plugin fiber is disposed', async () => { + const { ctx } = await ctxWithModel('SUMMARY') + const fiber = await ctx.plugin(BasicCompactService, { + contextWindow: 200, + thresholdRatio: 0.5, + retainTokens: 20, + summarizationMaxTokens: 50, + }) + const session = multiTurnSession(5, 1) + const agent = stubAgent(session, 'test-model') + + await fiber.dispose() + await firePreStep(ctx, agent, 1, '') + + expect(session.events.some(e => e.type === 'compact/start')).toBe(false) + expect(ctx.get('compact')).toBeUndefined() + }) }) describe('BasicCompactService._extractText branches', () => { @@ -1001,7 +1062,7 @@ describe('BasicCompactService._extractText branches', () => { s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const nodes = s.surface.nodes - await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') const { text } = svc.summarizeCalls[0]! expect(text).toContain('[Context: project context here]') @@ -1030,7 +1091,7 @@ describe('BasicCompactService._extractText branches', () => { s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const nodes = s.surface.nodes - await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') expect(svc.summarizeCalls[0]!.text).toContain('Tool error (call c9): boom failure') }) }) @@ -1064,7 +1125,7 @@ describe('BasicCompactService edge cases', () => { s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const nodes = s.surface.nodes - await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + 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('[custom-widget]') // unknown block placeholder @@ -1089,7 +1150,7 @@ describe('BasicCompactService edge cases', () => { const session = multiTurnSession(4, 1) const agent = stubAgent(session, 'test-model') - await ctx.serial('agent/pre-step', agent, 1, 1, '', 'test-model', SIGNAL) + await ctx.serial('agent/pre-step', agent, 1, 1, '', SIGNAL) expect(session.events.some(e => e.type === 'compact/summary')).toBe(true) // The surface was mutated; the head message is the framed summary checkpoint. expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) @@ -1111,7 +1172,7 @@ describe('BasicCompactService edge cases', () => { s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const nodes = s.surface.nodes - await expect(svc.compactRegion(s, nodes[0]!.seq, nodes[1]!.seq, 'm')) + await expect(compactRegion(svc, s, nodes[0]!.seq, nodes[1]!.seq, 'm')) .rejects.toThrow(/no open turn/) // The lock was never acquired — no compact/start landed. expect(s.events.some(e => e.type === 'compact/start')).toBe(false) @@ -1126,7 +1187,7 @@ describe('BasicCompactService edge cases', () => { s.append('user/message', { content: [{ type: 'text', text: 'orphan' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const nodes = s.surface.nodes - await expect(svc.compactRegion(s, nodes[0]!.seq, nodes[0]!.seq, 'm')) + await expect(compactRegion(svc, s, nodes[0]!.seq, nodes[0]!.seq, 'm')) .rejects.toThrow(/no open turn/) expect(s.events.some(e => e.type === 'compact/start')).toBe(false) }) @@ -1136,14 +1197,14 @@ describe('BasicCompactService edge cases', () => { const session = new Session(SessionId('empty-but-pressured')) // No surface nodes, but a large system prompt pushes the estimate over threshold. const bigPrompt = 'x'.repeat(800) // ceil(800/4) = 200 tokens >> threshold 100 - expect(await svc.compactIfNeeded(session, bigPrompt, 'm', SIGNAL)).toBeNull() + expect(await compactIfNeeded(svc, session, bigPrompt, 'm', SIGNAL)).toBeNull() }) it('compactRegion throws when end is not a surface node (start valid)', async () => { const svc = createTestService() const session = multiTurnSession(1, 1) const nodes = session.surface.nodes - await expect(svc.compactRegion(session, nodes[0]!.seq, 9999, 'm')) + await expect(compactRegion(svc, session, nodes[0]!.seq, 9999, 'm')) .rejects.toThrow(/end seq 9999 not found in surface/) }) @@ -1155,7 +1216,7 @@ describe('BasicCompactService edge cases', () => { const nodes = session.surface.nodes // Whole step (user → assistant): a step-aligned region that reaches summarize. - await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm')).rejects.toBe('plain string failure') + await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')).rejects.toBe('plain string failure') const endEvent = session.events.findLast(e => e.type === 'compact/end')! expect(endEvent.data).toMatchObject({ error: 'plain string failure' }) }) @@ -1170,7 +1231,7 @@ describe('BasicCompactService edge cases', () => { const agent = stubAgent(session, 'test-model') const before = session.surface.nodes.length - await ctx.serial('agent/pre-step', agent, 1, 1, '', 'test-model', SIGNAL) + await ctx.serial('agent/pre-step', agent, 1, 1, '', SIGNAL) // The failure was swallowed; the surface is untouched and a warning logged. expect(session.surface.nodes.length).toBe(before) expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) @@ -1187,7 +1248,7 @@ describe('BasicCompactService edge cases', () => { const agent = stubAgent(session, 'test-model') const bigSystem = 'x'.repeat(900) // ceil(900/4)=225 > threshold 200 - await ctx.serial('agent/pre-step', agent, 1, 1, bigSystem, 'test-model', SIGNAL) + await ctx.serial('agent/pre-step', agent, 1, 1, bigSystem, SIGNAL) expect(session.events.some(e => e.type === 'compact/start')).toBe(false) expect(svc.summarizeCalls.length).toBe(0) }) @@ -1221,7 +1282,7 @@ describe('BasicCompactService edge cases', () => { s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const nodes = s.surface.nodes - await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') // Every empty-content message (user text, empty reasoning, empty-content // tool/result, empty context, empty steering) extracted to nothing and was // skipped — the only surviving line is the assistant's tool-call (which a @@ -1256,7 +1317,7 @@ describe('BasicCompactService edge cases', () => { s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const nodes = s.surface.nodes - await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + 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]') @@ -1280,7 +1341,7 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a // First compaction: shadow the two oldest surface nodes. const nodes0 = session.surface.nodes - const first = await svc.compactRegion(session, nodes0[0]!.seq, nodes0[1]!.seq, 'm') + const first = await compactRegion(svc, session, nodes0[0]!.seq, nodes0[1]!.seq, 'm') // The summary node now sits at the head with a seq HIGHER than the // retained older nodes that follow it — the non-monotonic surface. (The @@ -1298,7 +1359,7 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a const startSeq = nodes1[0]!.seq const endSeq = nodes1[2]!.seq expect(startSeq).toBeGreaterThan(endSeq) - const second = await svc.compactRegion(session, startSeq, endSeq, 'm') + const second = await compactRegion(svc, session, startSeq, endSeq, 'm') // Exactly the three nodes at surface positions [0..2] are shadowed, in // surface order — the positional slice, regardless of their seq values. @@ -1316,14 +1377,14 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a // First compaction shadows the oldest two surface nodes, landing a high-seq // summary node at the head. const n0 = session.surface.nodes - await svc.compactRegion(session, n0[0]!.seq, n0[1]!.seq, 'm') + await compactRegion(svc, session, n0[0]!.seq, n0[1]!.seq, 'm') // Second compaction spans [head summary … turn-2's step end]. The head's seq // is higher than the older retained nodes' seqs, so a log-seq-order walk // would emit the older messages BEFORE the checkpoint. const n1 = session.surface.nodes svc.summarizeCalls = [] - await svc.compactRegion(session, n1[0]!.seq, n1[2]!.seq, 'm') + await compactRegion(svc, session, n1[0]!.seq, n1[2]!.seq, 'm') // The extracted transcript follows surface order: the checkpoint (head) // first, then the older retained messages — matching deriveMessages(). @@ -1355,7 +1416,7 @@ describe('BasicCompactService llm inject (real plugin-load path)', () => { const svc = ctx.compact as BasicCompactService const session = multiTurnSession(2, 1) const nodes = session.surface.nodes - const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') + const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') expect(result.summary).toEqual([{ type: 'text', text: 'CONDENSED' }]) // Tear the fiber down so this test owns no leaked registration; the @@ -1403,7 +1464,7 @@ describe('BasicCompactService under the real invariants plugin', () => { const nodes = session.surface.nodes // No invariant throws here: compact/* + the replacement are all in turn 3. - const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') + const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') expect(result.shadowedSeqs.length).toBe(2) expect(session.surface.nodes[0]!.seq).toBeGreaterThan(session.surface.nodes[1]!.seq) }) @@ -1416,15 +1477,14 @@ describe('BasicCompactService under the real invariants plugin', () => { session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }) const n0 = session.surface.nodes - await svc.compactRegion(session, n0[0]!.seq, n0[1]!.seq, 'test-model') + await compactRegion(svc, session, n0[0]!.seq, n0[1]!.seq, 'test-model') // Surface head now carries a higher seq than the older retained nodes. A // second compaction spanning [head … a later closed-step end] must pass the // invariants' positional replace check even though startSeq > endSeq. const n1 = session.surface.nodes expect(n1[0]!.seq).toBeGreaterThan(n1[2]!.seq) - const second = await svc.compactRegion(session, n1[0]!.seq, n1[2]!.seq, 'test-model') + const second = await compactRegion(svc, session, n1[0]!.seq, n1[2]!.seq, 'test-model') expect(second.shadowedSeqs).toEqual([n1[0]!.seq, n1[1]!.seq, n1[2]!.seq]) }) }) - diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 8a95277c17..3cc3ba0333 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -18,10 +18,10 @@ Both methods are **abstract** — the backend owns the entire strategy (token es | Member | Semantics | |---|---| -| `compactIfNeeded(session, system, model, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-step` checkpoint always supplies the assembled `system`, the `model`, and the turn `signal`. | -| `compactRegion(session, start, end, model, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | +| `compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-step` checkpoint supplies the agent, lifecycle context, assembled `fullSystemPrompt`, and turn `signal`; router-aware summarizers can use the agent lifecycle context to route their own model call through `agent/request`. | +| `compactRegion(session, start, end, agent, turn, step, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | -`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is not a parameter — it is recoverable from the log (the currently-open turn), so the backend stamps it without the caller supplying it. +`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The session being compacted comes from the agent context; the turn that the `compact/*` events belong to is recoverable from the log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value. ## Surface contract diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index c84c147ca7..3001783d7d 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -27,6 +27,12 @@ import type { CompactionResult } from './types.ts' export type { CompactionResult } from './types.ts' +/** Minimal agent context compaction needs without depending on the agent package. */ +export interface CompactAgentContext { + session: Session + options: { model?: string } +} + declare module 'cordis' { interface Context { compact: CompactService @@ -84,9 +90,10 @@ export abstract class CompactService extends Service { * exceeds the budget, compaction cannot help and the call may go out * over-budget. Bounding an individual unit's size is a separate concern. * - * @param session - the session whose surface may be compacted. - * @param system - the assembled system prompt, counted toward the estimate. - * @param model - the summarization model (a backend may override via config). + * @param agent - agent context owning the session surface and model options. + * @param turn - turn number of the pre-step checkpoint. + * @param step - step number about to start. + * @param fullSystemPrompt - assembled system prompt, counted toward the estimate. * @param signal - cancellation signal. A backend summarizing via * `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal` * so an abort/dispose tears down the in-flight summarization rather than @@ -94,9 +101,10 @@ export abstract class CompactService extends Service { * @returns the compaction result, or `null` if no compaction was needed. */ abstract compactIfNeeded( - session: Session, - system: string, - model: string, + agent: CompactAgentContext, + turn: number, + step: number, + fullSystemPrompt: string, signal: AbortSignal, ): Promise @@ -120,7 +128,9 @@ export abstract class CompactService extends Service { * @param session - the session whose surface is mutated. * @param start - inclusive seq of the first surface node to compact. * @param end - inclusive seq of the last surface node to compact. - * @param model - summarization model. + * @param agent - agent context used by router-aware summarizers. + * @param turn - lifecycle turn forwarded to request-routing seams. + * @param step - lifecycle step forwarded to request-routing seams. * @param signal - optional cancellation signal. A backend that summarizes via * `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal` * so an abort/dispose tears down the in-flight summarization rather than @@ -136,7 +146,9 @@ export abstract class CompactService extends Service { session: Session, start: number, end: number, - model: string, + agent: CompactAgentContext, + turn: number, + step: number, signal?: AbortSignal, ): Promise } diff --git a/packages/compact/compact/tests/compact.spec.ts b/packages/compact/compact/tests/compact.spec.ts index b3ad9d1501..5b9e033fcc 100644 --- a/packages/compact/compact/tests/compact.spec.ts +++ b/packages/compact/compact/tests/compact.spec.ts @@ -3,6 +3,7 @@ import { Context } from 'cordis' import { CompactService } from '@deepseek-ai/dsh-compact' import type { CompactionResult } from '@deepseek-ai/dsh-compact' import { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { CompactAgentContext } from '@deepseek-ai/dsh-compact' /** * A trivial concrete CompactService implementing the abstract contract. The @@ -15,10 +16,11 @@ class StubCompactService extends CompactService { lastSignal: AbortSignal | undefined override async compactIfNeeded( - _session: Session, - _systemPrompt?: string, - _model?: string, - signal?: AbortSignal, + _agent: CompactAgentContext, + _turn: number, + _step: number, + _fullSystemPrompt: string, + signal: AbortSignal, ): Promise { this.lastSignal = signal return null @@ -28,7 +30,9 @@ class StubCompactService extends CompactService { session: Session, start: number, end: number, - _model: string, + _agent: CompactAgentContext, + _turn: number, + _step: number, signal?: AbortSignal, ): Promise { this.lastSignal = signal @@ -54,6 +58,10 @@ class StubCompactService extends CompactService { } describe('CompactService seam', () => { + function stubAgent(session: Session, model?: string): CompactAgentContext { + return { session, options: model === undefined ? {} : { model } } + } + it('registers as ctx.compact', () => { const ctx = new Context() void new StubCompactService(ctx) @@ -72,7 +80,8 @@ describe('CompactService seam', () => { it('exposes the abstract contract methods', async () => { const ctx = new Context() const svc = new StubCompactService(ctx) - expect(await svc.compactIfNeeded(new Session(SessionId('s')))).toBeNull() + const session = new Session(SessionId('s')) + expect(await svc.compactIfNeeded(stubAgent(session), 1, 1, '', new AbortController().signal)).toBeNull() }) it('compact/* events merge into SessionEventMap and are log-only', async () => { @@ -80,7 +89,7 @@ describe('CompactService seam', () => { const svc = new StubCompactService(ctx) const session = new Session(SessionId('s')) - const result = await svc.compactRegion(session, 0, 0, 'm') + const result = await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'), 1, 1) const startEvent = session.events.find(e => e.type === 'compact/start') expect(startEvent).toBeDefined() @@ -98,10 +107,10 @@ describe('CompactService seam', () => { const session = new Session(SessionId('s')) const controller = new AbortController() - await svc.compactRegion(session, 0, 0, 'm', controller.signal) + await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'), 1, 1, controller.signal) expect(svc.lastSignal).toBe(controller.signal) - await svc.compactIfNeeded(session, undefined, undefined, controller.signal) + await svc.compactIfNeeded(stubAgent(session), 1, 1, '', controller.signal) expect(svc.lastSignal).toBe(controller.signal) }) }) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index f6b286cb28..2982a60fc8 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -388,29 +388,34 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, // (or turn-start listeners on the first step) joins before the request. drainSteering(ctx, agent, turn) - // Assemble the system prompt for this step. Done HERE (before step/start) - // because the pre-step seam needs it: compaction measures token pressure - // against the system prompt (it counts toward the budget) and a listener - // also receives the model to summarize with. runStep reuses this same - // assembly for the request, so the prompt is assembled once per step. - const assembly = await ctx.systemPrompt.assemble() - const system = [renderPrompt(assembly), agent.options.systemPrompt ?? ''] - .filter(text => text.length > 0) - .join('\n\n') - - // The step's AbortController exists BEFORE the pre-step seam so a cancel() - // during the seam aborts any in-flight work a listener started (e.g. a - // compaction summarization call). Cleared on every exit path below. + // The step's AbortController exists BEFORE any async pre-step work so a + // dispose() or cancel() — in a synchronous turn-start listener or an + // async listener whose effect fires before we block — always has an armed + // abort to cancel against. isDisposed below covers disposal, which does + // NOT set the cancel marker. Cleared on every exit path below. const abort = new AbortController() handle.setAbort(abort) - // Cancel landing before the seam: a synchronous `agent/turn-start` listener - // (or the previous step's continuation listeners) can have called - // `cancel()`. Drop the about-to-start step WITHOUT running the seam — no - // step is open yet, so end the turn `aborted` directly. - if (handle.isCancelled()) { + // Assemble the system prompt for this step. Done HERE (before step/start) + // 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') + + // Interruption landing after assembly: dispose() or cancel() in a + // turn-start listener (or a listener whose promise resolved before the + // await above) arms either handle.isDisposed() or handle.isCancelled(). + // The Abort was created first, so any concurrent abort also lands on it. + // Drop the about-to-start step WITHOUT running the seam — no step is open + // yet, so end the turn accordingly (disposed wins for an unambiguous + // reason). + if (handle.isCancelled() || handle.isDisposed()) { handle.setAbort(undefined) - reason = { kind: 'aborted', reason: handle.cancelReason() } + reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() } break } @@ -425,7 +430,7 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, // throwing listener escapes to the outer catch, which closes the (not-yet- // open) step as a no-op and ends the turn via failTurn — a broken // pre-step plugin ends the turn, not the loop. - await ctx.serial('agent/pre-step', agent, turn, step, system, agent.options.model ?? '', abort.signal) + await ctx.serial('agent/pre-step', agent, turn, step, fullSystemPrompt, abort.signal) session.append('step/start', { turn, step }) stepOpen = true @@ -433,19 +438,20 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, // Cancel landing in the seam / step-start window: a `cancel()` during the // pre-step seam (it aborted `abort.signal` above) OR a synchronous - // `agent/step-start` listener that cancels. Check AFTER setAbort/step-start - // and before `runStep`: drop the step, end the turn `aborted`. closeStep - // balances the already-appended step/start. - if (handle.isCancelled()) { + // `agent/step-start` listener that cancels. And disposal, which the earlier + // assembly check may have missed if it only checked isCancelled. Check + // AFTER step/start append + emit 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 = { kind: 'aborted', reason: handle.cancelReason() } + reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() } closeStep() break } let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error } try { - stepOutcome = await runStep(ctx, agent, turn, step, assembly, system, abort.signal) + stepOutcome = await runStep(ctx, agent, turn, step, assembly, fullSystemPrompt, abort.signal) } catch (error: unknown) { stepOutcome = { error: toError(error) } } finally { diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 2c6f9e06e8..aa565f15b5 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -322,9 +322,9 @@ describe('agent loop', () => { it('agent/pre-step fires once per step before the step is opened', async () => { // Two steps (a tool call, then a final text turn) → two model calls → two - // pre-step fires, each carrying the assembled system + model, BEFORE the - // step is opened and its request is derived (the request the adapter sees - // reflects any surface state at fire time). + // pre-step fires, each carrying the assembled full system prompt, BEFORE + // the step is opened and its request is derived (the request the adapter + // sees reflects any surface state at fire time). const adapter = new MockAdapter([ toolCallResponse('c1', 'echo', {}, 'calling echo'), textResponse('done'), @@ -336,18 +336,18 @@ describe('agent loop', () => { })) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - const fires: { turn: number; step: number; model: string }[] = [] - ctx.on('agent/pre-step', (subject, turn, step, _system, model) => { - if (subject === agent) fires.push({ turn, step, model }) + const fires: { turn: number; step: number; fullSystemPrompt: string }[] = [] + ctx.on('agent/pre-step', (subject, turn, step, fullSystemPrompt) => { + if (subject === agent) fires.push({ turn, step, fullSystemPrompt }) }) send(agent, 'go') await waitForIdle(ctx, agent) - // One fire per step, in order, each with the agent's model. + // One fire per step, in order, each with the assembled system prompt. expect(fires).toEqual([ - { turn: 1, step: 1, model: 'mock' }, - { turn: 1, step: 2, model: 'mock' }, + { turn: 1, step: 1, fullSystemPrompt: '' }, + { turn: 1, step: 2, fullSystemPrompt: '' }, ]) }) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index a092bd8419..5b02ca60c1 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -1047,3 +1047,273 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream expect(JSON.stringify(agent.session.deriveMessages())).toContain('injected') }) }) + + + +describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { + it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => { + // Block `system-prompt/assemble` on a promise. Start disposal (which + // calls stop() synchronously, setting status=disposed), then release the + // block. The loop must check isDisposed() after assembly and end the turn + // `disposed` — no LLM call. Don't await fiber.dispose() before releasing + // the blocker: the dispose chain awaits agent.done, which hangs until the + // loop unblocks. + const adapter = new MockAdapter(['hang']) + let releaseAssemble!: () => void + const blocked = new Promise(r => void (releaseAssemble = r)) + + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(Invariants, { freeze: false }) + 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) { + await blocked + return next() + }) + + let agent!: ReactLoopAgent + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + agent = inner.agentLoop.create(AgentId('a-dispose-assemble'), { model: 'mock' }) + }, { inject: ['agentLoop'] })) + + const reasons: TurnEndReason[] = [] + ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + + send(agent, 'go') + // Give the loop time to enter the step and reach assemble(). + await new Promise(r => setTimeout(r, 50)) + + // Start disposal — stop() sets status=disposed synchronously, then the + // disposer's await agent.done hangs because the loop is blocked in the + // waterfall. Do NOT await yet; release the blocker first. + const disposalDone = fiber.dispose() + + // Now release the blocked waterfall — the loop unblocks, checks + // isDisposed(), and exits, which resolves agent.done and disposalDone. + releaseAssemble() + await disposalDone + await agent.done + unlisten() + + const e = [...agent.session.events] + expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) + expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) + const turnEnd = e.findLast(x => x.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) + // 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. + }) + + it('cancel during system-prompt assembly drops the about-to-start step as aborted', { timeout: 30000 }, async () => { + const adapter = new MockAdapter([textResponse('should not appear')]) + let releaseAssemble!: () => void + const blocker = new Promise(r => void (releaseAssemble = r)) + + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(Invariants, { freeze: false }) + ctx.llm.registerAdapter(['mock'], adapter) + + const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, next) { + await blocker + return next() + }) + + let agent!: ReactLoopAgent + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + agent = inner.agentLoop.create(AgentId('a-cancel-assemble'), { model: 'mock' }) + }, { inject: ['agentLoop'] })) + + const reasons: TurnEndReason[] = [] + ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 50)) + agent.cancel('user cancelled during assembly') + + releaseAssemble() + await waitForIdle(ctx, agent) + await fiber.dispose() + await agent.done + unlisten() + + const e = [...agent.session.events] + expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) + expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) + const turnEnd = e.findLast(x => x.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ + kind: 'aborted', + reason: 'user cancelled during assembly', + }) + expect(e.some(x => x.type === 'step/start')).toBe(false) + 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) + expect(reasons).toEqual([{ kind: 'aborted', reason: 'user cancelled during assembly' }]) + }) + + it('disposal during agent/pre-step seam ends the turn disposed', { timeout: 15000 }, async () => { + // Block the `agent/pre-step` serial seam on a promise we control, then + // dispose the agent's fiber. When the block releases, the loop must see + // isDisposed() at the post-seam check and end the turn disposed. + const adapter = new MockAdapter(['hang']) + let releasePreStep!: () => void + const blocker = new Promise(r => void (releasePreStep = r)) + + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(Invariants, { freeze: false }) + ctx.llm.registerAdapter(['mock'], adapter) + + ctx.on('agent/pre-step', async () => { + await blocker + }) + + let agent!: ReactLoopAgent + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + agent = inner.agentLoop.create(AgentId('a-dispose-prestep'), { model: 'mock' }) + }, { inject: ['agentLoop'] })) + + const reasons: TurnEndReason[] = [] + ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 50)) + + // Start disposal, then release the block, then await disposal. + const disposalDone = fiber.dispose() + releasePreStep() + await disposalDone + await agent.done + + // After the pre-step seam finishes, the post-seam cancel/dispose check + // catches disposal. The step was never opened, no LLM call was made. + const e = [...agent.session.events] + expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) + expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) + const turnEnd = e.findLast(x => x.type === 'turn/end') + // Disposal wins the post-seam check — reason is `disposed`. + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) + 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. + }) + + it('cancel during agent/pre-step seam ends the turn aborted', { timeout: 15000 }, async () => { + // Block `agent/pre-step`, then cancel() the agent. When the block releases, + // the post-seam check catches cancellation and ends the turn aborted. + const adapter = new MockAdapter(['hang']) + let releasePreStep!: () => void + const blocker = new Promise(r => void (releasePreStep = r)) + + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(Invariants, { freeze: false }) + ctx.llm.registerAdapter(['mock'], adapter) + + ctx.on('agent/pre-step', async () => { + await blocker + }) + + let agent!: ReactLoopAgent + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + agent = inner.agentLoop.create(AgentId('a-cancel-prestep'), { model: 'mock' }) + }, { inject: ['agentLoop'] })) + + const reasons: TurnEndReason[] = [] + ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 30)) + agent.cancel('user cancelled') + + releasePreStep() + await waitForIdle(ctx, agent) + await fiber.dispose() + await agent.done + + const e = [...agent.session.events] + expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) + expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) + const turnEnd = e.findLast(x => x.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: 'user cancelled' }) + expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) + expect(reasons).toEqual([{ kind: 'aborted', reason: 'user cancelled' }]) + }) + + it('disposal during assembly does not leak an LLM call or append assistant/chunk', { timeout: 15000 }, async () => { + // The key assertion from the original bug report: after disposal, no + // assistant/chunk or assistant/message appears — the turn ends disposed + // before any model interaction. + const adapter = new MockAdapter([textResponse('should not appear')]) + let releaseAssemble!: () => void + const blocker = new Promise(r => void (releaseAssemble = r)) + + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(Invariants, { freeze: false }) + ctx.llm.registerAdapter(['mock'], adapter) + + ctx.on('system-prompt/assemble', async function (_assembly, next) { + await blocker + return next() + }) + + let agent!: ReactLoopAgent + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + agent = inner.agentLoop.create(AgentId('a-dispose-no-leak'), { model: 'mock' }) + }, { inject: ['agentLoop'] })) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 50)) + + const disposalDone = fiber.dispose() + releaseAssemble() + await disposalDone + await agent.done + + const e = [...agent.session.events] + expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) + expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) + // The critical assertions: after disposal, the turn has no assistant + // artifacts — the turn ended disposed before the model was invoked. + 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. + }) +}) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 5bb603f1f6..83b3dfa4e7 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -200,13 +200,12 @@ declare module 'cordis' { * transform or veto, but the loop must wait for the mutation to complete * before opening the step and deriving, and serial isolates listeners from * each other (one finishes its surface append before the next runs). - * `system`/`model` are the assembled values a listener needs to measure - * pressure (system counts toward the budget) and to summarize (the model). - * `signal` cancels any in-flight work a listener starts (e.g. a summarization - * model call). + * `fullSystemPrompt` is the assembled prompt a 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). * @mode serial */ - 'agent/pre-step'(agent: Agent, turn: number, step: number, system: string, model: string, signal: AbortSignal): Promise | void + 'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise | void /** * Waterfall: mutate the fully-assembled {@link GenerateOptions} before the * model call (hooks, model switching, tool filtering, …). Call `next()` to From 1808570933d866ec5c7ceff83181e0456f3ddaf6 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 29 Jun 2026 16:56:44 +0800 Subject: [PATCH 26/46] fix(compact): harden summarization convergence Use maxTokens as the provider generation cap and remove the confusing stored-summary max config. Strip reasoning blocks before storing compaction summaries, reject non-shrinking summaries, and retry bounded re-compaction when the surface remains over threshold. Add config validation for numeric and type-shaped knobs plus unit and real-API e2e coverage for reasoning-capable summarization. --- docs/core-data-structures/compaction.md | 2 +- .../2026-06-18-compaction-capability-seam.md | 2 +- examples/coding-agent/tests/compaction.e2e.ts | 13 +- packages/compact/compact-basic/README.md | 7 +- packages/compact/compact-basic/src/index.ts | 148 +++++++----- packages/compact/compact-basic/src/types.ts | 70 +++--- .../compact-basic/tests/compact-basic.spec.ts | 220 +++++++++++++----- .../tests/compact-loop-repro.spec.ts | 6 +- 8 files changed, 319 insertions(+), 149 deletions(-) diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index a1ca8978a6..05d1ce6c2e 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -52,4 +52,4 @@ interface CompactionResult { `CompactService` (`ctx.compact`, abstract — defined in [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts)) declares two abstract methods: `compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)` checks token pressure and compacts an older range if the history is too large (returning `null` when nothing needs it), and `compactRegion(session, start, end, agent, turn, step, signal?)` forcibly summarizes surface nodes `[start, end]` into a single replacement node. `compactIfNeeded`'s parameters are all required — the loop's `agent/pre-step` checkpoint supplies the agent, lifecycle context, assembled `fullSystemPrompt`, and turn `signal`. A backend summarizing via `ctx.llm.stream()` must forward `signal` into the call's `GenerateOptions.signal`, so an abort or dispose tears down the in-flight summarization. The entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. -Auto-compaction runs on the serial `agent/pre-step` loop seam (fired once per step, after `turn/start` and BEFORE the step opens and its request history is derived), not the `agent/request` waterfall: compaction mutates the session surface in place — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives the request from the already-compacted surface. Retention is turn-agnostic — the only structural guard is tool-pairing balance (a compacted region's edges are balanced cuts on the surface, so it never splits a step's tool-calls from their results), so a single runaway turn that alone exceeds the window compacts its own early closed steps rather than being retained verbatim. The backend that ships this (`dsh-compact-basic`) documents the retention walk, the approximate convergence invariant, and the crash/recoverable failure taxonomy. +Auto-compaction runs on the serial `agent/pre-step` loop seam (fired once per step, after `turn/start` and BEFORE the step opens and its request history is derived), not the `agent/request` waterfall: compaction mutates the session surface in place — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives the request from the already-compacted surface. Retention is turn-agnostic — the only structural guard is tool-pairing balance (a compacted region's edges are balanced cuts on the surface, so it never splits a step's tool-calls from their results), so a single runaway turn that alone exceeds the window compacts its own early closed steps rather than being retained verbatim. The backend that ships this (`dsh-compact-basic`) documents the retention walk, summary shrink validation, bounded re-compaction, and the crash/recoverable failure taxonomy. diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index ba6f6c6ae6..29371a3abb 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -66,7 +66,7 @@ A runaway turn thus compacts exactly like any other history: its early *closed* ### Approximate convergence invariant -`resolveConfig` **rejects** (throws at construction) any config where `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. The invariant bounds the two variable parts of post-compaction history — the bounded summary plus the retained recent tail — but it is intentionally approximate: checkpoint framing, per-message role overhead, system-prompt size, and the char/4 estimator's error can still leave a narrow accepted config near the threshold. The bound is **strict** (`>=` rejects, not `>`): the token-pressure gate declines only when the estimate is `< threshold`, so a post-compaction history sitting *exactly* at the threshold would re-trigger on the very next check — equality is a leak, not a safe boundary. `summarizationMaxTokens` stays an explicit *quality* knob (terse summaries); the invariant only forbids setting it so high it breaks the structural budget. The sole residual is the single-unit-overflow case above (a backward-rounded oversized step can push the retained tail over budget) — which is exactly the out-of-scope concern, not a thrash bug. Per the pre-release reject-don't-migrate stance, a config that cannot satisfy the structural bound is a bug at the call site, not something to silently clamp. +`resolveConfig` validates numeric knobs but does NOT reject based on a pretend summary-length invariant. Convergence is dynamic: provider output caps can be spent on hidden or surfaced reasoning tokens, and the model may emit a summary of unpredictable size. `maxTokens` is only the provider-side generation cap for the summarization call; reasoning blocks are stripped before the checkpoint is stored. If a compacted surface is still over threshold, `compactIfNeeded()` re-compacts the head checkpoint up to `compactionRetries` extra times, but each committed summary must be smaller than the content it shadows. The sole residual is the single-unit-overflow case above (a backward-rounded oversized step can push the retained tail over budget) — which is exactly the out-of-scope concern, not a thrash bug. ### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index 17186055b1..a300aac69a 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -40,18 +40,17 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa await writeFile(join(workdir, `file${i}.txt`), `This is file number ${i}. `.repeat(40)) } - // Tiny window so a couple of steps crosses the threshold. The convergence - // invariant requires summarizationMaxTokens + retainTokens to be strictly - // BELOW the threshold = floor(contextWindow * thresholdRatio) = - // floor(2400 * 0.5) = 1200; 600 + 500 = 1100 < 1200. The summary cap - // stays high enough for the live model to emit the required checkpoint - // sections; a truncated checkpoint fails closed and leaves no summary. + // Tiny window so a couple of steps crosses the threshold. The generation + // cap is deliberately larger than the final checkpoint because + // reasoning-capable APIs count reasoning tokens against the provider output + // budget even though those blocks are stripped before the checkpoint is + // stored. ctx = await codingHarness(workdir, { compact: { contextWindow: 2400, thresholdRatio: 0.5, retainTokens: 500, - summarizationMaxTokens: 600, + maxTokens: 2048, }, persistenceRoot: './.sessions', }) diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 2b13666033..5d4f2c755e 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -10,8 +10,8 @@ The abstract contract states only WHAT compaction does; this backend owns every - **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). - **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. -- **Single-pass convergence** — `resolveConfig()` rejects (throws) any config where `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. The invariant guarantees the post-compaction history (the bounded summary plus the retained recent tail) is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction. The bound is strict (`>=` rejects) because the token-pressure gate declines only when the estimate is `< threshold` — a post-compaction history sitting exactly at the threshold would re-trigger. -- **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. 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. +- **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; reasoning blocks from reasoning-capable APIs are stripped before the checkpoint is stored. 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. - **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 `` 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, no-veto) 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()`). @@ -27,7 +27,8 @@ The abstract contract states only WHAT compaction does; this backend owns every | `thresholdRatio` | `0.8` | Compact when estimated usage exceeds this fraction of the window. | | `retainTokens` | `20480` | Tokens of recent context to keep intact. | | `summarizationModel` | `''` | Model for summarization (empty → use the agent's model). | -| `summarizationMaxTokens` | `2048` | Max tokens for the summary response. | +| `maxTokens` | `8192` | Provider generation cap for the summarization call; may include reasoning tokens. | +| `compactionRetries` | `1` | Extra compaction attempts after the first if the compacted surface remains over threshold. | | `auto` | `true` | Register the `agent/pre-step` auto-compaction listener. Set `false` for manual-only. | ## Usage diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index e474be7cd9..7d84b52511 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -290,7 +290,7 @@ export class BasicCompactService extends CompactService { content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }], }], system: SUMMARIZE_SYSTEM_PROMPT, - maxTokens: this.config.summarizationMaxTokens, + maxTokens: this.config.maxTokens, } // exactOptionalPropertyTypes: only set `signal` when present — assigning // `undefined` to an optional `signal?: AbortSignal` is a type error. @@ -306,7 +306,12 @@ export class BasicCompactService extends CompactService { const error = finishError(assembler.finish) if (error) throw error - return assembler.message().content + const summary = this._stripReasoning(assembler.message().content) + if (!summary.some(block => block.type === 'text' && block.text.trim().length > 0)) { + throw new Error('summarization produced no non-reasoning summary content') + } + + return summary } // ---- Core API (implements the abstract contract) ---- @@ -345,62 +350,28 @@ export class BasicCompactService extends CompactService { signal: AbortSignal, ): Promise { const session = agent.session - const messages = session.deriveMessages() - const totalTokens = this.estimateTokens(messages, fullSystemPrompt) - const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio) - if (totalTokens < threshold) return null + let result: CompactionResult | null = null + for (let attempt = 0; attempt <= this.config.compactionRetries; attempt++) { + const totalTokens = this.estimateTokens(session.deriveMessages(), fullSystemPrompt) + if (totalTokens < threshold) return result - const nodes = session.surface.nodes - if (nodes.length === 0) return null + const range = this._compactableRange(session) + if (range === null) { + if (result === null) return null + break + } - const events = session.events - const retainBudget = this.config.retainTokens - - // Walk tail→head summing per-node token estimates. `keepFromIdx` is the - // index of the OLDEST node we retain verbatim; everything strictly older - // (`[0, keepFromIdx - 1]`) is the compactable range. - let accumulated = 0 - let keepFromIdx = nodes.length // nothing retained yet - for (let i = nodes.length - 1; i >= 0; i--) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const node = nodes[i]! - const event = events[node.seq] - /* v8 ignore next -- node.seq is a surface-node seq, always a valid log index by construction */ - if (event) accumulated += this.estimateEventTokens(event) - keepFromIdx = i - if (accumulated >= retainBudget) break + result = await this.compactRegion(session, range.start, range.end, agent, turn, step, signal) } - // The whole surface fits the retain budget — nothing to compact. - if (keepFromIdx === 0) return null + const totalTokens = this.estimateTokens(session.deriveMessages(), fullSystemPrompt) + if (totalTokens < threshold) return result - // Round the cutoff to a tool-pairing boundary: if the cut before - // `nodes[keepFromIdx]` is unbalanced (an unanswered tool-call sits before - // it — i.e. it is mid-step), extend the retained side head-ward until the - // cut is balanced, so the compacted range ends without splitting an - // assistant↔result pair. A node that belongs to no step is already a - // balanced (free) boundary. Decline if no balanced cut exists at or below - // `keepFromIdx` (the compactable range is only an un-splittable open tail - // step — retry once it closes). - while (keepFromIdx > 0) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!.seq)) break - keepFromIdx -= 1 - } - if (keepFromIdx === 0) return null - - // The compacted range is [head … keepFromIdx - 1], anchored at the head. - // The cutoff node `nodes[keepFromIdx - 1]` is necessarily a balanced END: - // the retained start `nodes[keepFromIdx]` opens on a balanced cut, and that - // same cut is the cut AFTER `nodes[keepFromIdx - 1]` — so no separate end - // check is needed. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const firstSeq = nodes[0]!.seq - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const cutoffSeq = nodes[keepFromIdx - 1]!.seq - - return this.compactRegion(session, firstSeq, cutoffSeq, agent, turn, step, signal) + throw new Error( + `compaction still above threshold after ${this.config.compactionRetries + 1} compaction attempts ` + + `(${totalTokens} estimated tokens >= threshold ${threshold})`, + ) } override async compactRegion( @@ -482,7 +453,12 @@ export class BasicCompactService extends CompactService { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion shadowedTokenCount += this.estimateEventTokens(session.events[seq]!) } - + const summaryTokenCount = this.estimateContentTokens(summary) + if (summaryTokenCount >= shadowedTokenCount) { + throw new Error( + `summary is not smaller than the shadowed content (${summaryTokenCount} estimated tokens >= ${shadowedTokenCount})`, + ) + } // --- Provenance record (log-only) --- const summaryEvent = session.append('compact/summary', { summary, @@ -580,6 +556,72 @@ export class BasicCompactService extends CompactService { return false } + /** Resolve the next head-anchored compactable surface range, or `null`. */ + private _compactableRange(session: Session): { start: number; end: number } | null { + const nodes = session.surface.nodes + if (nodes.length === 0) return null + + const events = session.events + const retainBudget = this.config.retainTokens + + // Walk tail→head summing per-node token estimates. `keepFromIdx` is the + // index of the OLDEST node we retain verbatim; everything strictly older + // (`[0, keepFromIdx - 1]`) is the compactable range. + let accumulated = 0 + let keepFromIdx = nodes.length // nothing retained yet + for (let i = nodes.length - 1; i >= 0; i--) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const node = nodes[i]! + const event = events[node.seq] + /* v8 ignore next -- node.seq is a surface-node seq, always a valid log index by construction */ + if (event) accumulated += this.estimateEventTokens(event) + keepFromIdx = i + if (accumulated >= retainBudget) break + } + + // The whole surface fits the retain budget — nothing to compact. + if (keepFromIdx === 0) return null + + // Round the cutoff to a tool-pairing boundary: if the cut before + // `nodes[keepFromIdx]` is unbalanced (an unanswered tool-call sits before + // it — i.e. it is mid-step), extend the retained side head-ward until the + // cut is balanced, so the compacted range ends without splitting an + // assistant↔result pair. A node that belongs to no step is already a + // balanced (free) boundary. Decline if no balanced cut exists at or below + // `keepFromIdx` (the compactable range is only an un-splittable open tail + // step — retry once it closes). + while (keepFromIdx > 0) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!.seq)) break + keepFromIdx -= 1 + } + if (keepFromIdx === 0) return null + + // The compacted range is [head … keepFromIdx - 1], anchored at the head. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const firstSeq = nodes[0]!.seq + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const cutoffSeq = nodes[keepFromIdx - 1]!.seq + return { start: firstSeq, end: cutoffSeq } + } + + /** Remove reasoning blocks from model-produced summary content before storing it. */ + private _stripReasoning(blocks: readonly ContentBlock[]): ContentBlock[] { + const stripped: ContentBlock[] = [] + for (const block of blocks) { + switch (block.type) { + case 'reasoning': + break + case 'tool-result': + stripped.push({ ...block, content: this._stripReasoning(block.content) }) + break + default: + stripped.push(block) + } + } + return stripped + } + /** * The turn number of the currently OPEN turn — a `turn/start` not yet * followed by its `turn/end` — or `null` if the session has no open turn. diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index 13365b7ed1..8c4753c84f 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -19,8 +19,10 @@ export interface BasicCompactConfig { retainTokens?: number /** Model to use for summarization (default '' — uses the agent's model). */ summarizationModel?: string - /** Maximum tokens for the summarization response (default 2048). */ - summarizationMaxTokens?: number + /** Provider generation cap for the summarization call (default 8192). */ + maxTokens?: number + /** Extra compaction attempts when the first compacted surface is still over threshold (default 1). */ + compactionRetries?: number /** Enable automatic compaction on the `agent/pre-step` seam (default true). */ auto?: boolean } @@ -34,40 +36,52 @@ export const DEFAULTS: ResolvedConfig = { thresholdRatio: 0.8, retainTokens: 20480, summarizationModel: '', - summarizationMaxTokens: 2048, + maxTokens: 8192, + compactionRetries: 1, auto: true, } /** - * Apply defaults to a partial config and enforce the approximate convergence - * invariant. + * Apply defaults to a partial config and reject nonsensical numeric knobs. * - * `summarizationMaxTokens + retainTokens` must be strictly BELOW the compaction - * threshold (`contextWindow * thresholdRatio`). The invariant bounds the two - * variable pieces of post-compaction history — the summary and the retained - * recent tail — but it is intentionally approximate: checkpoint framing, - * per-message role overhead, system-prompt size, and the char/4 estimator's - * error can still leave a narrow accepted config near the threshold. The bound - * is strict (`>=` rejects) because `compactIfNeeded` declines only when the - * estimate is `< threshold`: a post-compaction history sitting EXACTLY at the - * threshold would re-trigger on the next check. Pre-release we reject rather - * than clamp: a config that cannot satisfy even this structural bound is a bug - * at the call site, not something to silently paper over. - * - * @throws if `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. + * 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 + * of unpredictable size. The backend instead enforces convergence dynamically: + * each committed summary must be smaller than the content it shadows, and + * `compactIfNeeded` may re-compact up to `compactionRetries` extra times before + * throwing if the surface still exceeds the threshold. */ export function resolveConfig(config: BasicCompactConfig): ResolvedConfig { const resolved = { ...DEFAULTS, ...config } - const threshold = Math.floor(resolved.contextWindow * resolved.thresholdRatio) - const postCompactionFloor = resolved.summarizationMaxTokens + resolved.retainTokens - if (postCompactionFloor >= threshold) { - throw new Error( - `BasicCompactConfig: summarizationMaxTokens (${resolved.summarizationMaxTokens}) + ` - + `retainTokens (${resolved.retainTokens}) = ${postCompactionFloor} is not below the compaction ` - + `threshold contextWindow * thresholdRatio = ${threshold}; post-compaction history would ` - + 'stay at/over threshold and re-compact endlessly. Lower retainTokens/summarizationMaxTokens ' - + 'or raise contextWindow/thresholdRatio.', - ) + + assertPositiveInteger('contextWindow', resolved.contextWindow) + assertRatio('thresholdRatio', resolved.thresholdRatio) + assertNonNegativeInteger('retainTokens', resolved.retainTokens) + assertPositiveInteger('maxTokens', resolved.maxTokens) + assertNonNegativeInteger('compactionRetries', resolved.compactionRetries) + if (typeof resolved.summarizationModel !== 'string') { + throw new Error('BasicCompactConfig: summarizationModel must be a string.') + } + if (typeof resolved.auto !== 'boolean') { + throw new Error('BasicCompactConfig: auto must be a boolean.') } return resolved } + +function assertPositiveInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive integer.`) + } +} + +function assertNonNegativeInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`BasicCompactConfig: ${name} (${value}) must be a non-negative integer.`) + } +} + +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].`) + } +} diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index fbbb8258c5..73fc6246e6 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -17,14 +17,18 @@ const SIGNAL = new AbortController().signal * predictable token estimate, for deterministic unit tests of the algorithm. */ class TestCompactService extends BasicCompactService { + private readonly summaryOutputs = new WeakSet() /** Track calls to summarize for test assertions. */ summarizeCalls: { text: string; model: string }[] = [] /** The fixed summary to return. */ mockSummary: ContentBlock[] = [{ type: 'text', text: 'Test summary of compacted content.' }] + /** Per-call summaries; when set, each summarize() call shifts one value. */ + mockSummaryQueue: ContentBlock[][] = [] /** If set, summarize() throws this error. */ summarizeError: Error | null = null override estimateContentTokens(blocks: readonly ContentBlock[]): number { + if (this.summaryOutputs.has(blocks)) return blocks.length * 2 // 10 tokens per block — predictable for retention/threshold math. return blocks.length * 10 } @@ -33,18 +37,15 @@ class TestCompactService extends BasicCompactService { const model = this.config.summarizationModel || agent.options.model || '' this.summarizeCalls.push({ text, model }) if (this.summarizeError) throw this.summarizeError - return this.mockSummary + const summary = this.mockSummaryQueue.shift() ?? this.mockSummary + this.summaryOutputs.add(summary) + return summary } } -/** - * Create a test service with a throwaway context (auto disabled — no model). - * A small `summarizationMaxTokens` baseline keeps the convergence invariant - * (`summarizationMaxTokens + retainTokens <= contextWindow * thresholdRatio`) - * satisfied for the tiny windows these tests use; a test may override it. - */ +/** Create a test service with a throwaway context (auto disabled — no model). */ function createTestService(config: BasicCompactConfig = {}): TestCompactService { - return new TestCompactService(new Context(), { auto: false, summarizationMaxTokens: 1, ...config }) + return new TestCompactService(new Context(), { auto: false, ...config }) } /** @@ -182,7 +183,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai // region always ends on a step boundary, so no step's tool-call is split // from its result. retainTokens=55 keeps the recent tail; the older steps // compact intact. - const svc = createTestService({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 55 }) + const svc = createTestService({ contextWindow: 280, thresholdRatio: 0.5, retainTokens: 55 }) const session = toolTurnSession(3) const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL) @@ -520,7 +521,7 @@ describe('BasicCompactService.compactIfNeeded', () => { }) it('walks tail→head and retains nodes within token budget', async () => { - const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.2, retainTokens: 15 }) + const svc = createTestService({ contextWindow: 350, thresholdRatio: 0.2, retainTokens: 15 }) const session = multiTurnSession(5, 1) // 10 surface nodes = ~100 tokens const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL) @@ -531,13 +532,12 @@ describe('BasicCompactService.compactIfNeeded', () => { }) it('returns null when the whole surface fits the retain budget (over threshold by role/system overhead)', async () => { - // threshold = floor(470*0.1) = 47. The 4 surface nodes weigh 10 each (raw 40 + // threshold = floor(480*0.1) = 48. The 4 surface nodes weigh 10 each (raw 40 // for the retention walk), but the derived estimate adds 4 role tokens per - // message → 56 ≥ 47, so the threshold check passes and the walk runs. The + // message → 56 ≥ 48, so the threshold check passes and the walk runs. The // walk accumulates all 40 < retainTokens (45) without crossing the budget, - // so keepFromIdx reaches 0 and compaction declines. The invariant holds: - // summarizationMaxTokens (1) + retainTokens (45) = 46 < threshold 47. - const svc = createTestService({ contextWindow: 470, thresholdRatio: 0.1, retainTokens: 45 }) + // so keepFromIdx reaches 0 and compaction declines. + const svc = createTestService({ contextWindow: 480, thresholdRatio: 0.1, retainTokens: 45 }) const session = multiTurnSession(2, 1) expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull() }) @@ -553,7 +553,7 @@ describe('BasicCompactService.compactIfNeeded', () => { // verbatim (protectedIdx = first open-turn node = 0), so compactIfNeeded // returned null and shadowedSeqs would be empty — the runaway turn could // never compact and the next model call would overflow the window. - const svc = createTestService({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 25 }) + const svc = createTestService({ contextWindow: 800, thresholdRatio: 0.1, retainTokens: 25 }) const s = new Session(SessionId('runaway')) // ONE open turn with 5 closed steps; each step is [asst(tool-call), result]. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -598,7 +598,7 @@ describe('BasicCompactService.compactIfNeeded', () => { // never stranded. retainTokens=25 leaves a couple of retained nodes after // the first compaction (so the surface is [summary, …retained], not just // [summary]). - const svc = createTestService({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 25 }) + const svc = createTestService({ contextWindow: 800, thresholdRatio: 0.1, retainTokens: 25 }) const s = multiTurnSession(4, 1) // turns 1-4 closed, turn 5 open (no surface yet) const first = await compactIfNeeded(svc, s, '', 'm', SIGNAL) @@ -623,6 +623,45 @@ describe('BasicCompactService.compactIfNeeded', () => { const turn5UserSeq = s.events.find(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text === 'turn 5 work'))!.seq expect(second!.shadowedSeqs).not.toContain(turn5UserSeq) }) + + it('re-compacts smaller summaries until the post-compaction surface drops below threshold', async () => { + const svc = createTestService({ + contextWindow: 100, + thresholdRatio: 0.5, + retainTokens: 10, + compactionRetries: 2, + }) + svc.mockSummaryQueue = [ + Array.from({ length: 4 }, (_, index) => ({ type: 'text', text: `first ${index}` })), + [{ type: 'text', text: 'second' }], + ] + const session = multiTurnSession(4, 1) + + const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL) + + expect(result).not.toBeNull() + expect(svc.summarizeCalls).toHaveLength(2) + expect(session.events.filter(e => e.type === 'compact/summary')).toHaveLength(2) + expect(svc.estimateTokens(session.deriveMessages(), '')).toBeLessThan(50) + }) + + it('throws after the configured re-compaction attempts still leave the surface above threshold', async () => { + const svc = createTestService({ + contextWindow: 100, + thresholdRatio: 0.5, + retainTokens: 10, + compactionRetries: 1, + }) + svc.mockSummaryQueue = [ + Array.from({ length: 4 }, (_, index) => ({ type: 'text', text: `first ${index}` })), + Array.from({ length: 3 }, (_, index) => ({ type: 'text', text: `second ${index}` })), + ] + const session = multiTurnSession(4, 1) + + await expect(compactIfNeeded(svc, session, '', 'm', SIGNAL)) + .rejects.toThrow(/still above threshold after 2 compaction attempts/) + expect(svc.summarizeCalls).toHaveLength(2) + }) }) describe('BasicCompactService replay equivalence', () => { @@ -753,31 +792,31 @@ describe('BasicCompactService HMR safety', () => { }) }) -describe('BasicCompactService convergence invariant (config)', () => { - it('throws when summarizationMaxTokens + retainTokens exceeds the threshold', () => { - // threshold = floor(1000 * 0.5) = 500; 200 + 400 = 600 is not below 500 → reject. - expect(() => new BasicCompactService(new Context(), { - auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 400, summarizationMaxTokens: 200, - })).toThrow(/not below the compaction threshold/) +describe('BasicCompactService config validation', () => { + it('rejects invalid numeric config values', () => { + expect(() => new BasicCompactService(new Context(), { auto: false, contextWindow: 0 })).toThrow(/contextWindow .* positive integer/) + expect(() => new BasicCompactService(new Context(), { auto: false, thresholdRatio: 0 })).toThrow(/thresholdRatio .* \(0, 1\]/) + expect(() => new BasicCompactService(new Context(), { auto: false, thresholdRatio: 1.1 })).toThrow(/thresholdRatio .* \(0, 1\]/) + expect(() => new BasicCompactService(new Context(), { auto: false, retainTokens: -1 })).toThrow(/retainTokens .* non-negative integer/) + expect(() => new BasicCompactService(new Context(), { auto: false, maxTokens: 0 })).toThrow(/maxTokens .* positive integer/) + expect(() => new BasicCompactService(new Context(), { auto: false, compactionRetries: -1 })) + .toThrow(/compactionRetries .* non-negative integer/) + expect(() => new BasicCompactService(new Context(), { auto: false, summarizationModel: 1 } as unknown as BasicCompactConfig)) + .toThrow(/summarizationModel must be a string/) + expect(() => new BasicCompactService(new Context(), { auto: 'no' } as unknown as BasicCompactConfig)) + .toThrow(/auto must be a boolean/) }) - it('rejects the boundary case (sum equals the threshold — would re-trigger)', () => { - // threshold = floor(1000 * 0.5) = 500; 100 + 400 = 500 is NOT below 500, so - // post-compaction history would sit exactly at threshold and re-compact. + it('accepts a large retain budget because convergence is enforced dynamically', () => { expect(() => new BasicCompactService(new Context(), { - auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 400, summarizationMaxTokens: 100, - })).toThrow(/not below the compaction threshold/) - }) - - it('accepts the case just below the threshold', () => { - // threshold = floor(1000 * 0.5) = 500; 99 + 400 = 499 < 500 → allowed. - expect(() => new BasicCompactService(new Context(), { - auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 400, summarizationMaxTokens: 99, + auto: false, + contextWindow: 1000, + thresholdRatio: 0.5, + retainTokens: 900, })).not.toThrow() }) - it('the default config satisfies the invariant', () => { - // 2048 + 20480 = 22528 ≤ floor(128000 * 0.8) = 102400. + it('the default config is valid', () => { expect(() => new BasicCompactService(new Context(), { auto: false })).not.toThrow() }) }) @@ -797,6 +836,41 @@ class ScriptedAdapter extends LlmAdapter { } } +/** An adapter that emits arbitrary content blocks, preserving reasoning/text shape. */ +class BlocksAdapter extends LlmAdapter { + lastOptions: GenerateOptions | null = null + constructor(private blocks: readonly ContentBlock[]) { + super() + } + + async * stream(options: GenerateOptions): AsyncIterable { + this.lastOptions = options + for (const [index, block] of this.blocks.entries()) { + yield { type: 'block-start', index, blockType: block.type } + switch (block.type) { + case 'text': + yield { type: 'text-delta', index, text: block.text } + break + case 'reasoning': + yield { type: 'reasoning-delta', index, text: block.text } + break + default: + yield { type: 'block-end', index, block } + } + } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +/** Wire a real LlmService + arbitrary-block adapter into a context. */ +async function ctxWithBlocks(blocks: readonly ContentBlock[], model = 'test-model'): Promise<{ ctx: Context; adapter: BlocksAdapter }> { + const ctx = new Context() + await ctx.plugin(LlmService) + const adapter = new BlocksAdapter(blocks) + ctx.llm.registerAdapter([model], adapter) + return { ctx, adapter } +} + /** Wire a real LlmService + scripted adapter into a context. */ async function ctxWithModel(summaryText: string, model = 'test-model'): Promise<{ ctx: Context; adapter: ScriptedAdapter }> { const ctx = new Context() @@ -858,7 +932,7 @@ function summarize(svc: BasicCompactService, text: string, model: string) { describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('summarizes via the registered adapter and returns its content', async () => { const { ctx, adapter } = await ctxWithModel('SUMMARY TEXT') - const svc = new BasicCompactService(ctx, { auto: false, summarizationMaxTokens: 512 }) + const svc = new BasicCompactService(ctx, { auto: false, maxTokens: 512 }) const summary = await summarize(svc, 'User: hi\n\nAssistant: hello', 'test-model') expect(summary).toEqual([{ type: 'text', text: 'SUMMARY TEXT' }]) @@ -869,6 +943,37 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { expect(adapter.lastOptions!.messages[0]!.content[0]).toMatchObject({ type: 'text' }) }) + it('uses maxTokens as the summarization provider cap', async () => { + const { ctx, adapter } = await ctxWithModel('SUMMARY TEXT') + const svc = new BasicCompactService(ctx, { + auto: false, + maxTokens: 50, + }) + + await summarize(svc, 'User: hi', 'test-model') + + expect(adapter.lastOptions!.maxTokens).toBe(50) + }) + + it('strips reasoning blocks from the stored summary', async () => { + const { ctx } = await ctxWithBlocks([ + { type: 'reasoning', text: 'private chain of thought' }, + { type: 'text', text: 'PUBLIC SUMMARY' }, + ]) + const svc = new BasicCompactService(ctx, { auto: false }) + + const summary = await summarize(svc, 'User: hi', 'test-model') + + expect(summary).toEqual([{ type: 'text', text: 'PUBLIC SUMMARY' }]) + }) + + it('throws when stripping reasoning leaves no summary text', async () => { + const { ctx } = await ctxWithBlocks([{ type: 'reasoning', text: 'private only' }]) + const svc = new BasicCompactService(ctx, { auto: false }) + + await expect(summarize(svc, 'User: hi', 'test-model')).rejects.toThrow(/no non-reasoning summary content/) + }) + it('throws when no model is provided', async () => { const { ctx } = await ctxWithModel('x') const svc = new BasicCompactService(ctx, { auto: false }) @@ -930,6 +1035,17 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { // The raw summary is wrapped in the checkpoint framing on the surface. expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'CONDENSED' }) }) + + it('rejects a summary that is not smaller than the shadowed content', async () => { + const svc = createTestService({ auto: false }) + const session = multiTurnSession(2, 1) + const nodes = session.surface.nodes + svc.mockSummary = Array.from({ length: 20 }, (_, index) => ({ type: 'text', text: `large ${index}` })) + + await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')) + .rejects.toThrow(/summary is not smaller than the shadowed content/) + expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) + }) }) describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => { @@ -940,7 +1056,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => it('compacts (mutating the surface) when over threshold', async () => { const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20, summarizationMaxTokens: 50 }) + void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 }) const session = multiTurnSession(5, 1) // 10 surface nodes const agent = stubAgent(session, 'test-model') const before = session.surface.nodes.length @@ -956,7 +1072,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => it('compacts mid-turn on steps after the first (the surface grows within a turn)', async () => { const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10, summarizationMaxTokens: 30 }) + void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) const session = multiTurnSession(3, 1) // over the 0.5 threshold const agent = stubAgent(session, 'test-model') @@ -981,7 +1097,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => // surface is untouched (the loop derives the full history). const ctx = new Context() await ctx.plugin(LlmService) - void new BasicCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10, summarizationMaxTokens: 1 }) + void new BasicCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10 }) const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'missing-model') const before = session.surface.nodes.length @@ -994,7 +1110,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => it('does not register the listener when auto is false', async () => { const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, { auto: false, contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5, summarizationMaxTokens: 1 }) + void new BasicCompactService(ctx, { auto: false, contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5 }) const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'test-model') @@ -1008,7 +1124,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => options.model = 'routed-model' return next() }) - void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20, summarizationMaxTokens: 50 }) + void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 }) const session = multiTurnSession(5, 1) const agent = stubAgent(session) @@ -1025,7 +1141,6 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20, - summarizationMaxTokens: 50, }) const session = multiTurnSession(5, 1) const agent = stubAgent(session, 'test-model') @@ -1139,14 +1254,16 @@ describe('BasicCompactService edge cases', () => { expect(svc.estimateContentTokens([unknown])).toBeGreaterThan(0) }) - it('compacts once without re-checking a post-compaction threshold', async () => { + it('auto-compaction reports bounded retry exhaustion after committing a smaller summary', async () => { const { ctx } = await ctxWithModel('SUMMARY') - // Even with a window so tiny the post-compaction history still exceeds the - // threshold, the agnostic listener does NOT re-gate or warn — it compacts - // once (the single check lives in compactIfNeeded) and proceeds. const warnings: string[] = [] ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn - void new BasicCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 5, summarizationMaxTokens: 5 }) + void new BasicCompactService(ctx, { + contextWindow: 300, + thresholdRatio: 0.1, + retainTokens: 5, + compactionRetries: 0, + }) const session = multiTurnSession(4, 1) const agent = stubAgent(session, 'test-model') @@ -1154,8 +1271,7 @@ describe('BasicCompactService edge cases', () => { expect(session.events.some(e => e.type === 'compact/summary')).toBe(true) // The surface was mutated; the head message is the framed summary checkpoint. expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) - // No cascade warning is emitted. - expect(warnings.length).toBe(0) + expect(warnings.some(w => w.includes('still above threshold after 1 compaction attempts'))).toBe(true) }) it('rejects compaction when no turn is open (compaction events must be turn-enclosed)', async () => { @@ -1225,7 +1341,7 @@ describe('BasicCompactService edge cases', () => { const { ctx } = await ctxWithModel('SUMMARY') const warnings: string[] = [] ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn - const svc = new TestCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10, summarizationMaxTokens: 10 }) + const svc = new TestCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10 }) svc.summarizeError = 'boom' as unknown as Error const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'test-model') @@ -1243,7 +1359,7 @@ describe('BasicCompactService edge cases', () => { // A large system prompt pushes the listener's estimate over threshold, but // retainTokens is huge so compactIfNeeded walks everything and returns null. // threshold = floor(2000*0.1) = 200; invariant: 5 + 150 = 155 ≤ 200. - const svc = new TestCompactService(ctx, { contextWindow: 2000, thresholdRatio: 0.1, retainTokens: 150, summarizationMaxTokens: 5 }) + const svc = new TestCompactService(ctx, { contextWindow: 2000, thresholdRatio: 0.1, retainTokens: 150 }) const session = multiTurnSession(2, 1) const agent = stubAgent(session, 'test-model') const bigSystem = 'x'.repeat(900) // ceil(900/4)=225 > threshold 200 diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index e12861a43a..dddb636b21 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -91,14 +91,12 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr }, })) // Tiny window so a couple of tool steps cross the threshold and compaction - // fires within the runaway turn. Convergence invariant holds: - // summarizationMaxTokens(1) + retainTokens(20) = 21 <= floor(60*0.5) = 30. + // fires within the runaway turn. const compact = new ReproCompactService(ctx, { auto: true, - contextWindow: 60, + contextWindow: 64, thresholdRatio: 0.5, retainTokens: 20, - summarizationMaxTokens: 1, }) return { ctx, compact } } From 4bda95b5897c471dc6befd48e664bcbed298e8f0 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 29 Jun 2026 18:04:11 +0800 Subject: [PATCH 27/46] ci: remove gitlab mirror workflow --- .github/workflows/mirror-to-gitlab.yml | 33 -------------------------- 1 file changed, 33 deletions(-) delete mode 100644 .github/workflows/mirror-to-gitlab.yml diff --git a/.github/workflows/mirror-to-gitlab.yml b/.github/workflows/mirror-to-gitlab.yml deleted file mode 100644 index 671915ba49..0000000000 --- a/.github/workflows/mirror-to-gitlab.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Mirror to GitLab - -on: - push: - branches: ['**'] - tags: ['**'] - delete: - workflow_dispatch: - -concurrency: - group: mirror-to-gitlab - cancel-in-progress: false - -jobs: - mirror: - runs-on: ubuntu-latest - steps: - - name: Checkout full history - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup SSH - run: | - mkdir -p ~/.ssh - echo "${{ secrets.GITLAB_SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 - chmod 600 ~/.ssh/id_ed25519 - ssh-keyscan -t rsa,ecdsa,ed25519 gitlab.com >> ~/.ssh/known_hosts - - - name: Push to GitLab - run: | - git remote add gitlab "${{ secrets.GITLAB_REPO_URL }}" - git push --mirror gitlab From 170643ec9b9daea0311f1a2c3c20a16c46f65f72 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 29 Jun 2026 18:04:59 +0800 Subject: [PATCH 28/46] test(compact-basic): restore coverage gate --- packages/compact/compact-basic/README.md | 2 +- packages/compact/compact-basic/src/index.ts | 34 +++++++------- .../compact-basic/tests/compact-basic.spec.ts | 47 +++++++++++++++++-- 3 files changed, 62 insertions(+), 21 deletions(-) diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 5d4f2c755e..6e8eb0765d 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -11,7 +11,7 @@ The abstract contract states only WHAT compaction does; this backend owns every - **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). - **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; reasoning blocks from reasoning-capable APIs are stripped before the checkpoint is stored. 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 (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. - **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 `` 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, no-veto) 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()`). diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 7d84b52511..9d0849b19a 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -306,9 +306,9 @@ export class BasicCompactService extends CompactService { const error = finishError(assembler.finish) if (error) throw error - const summary = this._stripReasoning(assembler.message().content) + const summary = this._textOnly(assembler.message().content) if (!summary.some(block => block.type === 'text' && block.text.trim().length > 0)) { - throw new Error('summarization produced no non-reasoning summary content') + throw new Error('summarization produced no text summary content') } return summary @@ -358,7 +358,9 @@ export class BasicCompactService extends CompactService { const range = this._compactableRange(session) if (range === null) { + /* v8 ignore else -- defensive for non-standard subclass mutations; the concrete replace keeps a compactable head checkpoint. */ if (result === null) return null + /* v8 ignore next -- paired with the ignored defensive branch above. */ break } @@ -605,21 +607,19 @@ export class BasicCompactService extends CompactService { return { start: firstSeq, end: cutoffSeq } } - /** Remove reasoning blocks from model-produced summary content before storing it. */ - private _stripReasoning(blocks: readonly ContentBlock[]): ContentBlock[] { - const stripped: ContentBlock[] = [] - for (const block of blocks) { - switch (block.type) { - case 'reasoning': - break - case 'tool-result': - stripped.push({ ...block, content: this._stripReasoning(block.content) }) - break - default: - stripped.push(block) - } - } - return stripped + /** + * Keep ONLY text blocks from the model-produced summary before storing it. + * + * The summary lands on the surface as a synthesized `user/message` (see + * {@link _frameSummary}), so the only block type that is both useful and safe + * there is `text`. A model assistant message can otherwise carry `reasoning` + * (private chain-of-thought, must not leak into the durable checkpoint) and + * `tool-call` blocks — and a surviving `tool-call` in a user message would be + * an orphaned call with no matching `tool-result`, exactly the tool-pairing + * breakage compaction works to avoid. Filtering to text drops both. + */ + private _textOnly(blocks: readonly ContentBlock[]): ContentBlock[] { + return blocks.filter((block): block is Extract => block.type === 'text') } /** diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 73fc6246e6..906841c39f 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -520,6 +520,24 @@ describe('BasicCompactService.compactIfNeeded', () => { expect(result!.shadowedSeqs.length).toBeGreaterThan(0) }) + it('returns the first compaction result when a zero-retry pass converges after the loop', async () => { + // With compactionRetries=0 there is no next-loop threshold check after the + // first mutation, so the success path is the post-loop `return result`. + const svc = createTestService({ + contextWindow: 100, + thresholdRatio: 0.7, + retainTokens: 10, + compactionRetries: 0, + }) + const session = multiTurnSession(3, 1) // 6 derived messages = 84 estimated tokens. + + const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL) + + expect(result).not.toBeNull() + expect(session.events.filter(e => e.type === 'compact/summary')).toHaveLength(1) + expect(svc.estimateTokens(session.deriveMessages(), '')).toBeLessThan(70) + }) + it('walks tail→head and retains nodes within token budget', async () => { const svc = createTestService({ contextWindow: 350, thresholdRatio: 0.2, retainTokens: 15 }) const session = multiTurnSession(5, 1) // 10 surface nodes = ~100 tokens @@ -955,10 +973,13 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { expect(adapter.lastOptions!.maxTokens).toBe(50) }) - it('strips reasoning blocks from the stored summary', async () => { + it('keeps only text blocks in the stored summary (drops reasoning and tool-call)', async () => { const { ctx } = await ctxWithBlocks([ { type: 'reasoning', text: 'private chain of thought' }, { type: 'text', text: 'PUBLIC SUMMARY' }, + // A model reply can carry a tool-call; it must not survive into the + // synthesized user/message summary as an orphaned call. + { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }, ]) const svc = new BasicCompactService(ctx, { auto: false }) @@ -967,11 +988,11 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { expect(summary).toEqual([{ type: 'text', text: 'PUBLIC SUMMARY' }]) }) - it('throws when stripping reasoning leaves no summary text', async () => { + it('throws when no text block remains after filtering', async () => { const { ctx } = await ctxWithBlocks([{ type: 'reasoning', text: 'private only' }]) const svc = new BasicCompactService(ctx, { auto: false }) - await expect(summarize(svc, 'User: hi', 'test-model')).rejects.toThrow(/no non-reasoning summary content/) + await expect(summarize(svc, 'User: hi', 'test-model')).rejects.toThrow(/no text summary content/) }) it('throws when no model is provided', async () => { @@ -1070,6 +1091,26 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) }) + it('logs compaction details when auto-compaction returns a converged result', async () => { + const ctx = new Context() + const infos: string[] = [] + ctx.logger.info = ((msg: string) => void infos.push(msg)) as typeof ctx.logger.info + void new TestCompactService(ctx, { + contextWindow: 100, + thresholdRatio: 0.7, + retainTokens: 10, + compactionRetries: 0, + }) + const session = multiTurnSession(3, 1) + const agent = stubAgent(session, 'test-model') + + await firePreStep(ctx, agent, 1, '') + + expect(session.events.filter(e => e.type === 'compact/summary')).toHaveLength(1) + expect(infos.some(msg => msg.includes('compaction: shadowed'))).toBe(true) + expect(infos.some(msg => msg.includes('estimated tokens after compaction'))).toBe(true) + }) + it('compacts mid-turn on steps after the first (the surface grows within a turn)', async () => { const { ctx } = await ctxWithModel('SUMMARY') void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) From 6ae1e229fddd7b528dbf679b092313a58ae35eb9 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 30 Jun 2026 09:40:51 +0800 Subject: [PATCH 29/46] docs(cordis): clarify serial bail semantics --- AGENTS.md | 2 +- docs/cordis-catalog/events-and-services.md | 20 +++++++++---------- .../2026-06-18-compaction-capability-seam.md | 2 +- packages/compact/compact-basic/README.md | 2 +- packages/core/agent/src/types.ts | 17 ++++++++-------- scripts/gen-cordis-catalog.ts | 4 ++-- 6 files changed, 24 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 47893c81de..d4f04b1eed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,7 +250,7 @@ In the **core** packages (`packages/llm/llm`, `packages/core/tools`, `packages/c Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-cordis-catalog` + `verify-md-wrap` + `verify-md-links` + `verify-doc-refs` + `verify-package-paths` + `verify-rfc-classification` + `verify-type-equiv`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, regenerates the cordis events/services catalog from source and fails if the committed copy is stale, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, checks that every `docs/*.md` path cited in a source comment resolves, checks that every `packages/` reference naming a real package resolves, checks that every RFC is filed under a valid class folder and listed in its index, and checks that every ` ```ts type-equiv ` doc block still matches its source type — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. -**Tag every new event with `@mode`.** The cordis events/services catalog ([docs/cordis-catalog/events-and-services.md](docs/cordis-catalog/events-and-services.md)) is GENERATED from source by `scripts/gen-cordis-catalog.ts` — never hand-edit it; run `pnpm run gen-cordis-catalog` and commit the result. When you add an event to an `interface Events` block, its JSDoc MUST carry a `@mode emit|waterfall|parallel|serial` tag (the generator hard-errors without it): use `waterfall` when the signature ends with a `next: () => …` parameter (the listener transforms or vetoes via `next()`), `parallel` when the loop awaits a fan-out with no veto (e.g. an awaited `Promise | void` checkpoint like `session/flush`), `serial` when the loop awaits listeners in registration order with no veto (e.g. an ordered surface-mutation checkpoint like `agent/pre-step`), and `emit` for plain fire-and-forget notifications. The generator also cross-checks the tag against the signature where the shape is conclusive (a trailing `next` ⇒ waterfall) and hard-errors on a contradiction. Write the rest of the event's JSDoc to stand alone — it is the catalog entry's prose. +**Tag every new event with `@mode`.** The cordis events/services catalog ([docs/cordis-catalog/events-and-services.md](docs/cordis-catalog/events-and-services.md)) is GENERATED from source by `scripts/gen-cordis-catalog.ts` — never hand-edit it; run `pnpm run gen-cordis-catalog` and commit the result. When you add an event to an `interface Events` block, its JSDoc MUST carry a `@mode emit|waterfall|parallel|serial` tag (the generator hard-errors without it): use `waterfall` when the signature ends with a `next: () => …` parameter (the listener transforms or vetoes via `next()`), `parallel` when the loop awaits a fan-out and must run every listener (e.g. an awaited `Promise | void` checkpoint like `session/flush`), `serial` when the loop awaits listeners in registration order and should isolate side effects (e.g. an ordered surface-mutation checkpoint like `agent/pre-step`; Cordis stops early if a listener returns a bail value, so `void` serial listeners must not return a semantic veto), and `emit` for plain fire-and-forget notifications. The generator also cross-checks the tag against the signature where the shape is conclusive (a trailing `next` ⇒ waterfall) and hard-errors on a contradiction. Write the rest of the event's JSDoc to stand alone — it is the catalog entry's prose. **The core-data-structures catalog is a maintained surface, not a write-once artifact.** [docs/core-data-structures/](docs/core-data-structures/core.md) catalogs the spine vocabulary (core.md) and the per-seam types (sub-pages). When a change adds, removes, or reshapes a type the catalog documents — a new `…Map` variant, a new content-block or session-event type, a field on `GenerateOptions`/`Agent`/`ToolDefinition`/a bash type, or a whole new core/seam type — update the catalog in the SAME change: edit the prose, and for a pasted ` ```ts type-equiv ` block, re-copy it verbatim and keep `scripts/type-equiv.manifest.json` 1:1 with the blocks. The `verify-type-equiv` gate catches a *drifted paste* of an already-documented type, but it canNOT tell you a brand-new core type was never documented — that judgment is on the author and the reviewer. The definition of "core" (the spine-vs-seam line) is in [core.md § What counts as "core"](docs/core-data-structures/core.md#what-counts-as-core); a genuinely spine-level new type belongs in core.md, a new capability's vocabulary on a sub-page. See [development.md](docs/development.md#documenting-types-verbatim-ts-type-equiv) for the `ts type-equiv` mechanics. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index a2bb8a4229..6a43db0b0e 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -11,7 +11,7 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary ## Events -Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto), **serial** (awaited, in registration order, no veto). +Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`). ### `agent/*` @@ -49,13 +49,13 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:249`](../../packages/core/agent/src/types.ts) #### `agent/pre-step` — serial Awaited pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step's `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`. `step` is the number of the step about to start. The loop awaits `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then opens the step and derives the request history ONCE from whatever the surface now holds. This is where compaction belongs: it mutates the session surface in place (shadowing an older range with a summary node) with its log-only `compact/*` records cleanly outside any step, and the single subsequent derive reflects the mutation — so there is no double-derive and no listener can see (or be expected to act on) an assembled `messages` array that does not exist yet. -Serial (awaited, in registration order, no veto), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform or veto, but the loop must wait for the mutation to complete before opening the step and deriving, and serial isolates listeners from each other (one finishes its surface append before the next runs). `fullSystemPrompt` is the assembled prompt a 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). +Serial (awaited in registration order), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform, but the loop must wait for the mutation to complete before opening the step and deriving. Cordis `serial` bails early if a listener returns a bail value; this event is typed and documented as `void`, so listeners must not return a semantic veto value. `fullSystemPrompt` is the assembled prompt a 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). ```ts cordis-catalog 'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise | void @@ -63,7 +63,7 @@ Serial (awaited, in registration order, no veto), not a waterfall: a listener mu Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -87,7 +87,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -111,7 +111,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:242`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -135,7 +135,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:223`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -159,7 +159,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:237`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:238`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -171,7 +171,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:231`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -511,7 +511,7 @@ The framework surface every plugin inherits, beyond the harness vocabulary above ### Inherited `ctx` members - `ctx.on / ctx.once` — Register an event listener (disposable). ([`vendor/cordis/src/events.ts:29`](../../vendor/cordis/src/events.ts)) -- `ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall` — Dispatch an event (sync / awaited / first-non-nullish / veto-chain). ([`vendor/cordis/src/events.ts:29`](../../vendor/cordis/src/events.ts)) +- `ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall` — Dispatch an event (sync / awaited / first-bail / veto-chain). ([`vendor/cordis/src/events.ts:29`](../../vendor/cordis/src/events.ts)) - `ctx.plugin / ctx.inject` — Load a plugin / declare required services. ([`vendor/cordis/src/registry.ts:144`](../../vendor/cordis/src/registry.ts)) - `ctx.effect` — Register a disposable side effect tied to the fiber. ([`vendor/cordis/src/fiber.ts:9`](../../vendor/cordis/src/fiber.ts)) - `ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin` — Low-level service-store access and binding. ([`vendor/cordis/src/reflect.ts:7`](../../vendor/cordis/src/reflect.ts)) diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index 29371a3abb..9e08df2fbd 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -46,7 +46,7 @@ messages = session.deriveMessages() ⟵ single derive, reflects the compaction request = waterfall agent/request ⟵ pure request transform (hooks, model switch) ``` -This makes the layering correct *by construction*: compaction mutates the surface, the loop derives **once** from the result (no double-derive), and at `pre-step` the assembled `messages` do not yet exist — so a listener structurally *cannot* see or be expected to act on downstream-injected context. `agent/request` reverts to a pure request transformer. Firing the seam **before** `step/start` (not inside the open step) is load-bearing for crash-safety: compaction's log-only `compact/*` records and its replacement node land *outside* any step, so the honest log structure a crash leaves (a dangling `compact/start` sitting before the synthetic `turn/end` that turn-repair appends) holds without a half-open step to reconcile. The seam is `serial` (awaited, in registration order, no veto), not `parallel`: a listener mutates the surface as a side effect — there is nothing to transform or return — and serial isolates listeners from each other so two surface-mutating listeners can never interleave their `session.append`s. +This makes the layering correct *by construction*: compaction mutates the surface, the loop derives **once** from the result (no double-derive), and at `pre-step` the assembled `messages` do not yet exist — so a listener structurally *cannot* see or be expected to act on downstream-injected context. `agent/request` reverts to a pure request transformer. Firing the seam **before** `step/start` (not inside the open step) is load-bearing for crash-safety: compaction's log-only `compact/*` records and its replacement node land *outside* any step, so the honest log structure a crash leaves (a dangling `compact/start` sitting before the synthetic `turn/end` that turn-repair appends) holds without a half-open step to reconcile. The seam is `serial` (awaited, in registration order), not `parallel`: a listener mutates the surface as a side effect — there is nothing to transform or return — and serial isolates listeners from each other so two surface-mutating listeners can never interleave their `session.append`s. Cordis `serial` does bail early if a listener returns a bail value, so `agent/pre-step` listeners are typed/documented to return `void` and must not use that bail channel as a semantic veto surface. This **amends** the original RFC's claim of "NO changes to `dsh-agent-loop`; compaction is a pure plugin." That claim was load-bearing for a wrong design — reusing `agent/request` was the mistake. Per the pre-release "foundation over blast radius" stance, adding the correct seam (one event declaration in `dsh-agent`, one awaited emit in the loop) beats preserving a no-change boast that locked in the double-derive. diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 6e8eb0765d..5b2e177997 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -14,7 +14,7 @@ The abstract contract states only WHAT compaction does; this backend owns every - **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. - **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 `` 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, no-veto) 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()`). +- **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. - **Failure handling** — the `compact/start … compact/end` bracket is a log-recorded lock: it makes a crash mid-summarization a detectable orphan (a `compact/start` with no `compact/end`), records provenance, and prevents a concurrent compaction. Two failure paths: a **crash** (the loop dies mid-summarization) leaves a dangling `compact/start` that is inert — `compact/*` events are log-only, the surface replacement never landed, so the full history derives fine and generic turn-repair closes the turn; a **recoverable** failure (summarization throws but the loop survives) appends `compact/end` with its `error` field set, leaving the surface untouched so the call proceeds with full history. Core session repair stays compaction-agnostic by design — it never learns about `compact/*`. `estimateContentTokens()` and `summarize()` are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the retention walk and surface plumbing. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 83b3dfa4e7..0edc787f9c 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -195,14 +195,15 @@ declare module 'cordis' { * no listener can see (or be expected to act on) an assembled `messages` * array that does not exist yet. * - * Serial (awaited, in registration order, no veto), not a waterfall: a - * listener mutates the surface as a side effect; there is nothing to - * transform or veto, but the loop must wait for the mutation to complete - * before opening the step and deriving, and serial isolates listeners from - * each other (one finishes its surface append before the next runs). - * `fullSystemPrompt` is the assembled prompt a 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). + * Serial (awaited in registration order), not a waterfall: a listener + * mutates the surface as a side effect; there is nothing to transform, but + * the loop must wait for the mutation to complete before opening the step + * and deriving. Cordis `serial` bails early if a listener returns a bail + * value; this event is typed and documented as `void`, so listeners must not + * return a semantic veto value. `fullSystemPrompt` is the assembled prompt a + * 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). * @mode serial */ 'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise | void diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index ea1c4b6661..de477d8ba3 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -331,7 +331,7 @@ const INHERITED_EVENTS: InheritedEntry[] = [ const INHERITED_SERVICES: InheritedEntry[] = [ { name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:29' }, - { name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-non-nullish / veto-chain).', source: 'vendor/cordis/src/events.ts:29' }, + { name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:29' }, { name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:144' }, { name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' }, { name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.', source: 'vendor/cordis/src/reflect.ts:7' }, @@ -394,7 +394,7 @@ function render(events: EventEntry[], services: ServiceEntry[]): string { '', '## Events', '', - 'Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto), **serial** (awaited, in registration order, no veto).', + 'Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`).', '', ] const scopes = [...new Set(events.map(e => e.scope))].sort() From b0eae94fc8062ab66d4776f32e484c2f7eaa1029 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 30 Jun 2026 10:56:34 +0800 Subject: [PATCH 30/46] fix pre-step cancellation and compaction convergence --- packages/compact/compact-basic/src/index.ts | 9 ++-- .../compact-basic/tests/compact-basic.spec.ts | 45 +++++++++++++++++-- packages/core/agent-loop/src/loop.ts | 20 ++++++--- .../agent-loop/tests/review-fixes.spec.ts | 2 + packages/core/agent/src/types.ts | 2 +- 5 files changed, 63 insertions(+), 15 deletions(-) diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 9d0849b19a..4c7bce00bc 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -455,10 +455,11 @@ export class BasicCompactService extends CompactService { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion shadowedTokenCount += this.estimateEventTokens(session.events[seq]!) } - const summaryTokenCount = this.estimateContentTokens(summary) - if (summaryTokenCount >= shadowedTokenCount) { + const framedSummary = this._frameSummary(summary) + const framedSummaryTokenCount = this.estimateContentTokens(framedSummary) + if (framedSummaryTokenCount >= shadowedTokenCount) { throw new Error( - `summary is not smaller than the shadowed content (${summaryTokenCount} estimated tokens >= ${shadowedTokenCount})`, + `summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${shadowedTokenCount})`, ) } // --- Provenance record (log-only) --- @@ -477,7 +478,7 @@ export class BasicCompactService extends CompactService { // The landed content is FRAMED (checkpoint preamble + tag-wrapped summary); // the compact/summary provenance event above holds the raw model output. session.append('user/message', { - content: this._frameSummary(summary), + content: framedSummary, source: { kind: 'plugin', plugin: 'compact' }, }, { surfaceOp: { op: 'replace', start, end }, diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 906841c39f..1134862793 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -12,12 +12,17 @@ import type { Agent } from '@deepseek-ai/dsh-agent' /** A never-aborted signal for the required `compactIfNeeded`/listener arg. */ const SIGNAL = new AbortController().signal +/** Long enough that the real checkpoint preamble is smaller than two fixture messages. */ +const LONG_FIXTURE_TEXT = ' Detailed fixture context that makes framed checkpoint compaction genuinely shrinking.'.repeat(20) + /** * A BasicCompactService with summarize() stubbed (no real model call) and a * predictable token estimate, for deterministic unit tests of the algorithm. */ class TestCompactService extends BasicCompactService { private readonly summaryOutputs = new WeakSet() + /** Boundary/unit tests use tiny fixtures; keep framing from dominating them unless a test opts out. */ + estimateFramedSummariesCheaply = true /** Track calls to summarize for test assertions. */ summarizeCalls: { text: string; model: string }[] = [] /** The fixed summary to return. */ @@ -29,6 +34,7 @@ class TestCompactService extends BasicCompactService { override estimateContentTokens(blocks: readonly ContentBlock[]): number { if (this.summaryOutputs.has(blocks)) return blocks.length * 2 + if (this.estimateFramedSummariesCheaply && isFramedCheckpoint(blocks)) return blocks.length * 2 // 10 tokens per block — predictable for retention/threshold math. return blocks.length * 10 } @@ -43,6 +49,15 @@ class TestCompactService extends BasicCompactService { } } +function isFramedCheckpoint(blocks: readonly ContentBlock[]): boolean { + const first = blocks[0] + const last = blocks[blocks.length - 1] + return first?.type === 'text' + && first.text.includes('') + && last?.type === 'text' + && last.text === '' +} + /** Create a test service with a throwaway context (auto disabled — no model). */ function createTestService(config: BasicCompactConfig = {}): TestCompactService { return new TestCompactService(new Context(), { auto: false, ...config }) @@ -65,12 +80,12 @@ function multiTurnSession(turns: number, messagesPerTurn: number = 2, opts: { le s.append('step/start', { turn: t, step: 1 }) for (let m = 0; m < messagesPerTurn; m++) { s.append('user/message', { - content: [{ type: 'text', text: `turn ${t} user message ${m + 1}` }], + content: [{ type: 'text', text: `turn ${t} user message ${m + 1}.${LONG_FIXTURE_TEXT}` }], source: { kind: 'user' }, }, { surfaceOp: 'append' }) s.append('assistant/message', { turn: t, step: 1, - content: [{ type: 'text', text: `turn ${t} assistant response ${m + 1}` }], + content: [{ type: 'text', text: `turn ${t} assistant response ${m + 1}.${LONG_FIXTURE_TEXT}` }], }, { surfaceOp: 'append' }) } s.append('step/end', { turn: t, step: 1 }) @@ -649,6 +664,7 @@ describe('BasicCompactService.compactIfNeeded', () => { retainTokens: 10, compactionRetries: 2, }) + svc.estimateFramedSummariesCheaply = false svc.mockSummaryQueue = [ Array.from({ length: 4 }, (_, index) => ({ type: 'text', text: `first ${index}` })), [{ type: 'text', text: 'second' }], @@ -670,6 +686,7 @@ describe('BasicCompactService.compactIfNeeded', () => { retainTokens: 10, compactionRetries: 1, }) + svc.estimateFramedSummariesCheaply = false svc.mockSummaryQueue = [ Array.from({ length: 4 }, (_, index) => ({ type: 'text', text: `first ${index}` })), Array.from({ length: 3 }, (_, index) => ({ type: 'text', text: `second ${index}` })), @@ -1067,6 +1084,26 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { .rejects.toThrow(/summary is not smaller than the shadowed content/) expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) }) + + it('rejects when the framed checkpoint is not smaller than the shadowed content', async () => { + const svc = createTestService({ auto: false }) + svc.estimateFramedSummariesCheaply = false + const session = new Session(SessionId('framed-nonshrinking')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('user/message', { content: [{ type: 'text', text: 'tiny user' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'tiny assistant' }] }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + const before = [...session.surface.nodes] + const nodes = session.surface.nodes + + await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')) + .rejects.toThrow(/summary is not smaller than the shadowed content/) + expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) + expect(session.surface.nodes).toEqual(before) + }) }) describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => { @@ -1606,8 +1643,8 @@ describe('BasicCompactService under the real invariants plugin', () => { function closedTurn(session: Session, turn: number): void { session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn, step: 1 }) - session.append('user/message', { content: [{ type: 'text', text: `turn ${turn} user` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - session.append('assistant/message', { turn, step: 1, content: [{ type: 'text', text: `turn ${turn} assistant` }] }, { surfaceOp: 'append' }) + session.append('user/message', { content: [{ type: 'text', text: `turn ${turn} user.${LONG_FIXTURE_TEXT}` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + session.append('assistant/message', { turn, step: 1, content: [{ type: 'text', text: `turn ${turn} assistant.${LONG_FIXTURE_TEXT}` }] }, { surfaceOp: 'append' }) session.append('step/end', { turn, step: 1 }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 2982a60fc8..200115af9a 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -432,16 +432,24 @@ 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. + 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 }) stepOpen = true ctx.emit('agent/step-start', agent, turn, step) - // Cancel landing in the seam / step-start window: a `cancel()` during the - // pre-step seam (it aborted `abort.signal` above) OR a synchronous - // `agent/step-start` listener that cancels. And disposal, which the earlier - // assembly check may have missed if it only checked isCancelled. 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 + // `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. if (handle.isCancelled() || handle.isDisposed()) { handle.setAbort(undefined) reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() } diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 5b02ca60c1..346d70e2f7 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -1216,6 +1216,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { const turnEnd = e.findLast(x => x.type === 'turn/end') // Disposal wins the post-seam check — reason is `disposed`. 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 @@ -1265,6 +1266,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) const turnEnd = e.findLast(x => x.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: 'user cancelled' }) + expect(e.some(x => x.type === 'step/start')).toBe(false) expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) expect(reasons).toEqual([{ kind: 'aborted', reason: 'user cancelled' }]) }) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 0edc787f9c..fc412f0e0e 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -179,7 +179,7 @@ declare module 'cordis' { */ 'agent/step-end'(agent: Agent, turn: number, step: number): void - // ---- interception seams (waterfall) ---- + // ---- step/request extension seams (serial + waterfall) ---- /** * Awaited pre-step surface-mutation checkpoint, fired once per step AFTER * `turn/start` (and after the prior step closed) but BEFORE this step's From ed5d8550ae278fb91f6baf5fe5af8e23e1935d5c Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 30 Jun 2026 11:33:40 +0800 Subject: [PATCH 31/46] test(agent-loop): cover step-start disposal --- packages/core/agent-loop/tests/cancel.spec.ts | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index d6394acd54..32c46f72f0 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -13,7 +13,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' @@ -224,6 +224,43 @@ 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 () => { + const adapter = new MockAdapter([textResponse('should not stream')]) + 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) + + const handle = ctx.agents.create({ + agentId: AgentId('a-dispose-step-start'), + sessionId: SessionId('dispose-step-start-session'), + agentOptions: { model: 'mock' }, + }) + const agent = handle.agent as ReactLoopAgent + + let disposalDone: Promise | undefined + let streamed = false + ctx.on('agent/stream-chunk', () => { streamed = true }) + ctx.on('agent/step-start', (subject) => { + if (subject === agent) disposalDone = handle.dispose() + }) + + send(agent, 'go') + await disposalDone + await agent.done + + expect(streamed).toBe(false) + expect(adapter.requests).toHaveLength(0) + const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) + const types = agent.session.events.map(e => e.type) + expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length) + }) + it('cancel during the continuation window ends the turn aborted and runs no further step', async () => { // A continuation-waterfall listener cancels DURING the continuation decision // (the finished step's AbortController is already cleared), and votes to From 1a5302dbcfa2a9bd61184d1a3a6c4d41c92de726 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 30 Jun 2026 12:07:20 +0800 Subject: [PATCH 32/46] fix(compact): stamp summarization session ids --- packages/compact/compact-basic/src/index.ts | 3 +-- packages/compact/compact-basic/tests/compact-basic.spec.ts | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 4c7bce00bc..f7fdadb3fa 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -291,6 +291,7 @@ export class BasicCompactService extends CompactService { }], system: SUMMARIZE_SYSTEM_PROMPT, maxTokens: this.config.maxTokens, + sessionId: agent.session.id, } // exactOptionalPropertyTypes: only set `signal` when present — assigning // `undefined` to an optional `signal?: AbortSignal` is a type error. @@ -512,8 +513,6 @@ export class BasicCompactService extends CompactService { // ---- Internal helpers ---- - /** - /** * Frame the raw summary blocks into the content that lands on the surface: * a checkpoint preamble (so a resuming model reads it as a checkpoint, not a diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 1134862793..10aae6d8be 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -975,6 +975,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { expect(adapter.lastOptions!.system).toContain('compaction engine') expect(adapter.lastOptions!.system).toContain('## Next Step') expect(adapter.lastOptions!.maxTokens).toBe(512) + expect(adapter.lastOptions!.sessionId).toBe(SessionId('summary')) expect(adapter.lastOptions!.messages[0]!.content[0]).toMatchObject({ type: 'text' }) }) From 5252477bc9130e6ee7b446f212035d064d801729 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:10:49 +0800 Subject: [PATCH 33/46] fix(compact): make config knobs explicit and flag two review smells Address @tianyicui's minor-revision review on PR #110: - Make every BasicCompactConfig knob required except `auto` (defaults true): there is no data yet to justify default thresholds/budgets, so a consumer states each value explicitly. Drop the DEFAULTS export and the constructor's `= {}` default; example cordis.yml, the compaction e2e, the README, and every test construction site now pass a complete config (tests route through a `cfg()` helper). - Add a TODO on estimateContentTokens: char/4 is coarse; replace with a real tokenizer or post-response usage feedback in a follow-up. - Add a TODO on the agent/pre-step `fullSystemPrompt` param flagging it as a smell on a generic per-step seam (compaction is its sole consumer); a `//` line comment so it stays out of the generated catalog. --- docs/cordis-catalog/events-and-services.md | 14 +- examples/coding-agent/cordis.yml | 4 + examples/coding-agent/tests/compaction.e2e.ts | 2 + packages/compact/compact-basic/README.md | 27 ++-- packages/compact/compact-basic/src/index.ts | 9 +- packages/compact/compact-basic/src/types.ts | 48 +++---- .../compact-basic/tests/compact-basic.spec.ts | 124 +++++++++++------- .../tests/compact-loop-repro.spec.ts | 3 + packages/core/agent/src/types.ts | 5 + 9 files changed, 139 insertions(+), 97 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 6a43db0b0e..d6a34d48ed 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:249`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:254`](../../packages/core/agent/src/types.ts) #### `agent/pre-step` — serial @@ -63,7 +63,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -87,7 +87,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:223`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -111,7 +111,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -135,7 +135,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:229`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -159,7 +159,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:238`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -171,7 +171,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:231`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:236`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 82253a0aef..e5bbea2811 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -81,7 +81,11 @@ name: '@deepseek-ai/dsh-compact-basic' config: contextWindow: 128000 + thresholdRatio: 0.8 retainTokens: 20480 + summarizationModel: '' + maxTokens: 8192 + compactionRetries: 1 # The subagent seam + BOTH in-process backends + two model-facing tools, as leaf # entries after the app (which provides ctx.agents/ctx.tools). spawn (a fresh diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index a300aac69a..39483dc848 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -50,7 +50,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa contextWindow: 2400, thresholdRatio: 0.5, retainTokens: 500, + summarizationModel: '', maxTokens: 2048, + compactionRetries: 1, }, persistenceRoot: './.sessions', }) diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 5b2e177997..c195ae42fb 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -21,15 +21,17 @@ The abstract contract states only WHAT compaction does; this backend owns every ## Config (`BasicCompactConfig`) -| Key | Default | Meaning | +Every knob is **required** except `auto` — there is no concrete data yet to justify default thresholds/budgets, so a consumer states each value explicitly rather than inherit a guessed default. `auto` alone defaults to `true`. + +| Key | Required | Meaning | |---|---|---| -| `contextWindow` | `128000` | Context window size in tokens. | -| `thresholdRatio` | `0.8` | Compact when estimated usage exceeds this fraction of the window. | -| `retainTokens` | `20480` | Tokens of recent context to keep intact. | -| `summarizationModel` | `''` | Model for summarization (empty → use the agent's model). | -| `maxTokens` | `8192` | Provider generation cap for the summarization call; may include reasoning tokens. | -| `compactionRetries` | `1` | Extra compaction attempts after the first if the compacted surface remains over threshold. | -| `auto` | `true` | Register the `agent/pre-step` auto-compaction listener. Set `false` for manual-only. | +| `contextWindow` | yes | Context window size in tokens. | +| `thresholdRatio` | yes | Compact when estimated usage exceeds this fraction of the window. | +| `retainTokens` | yes | Tokens of recent context to keep intact. | +| `summarizationModel` | yes | Model for summarization (`''` → use the agent's model). | +| `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. | ## Usage @@ -41,7 +43,14 @@ export const name = 'compact-basic' export const inject = ['llm'] export function apply(ctx: Context): void { - ctx.plugin(BasicCompactService, { contextWindow: 128000, retainTokens: 20480 }) + ctx.plugin(BasicCompactService, { + contextWindow: 128000, + thresholdRatio: 0.8, + retainTokens: 20480, + summarizationModel: '', + maxTokens: 8192, + compactionRetries: 1, + }) } ``` diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index f7fdadb3fa..f53dace461 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -39,7 +39,7 @@ import type { BasicCompactConfig, ResolvedConfig } from './types.ts' import { resolveConfig } from './types.ts' export type { BasicCompactConfig, ResolvedConfig } from './types.ts' -export { DEFAULTS, resolveConfig } from './types.ts' +export { resolveConfig } from './types.ts' /** Per-block structural overhead for JSON framing / type tag. */ const BLOCK_OVERHEAD = 4 @@ -155,10 +155,10 @@ function finishError(finish: FinishReason): Error | undefined { export class BasicCompactService extends CompactService { static inject = ['llm'] - /** Resolved configuration (defaults applied). */ + /** Resolved configuration (`auto` defaulted). */ readonly config: ResolvedConfig - constructor(ctx: Context, config: BasicCompactConfig = {}) { + constructor(ctx: Context, config: BasicCompactConfig) { super(ctx) this.config = resolveConfig(config) @@ -207,6 +207,9 @@ 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. /** * Estimate the token count of content blocks — char/4 with per-block * overhead. Override in a subclass to plug in a real tokenizer. diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index 8c4753c84f..98195d8883 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -9,40 +9,34 @@ * @module @deepseek-ai/dsh-compact-basic/types */ -/** Backend configuration — all optional with sensible defaults. */ +/** + * 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). + */ export interface BasicCompactConfig { - /** Context window size in tokens (default 128000). */ - contextWindow?: number - /** Compact when estimated token usage exceeds this fraction of context window (default 0.8). */ - thresholdRatio?: number - /** Number of tokens of recent context to retain during compaction (default 20480). */ - retainTokens?: number - /** Model to use for summarization (default '' — uses the agent's model). */ - summarizationModel?: string - /** Provider generation cap for the summarization call (default 8192). */ - maxTokens?: number - /** Extra compaction attempts when the first compacted surface is still over threshold (default 1). */ - compactionRetries?: number + /** Context window size in tokens. */ + contextWindow: number + /** Compact when estimated token usage exceeds this fraction of context window. */ + thresholdRatio: number + /** Number of tokens of recent context to retain during compaction. */ + retainTokens: number + /** Model to use for summarization (`''` — uses the agent's model). */ + summarizationModel: string + /** Provider generation cap for the summarization call. */ + maxTokens: number + /** Extra compaction attempts when the first compacted surface is still over threshold. */ + compactionRetries: number /** Enable automatic compaction on the `agent/pre-step` seam (default true). */ auto?: boolean } -/** Resolved config with all defaults applied. */ +/** Resolved config with `auto` defaulted. */ export type ResolvedConfig = Required -/** Default configuration values. */ -export const DEFAULTS: ResolvedConfig = { - contextWindow: 128000, - thresholdRatio: 0.8, - retainTokens: 20480, - summarizationModel: '', - maxTokens: 8192, - compactionRetries: 1, - auto: true, -} - /** - * Apply defaults to a partial config and reject nonsensical numeric knobs. + * Default `auto` 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 @@ -52,7 +46,7 @@ export const DEFAULTS: ResolvedConfig = { * throwing if the surface still exceeds the threshold. */ export function resolveConfig(config: BasicCompactConfig): ResolvedConfig { - const resolved = { ...DEFAULTS, ...config } + const resolved: ResolvedConfig = { auto: true, ...config } assertPositiveInteger('contextWindow', resolved.contextWindow) assertRatio('thresholdRatio', resolved.thresholdRatio) diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 10aae6d8be..1929e656be 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -12,6 +12,25 @@ import type { Agent } from '@deepseek-ai/dsh-agent' /** A never-aborted signal for the required `compactIfNeeded`/listener arg. */ const SIGNAL = new AbortController().signal +/** + * Baseline config with every required knob set. `BasicCompactConfig` has no + * defaults for the numeric/model knobs (only `auto` defaults), so each test + * builds a complete config via `cfg()` and overrides only the knob under test. + */ +const TEST_CONFIG: BasicCompactConfig = { + contextWindow: 128000, + thresholdRatio: 0.8, + retainTokens: 20480, + summarizationModel: '', + maxTokens: 8192, + compactionRetries: 1, +} + +/** A complete config with `overrides` applied over the baseline. */ +function cfg(overrides: Partial = {}): BasicCompactConfig { + return { ...TEST_CONFIG, ...overrides } +} + /** Long enough that the real checkpoint preamble is smaller than two fixture messages. */ const LONG_FIXTURE_TEXT = ' Detailed fixture context that makes framed checkpoint compaction genuinely shrinking.'.repeat(20) @@ -59,8 +78,8 @@ function isFramedCheckpoint(blocks: readonly ContentBlock[]): boolean { } /** Create a test service with a throwaway context (auto disabled — no model). */ -function createTestService(config: BasicCompactConfig = {}): TestCompactService { - return new TestCompactService(new Context(), { auto: false, ...config }) +function createTestService(overrides: Partial = {}): TestCompactService { + return new TestCompactService(new Context(), cfg({ auto: false, ...overrides })) } /** @@ -761,7 +780,7 @@ describe('BasicCompactService blocking (compaction in progress)', () => { describe('BasicCompactService token estimation (char/4 heuristic)', () => { it('estimates text blocks with char/4 + overhead', () => { - const svc = new BasicCompactService(new Context(), { auto: false }) + const svc = new BasicCompactService(new Context(), cfg({ auto: false })) // 'this is a somewhat longer text block' = 36 → ceil(36/4)+4 = 13; 'short' = 5 → 2+4 = 6 const blocks: ContentBlock[] = [ { type: 'text', text: 'this is a somewhat longer text block' }, @@ -771,13 +790,13 @@ describe('BasicCompactService token estimation (char/4 heuristic)', () => { }) it('estimates reasoning blocks same as text', () => { - const svc = new BasicCompactService(new Context(), { auto: false }) + const svc = new BasicCompactService(new Context(), cfg({ auto: false })) // 'thinking about this...' = 22 → ceil(22/4)+4 = 10 expect(svc.estimateContentTokens([{ type: 'reasoning', text: 'thinking about this...' }])).toBe(10) }) it('estimates tool-call blocks from name + arguments', () => { - const svc = new BasicCompactService(new Context(), { auto: false }) + const svc = new BasicCompactService(new Context(), cfg({ auto: false })) // 'bash' = 4 → 1; '{"command":"ls"}' = 16 → 4; + 4 overhead = 9 expect(svc.estimateContentTokens([ { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' }, @@ -785,7 +804,7 @@ describe('BasicCompactService token estimation (char/4 heuristic)', () => { }) it('estimates tool-result blocks recursively', () => { - const svc = new BasicCompactService(new Context(), { auto: false }) + const svc = new BasicCompactService(new Context(), cfg({ auto: false })) // inner text 5 → 2+4 = 6; outer 6 + 4 overhead = 10 expect(svc.estimateContentTokens([ { type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'hello' }], isError: false }, @@ -793,12 +812,12 @@ describe('BasicCompactService token estimation (char/4 heuristic)', () => { }) it('estimates image blocks at fixed 85 tokens', () => { - const svc = new BasicCompactService(new Context(), { auto: false }) + 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(), { auto: false }) + const svc = new BasicCompactService(new Context(), cfg({ auto: false })) expect(svc.estimateContentTokens([])).toBe(0) }) }) @@ -806,7 +825,7 @@ describe('BasicCompactService token estimation (char/4 heuristic)', () => { describe('BasicCompactService HMR safety', () => { it('registers as ctx.compact', () => { const ctx = new Context() - void new BasicCompactService(ctx, { auto: false }) + void new BasicCompactService(ctx, cfg({ auto: false })) expect(ctx.compact).toBeDefined() expect(ctx.compact).toBeInstanceOf(BasicCompactService) }) @@ -819,7 +838,7 @@ describe('BasicCompactService HMR safety', () => { // under the "llm inject (real plugin-load path)" suite.) const ctx = new Context() await ctx.plugin(LlmService) - const fiber = await ctx.plugin(BasicCompactService, { auto: false }) + const fiber = await ctx.plugin(BasicCompactService, cfg({ auto: false })) expect(ctx.get('compact')).toBeInstanceOf(BasicCompactService) await fiber.dispose() @@ -829,30 +848,33 @@ describe('BasicCompactService HMR safety', () => { describe('BasicCompactService config validation', () => { it('rejects invalid numeric config values', () => { - expect(() => new BasicCompactService(new Context(), { auto: false, contextWindow: 0 })).toThrow(/contextWindow .* positive integer/) - expect(() => new BasicCompactService(new Context(), { auto: false, thresholdRatio: 0 })).toThrow(/thresholdRatio .* \(0, 1\]/) - expect(() => new BasicCompactService(new Context(), { auto: false, thresholdRatio: 1.1 })).toThrow(/thresholdRatio .* \(0, 1\]/) - expect(() => new BasicCompactService(new Context(), { auto: false, retainTokens: -1 })).toThrow(/retainTokens .* non-negative integer/) - expect(() => new BasicCompactService(new Context(), { auto: false, maxTokens: 0 })).toThrow(/maxTokens .* positive integer/) - expect(() => new BasicCompactService(new Context(), { auto: false, compactionRetries: -1 })) + expect(() => new BasicCompactService(new Context(), cfg({ auto: false, contextWindow: 0 }))) + .toThrow(/contextWindow .* positive integer/) + expect(() => new BasicCompactService(new Context(), cfg({ auto: false, thresholdRatio: 0 }))).toThrow(/thresholdRatio .* \(0, 1\]/) + expect(() => new BasicCompactService(new Context(), cfg({ auto: false, thresholdRatio: 1.1 }))).toThrow(/thresholdRatio .* \(0, 1\]/) + expect(() => new BasicCompactService(new Context(), cfg({ auto: false, retainTokens: -1 }))) + .toThrow(/retainTokens .* non-negative integer/) + expect(() => new BasicCompactService(new Context(), cfg({ auto: false, maxTokens: 0 }))).toThrow(/maxTokens .* positive integer/) + expect(() => new BasicCompactService(new Context(), cfg({ auto: false, compactionRetries: -1 }))) .toThrow(/compactionRetries .* non-negative integer/) - expect(() => new BasicCompactService(new Context(), { auto: false, summarizationModel: 1 } as unknown as BasicCompactConfig)) - .toThrow(/summarizationModel must be a string/) - expect(() => new BasicCompactService(new Context(), { auto: 'no' } as unknown as BasicCompactConfig)) + expect(() => new BasicCompactService( + new Context(), cfg({ auto: false, summarizationModel: 1 } as unknown as Partial), + )).toThrow(/summarizationModel must be a string/) + expect(() => new BasicCompactService(new Context(), cfg({ auto: 'no' } as unknown as Partial))) .toThrow(/auto must be a boolean/) }) it('accepts a large retain budget because convergence is enforced dynamically', () => { - expect(() => new BasicCompactService(new Context(), { + expect(() => new BasicCompactService(new Context(), cfg({ auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 900, - })).not.toThrow() + }))).not.toThrow() }) it('the default config is valid', () => { - expect(() => new BasicCompactService(new Context(), { auto: false })).not.toThrow() + expect(() => new BasicCompactService(new Context(), cfg({ auto: false }))).not.toThrow() }) }) @@ -967,7 +989,7 @@ function summarize(svc: BasicCompactService, text: string, model: string) { describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('summarizes via the registered adapter and returns its content', async () => { const { ctx, adapter } = await ctxWithModel('SUMMARY TEXT') - const svc = new BasicCompactService(ctx, { auto: false, maxTokens: 512 }) + const svc = new BasicCompactService(ctx, cfg({ auto: false, maxTokens: 512 })) const summary = await summarize(svc, 'User: hi\n\nAssistant: hello', 'test-model') expect(summary).toEqual([{ type: 'text', text: 'SUMMARY TEXT' }]) @@ -981,10 +1003,10 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('uses maxTokens as the summarization provider cap', async () => { const { ctx, adapter } = await ctxWithModel('SUMMARY TEXT') - const svc = new BasicCompactService(ctx, { + const svc = new BasicCompactService(ctx, cfg({ auto: false, maxTokens: 50, - }) + })) await summarize(svc, 'User: hi', 'test-model') @@ -999,7 +1021,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { // synthesized user/message summary as an orphaned call. { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }, ]) - const svc = new BasicCompactService(ctx, { auto: false }) + const svc = new BasicCompactService(ctx, cfg({ auto: false })) const summary = await summarize(svc, 'User: hi', 'test-model') @@ -1008,26 +1030,26 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('throws when no text block remains after filtering', async () => { const { ctx } = await ctxWithBlocks([{ type: 'reasoning', text: 'private only' }]) - const svc = new BasicCompactService(ctx, { auto: false }) + const svc = new BasicCompactService(ctx, cfg({ auto: false })) await expect(summarize(svc, 'User: hi', 'test-model')).rejects.toThrow(/no text summary content/) }) it('throws when no model is provided', async () => { const { ctx } = await ctxWithModel('x') - const svc = new BasicCompactService(ctx, { auto: false }) + const svc = new BasicCompactService(ctx, cfg({ auto: false })) await expect(summarize(svc, 'text', '')).rejects.toThrow(/no model available/) }) it('rethrows when the stream ends with a finish-error chunk', async () => { const ctx = await ctxWithFinish({ kind: 'error', message: 'provider 401', code: 'UNAUTHORIZED' }) - const svc = new BasicCompactService(ctx, { auto: false }) + const svc = new BasicCompactService(ctx, cfg({ auto: false })) await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ message: 'provider 401', code: 'UNAUTHORIZED' }) }) it('rethrows a finish-error chunk without a code (code stays undefined)', async () => { const ctx = await ctxWithFinish({ kind: 'error', message: 'opaque failure' }) - const svc = new BasicCompactService(ctx, { auto: false }) + const svc = new BasicCompactService(ctx, cfg({ auto: false })) const error = await summarize(svc, 'text', 'test-model').then(() => null, (e: unknown) => e as Error & { code?: string }) expect(error?.message).toBe('opaque failure') expect(error?.code).toBeUndefined() @@ -1035,19 +1057,19 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('rethrows when the stream ends with a finish-aborted chunk', async () => { const ctx = await ctxWithFinish({ kind: 'aborted' }) - const svc = new BasicCompactService(ctx, { auto: false }) + const svc = new BasicCompactService(ctx, cfg({ auto: false })) await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ message: 'summarization stream aborted', code: 'ABORTED' }) }) it('fails closed on a max-tokens finish (an incomplete checkpoint must not commit)', async () => { const ctx = await ctxWithFinish({ kind: 'max-tokens' }) - const svc = new BasicCompactService(ctx, { auto: false }) + const svc = new BasicCompactService(ctx, cfg({ auto: false })) await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ code: 'MAX_TOKENS' }) }) it('compactRegion leaves the surface intact when summarization hits max-tokens', async () => { const ctx = await ctxWithFinish({ kind: 'max-tokens' }) - const svc = new BasicCompactService(ctx, { auto: false }) + const svc = new BasicCompactService(ctx, cfg({ auto: false })) const session = multiTurnSession(2, 1) const before = [...session.surface.nodes] const nodes = session.surface.nodes @@ -1065,7 +1087,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('compactRegion uses the real summarizer end-to-end', async () => { const { ctx } = await ctxWithModel('CONDENSED') - const svc = new BasicCompactService(ctx, { auto: false }) + const svc = new BasicCompactService(ctx, cfg({ auto: false })) const session = multiTurnSession(2, 1) const nodes = session.surface.nodes @@ -1115,7 +1137,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => it('compacts (mutating the surface) when over threshold', async () => { const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 }) + void new BasicCompactService(ctx, cfg({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 })) const session = multiTurnSession(5, 1) // 10 surface nodes const agent = stubAgent(session, 'test-model') const before = session.surface.nodes.length @@ -1133,12 +1155,12 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => const ctx = new Context() const infos: string[] = [] ctx.logger.info = ((msg: string) => void infos.push(msg)) as typeof ctx.logger.info - void new TestCompactService(ctx, { + void new TestCompactService(ctx, cfg({ contextWindow: 100, thresholdRatio: 0.7, retainTokens: 10, compactionRetries: 0, - }) + })) const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'test-model') @@ -1151,7 +1173,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => it('compacts mid-turn on steps after the first (the surface grows within a turn)', async () => { const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) + void new BasicCompactService(ctx, cfg({ contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 })) const session = multiTurnSession(3, 1) // over the 0.5 threshold const agent = stubAgent(session, 'test-model') @@ -1163,7 +1185,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => it('does nothing when under threshold', async () => { const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, { contextWindow: 128000, thresholdRatio: 0.8 }) + void new BasicCompactService(ctx, cfg({ contextWindow: 128000, thresholdRatio: 0.8 })) const session = multiTurnSession(1, 1) const agent = stubAgent(session, 'test-model') @@ -1176,7 +1198,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => // surface is untouched (the loop derives the full history). const ctx = new Context() await ctx.plugin(LlmService) - void new BasicCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10 }) + void new BasicCompactService(ctx, cfg({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10 })) const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'missing-model') const before = session.surface.nodes.length @@ -1189,7 +1211,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => it('does not register the listener when auto is false', async () => { const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, { auto: false, contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5 }) + void new BasicCompactService(ctx, cfg({ auto: false, contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5 })) const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'test-model') @@ -1203,7 +1225,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => options.model = 'routed-model' return next() }) - void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 }) + void new BasicCompactService(ctx, cfg({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 })) const session = multiTurnSession(5, 1) const agent = stubAgent(session) @@ -1216,11 +1238,11 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => it('removes the auto pre-step listener when the plugin fiber is disposed', async () => { const { ctx } = await ctxWithModel('SUMMARY') - const fiber = await ctx.plugin(BasicCompactService, { + const fiber = await ctx.plugin(BasicCompactService, cfg({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20, - }) + })) const session = multiTurnSession(5, 1) const agent = stubAgent(session, 'test-model') @@ -1327,7 +1349,7 @@ describe('BasicCompactService edge cases', () => { }) it('estimates unknown block types via JSON length (default branch)', () => { - const svc = new BasicCompactService(new Context(), { auto: false }) + const svc = new BasicCompactService(new Context(), cfg({ auto: false })) // A block whose type is none of the known kinds — exercises the default arm. const unknown = { type: 'custom-widget', payload: 'some data' } as unknown as ContentBlock expect(svc.estimateContentTokens([unknown])).toBeGreaterThan(0) @@ -1337,12 +1359,12 @@ describe('BasicCompactService edge cases', () => { const { ctx } = await ctxWithModel('SUMMARY') const warnings: string[] = [] ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn - void new BasicCompactService(ctx, { + void new BasicCompactService(ctx, cfg({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 5, compactionRetries: 0, - }) + })) const session = multiTurnSession(4, 1) const agent = stubAgent(session, 'test-model') @@ -1420,7 +1442,7 @@ describe('BasicCompactService edge cases', () => { const { ctx } = await ctxWithModel('SUMMARY') const warnings: string[] = [] ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn - const svc = new TestCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10 }) + const svc = new TestCompactService(ctx, cfg({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10 })) svc.summarizeError = 'boom' as unknown as Error const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'test-model') @@ -1438,7 +1460,7 @@ describe('BasicCompactService edge cases', () => { // A large system prompt pushes the listener's estimate over threshold, but // retainTokens is huge so compactIfNeeded walks everything and returns null. // threshold = floor(2000*0.1) = 200; invariant: 5 + 150 = 155 ≤ 200. - const svc = new TestCompactService(ctx, { contextWindow: 2000, thresholdRatio: 0.1, retainTokens: 150 }) + const svc = new TestCompactService(ctx, cfg({ contextWindow: 2000, thresholdRatio: 0.1, retainTokens: 150 })) const session = multiTurnSession(2, 1) const agent = stubAgent(session, 'test-model') const bigSystem = 'x'.repeat(900) // ceil(900/4)=225 > threshold 200 @@ -1606,7 +1628,7 @@ describe('BasicCompactService llm inject (real plugin-load path)', () => { ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter('CONDENSED')) // Mount the service through its real plugin fiber (NOT new …(rootCtx)), so // the sibling-fiber ctx.llm resolution actually exercises the inject. - const fiber = await ctx.plugin(BasicCompactService, { auto: false }) + const fiber = await ctx.plugin(BasicCompactService, cfg({ auto: false })) const svc = ctx.compact as BasicCompactService const session = multiTurnSession(2, 1) @@ -1635,7 +1657,7 @@ describe('BasicCompactService under the real invariants plugin', () => { await ctx.plugin(Invariants, {}) await ctx.plugin(LlmService) ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter('CONDENSED')) - await ctx.plugin(BasicCompactService, { auto: false }) + await ctx.plugin(BasicCompactService, cfg({ auto: false })) const session = ctx.sessions.create() return { ctx, session, svc: ctx.compact as BasicCompactService } } diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index dddb636b21..319e30a73c 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -97,6 +97,9 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr contextWindow: 64, thresholdRatio: 0.5, retainTokens: 20, + summarizationModel: '', + maxTokens: 8192, + compactionRetries: 1, }) return { ctx, compact } } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index fc412f0e0e..407cce5250 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -206,6 +206,11 @@ declare module 'cordis' { * summarization model call). * @mode serial */ + // TODO: `fullSystemPrompt` is a smell on a generic per-step seam — compaction + // is its only consumer, so a wide event carries a string just one listener + // reads. Revisit if no second consumer appears: e.g. hand listeners a lazy + // 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 /** * Waterfall: mutate the fully-assembled {@link GenerateOptions} before the From 57900f9cbd7d04ed4dcfd1cd00902bdf0509fb68 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:08:45 +0800 Subject: [PATCH 34/46] docs(i18n): address round-2 terminology review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ACP/SSE: show full English name in parens on first occurrence - fixture: drop 测试夹具 gloss, keep descriptive note - manifest: keep English, drop 首次出现可写 clause --- docs/i18n/terminology.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 6031f3f70a..f9baa40adc 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -4,7 +4,7 @@ | English | 中文 | 备注 | |---|---|---| -| ACP | ACP | | +| ACP | ACP | 首次出现可写:ACP(Agent Client Protocol) | | AI | AI | 首次出现可写:人工智能(AI) | | API | API | | | CLI | CLI | 首次出现可写:命令行界面(CLI) | @@ -19,14 +19,14 @@ | MCP | MCP | | | RAG | RAG | 首次出现可写:检索增强生成(RAG) | | SDK | SDK | | -| SSE | SSE | | +| SSE | SSE | 首次出现可写:SSE(Server-Sent Events) | | agent | agent | 首次出现可写:agent(智能体) | | agent loop | agent loop | | | fiber | fiber | 首次出现可写:fiber(插件运行时) | -| fixture | fixture | 首次出现可写:fixture(测试夹具);指测试前置数据或环境 | +| fixture | fixture | 指测试前置数据或环境 | | fork | fork | 保留英文 | | harness | harness | 保留英文 | -| manifest | manifest | 首次出现可写:manifest(描述模块或工具元数据的文件) | +| manifest | manifest | 描述模块或工具元数据的文件 | | schema DSL | schema DSL | | | schema | schema | 保留英文 | | seam | seam | 首次出现可写:seam(扩展点) | From 58b86839afe184ab22b4abc98ddd55428b653944 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:29:33 +0800 Subject: [PATCH 35/46] docs(compaction): flag missing snapshot coverage with a FIXME The compaction e2e is the only coverage of runaway compaction; there is no keyless full-transcript snapshot. Record why in a FIXME on the e2e module doc: dsh-llm-replay rebuilds one model call per (turn, step) from assistant/chunk events, but summarize() assembles its stream locally and appends none, so the interleaved summarization call is unreplayable until the replay harness can serve it. --- examples/coding-agent/tests/compaction.e2e.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index 39483dc848..2b8f278be3 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -18,6 +18,13 @@ import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness. * landed in the real session log, the surface actually shrank (a replace node * exists and shadowed older nodes), and the agent still produced a final answer * after compaction (so the summarized history did not break the conversation). + * + * FIXME(compaction-snapshot): this key-gated e2e is the ONLY coverage of runaway + * compaction — there is no keyless full-transcript snapshot of it. dsh-llm-replay + * reconstructs one model call per (turn, step) from `assistant/chunk` events, but + * `summarize()` assembles its stream into a local BlockAssembler and appends no + * `assistant/chunk`, so the interleaved summarization call is unreplayable. A + * snapshot needs replay-harness work to serve that call; deferred as a follow-up. */ let workdir: string | undefined From df0e7bd5f2add4b78318803559496db23bbc7c93 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:20:24 +0800 Subject: [PATCH 36/46] feat(docs): generate a tool-schema catalog by booting the tool plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add docs/tool-catalog/tools.md, a generated reference of every model-facing tool a shipped `packages/*/tool-*` plugin contributes (name, description, JSON-Schema parameters) — the third generated catalog alongside the cordis events/services and core-data-structures catalogs. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real cordis Context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable: `todo_write` builds its enum with a runtime spread, descriptions are string-concatenated, `subagent`'s name is config-driven, and MCP tools register raw JSON Schema without `defineTool`. A completeness guard globs the on-disk `tool-*` packages and fails if any is absent from the boot manifest, restoring the "nothing silently omitted" property booting would otherwise lose. `verify-tool-catalog` runs inside `doc-sync`, so the artifact cannot drift. The boot-over-AST decision and the discovered-inventory / hand-written-recipe split are recorded in a process RFC. --- docs/rfc/README.md | 1 + .../process/2026-07-02-tool-schema-catalog.md | 45 ++++ docs/tool-catalog/tools.md | 165 +++++++++++++ package.json | 4 +- packages/core/tools/README.md | 2 +- .../core/tools/tests/gen-tool-catalog.spec.ts | 105 ++++++++ scripts/gen-tool-catalog.ts | 229 ++++++++++++++++++ 7 files changed, 549 insertions(+), 2 deletions(-) create mode 100644 docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md create mode 100644 docs/tool-catalog/tools.md create mode 100644 packages/core/tools/tests/gen-tool-catalog.spec.ts create mode 100644 scripts/gen-tool-catalog.ts diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 348a7fcdaf..981710c4f5 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -135,6 +135,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Core-data-structures catalog and the `ts type-equiv` drift gate](implemented/process/2026-06-20-core-data-structures-catalog.md) | 2026-06-20 | | [Generated cordis events + services catalog](implemented/process/2026-06-20-generated-cordis-catalog.md) | 2026-06-20 | | [Classify RFCs by kind via path-encoded subdirectories](implemented/process/2026-06-20-rfc-classification.md) | 2026-06-20 | +| [Generated tool-schema catalog (boot-and-harvest)](implemented/process/2026-07-02-tool-schema-catalog.md) | 2026-07-02 | ### Testing diff --git a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md new file mode 100644 index 0000000000..9af018d2d6 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md @@ -0,0 +1,45 @@ +# RFC: Generated tool-schema catalog (boot-and-harvest) + +Status: implemented (accepted 2026-07-02) + +## Context + +A reader — a plugin author, a prompt engineer, someone auditing what the agent can do — has no single place that lists the model-facing tools the harness ships. The `name` / `description` / JSON-Schema `parameters` a tool contributes are what the model actually receives (via `ctx.systemPrompt.tools()` off `ctx.tools.schemas()`), but they are scattered across each `defineTool` call in each `packages/*/tool-*` package, buried in string concatenation and runtime spreads. The [cordis events & services catalog](../../../cordis-catalog/events-and-services.md) ([its RFC](2026-06-20-generated-cordis-catalog.md)) documents the *wiring* a plugin works against and the [core-data-structures catalog](../../../core-data-structures/core.md) documents the *vocabulary* those signatures move — but neither documents the *tools* the agent is offered. This RFC adds that third reference surface, `docs/tool-catalog/tools.md`, and a freshness gate so it cannot drift. + +## Decision + +Generate the catalog by **booting each tool plugin and reading its registered schemas**, not by parsing source. `scripts/gen-tool-catalog.ts` mounts each shipped tool package on a fresh cordis `Context` (with `SystemPrompt` + `ToolRegistry` and the injected seams the plugin's `apply` reads), calls `ctx.tools.schemas()` — exactly the `ToolSchema[]` the model is sent — disposes the context, and renders one `## ` section per package with a ` ```json ` `parameters` block per tool. It mirrors the `gen-cordis-catalog` / `gen-module-graph` CLI shape: default `--write` regenerates, `--check` fails if the committed copy is stale, output is deterministic (manifest-ordered, tools sorted by name). `verify-tool-catalog` (the `--check`) runs inside `doc-sync`, so the freshness gate fires in the same lefthook pre-push and CI paths as every other doc gate. + +### Why boot, not parse (the crux) + +The cordis catalog is a pure TypeScript-AST pass because every event/service name is a string literal that round-trips to a static declaration — the AST is the whole truth. **Tool schemas are not statically knowable**, so the same technique would produce a doc that lies: + +- `tool-todo` writes `enum: [...STATUSES]` — a spread of a runtime `const`. The AST sees the spread expression, not `["pending","in_progress","completed"]`. +- Every description is built by string **concatenation** (`'…' + '…'`). The AST sees concatenation nodes, not the final prose the model reads. +- `tool-subagent`'s tool name is `config.toolName ?? 'subagent'` — chosen at load, not a literal. +- An MCP plugin can register **raw JSON Schema** directly via `ctx.tools.register()` without `defineTool` at all, so enumerating `defineTool(` call sites structurally under-counts. + +The only faithful source of truth is the schema the registry actually holds after the plugin loads. Booting is the [unit-test discipline](../../../../AGENTS.md) "verify the world, not a synthetic stand-in" applied to a doc generator: read the shipped artifact, not a re-derivation of it. + +### Restoring "nothing silently omitted" + +Booting has a cost the AST pass did not: there is no source declaration set to enumerate, so a new tool package could simply be forgotten. A **completeness guard** restores the guarantee — `assertManifestComplete` globs every `tool-*` package under `packages/` and hard-errors if any is absent from the generator's boot manifest. A new tool package fails the generator, and therefore `doc-sync`, until it is registered. This is the same structural property the cordis generator gets for free from enumerating source, re-created for a boot-based generator. + +### A hand-maintained boot manifest is the irreducible policy + +The boot manifest (`TOOL_PACKAGES`) is a hand-written list — in tension with the proposed [Discover package inventories instead of maintaining static lists](../../proposed/process/2026-06-20-discover-package-inventory.md). The tension is deliberate and resolved as follows: the *inventory* is discovered (the glob guard means no one maintains "the list of tool packages" — the filesystem is the source of truth, and drift fails the gate), but the *boot recipe* per package — which seams to plug (`bash-local` for `ctx.bash`, `subagent` + `subagent-mock` for `ctx.subagents`) and with what config (`{ provider: 'mock' }`) — is genuine policy that no layout fact encodes. Per that RFC's own "what we give up" ("stay boring: read manifests, filter on explicit fields, print the resolved list, and fail loud"), a recipe closure is the boring, explicit form; inferring seam wiring from injects would be the "too clever" path it warns against. So: discovered inventory, hand-written recipe, gate on completeness. + +### Scope + +Shipped product tools under `packages/*/tool-*` only: `dsh-tool-bash` (`bash`, `bash_output`, `bash_kill`), `dsh-tool-todo` (`todo_write`), `dsh-tool-subagent` (`subagent`). The `examples/` demo tools (`echo`) are excluded, matching the cordis catalog's packages-only scope — a demo tool is not part of the product surface a reader is cataloguing. + +### A plain `json` fence + +Schema blocks use ` ```json `, not a bespoke `ts`-family fence. `doc-typecheck` only extracts `ts*` fences, so a JSON block is invisible to it — no `BlockKind` wiring is needed (unlike the cordis catalog's `ts cordis-catalog` fence, which had to be allowlisted so a bare signature fragment isn't compiled). + +## Consequences + +- The catalog cannot drift: a tool schema change the committed file doesn't reflect fails `verify-tool-catalog` in the pre-push hook and CI. A new `tool-*` package not added to the manifest fails the completeness guard outright. +- Tool description prose has a single home — the `defineTool` `description` at the source — and the generated entry is only as good as it, the same forcing function the cordis catalog applies to event JSDoc. +- The generator imports and executes workspace packages (the first repo script to do so; the others only read text). It runs under `tsx` via the root `tsconfig` `paths` map, the same unbuilt-source path the demos and tests use, so it needs no build step. +- A new capability seam behind a future tool means a new manifest recipe entry (which seams to mount). This is the deliberate hand-written cost called out above; it changes only when a tool package is added. diff --git a/docs/tool-catalog/tools.md b/docs/tool-catalog/tools.md new file mode 100644 index 0000000000..9e625d17ad --- /dev/null +++ b/docs/tool-catalog/tools.md @@ -0,0 +1,165 @@ + + +# Tool Schema Catalog + +Every model-facing tool a shipped plugin contributes to `ctx.tools`: the exact `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [cordis events & services catalog](../cordis-catalog/events-and-services.md) (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered. + +This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator's boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](../rfc/implemented/process/2026-07-02-tool-schema-catalog.md). + +Scope: shipped product tools under `packages/*/tool-*`. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog's packages-only scope. + +## `@deepseek-ai/dsh-tool-bash` + +### `bash` + +Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately. No timeout applies." + } + }, + "required": [ + "command", + "description" + ] +} +``` + +Source: [`packages/bash/tool-bash/src/index.ts`](../../packages/bash/tool-bash/src/index.ts) + +### `bash_kill` + +Ask the executor to kill a running background bash task by task id. + +```json +{ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the bash tool." + } + }, + "required": [ + "task_id" + ] +} +``` + +Source: [`packages/bash/tool-bash/src/index.ts`](../../packages/bash/tool-bash/src/index.ts) + +### `bash_output` + +Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. + +```json +{ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the bash tool." + } + }, + "required": [ + "task_id" + ] +} +``` + +Source: [`packages/bash/tool-bash/src/index.ts`](../../packages/bash/tool-bash/src/index.ts) + +## `@deepseek-ai/dsh-tool-subagent` + +### `subagent` + +Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. + +```json +{ + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + } + }, + "required": [ + "description", + "prompt" + ] +} +``` + +Source: [`packages/subagent/tool-subagent/src/index.ts`](../../packages/subagent/tool-subagent/src/index.ts) + +## `@deepseek-ai/dsh-tool-todo` + +### `todo_write` + +Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). + +```json +{ + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] +} +``` + +Source: [`packages/todo/tool-todo/src/index.ts`](../../packages/todo/tool-todo/src/index.ts) diff --git a/package.json b/package.json index 2e83cdca61..7d82af4aad 100644 --- a/package.json +++ b/package.json @@ -34,10 +34,12 @@ "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", + "gen-tool-catalog": "tsx scripts/gen-tool-catalog.ts", + "verify-tool-catalog": "tsx scripts/gen-tool-catalog.ts --check", "gen-module-graph": "tsx scripts/gen-module-graph.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", - "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-rfc-classification && pnpm run verify-type-equiv", + "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-tool-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-rfc-classification && pnpm run verify-type-equiv", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types", "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", "demo:coding": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 6b1634cd70..102645ad82 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -8,7 +8,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). +- `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` Execute one tool call through the `tools/execute` waterfall. ### Injected services diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts new file mode 100644 index 0000000000..57d6b6ccc4 --- /dev/null +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -0,0 +1,105 @@ +/** + * Guarantee tests for the tool-schema catalog generator + * (`scripts/gen-tool-catalog.ts`). + * + * The generated catalog is frozen by a regenerate-and-diff freshness gate, so + * the freshness half is exercised by `pnpm run verify-tool-catalog` in CI. What + * a freshness diff CANNOT prove is (a) that BOOTING the tool plugins yields the + * shipped schema — the whole reason this generator boots instead of parsing + * source (a runtime-spread enum resolves to its literal members) — and (b) that + * the completeness guard REJECTS a tool package missing from the boot manifest, + * the property that replaces the AST pass's "nothing silently omitted". These + * tests drive the exported `collectToolCatalog` / `assertManifestComplete` / + * `render` directly, mirroring the negative-path style of the cordis-catalog + * generator tests. + */ + +import { describe, expect, it } from 'vitest' +import { + assertManifestComplete, + collectToolCatalog, + render, + type ToolCatalog, +} from '../../../../scripts/gen-tool-catalog.ts' + +/** JSON Schema shape enough to reach the values AST extraction can't. */ +interface JsonSchema { + type: string + properties?: Record + items?: JsonSchema + enum?: string[] + required?: string[] +} + +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']) + // Every tool carries a JSON-Schema `parameters` object (what the model sees). + for (const entry of catalog) { + for (const schema of entry.schemas) { + expect((schema.parameters as unknown as JsonSchema).type).toBe('object') + } + } + }) + + it('resolves a runtime-spread enum to its literal members (the payoff over AST)', async () => { + const catalog = await collectToolCatalog() + const todo = catalog + .flatMap(entry => entry.schemas) + .find(s => s.name === 'todo_write') + // `todo-todo` writes `enum: [...STATUSES]` — a source AST would see the + // spread, not the values. Booting yields the shipped enum literals. + const status = (((todo?.parameters as unknown as JsonSchema).properties?.todos)?.items)?.properties?.status + expect(status?.enum).toEqual(['pending', 'in_progress', 'completed']) + }) + + it('attributes each package with a source pointer that names its index', async () => { + const catalog = await collectToolCatalog() + const bash = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-bash') + expect(bash?.source).toBe('packages/bash/tool-bash/src/index.ts') + }) +}) + +describe('gen-tool-catalog assertManifestComplete', () => { + it('passes when the manifest lists every on-disk tool package (the default)', () => { + expect(() => { assertManifestComplete() }).not.toThrow() + }) + + it('throws, naming the omitted package, when a tool package is missing from the manifest', () => { + // An empty manifest scanned against the real tree: every `tool-*` package + // is unlisted, so the guard must fire and name them. + expect(() => { assertManifestComplete([]) }).toThrow(/not in the boot manifest/) + expect(() => { assertManifestComplete([]) }).toThrow(/tool-bash/) + }) +}) + +describe('gen-tool-catalog render', () => { + it('emits a package heading, a tool heading, and a json schema fence', () => { + const catalog: ToolCatalog = [ + { + pkg: '@deepseek-ai/dsh-tool-demo', + source: 'packages/demo/tool-demo/src/index.ts', + schemas: [{ name: 'demo', description: 'A demo tool.', parameters: { type: 'object', properties: {} } }], + }, + ] + const md = render(catalog) + 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`') + }) +}) diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts new file mode 100644 index 0000000000..8e62277713 --- /dev/null +++ b/scripts/gen-tool-catalog.ts @@ -0,0 +1,229 @@ +/** + * Generate (and verify) the tool-schema catalog in docs/tool-catalog/tools.md. + * + * The catalog is the MODEL-FACING TOOL reference: every tool a shipped plugin + * contributes to `ctx.tools`, with the exact `name` / `description` / JSON-Schema + * `parameters` the model receives via the system-prompt assembly. It complements + * the cordis events/services catalog (the wiring a plugin author works against) + * and the core-data-structures catalog (the vocabulary those signatures move): + * this page is the TOOLS the agent is offered. + * + * `tsx scripts/gen-tool-catalog.ts` → write the catalog + * `tsx scripts/gen-tool-catalog.ts --check` → exit 1 if the committed file + * is stale (CI / pre-push gate) + * + * Why this generator BOOTS PLUGINS instead of parsing source (unlike its AST + * sibling `gen-cordis-catalog.ts`): a tool's schema is not statically knowable. + * `tool-todo` writes `enum: [...STATUSES]` (a runtime spread), descriptions are + * built by string concatenation, `tool-subagent`'s tool name is `config.toolName`, + * and an MCP plugin can register RAW JSON Schema without `defineTool` at all. The + * faithful source of truth is therefore the SHIPPED schema: mount each tool + * plugin on a real cordis Context and read `ctx.tools.schemas()` — exactly the + * `ToolSchema[]` the model is sent. See + * docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md. + * + * Booting sacrifices the AST pass's structural "nothing can be silently omitted" + * property (there is no source declaration to enumerate), so a COMPLETENESS GUARD + * restores it: the generator globs every `tool-*` package under `packages/` and + * hard-errors if any such package is absent from the boot manifest below. A new + * tool package fails the generator — and thus the freshness gate — until it is + * registered here, mirroring how a new event appears in the cordis regenerate. + * + * Schema blocks use a plain ` ```json ` fence: doc-typecheck only extracts `ts*` + * fences, so no BlockKind wiring is needed there. + */ + +import { globSync, readFileSync, writeFileSync } from 'node:fs' +import { basename, resolve } from 'node:path' +import { Context } from 'cordis' +import type { ToolSchema } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' +import SubagentService from '@deepseek-ai/dsh-subagent' +import * as SubagentMock from '@deepseek-ai/dsh-subagent-mock' +import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' +import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' + +const root = resolve(import.meta.dirname, '..') +const OUT = 'docs/tool-catalog/tools.md' + +/** + * One tool-plugin package to boot. `mount` is a per-entry recipe (async): it + * plugs the injected seams the plugin's `apply` reads (an executor for + * `ctx.bash`, a provider for `ctx.subagents`) BEFORE the tool plugin itself. + * `SystemPrompt` + `ToolRegistry` are mounted for every entry by the caller + * (`ToolRegistry` injects `systemPrompt`), so `mount` only handles the extras. + * + * The recipe is irreducible policy — WHICH seams a given tool needs and with + * WHAT config is not derivable from the package layout — so it stays a hand- + * maintained closure. The `dir` field is what the completeness guard matches + * against the on-disk `tool-*` package glob, so a NEW tool package cannot be + * silently omitted (see the module doc). + */ +interface ToolPackage { + /** The npm package name, used as the catalog section heading. */ + pkg: string + /** The `packages//` leaf name — matched by the completeness guard. */ + dir: string + /** Repo-relative source path linked from the catalog entry. */ + source: string + /** Plug the injected seams + the tool plugin onto a context that already + * carries `systemPrompt` + `tools`. */ + mount: (ctx: Context) => Promise +} + +/** + * The boot manifest: every shipped tool package (a `tool-*` leaf under + * `packages/`). Ordered by package name (the render order); the completeness + * guard proves it is exhaustive against the on-disk glob. + */ +const TOOL_PACKAGES: ToolPackage[] = [ + { + pkg: '@deepseek-ai/dsh-tool-bash', + dir: 'tool-bash', + source: 'packages/bash/tool-bash/src/index.ts', + async mount(ctx) { + await ctx.plugin(LocalBashExecutor) + await ctx.plugin(ToolBash) + }, + }, + { + pkg: '@deepseek-ai/dsh-tool-subagent', + dir: 'tool-subagent', + source: 'packages/subagent/tool-subagent/src/index.ts', + async mount(ctx) { + await ctx.plugin(SubagentService) + // Register a scripted provider under the name the tool delegates to. + await ctx.plugin(SubagentMock, { name: 'mock' }) + await ctx.plugin(ToolSubagent, { provider: 'mock' }) + }, + }, + { + pkg: '@deepseek-ai/dsh-tool-todo', + dir: 'tool-todo', + source: 'packages/todo/tool-todo/src/index.ts', + async mount(ctx) { + await ctx.plugin(ToolTodo) + }, + }, +] + +/** One package's contribution to the catalog: its schemas plus attribution. */ +interface CatalogPackage { + pkg: string + source: string + schemas: ToolSchema[] +} + +/** The whole catalog: one entry per booted tool package, in manifest order. */ +export type ToolCatalog = CatalogPackage[] + +/** + * Assert the boot manifest covers every shipped tool package on disk (a + * `tool-*` leaf under `packages/`). + * Booting has no source declaration to enumerate, so this glob restores the + * "a new tool cannot be silently undocumented" guarantee: an unlisted package + * fails the generator (and the freshness gate) until it is added to + * {@link TOOL_PACKAGES}. Exported for a direct negative test. + * + * `scanRoot` defaults to the repo root; a test may point it at a fixture tree. + */ +export function assertManifestComplete(packages: ToolPackage[] = TOOL_PACKAGES, scanRoot: string = root): void { + const onDisk = globSync('packages/*/tool-*', { cwd: scanRoot }).map(p => basename(p)).sort() + const listed = new Set(packages.map(p => p.dir)) + const missing = onDisk.filter(dir => !listed.has(dir)) + if (missing.length > 0) { + throw new Error( + `gen-tool-catalog: ${missing.length} tool package(s) not in the boot manifest: ${missing.join(', ')}. ` + + 'Add each to TOOL_PACKAGES in scripts/gen-tool-catalog.ts so its schema is catalogued.', + ) + } +} + +/** + * Boot each tool package on a fresh Context and harvest its model-facing + * schemas. A fresh Context per package keeps attribution clean (each entry's + * schemas come from exactly that package) and isolates a boot failure to its + * own entry. Disposed after harvest so no executor/provider outlives the run. + */ +export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES): Promise { + assertManifestComplete(packages) + const catalog: ToolCatalog = [] + for (const entry of packages) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await entry.mount(ctx) + // Copy the schemas out before the context is torn down. + const schemas = ctx.tools.schemas().sort((a, b) => a.name.localeCompare(b.name)) + await ctx.fiber.dispose() + catalog.push({ pkg: entry.pkg, source: entry.source, schemas }) + } + return catalog +} + +/** Render one tool's entry: name, description, JSON-Schema parameters, source. */ +function renderTool(schema: ToolSchema, source: string): string[] { + const out = [`### \`${schema.name}\``, ''] + if (schema.description) out.push(schema.description, '') + if (schema.strict !== undefined) out.push(`Strict: \`${String(schema.strict)}\``, '') + out.push('```json', JSON.stringify(schema.parameters, null, 2), '```', '') + out.push(`Source: [\`${source}\`](../../${source})`, '') + return out +} + +/** Render the full catalog (pure, deterministic given the manifest-ordered input). */ +export function render(catalog: ToolCatalog): string { + const lines: string[] = [ + '', + '', + '# Tool Schema Catalog', + '', + 'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the exact `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [cordis events & services catalog](../cordis-catalog/events-and-services.md) (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.', + '', + 'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](../rfc/implemented/process/2026-07-02-tool-schema-catalog.md).', + '', + 'Scope: shipped product tools under `packages/*/tool-*`. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.', + '', + ] + for (const entry of catalog) { + lines.push(`## \`${entry.pkg}\``, '') + for (const schema of entry.schemas) lines.push(...renderTool(schema, entry.source)) + } + return lines.join('\n') +} + +/** CLI entry: default writes the catalog, `--check` fails if the committed copy + * is stale. Guarded behind an entry-point check so importing this module for + * tests neither regenerates the committed file nor calls process.exit. */ +async function main(): Promise { + const content = render(await collectToolCatalog()) + if (process.argv.includes('--check')) { + let committed: string | null = null + try { + committed = readFileSync(resolve(root, OUT), 'utf8') + } catch { + // Only ENOENT (not yet generated) is expected; a present-but-unreadable + // file is not a state this repo produces. Either way the remedy is the + // same — regenerate — so treat a read failure as "stale". + committed = null + } + if (committed === content) { + console.log(`gen-tool-catalog: ${OUT} is up to date.`) + process.exit(0) + } + console.error(`gen-tool-catalog: ${OUT} is stale. Run \`pnpm run gen-tool-catalog\` and commit ${OUT}.`) + process.exit(1) + } + + writeFileSync(resolve(root, OUT), content) + console.log(`gen-tool-catalog: wrote ${OUT}.`) +} + +// Run only when invoked as a script, not when imported by a test. +if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { + await main() +} From 30c18637553d52bb66e811935967f139e0d54d6c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:12:38 +0800 Subject: [PATCH 37/46] =?UTF-8?q?fix(fs):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20rename=20to=20dsh-fs-policy,=20fs/*-intent=20events,=20RFC?= =?UTF-8?q?=20currency,=20ENOTDIR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename per review naming decisions: - package dsh-file-context → dsh-fs-policy (dir, package name, plugin name, tsconfig refs, importers, type-equiv manifest, generated catalog + module-graph) - events fs/write-expectation → fs/write-intent, fs/edit-expectation → fs/edit-intent (fs/observed unchanged); type FsWriteExpectation → FsWriteIntent, "expectation" wording → "intent" throughout - exported FileContextExec → FsPolicyExec Make the implemented RFCs describe what shipped, not the superseded designs: the 2026-06-17 capability-seam + tool-schemas RFCs no longer place policy on ctx.fs or use full/partial-view authorization, and the fsspec RFC's ctx.fileContext service prose is rewritten to the fs/* event-gate reality (freshness-based auth). Sharpen docs/rfc/implemented/AGENTS.md: a rename is a fact to fix IN PLACE — the "new RFC" escape hatch is for macro decision reversals only, not renames. Code fixes from review: - fsio.ts resolveLocalTarget/probe translate ENOTDIR (a parent path segment is a file) into the structured FsError taxonomy instead of leaking a raw Node error; resolve reports FS_NOT_FOUND, probe reports absent. Regression tests proven to fail on the unfixed code. - tool-fs HMR test now asserts prompt sections (not just tool schemas) are withdrawn on disposal. - fs/observed is a plain (unguarded) ctx.emit: correct the fs-policy comment, filesystem.md, and tool-fs module doc that wrongly claimed the tool "contains" a throwing listener; a throw surfaces as the tool's isError result. - drop the false "loaded by the default product config" claim (no config wires the fs tools yet), the duplicate ctx.bash service-map row, the stale FileReadRequest catalog link-map entry, and the fs/fs README EOF blank line; correct the dsh-fs package.json description. --- docs/architecture.md | 5 +- docs/cordis-catalog/events-and-services.md | 22 ++--- docs/core-data-structures/filesystem.md | 22 ++--- docs/module-graph.md | 4 +- docs/rfc/README.md | 4 +- docs/rfc/implemented/AGENTS.md | 2 +- .../2026-06-17-filesystem-capability-seam.md | 48 +++++----- .../2026-06-26-file-context-as-event-gate.md | 92 +++++++++---------- .../2026-06-17-filesystem-tool-schemas.md | 20 ++-- .../2026-06-26-fsspec-style-fs-seam.md | 46 +++++----- packages/README.md | 4 +- packages/fs/README.md | 4 +- packages/fs/fs-local/README.md | 2 +- packages/fs/fs-local/src/fsio.ts | 22 ++++- packages/fs/fs-local/src/index.ts | 4 +- packages/fs/fs-local/tests/filesystem.spec.ts | 2 +- packages/fs/fs-local/tests/fsio.spec.ts | 19 +++- .../fs/{file-context => fs-policy}/README.md | 18 ++-- .../{file-context => fs-policy}/package.json | 2 +- .../{file-context => fs-policy}/src/index.ts | 43 ++++----- .../{file-context => fs-policy}/src/types.ts | 6 +- .../tests/policy.spec.ts | 92 +++++++++---------- .../{file-context => fs-policy}/tsconfig.json | 0 packages/fs/fs/README.md | 11 +-- packages/fs/fs/package.json | 2 +- packages/fs/fs/src/index.ts | 32 +++---- packages/fs/fs/src/types.ts | 8 +- packages/fs/fs/tests/service.spec.ts | 6 +- packages/fs/tool-fs/README.md | 14 +-- packages/fs/tool-fs/package.json | 2 +- packages/fs/tool-fs/src/edit.ts | 10 +- packages/fs/tool-fs/src/index.ts | 19 ++-- packages/fs/tool-fs/src/read.ts | 2 +- packages/fs/tool-fs/src/write.ts | 10 +- packages/fs/tool-fs/tests/integration.spec.ts | 10 +- packages/fs/tool-fs/tests/tools.spec.ts | 29 +++--- packages/fs/tool-fs/tsconfig.json | 2 +- pnpm-lock.yaml | 30 +++--- scripts/gen-cordis-catalog.ts | 5 +- scripts/type-equiv.manifest.json | 4 +- tsconfig.build.json | 2 +- tsconfig.json | 2 +- 42 files changed, 360 insertions(+), 323 deletions(-) rename packages/fs/{file-context => fs-policy}/README.md (60%) rename packages/fs/{file-context => fs-policy}/package.json (95%) rename packages/fs/{file-context => fs-policy}/src/index.ts (79%) rename packages/fs/{file-context => fs-policy}/src/types.ts (86%) rename packages/fs/{file-context => fs-policy}/tests/policy.spec.ts (56%) rename packages/fs/{file-context => fs-policy}/tsconfig.json (100%) diff --git a/docs/architecture.md b/docs/architecture.md index 3539a0dae3..eddb1ea235 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,7 +25,7 @@ For a catalog of the **data structures** this architecture moves around — the │ @deepseek-ai/dsh-bash-local (bash impl) │ │ @deepseek-ai/dsh-tool-bash (bash tool schemas) │ │ @deepseek-ai/dsh-fs-local (filesystem impl) │ -│ @deepseek-ai/dsh-file-context (filesystem policy gate) │ +│ @deepseek-ai/dsh-fs-policy (filesystem policy gate) │ │ @deepseek-ai/dsh-tool-fs (filesystem tools+executor)│ │ @deepseek-ai/dsh-subagent-* (subagent providers) │ │ @deepseek-ai/dsh-session-persistence-jsonl (persistence impl)│ @@ -60,7 +60,6 @@ Dependency rule: **extension** plugins depend on interface packages, never on `d | `ctx.agents` | `AgentRegistry` | dsh-agent | live `Agent` handles + the create/resume factory seam (returns an `AgentHandle` = `{ agent, dispose() }` for owned per-agent teardown) | | `ctx.agentLoop` | `AgentLoop` | dsh-agent-loop | creates `ReactLoopAgent`s and drives their loops | | `ctx.bash` | `BashExecutor` (abstract) | dsh-bash | bash execution seam: foreground runs + background tasks | -| `ctx.bash` | `BashExecutor` (abstract) | dsh-bash | bash execution seam: foreground runs + background tasks | | `ctx.fs` | `FileSystem` (abstract) | dsh-fs | filesystem provider seam: path resolution, stat, text read/stream, atomic writes/edits (optional version guard); owns the `fs/*` policy events | | `ctx.compact` | `CompactService` (abstract) | dsh-compact | compaction seam: decide when history is too large, summarize an older range into a single surface node | | `ctx.subagents` | `SubagentService` | dsh-subagent | named provider registry for delegating a task to child agents | @@ -79,7 +78,7 @@ Swappable capabilities are split into **three packages** so each part evolves in The LLM seam has the same topology folded differently: `dsh-llm` carries the interface (`LlmAdapter`) AND the consumer surface (`ctx.llm.stream()`), with adapters as implementation packages — there the consumer is the loop itself, not a swappable schema surface. Use the full three-package split when the consumer is independently replaceable; keep interface + consumer together when they are one concern. Don't split preemptively: a capability with one conceivable implementation and one consumer stays one package until proven otherwise. -The filesystem capability follows the bash topology with a fourth layer, but the policy is contributed through an **event gate**, not a method service: `dsh-fs` owns the abstract `ctx.fs` provider seam (text IO + atomic mutation primitives whose version guard is optional) and the `fs/*` policy event vocabulary, `dsh-fs-local` provides the local backend, `dsh-tool-fs` is the model-facing `read`/`write`/`edit` tools AND the executor (it reads/writes/edits through `ctx.fs` directly, owns read windowing, dispatches the `fs/*` events), and `dsh-file-context` is a policy PLUGIN (no service) that decides the `fs/write-expectation`/`fs/edit-expectation` waterfalls and records on `fs/observed` to add observed-state + read-before-edit + version-guarded write/edit. Because the tool is not method-coupled to the policy, dropping `dsh-file-context` gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool at a service-injection boundary. The fs tools are not wired into any default/example config yet (the demo agents do file ops through bash); a deployment that loads `dsh-tool-fs` is expected to also load `dsh-file-context` so the default behavior is read-before-write/edit. See [the file-context event-gate RFC](rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md). +The filesystem capability follows the bash topology with a fourth layer, but the policy is contributed through an **event gate**, not a method service: `dsh-fs` owns the abstract `ctx.fs` provider seam (text IO + atomic mutation primitives whose version guard is optional) and the `fs/*` policy event vocabulary, `dsh-fs-local` provides the local backend, `dsh-tool-fs` is the model-facing `read`/`write`/`edit` tools AND the executor (it reads/writes/edits through `ctx.fs` directly, owns read windowing, dispatches the `fs/*` events), and `dsh-fs-policy` is a policy PLUGIN (no service) that decides the `fs/write-intent`/`fs/edit-intent` waterfalls and records on `fs/observed` to add observed-state + read-before-edit + version-guarded write/edit. Because the tool is not method-coupled to the policy, dropping `dsh-fs-policy` gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool at a service-injection boundary. The fs tools are not wired into any default/example config yet (the demo agents do file ops through bash); a deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit. See [the fs-policy event-gate RFC](rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md). > **"Capability" — two unrelated meanings.** (1) The *seam pattern* above ("one plugin provides a capability, another needs it") is realized by plain Cordis **services + `inject`**: a provider registers a service (`ctx.bash`, declared in `interface Context`); a consumer declares `inject: ['bash']` and its fiber stays pending until the service exists, tearing down via HMR if it later vanishes. No extra library is needed. (2) `@cordisjs/plugin-capability` is a different axis entirely — a **permission/capability-security** service (named permissions with inheritance/dependency, tested against a session via `ctx.capability.test`). It is a candidate for the deferred permissions/sandbox work (the `tools/execute` veto seam), NOT a mechanism for swapping implementations. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 7c1701af59..5a0f203e23 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -199,12 +199,12 @@ Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/t ### `fs/*` -#### `fs/edit-expectation` — waterfall +#### `fs/edit-intent` — waterfall -Single-slot decision: produce the optional version guard for the next 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-file-context` 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 Events.'fs/write-expectation'). +Single-slot decision: produce the optional version guard for the next 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 Events.'fs/write-intent'). ```ts cordis-catalog -'fs/edit-expectation'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> +'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> ``` Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) @@ -213,7 +213,7 @@ Source: [`packages/fs/fs/src/index.ts:117`](../../packages/fs/fs/src/index.ts) #### `fs/observed` — emit -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-file-context`'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. +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. ```ts cordis-catalog 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void @@ -223,15 +223,15 @@ Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core- Source: [`packages/fs/fs/src/index.ts:129`](../../packages/fs/fs/src/index.ts) -#### `fs/write-expectation` — waterfall +#### `fs/write-intent` — waterfall -Single-slot decision: produce the write expectation for the next 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-file-context` 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. +Single-slot decision: produce the write intent for the next 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. ```ts cordis-catalog -'fs/write-expectation'(target: FsTarget, actor: object | undefined, next: () => FsWriteExpectation | undefined | Promise): Promise +'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise ``` -Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteExpectation](../core-data-structures/filesystem.md) +Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) Source: [`packages/fs/fs/src/index.ts:105`](../../packages/fs/fs/src/index.ts) @@ -440,7 +440,7 @@ Semantics every backend must honor: - resolve returns a stable 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). - stat returns FsInfo metadata (never content) or `undefined` when the target is absent. - readText/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`. -- writeText is atomic temp-file + rename. `expected` is OPTIONAL: omit it for an unconditional create-or-overwrite (the bare-provider default), or supply a FsWriteExpectation to guard the write. +- writeText is atomic temp-file + rename. `expected` is OPTIONAL: omit it for an unconditional create-or-overwrite (the bare-provider default), or supply a FsWriteIntent to guard the write. - 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`). ```ts cordis-catalog @@ -448,11 +448,11 @@ abstract resolve(path: string): Promise abstract stat(target: FsTarget, signal?: AbortSignal): Promise abstract readText(target: FsTarget, signal?: AbortSignal): Promise abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> -abstract writeText(target: FsTarget, content: string, expected?: FsWriteExpectation, signal?: AbortSignal): Promise +abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise ``` -Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteExpectation](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) +Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) Source: [`packages/fs/fs/src/index.ts:158`](../../packages/fs/fs/src/index.ts) diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index 22cda4672d..2e66dd9d9e 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -1,10 +1,10 @@ # Filesystem -The filesystem stack is split across four packages: a provider seam ([dsh-fs](../../packages/fs/fs), `ctx.fs`, text IO + atomic mutation primitives whose version guard is optional), a local implementation ([dsh-fs-local](../../packages/fs/fs-local), local disk), a policy plugin ([dsh-file-context](../../packages/fs/file-context), observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate — NO service), and a consumer ([dsh-tool-fs](../../packages/fs/tool-fs), the model-facing `read`/`write`/`edit` tools, which is also the EXECUTOR — it reads/writes/edits through `ctx.fs` directly and owns read windowing). Filesystem access is an optional capability, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). A sandboxed, remote, virtual, or project-scoped backend can implement the same `FileSystem` service without changing the policy plugin or the tool schemas. +The filesystem stack is split across four packages: a provider seam ([dsh-fs](../../packages/fs/fs), `ctx.fs`, text IO + atomic mutation primitives whose version guard is optional), a local implementation ([dsh-fs-local](../../packages/fs/fs-local), local disk), a policy plugin ([dsh-fs-policy](../../packages/fs/fs-policy), observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate — NO service), and a consumer ([dsh-tool-fs](../../packages/fs/tool-fs), the model-facing `read`/`write`/`edit` tools, which is also the EXECUTOR — it reads/writes/edits through `ctx.fs` directly and owns read windowing). Filesystem access is an optional capability, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). A sandboxed, remote, virtual, or project-scoped backend can implement the same `FileSystem` service without changing the policy plugin or the tool schemas. -The model is **additive, not subtractive**: `ctx.fs` alone is a complete, unconstrained text-storage seam (`write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text). `dsh-file-context` is a plugin that *adds* policy on top by deciding the `fs/*` waterfalls; removing it leaves the bare provider rather than breaking the tool, because the tool is not method-coupled to the policy. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-file-context` so the default behavior is read-before-write/edit. +The model is **additive, not subtractive**: `ctx.fs` alone is a complete, unconstrained text-storage seam (`write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text). `dsh-fs-policy` is a plugin that *adds* policy on top by deciding the `fs/*` waterfalls; removing it leaves the bare provider rather than breaking the tool, because the tool is not method-coupled to the policy. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit. -Provider source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) and [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts). Policy source: [`packages/fs/file-context/src/types.ts`](../../packages/fs/file-context/src/types.ts). Read-rendering source: [`packages/fs/tool-fs/src/read-render.ts`](../../packages/fs/tool-fs/src/read-render.ts). +Provider source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) and [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts). Policy source: [`packages/fs/fs-policy/src/types.ts`](../../packages/fs/fs-policy/src/types.ts). Read-rendering source: [`packages/fs/tool-fs/src/read-render.ts`](../../packages/fs/tool-fs/src/read-render.ts). ## Target identity and metadata (provider seam) @@ -40,10 +40,10 @@ interface FsInfo { ## Write and edit guards (provider seam) -Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteExpectation` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit share one symmetric `expected?` shape. +Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteIntent` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit share one symmetric `expected?` shape. ```ts type-equiv -type FsWriteExpectation = +type FsWriteIntent = | { kind: 'createIfAbsent' } | { kind: 'replaceIfVersion'; version: FsVersion } ``` @@ -75,16 +75,16 @@ interface FsEditOutcome { ## The fs policy events (provider-seam vocabulary) -`dsh-fs` owns three events the tool dispatches and the policy plugin listens for, so the emitter (`dsh-tool-fs`) and the listener (`dsh-file-context`) share a vocabulary without the emitter depending on the policy plugin. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure. +`dsh-fs` owns three events the tool dispatches and the policy plugin listens for, so the emitter (`dsh-tool-fs`) and the listener (`dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure. -`fs/write-expectation` and `fs/edit-expectation` are **single-slot decision waterfalls**: the tool dispatches each with a default thunk returning `undefined` (the bare provider), and a listener fully decides without calling `next()`. The slot is first-wins by registration order — the policy plugin owning it is a deployment convention, not an enforced invariant. `fs/observed` is a fire-and-forget recording event whose listener must be synchronous and side-effect-only; the tool contains a throw so a recording bug never fails the already-completed mutation. The generated catalog shows the exact signatures on [events-and-services.md](../cordis-catalog/events-and-services.md). +`fs/write-intent` and `fs/edit-intent` are **single-slot decision waterfalls**: the tool dispatches each with a default thunk returning `undefined` (the bare provider), and a listener fully decides without calling `next()`. The slot is first-wins by registration order — the policy plugin owning it is a deployment convention, not an enforced invariant. `fs/observed` is a fire-and-forget recording event dispatched with a plain `ctx.emit`; its listener MUST be synchronous and side-effect-only, because the tool does NOT guard the emit — a throwing listener would surface as the tool's `isError` result for a mutation that already succeeded. The generated catalog shows the exact signatures on [events-and-services.md](../cordis-catalog/events-and-services.md). ## Execution context (policy plugin) -The policy plugin needs just enough execution context to derive the observed-state owner by narrowing the opaque `object` actor the `fs/*` events carry. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through as the actor without making `dsh-file-context` import the tool, agent, or session packages. +The policy plugin needs just enough execution context to derive the observed-state owner by narrowing the opaque `object` actor the `fs/*` events carry. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through as the actor without making `dsh-fs-policy` import the tool, agent, or session packages. ```ts type-equiv -interface FileContextExec { +interface FsPolicyExec { agent?: { session?: object } @@ -108,7 +108,7 @@ interface FileReadOutcome { ## Observed-file state (policy plugin) -Observed state is a `WeakMap>` held inside the `dsh-file-context` plugin. 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 separate `hasRead` flag and no view distinction. The owner is derived from the event actor (normally `exec.agent.session`), treated as opaque and never read. A successful read/write/edit refreshes the recorded version for that owner; disposal drops everything (HMR safety). +Observed state is a `WeakMap>` held inside the `dsh-fs-policy` plugin. 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 separate `hasRead` flag and no view distinction. The owner is derived from the event actor (normally `exec.agent.session`), treated as opaque and never read. A successful read/write/edit refreshes the recorded version for that owner; disposal drops everything (HMR safety). ## Error taxonomy (provider seam) @@ -130,4 +130,4 @@ type FsErrorCode = ## The service and the plugin -`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `stat`, `readText`, `streamText`, `writeText`, and `editText`. `dsh-file-context` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit expectation waterfalls (supplying `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED`) and records on `fs/observed`. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated wiring catalog shows the exact `ctx.fs` signatures on [events-and-services.md](../cordis-catalog/events-and-services.md#ctxfs--filesystem-abstract-seam). +`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `stat`, `readText`, `streamText`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls (supplying `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED`) and records on `fs/observed`. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated wiring catalog shows the exact `ctx.fs` signatures on [events-and-services.md](../cordis-catalog/events-and-services.md#ctxfs--filesystem-abstract-seam). diff --git a/docs/module-graph.md b/docs/module-graph.md index 3fe0d5aefe..69391388ac 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -22,8 +22,8 @@ graph TD agent --> session compact --> llm compact --> session - file-context --> fs fs-local --> fs + fs-policy --> fs llm-replay --> llm llm-replay --> session session-persistence --> session @@ -120,8 +120,8 @@ graph TD | `system-prompt` | `llm` | | `agent` | `brand`, `llm`, `session` | | `compact` | `llm`, `session` | -| `file-context` | `fs` | | `fs-local` | `fs` | +| `fs-policy` | `fs` | | `llm-replay` | `llm`, `session` | | `session-persistence` | `session` | | `compact-basic` | `agent`, `compact`, `llm`, `session` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 6d12cc9c3d..a79d15888c 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -98,7 +98,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Prune dead methods from the persistence seam](implemented/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | | [Keep one public stop primitive](implemented/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | | [Fold trace-only session facts into load-bearing events](implemented/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | -| [Split the filesystem seam — provider text mutations plus policy `ctx.fileContext`](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | +| [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | ### Architecture @@ -123,7 +123,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | | [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | | [Extract example apps into packages](implemented/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | -| [Make `dsh-file-context` an event-gate plugin, not a method interface](implemented/architecture/2026-06-26-file-context-as-event-gate.md) | 2026-06-26 | +| [Make `dsh-fs-policy` an event-gate plugin, not a method interface](implemented/architecture/2026-06-26-file-context-as-event-gate.md) | 2026-06-26 | ### Process diff --git a/docs/rfc/implemented/AGENTS.md b/docs/rfc/implemented/AGENTS.md index e5ddc5eeea..831b8d5325 100644 --- a/docs/rfc/implemented/AGENTS.md +++ b/docs/rfc/implemented/AGENTS.md @@ -10,6 +10,6 @@ Update it **in place** to state the current truth. Do **not** leave the outdated ### This is not a license to rewrite the *decision* -Keeping the shipped-state description current is about **facts** (paths, names, structure, defaults) — not about silently flipping the **decision and its rationale** into a different one. If the underlying choice itself is reversed or materially changed (not just relocated), that is a new decision: write a new RFC and cross-link, per [rfc/README.md](../README.md) ("An RFC is never edited into a different decision"). The line: a refactor that moves where the decision is *realized* → edit this RFC to match; a reversal of *what was decided* → a new RFC. +Keeping the shipped-state description current is about **facts** (paths, names, structure, defaults) — not about silently flipping the **decision and its rationale** into a different one. The "new RFC" escape hatch is for **macro** changes — a genuine reversal of *what was decided* or its rationale — NOT for renames, moves, or structural relocations. A rename is always a fact to fix **in place**: leaving a package/symbol/path at its old name (even with a "was renamed to…" aside) only confuses a reader who greps the current tree for a name that no longer exists. So: the package was renamed, a symbol changed, a plugin moved, the decision is now realized through a different mechanism → edit this RFC to state the current names and structure. Only a reversal of *what was decided* → a new RFC and cross-link, per [rfc/README.md](../README.md) ("An RFC is never edited into a different decision"). When in doubt, ask whether a reader following this RFC to the code would land on something real. If not, it needs updating. diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md index 0a4365bc82..731ee41ad7 100644 --- a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -20,19 +20,21 @@ We need the filesystem tools to land in the same capability-seam shape as bash b Introduce filesystem access as a first-class capability seam following [the capability-seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md): -1. `@deepseek-ai/dsh-fs` (`packages/fs/fs`) owns the abstract `ctx.fs` service, filesystem vocabulary types, and file-state tracking contract. +1. `@deepseek-ai/dsh-fs` (`packages/fs/fs`) owns the abstract `ctx.fs` service, the filesystem vocabulary types, and the `fs/*` policy event vocabulary. 2. `@deepseek-ai/dsh-fs-local` (`packages/fs/fs-local`) provides the first implementation, backed by the local filesystem. -3. `@deepseek-ai/dsh-tool-fs` (`packages/fs/tool-fs`) provides the model-facing `read`, `write`, and `edit` tools over `ctx.fs`. +3. `@deepseek-ai/dsh-tool-fs` (`packages/fs/tool-fs`) provides the model-facing `read`, `write`, and `edit` tools over `ctx.fs`, and is the executor that dispatches the `fs/*` events. The consumer package depends only on the interface package, never on `dsh-fs-local`. A deployment that wants a different backend loads a different provider for `ctx.fs` without changing the tool schemas or model-facing prompt guidance. +The read-before-write/edit and observed-state policy is a fourth package, `@deepseek-ai/dsh-fs-policy` (`packages/fs/fs-policy`), contributed through the `fs/*` event gate rather than living on `ctx.fs`. This RFC established the three-package seam; the split of policy off the provider base class is decided by [the split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md), and its realization as an event-gate plugin (not a method service) by [the event-gate RFC](2026-06-26-file-context-as-event-gate.md). This document is updated to describe that landed four-package shape. + The first backend is deliberately local-only: `dsh-fs-local` implements `ctx.fs` against the host filesystem. Future sibling backends can provide sandboxed, remote, virtual, or project-scoped filesystems behind the same interface. The first consumer is deliberately text-file-only: `dsh-tool-fs` exposes model-facing `read`, `write`, and `edit` tools for UTF-8 text files. Future consumers can add directory listing, search/glob, binary-safe operations, file watching, or higher-level project operations without changing the local backend package, as long as the needed capability exists on `ctx.fs`. Filesystem permissions and sandboxing are not implied by this split. The local backend resolves relative paths from its configured base directory, but containment policy is a separate decision: either a stricter `ctx.fs` implementation enforces it, or a permission/sandbox plugin wraps `tools/execute` and vetoes calls before they reach the consumer. -Read-before-write/edit is part of the filesystem seam, not a separate service. `ctx.fs` records which file states the current execution context has seen and validates write-like operations against that state. The first `tool-fs` consumer passes the current tool execution context, or a structural projection of it, through to `ctx.fs`; `ctx.fs` derives the file-state owner from that context, normally `exec.agent.session`. `tool-fs` does not know the cache shape, the owner key, or the `read` tool name/schema. +Read-before-write/edit and observed-state are policy, contributed by the `dsh-fs-policy` plugin through the `fs/*` event gate — NOT stored on `ctx.fs`. The provider seam offers an optional version guard on its mutations (`writeText`/`editText` take an optional expectation); the policy plugin decides that guard by listening on `fs/write-intent`/`fs/edit-intent` and records observed versions on `fs/observed`. The executor (`dsh-tool-fs`) passes the current tool execution context as the opaque event actor; the policy plugin derives the observed-state owner from it, normally `exec.agent.session`. `dsh-fs` treats the actor as opaque and never reads it; `dsh-tool-fs` never reaches into the policy plugin. Authorization is version freshness: any read records the file's version, and a later write/edit is authorized as long as the file is unchanged. (This RFC first placed the observed-state store on `ctx.fs`; the split to `dsh-fs-policy` on the `fs/*` event gate is decided by [the split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) and [event-gate](2026-06-26-file-context-as-event-gate.md) RFCs.) ## Package topology @@ -43,9 +45,9 @@ The filesystem seam uses the same dependency direction as the bash trio: consumer interface implementation ``` -`@deepseek-ai/dsh-fs` depends only on `cordis` plus the repo-wide `HarnessError` base from `@deepseek-ai/dsh-llm`. It declares the `ctx.fs` key, the abstract `FileSystem` service, the vocabulary types shared by backends and consumers, the filesystem error vocabulary, and the file-state contract. The interface defines a minimal structural execution context shape rather than importing `dsh-tools`, `dsh-agent`, or `dsh-session`; the implementation derives a file-state owner from that shape when one is available. The owner object is opaque to `dsh-fs`: `tool-fs` may pass the `ToolExecution` it already receives, or a projected object containing only the owner-bearing fields, without making `dsh-fs` depend on the tool or agent packages. +`@deepseek-ai/dsh-fs` depends only on `cordis` plus the repo-wide `HarnessError` base from `@deepseek-ai/dsh-llm`. It declares the `ctx.fs` key, the abstract `FileSystem` service, the vocabulary types shared by backends and consumers, the filesystem error vocabulary, and the `fs/*` policy event vocabulary. It carries no observed-state store and no owner-derivation shape; the events pass an opaque `object` actor that the provider never reads, and the `dsh-fs-policy` plugin owns the owner-derivation shape and the observed-state store on top of those events. -`@deepseek-ai/dsh-fs-local` depends on `@deepseek-ai/dsh-fs` and `cordis`. It subclasses `FileSystem`, registers itself as `ctx.fs`, owns local-backend configuration such as the base directory, contains all direct `node:fs` / `node:path` access, and provides the in-memory file-state store for the local backend. +`@deepseek-ai/dsh-fs-local` depends on `@deepseek-ai/dsh-fs` and `cordis`. It subclasses `FileSystem`, registers itself as `ctx.fs`, owns local-backend configuration such as the base directory, and contains all direct `node:fs` / `node:path` access. It holds no observed-state store — freshness is a version token the backend mints and the policy plugin records. `@deepseek-ai/dsh-tool-fs` depends on `@deepseek-ai/dsh-fs`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and `cordis`. It registers model-facing tools and prompt sections. It must not import `node:fs`, `node:path`, or `@deepseek-ai/dsh-fs-local`; filesystem execution always goes through `ctx.fs`. If the implementation needs concrete agent or session helper types, those dependencies belong in `tool-fs`; they must not leak back into `dsh-fs`. @@ -62,15 +64,13 @@ The exact TypeScript signatures are implementation details for the PR, but the i - Create or replace a UTF-8 text file. - Edit an existing UTF-8 text file by literal replacement. -The interface must also cover file state: +The provider seam also carries the freshness hooks that policy builds on — but the observed-state store and owner derivation live in the `dsh-fs-policy` plugin, not on `ctx.fs`: -- Derive a file-state owner from the current execution context, normally the active agent session. -- Record that the owner saw a target at a backend-defined version. -- Determine whether that owner has a full editable view of a target. -- Use the recorded version as the stale guard for write/edit operations that require prior observation. -- Refresh the recorded state after a successful write/edit so follow-up modifications can proceed without forcing another read. +- The backend mints an opaque `version` token per target (in `stat` and in every read/mutation outcome). +- `writeText`/`editText` take an OPTIONAL version expectation: omit it for an unconditional bare-provider mutation, or supply it to guard the mutation inside the backend's atomic critical section. +- The `dsh-fs-policy` plugin decides that expectation on `fs/write-intent`/`fs/edit-intent` and records observed versions on `fs/observed`, keyed by an owner it derives from the opaque event actor (normally `exec.agent.session`). -The in-memory shape is conceptually a weakly-owned cache: file state is keyed first by the derived owner object, then by the backend `targetKey`. The owner is usually `exec.agent.session`, but `dsh-fs` treats it as opaque and does not import `dsh-session`. Each cached `FileState` records the `targetKey`, `displayPath`, backend `version`, current view (`full` or `partial`), update time, and source (`read`, `write`, `edit`, or a future seed path). Only a `full` view authorizes write/edit. A `partial` view records useful context (paged read, truncated read, injected context) but does not grant edit authority. +Authorization is version freshness, not a full/partial view distinction: any read records the target's version, and a later write/edit is authorized as long as the file is still at that version — so a windowed read of lines 100-150 authorizes an edit of line 120. The observed-state store is a `WeakMap>` inside `dsh-fs-policy`; `dsh-fs` holds none of it and treats the actor as opaque. (This RFC first modeled a `FileState` cache with `full`/`partial` views on `ctx.fs`; the split-fs-seam and event-gate RFCs replaced that with the freshness-based policy plugin described here.) Path resolution should be explicit and allowed to be async. Local resolution may only normalize a path, but sandboxed/remote/project-scoped backends may need I/O to resolve a user-supplied path into a stable target identity. @@ -82,17 +82,17 @@ Resolved targets must expose at least three concepts: Read and mutation results must include an opaque file `version`. A local backend can use mtime/size or a hash-like token; a remote backend can use a revision id. `ctx.fs` records versions in its file-state store for stale checks; consumers may display related metadata but must not interpret the version token. -Text reads return structured UTF-8 line records or ranges with pagination metadata. `tool-fs` owns line-numbered model text rendering; the backend owns bounded line length, bounded output bytes, binary-file rejection, total-line accounting, and whether the returned content is a partial view of the file. +The provider hands back decoded text: `readText` returns a whole regular text file, `streamText` streams the same text semantics for large files. Both own regular-file checks, bounded line/output handling is NOT theirs — line windowing, numbered-line rendering, and total-line accounting live in the executor (`dsh-tool-fs`), which reads through `ctx.fs` and renders the model-facing window. The provider owns UTF-8 decoding and binary/NUL rejection; it does not know about line windows or views. -When a read has a file-state owner, `ctx.fs` records the target, version, display path, view metadata, timestamp, and source. Partial views are useful context but do not authorize write/edit unless a future operation can prove the model saw the raw editable content. +Observed-state recording is not on `ctx.fs`: after a successful read the executor emits `fs/observed`, and the `dsh-fs-policy` plugin records `{ version }` for the deriving owner. There is no `full`/`partial` view — a read at any window records the version, and freshness (not view completeness) authorizes a later write/edit. -Full-file writes create or replace UTF-8 text files. Backends may create parent directories when that behavior is supported and documented. Existing non-regular targets are rejected. For updates to existing files, `ctx.fs` should require a full prior file state for the current owner and reject absent or partial state. The backend then compares the current file version to the recorded version and rejects stale writes. If the recorded target no longer exists, the write is stale rather than a create. A create is expressed as a write to a target with no existing file and does not require prior state or a file-state owner. +Full-file writes create or replace UTF-8 text files. Backends may create parent directories when that behavior is supported and documented. Existing non-regular targets are rejected. `writeText` takes an optional expectation: `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED` (the path the policy uses for an unobserved owner); `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`; omitting the expectation is the unconditional bare-provider create-or-overwrite. The policy plugin chooses which expectation to supply from the owner's observed state. -Literal edit is part of `ctx.fs`, not composed in `tool-fs` from a read plus write. Literal matching, duplicate-match rejection, CRLF preservation, binary rejection, prior-file-state checking, stale-version checking, and atomic read-modify-write are filesystem/backend semantics. A remote backend may implement edit as a native compare-and-edit operation; the consumer should not force local-style composition. +Literal edit is a provider primitive (`editText`), not composed in `tool-fs` from a read plus write. Literal matching, duplicate-match rejection, CRLF preservation, binary rejection, optional stale-version checking, and atomic read-modify-write must stay together inside the backend's mutation critical section. `editText` takes the same optional version expectation; the stale check runs before literal matching so an edit against an old read reports `FS_STALE_VERSION`. A remote backend may implement edit as a native compare-and-edit operation; the consumer should not force local-style composition. -Direct tool executions without a derivable file-state owner can still exercise lower-level helpers in tests. Production `write`/`edit` tool calls should reject without an owner when they update an existing target, because those operations require prior state. Owner-less `write` may still create a new file when the backend confirms that the target does not already exist. +The policy plugin, not `ctx.fs`, gates on prior observation: an `edit` requires a prior observation by the owner (else `FS_NOT_OBSERVED`), and the recorded version is passed to `editText` as the CAS basis. With the policy plugin absent, `ctx.fs` alone is a complete unconstrained seam (unconditional write/edit); the tool is never method-coupled to the policy. -Filesystem contract failures are thrown as `FsError extends HarnessError` in the first implementation, and the tool registry converts them into `isError` tool results with structured `{ name, code }` metadata. `dsh-fs` owns this vocabulary rather than each tool inventing messages. Initial codes should include `FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_PARTIAL_OBSERVATION`, `FS_NOT_REGULAR_FILE`, `FS_AMBIGUOUS_EDIT`, and `FS_EDIT_NOT_FOUND`. +Filesystem contract failures are thrown as `FsError extends HarnessError`, and the tool registry converts them into `isError` tool results with structured `{ name, code }` metadata. `dsh-fs` owns this vocabulary rather than each tool inventing messages. The codes are `FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_NOT_REGULAR_FILE`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, and `FS_ABORTED`. (An earlier draft included `FS_PARTIAL_OBSERVATION`; freshness-based authorization has no partial/full distinction, so it was dropped.) ## Tool consumer behavior @@ -115,7 +115,7 @@ The package registers prompt guidance through `ctx.systemPrompt.section(...)` an The tool package must keep model-facing contracts stable when backends change. A local backend and a remote backend may resolve paths differently internally, but the `read` / `write` / `edit` schemas should not change solely because the backend changes. -The first implementation requires a prior full `read` before updating an existing file with `write` or `edit`. `tool-fs` does not implement this by checking whether a tool named `read` ran or by reading the file-state cache. It passes the current execution context to `ctx.fs`, and `ctx.fs` derives the file-state owner and enforces file-state/stale-version policy. Creating a new file with `write` does not require prior state or an owner. +The default deployment requires a prior `read` before updating an existing file with `write` or `edit`. `tool-fs` does not implement this by checking whether a tool named `read` ran: it dispatches the `fs/write-intent`/`fs/edit-intent` events (passing the execution context as the opaque actor), and the `dsh-fs-policy` plugin derives the owner, gates on prior observation, and supplies the version expectation. Any windowed read authorizes a later write/edit as long as the file is unchanged. Creating a new file with `write` does not require prior observation. The root plugin registers the full suite by composing the per-tool registration helpers. It injects `fs`, `tools`, and `systemPrompt`. @@ -128,7 +128,7 @@ This RFC starts from `origin/master`, where no filesystem tool package exists ye 3. Add `packages/fs/tool-fs` with the model-facing `read`, `write`, and `edit` tools over `ctx.fs`. 4. Update `docs/architecture.md`, `packages/README.md`, package READMEs, build/typecheck config, and aggregate maintenance scripts such as `scripts/publint-all.ts`. -This first pass does not add a separate `@deepseek-ai/dsh-file-context` package. The file-state store lives behind `ctx.fs` so the `tool-fs` plugin gets the read-before-write/edit policy automatically. +This RFC's first landing kept the observed-state store behind `ctx.fs`. The split-fs-seam and event-gate RFCs then moved it into the standalone `@deepseek-ai/dsh-fs-policy` plugin on the `fs/*` event gate, which is the shipped shape; a deployment loading `dsh-tool-fs` also loads `dsh-fs-policy` to get read-before-write/edit. Example leaf configs stay bash-only in this landing. Wiring `examples/coding-agent` or `examples/acp-agent` to `dsh-fs-local` + `dsh-tool-fs` changes the model prompt, visible tool schemas, and ACP snapshot transcript, so it should land as a follow-up UX/example change with prompt and snapshot updates in the same PR. @@ -146,7 +146,7 @@ Tests should follow the package boundary, not only the user-visible tools. `dsh-fs` tests cover the service seam itself: a provider registers `ctx.fs`, duplicate providers follow Cordis service behavior, disposal removes the service, and any shared contract helpers or type-level utilities behave as documented. -`dsh-fs-local` tests cover real filesystem behavior through the `ctx.fs` interface, not through model tools. They should include path resolution, absolute paths, `..` segments, symlinks inside and outside the configured base directory, reading small and large text files, pagination, output caps, binary-file rejection, abort handling, full-file create/update writes, owner-less creates, owner-less update rejection, parent-directory creation, non-regular target rejection, literal edit success/failure, unique-match enforcement, replace-all behavior, line-ending preservation, file-state recording after reads, session/owner isolation, read-before-update rejection, stale-version rejection, partial-view rejection, structured `FsError` codes, and file-state refresh after successful writes/edits. +`dsh-fs-local` tests cover real filesystem behavior through the `ctx.fs` interface, not through model tools. They should include path resolution, absolute paths, `..` segments, symlinks inside and outside the configured base directory, reading small and large text files, streaming, binary-file rejection, invalid-UTF-8 rejection, abort handling, unconditional and version-guarded full-file writes, `createIfAbsent`/`replaceIfVersion` semantics, parent-directory creation, non-regular target rejection, literal edit success/failure, unique-match enforcement, replace-all behavior, line-ending preservation, stale-version rejection (guarded edit against an old version), and structured `FsError` codes. The observed-state/owner-derivation policy is NOT here — it lives in `dsh-fs-policy` and is tested there. Beyond the happy/sad paths above, `dsh-fs-local` tests must cover the defensive-pattern classes this repo has been bitten by: @@ -156,9 +156,9 @@ Beyond the happy/sad paths above, `dsh-fs-local` tests must cover the defensive- - **Concurrency / stale races.** The RFC names edit as race-prone (see Risks). Test that two concurrent write/edit operations against the same target settle deterministically: one succeeds and the other is rejected with `FS_STALE_VERSION` rather than silently overwriting, and that a successful edit refreshes recorded state so an immediately-following edit by the same owner proceeds. - **HMR safety and disposal.** `dsh-fs-local` registers `ctx.fs` and owns the in-memory file-state store, so it needs its own HMR-safety test (register the backend on a fiber, dispose it, assert the `ctx.fs` provider is withdrawn and the file-state store is released — a later provider starts with no inherited state). -`dsh-tool-fs` tests cover the consumer surface with a fake `ctx.fs` implementation. They should verify tool schemas, argument validation, prompt-section registration, formatting of successful results, propagation of backend `FsError` codes into `isError` tool results through `ctx.tools.execute()`, that read/write/edit pass the current execution context or structural projection through to `ctx.fs`, root-plugin suite registration, and HMR cleanup. +`dsh-tool-fs` tests cover the consumer surface against the real `dsh-fs-local` provider (mock only the model/clock, not the collaborator). They should verify tool schemas, argument validation, prompt-section registration, formatting of successful results, propagation of backend `FsError` codes into `isError` tool results through `ctx.tools.execute()`, that read/write/edit dispatch the `fs/*` events (passing the execution context as the actor), root-plugin suite registration, and HMR cleanup of both tool schemas and prompt sections. -Integration tests should load `dsh-fs-local` plus `dsh-tool-fs` and execute `read`, `write`, and `edit` through `ctx.tools.execute()` to prove the three packages work together without bypassing the tool registry. They must verify the world, not the tool's self-report: after a `write`/`edit`, read the file back from disk and assert byte-identical content (and that untouched files are unchanged), rather than trusting the returned `ContentBlock[]`. Each integration/e2e test owns its resources — create the harness in the test, run against a per-test temporary directory, and dispose the harness and remove the directory in `afterEach` even on failure or timeout. +Integration tests should load `dsh-fs-local` plus `dsh-tool-fs` (and, for the default deployment, `dsh-fs-policy`) and execute `read`, `write`, and `edit` through `ctx.tools.execute()` to prove the packages work together without bypassing the tool registry — including a bare-provider path (no `dsh-fs-policy`) where an unread edit/overwrite succeeds. They must verify the world, not the tool's self-report: after a `write`/`edit`, read the file back from disk and assert byte-identical content (and that untouched files are unchanged), rather than trusting the returned `ContentBlock[]`. Each integration/e2e test owns its resources — create the harness in the test, run against a per-test temporary directory, and dispose the harness and remove the directory in `afterEach` even on failure or timeout. Repo gates for the implementation include the focused vitest suites, `pnpm run typecheck`, `pnpm run test:coverage` for runtime code, and build/publint coverage after adding package entrypoints. @@ -172,7 +172,7 @@ Repo gates for the implementation include the focused vitest suites, `pnpm run t **Edit semantics are race-prone.** Literal edit is a read-modify-write operation. Without a stale-content guard or backend-level atomic edit primitive, concurrent edits can overwrite each other. The first implementation should document its guarantees clearly; stronger compare-and-swap semantics can be added later if needed. -**File state inside `ctx.fs` can blur concerns.** Recording what an execution context has seen is workflow state, not raw filesystem I/O. This RFC still keeps it inside the filesystem seam because write/edit safety depends on backend-defined target identity and version tokens, and because putting it in `tool-fs` would couple write/edit to the read tool implementation. The boundary is narrow: `ctx.fs` derives the file-state owner, records file state, and checks stale versions, while `tool-fs` owns only model-facing schemas and formatting. +**Observed state does not belong on `ctx.fs`.** Recording what an execution context has seen is workflow policy, not raw filesystem I/O. This RFC first placed it inside the filesystem seam; the split-fs-seam RFC then established that a sandboxed/remote backend should not inherit model-facing observation policy, and moved it into the `dsh-fs-policy` plugin. The provider seam keeps only what write/edit safety genuinely needs at the storage layer — a backend-minted version token and an optional version-guarded mutation — while the policy plugin owns owner derivation, observed-state, and read-before-edit gating over the `fs/*` events. **The `resolve`-then-operate shape costs an extra round-trip per call.** Each tool may resolve a path to an `FsTarget` and then issue the read/write/edit as a separate `ctx.fs` call. For the local backend this is negligible (resolution is in-memory path normalization), but a remote/sandboxed backend may turn each step into its own request, so a single `read` can become two network round-trips. Backends where the round-trip matters can cache or fold resolution internally while preserving the observable contract. diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md index a51cde555d..3d5e67ce20 100644 --- a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -1,4 +1,4 @@ -# RFC: Make `dsh-file-context` an event-gate plugin, not a method interface +# RFC: Make `dsh-fs-policy` an event-gate plugin, not a method interface Status: implemented @@ -9,48 +9,48 @@ Status: implemented This couples three things that should be separable: 1. **What the tool does** — resolve a path, read a window, write/edit a file. This is the tool's job and needs only `ctx.fs`. -2. **The freshness/observation policy** — "edit requires a prior read", "write/edit must be based on the version you read". This is the `dsh-file-context` plugin's job. +2. **The freshness/observation policy** — "edit requires a prior read", "write/edit must be based on the version you read". This is the `dsh-fs-policy` plugin's job. 3. **The recording of observed state** — a side effect that should never block the tool from functioning. Because the tool calls `fileContext` methods, removing the policy layer is a breaking change rather than a graceful loss of an *add-on*. The policy is load-bearing for the tool to even run, not an opt-in tightening. ## Decision -Invert the control flow. **`dsh-tool-fs` becomes the executor and calls `ctx.fs` directly**; **`dsh-file-context` becomes a gate + recorder plugin** that participates through events, never through a method the tool calls and never by registering a `ctx.fileContext` service. +Invert the control flow. **`dsh-tool-fs` becomes the executor and calls `ctx.fs` directly**; **`dsh-fs-policy` becomes a gate + recorder plugin** that participates through events, never through a method the tool calls and never by registering a `ctx.fileContext` service. ```text tool dsh-tool-fs executor: resolves, reads windows, writes/edits via ctx.fs; emits fs policy events; renders results -policy dsh-file-context plugin: listens to fs/write-expectation + - fs/edit-expectation (single-slot waterfall) and fs/observed +policy dsh-fs-policy plugin: listens to fs/write-intent + + fs/edit-intent (single-slot waterfall) and fs/observed (emit) events; adds observed-state + freshness. provider seam dsh-fs ctx.fs: text IO + ATOMIC mutation primitives whose version guard is OPTIONAL; owns the fs policy event vocabulary provider dsh-fs-local local implementation of ctx.fs ``` -The model is **additive, 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. There is no "先读后写", no version check, nothing to remove; the bare provider just does the I/O atomically. `dsh-file-context` is a plugin that *adds* constraints on top: observed-state, read-before-edit, and "write/edit must be based on the version you read". So removing `dsh-file-context` does not break `dsh-tool-fs` at the service-injection boundary; it removes the policy gate and leaves the bare provider behavior. The intended deployment stance is that a config loading the fs tools also loads `dsh-file-context`, so the user-facing behavior and prompt discipline are read-before-write/edit (no default/example config wires the fs tools yet — the demo agents do file ops through bash). The bare-provider mode exists because the tool should not be method-coupled to the policy plugin, not because an unconstrained filesystem is the normal product stance. +The model is **additive, 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. There is no "先读后写", no version check, nothing to remove; the bare provider just does the I/O atomically. `dsh-fs-policy` is a plugin that *adds* constraints on top: observed-state, read-before-edit, and "write/edit must be based on the version you read". So removing `dsh-fs-policy` does not break `dsh-tool-fs` at the service-injection boundary; it removes the policy gate and leaves the bare provider behavior. The intended deployment stance is that a config loading the fs tools also loads `dsh-fs-policy`, so the user-facing behavior and prompt discipline are read-before-write/edit (no default/example config wires the fs tools yet — the demo agents do file ops through bash). The bare-provider mode exists because the tool should not be method-coupled to the policy plugin, not because an unconstrained filesystem is the normal product stance. `dsh-tool-fs` no longer injects `fileContext`. It injects `fs` and `tools`/`systemPrompt`. -## The policy is enforced by provider CAS, not by `dsh-file-context` stat +## The policy is enforced by provider CAS, not by `dsh-fs-policy` stat -`dsh-file-context` enforces "you must write/edit based on the version you read" **without ever calling `stat` or comparing versions itself**. It supplies the observed version as the CAS basis and lets the provider's mutation critical section detect staleness: +`dsh-fs-policy` enforces "you must write/edit based on the version you read" **without ever calling `stat` or comparing versions itself**. It supplies the observed version as the CAS basis and lets the provider's mutation critical section detect staleness: -- "Have you read this file?" is the one thing `dsh-file-context` decides locally — a `WeakMap` lookup, no I/O. 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 read-match-rename. `dsh-file-context` passes `vObserved` as the expectation; the provider raises `FS_STALE_VERSION` if the file has moved on. +- "Have you read this file?" is the one thing `dsh-fs-policy` decides locally — a `WeakMap` lookup, no I/O. 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 read-match-rename. `dsh-fs-policy` passes `vObserved` as the expectation; the provider raises `FS_STALE_VERSION` if the file has moved on. -This is deliberate. If `dsh-file-context` stat-ed and compared versions in its waterfall handler, there would be a TOCTOU gap between that check and the tool's actual write — the file could change in between, so the check would be a false guarantee that the provider's lock has to back up anyway. Putting the version check in the provider's critical section is both race-free and zero extra `stat`. So `dsh-file-context` does **no** filesystem I/O; the "must be based on the latest read" guarantee is *realized* by CAS, and `dsh-file-context` only chooses the basis (`vObserved`) and gates on prior observation. +This is deliberate. If `dsh-fs-policy` stat-ed and compared versions in its waterfall handler, there would be a TOCTOU gap between that check and the tool's actual write — the file could change in between, so the check would be a false guarantee that the provider's lock has to back up anyway. Putting the version check in the provider's critical section is both race-free and zero extra `stat`. So `dsh-fs-policy` does **no** filesystem I/O; the "must be based on the latest read" guarantee is *realized* by CAS, and `dsh-fs-policy` only chooses the basis (`vObserved`) and gates on prior observation. ## Provider contract change: the version guard is optional For the bare provider to be unconstrained, the version guard on its two mutations becomes **optional** — present ⇒ guarded, absent ⇒ unconditional: ```ts ignore-check -// writeText: expected is now optional. The FsWriteExpectation union is UNCHANGED. -writeText(target: FsTarget, content: string, expected?: FsWriteExpectation, signal?: AbortSignal): Promise +// writeText: expected is now optional. The FsWriteIntent union is UNCHANGED. +writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise // undefined → unconditionally create-or-overwrite (bare default) -// createIfAbsent → create only, reject an existing file (dsh-file-context, unobserved) [unchanged] +// createIfAbsent → create only, reject an existing file (dsh-fs-policy, unobserved) [unchanged] // replaceIfVersion → overwrite only at the observed version, else FS_STALE_VERSION [unchanged] // editText: expected becomes optional (was the required { version: FsVersion }). @@ -60,22 +60,22 @@ editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion // { version } → edit only at that version, else FS_STALE_VERSION (the current behavior) ``` -The `FsWriteExpectation` union itself does not change — the third "unconditional" state is expressed by *omitting* `expected`, so both mutations share one symmetric shape (`expected?`: omit = no guard, present = guarded). This keeps full backward compatibility for the guarded paths `dsh-file-context` uses; only the previously-impossible "no guard" case is new, and it is the bare-provider default. The mutation still runs inside the backend's per-target lock either way, so an unconditional write/edit is still atomic (no torn files); "unconditional" drops the *version* precondition, not the atomicity. `editText` reports a missing target as `FS_STALE_VERSION` on both guarded and unguarded paths, preserving one edit failure code for "the target cannot be edited at this moment". +The `FsWriteIntent` union itself does not change — the third "unconditional" state is expressed by *omitting* `expected`, so both mutations share one symmetric shape (`expected?`: omit = no guard, present = guarded). This keeps full backward compatibility for the guarded paths `dsh-fs-policy` uses; only the previously-impossible "no guard" case is new, and it is the bare-provider default. The mutation still runs inside the backend's per-target lock either way, so an unconditional write/edit is still atomic (no torn files); "unconditional" drops the *version* precondition, not the atomicity. `editText` reports a missing target as `FS_STALE_VERSION` on both guarded and unguarded paths, preserving one edit failure code for "the target cannot be edited at this moment". ## Event vocabulary (owned by `dsh-fs`) -The events live in `@deepseek-ai/dsh-fs`, not in `dsh-file-context`. This is forced by the decoupling contract: `dsh-tool-fs` is the emitter, so it must reference the event types, and it must keep compiling even though `dsh-file-context` no longer provides a method service. `dsh-fs` is the package both `dsh-tool-fs` and `dsh-file-context` already depend on, so it is the only home that lets the emitter and the policy listener share a vocabulary without the emitter depending on the policy plugin. +The events live in `@deepseek-ai/dsh-fs`, not in `dsh-fs-policy`. This is forced by the decoupling contract: `dsh-tool-fs` is the emitter, so it must reference the event types, and it must keep compiling even though `dsh-fs-policy` no longer provides a method service. `dsh-fs` is the package both `dsh-tool-fs` and `dsh-fs-policy` already depend on, so it is the only home that lets the emitter and the policy listener share a vocabulary without the emitter depending on the policy plugin. -These events carry existing `dsh-fs` vocabulary (`FsTarget`, `FsVersion`, `FsWriteExpectation`) plus an opaque actor — not model-facing concepts (no line windows, numbered lines, or rendered footers leak down). +These events carry existing `dsh-fs` vocabulary (`FsTarget`, `FsVersion`, `FsWriteIntent`) plus an opaque actor — not model-facing concepts (no line windows, numbered lines, or rendered footers leak down). -**The two `fs/*` decision events are single-slot decision points, NOT a composable interception chain.** A waterfall listener that does not call `next()` short-circuits the rest of the chain (verified in [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts) — `waterfall` runs listeners around the final `next` thunk, and a listener that returns without calling `next()` reaches neither later listeners nor the tool's default thunk). `dsh-file-context` fully decides the write/edit expectation and does not call `next()`, so it occupies that one decision slot in the default deployment. This is deliberate: "what version basis does this mutation guard against" is a single decision, not an accumulation. The names (`fs/write-expectation`, `fs/edit-expectation`) say "produce the value", not "authorize", so they do not imply a stackable authorization chain. Genuinely composable interception (permission, audit, sandbox) belongs on the existing `tools/execute` waterfall, which every tool call already flows through — not on this fs version-decision slot. +**The two `fs/*` decision events are single-slot decision points, NOT a composable interception chain.** A waterfall listener that does not call `next()` short-circuits the rest of the chain (verified in [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts) — `waterfall` runs listeners around the final `next` thunk, and a listener that returns without calling `next()` reaches neither later listeners nor the tool's default thunk). `dsh-fs-policy` fully decides the write/edit expectation and does not call `next()`, so it occupies that one decision slot in the default deployment. This is deliberate: "what version basis does this mutation guard against" is a single decision, not an accumulation. The names (`fs/write-intent`, `fs/edit-intent`) say "produce the value", not "authorize", so they do not imply a stackable authorization chain. Genuinely composable interception (permission, audit, sandbox) belongs on the existing `tools/execute` waterfall, which every tool call already flows through — not on this fs version-decision slot. -**The occupant is decided by registration order — first-registered (or `prepend`ed) wins.** cordis dispatches waterfall listeners in registration order (`push`, or `unshift` for `prepend` — [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts)), and the first non-`next()` decider short-circuits the rest. So the slot is **first-wins**, and `dsh-file-context` owning it rests on the default deployment convention: it is the decider registered for these events. The event shape does NOT itself guarantee "an unread edit is rejected" — a plugin that registers a looser `fs/edit-expectation` decider BEFORE `dsh-file-context` (or with `prepend`) would decide first and bypass the `FS_NOT_OBSERVED` gate. That is the inherent property of a first-wins single slot, stated here so it is not mistaken for an enforced invariant. This RFC does not add a multi-policy composition mechanism; the implementation requirement is that `dsh-tool-fs` dispatches these waterfalls on every write/edit path and that a config wiring the fs tools loads `dsh-file-context` as the policy decider. +**The occupant is decided by registration order — first-registered (or `prepend`ed) wins.** cordis dispatches waterfall listeners in registration order (`push`, or `unshift` for `prepend` — [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts)), and the first non-`next()` decider short-circuits the rest. So the slot is **first-wins**, and `dsh-fs-policy` owning it rests on the default deployment convention: it is the decider registered for these events. The event shape does NOT itself guarantee "an unread edit is rejected" — a plugin that registers a looser `fs/edit-intent` decider BEFORE `dsh-fs-policy` (or with `prepend`) would decide first and bypass the `FS_NOT_OBSERVED` gate. That is the inherent property of a first-wins single slot, stated here so it is not mistaken for an enforced invariant. This RFC does not add a multi-policy composition mechanism; the implementation requirement is that `dsh-tool-fs` dispatches these waterfalls on every write/edit path and that a config wiring the fs tools loads `dsh-fs-policy` as the policy decider. -The actor is typed `object` in `dsh-fs` — a pure opaque carrier the provider seam never reads or narrows. The owner-derivation (`actor.agent?.session`) and the `{ agent?: { session? } }` structural shape stay entirely inside `dsh-file-context`, which narrows the `object` actor to that shape in its listeners. `dsh-fs` owns the event names and the fs vocabulary; it does NOT own the policy layer's runtime owner structure. +The actor is typed `object` in `dsh-fs` — a pure opaque carrier the provider seam never reads or narrows. The owner-derivation (`actor.agent?.session`) and the `{ agent?: { session? } }` structural shape stay entirely inside `dsh-fs-policy`, which narrows the `object` actor to that shape in its listeners. `dsh-fs` owns the event names and the fs vocabulary; it does NOT own the policy layer's runtime owner structure. ```ts -import type { FsTarget, FsVersion, FsWriteExpectation } from '@deepseek-ai/dsh-fs' +import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs' interface Events { /** @@ -85,7 +85,7 @@ interface Events { * (unobserved) or { kind: 'replaceIfVersion', version: vObserved } (observed). * The listener does NOT call next(): one decision, not a composable chain. @mode waterfall */ - 'fs/write-expectation'(target: FsTarget, actor: object | undefined, next: () => FsWriteExpectation | undefined | Promise): Promise + 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise /** * Single-slot decision: produce the optional version guard for the next * ctx.fs.editText. The default returns undefined (unconditional edit of the @@ -93,11 +93,11 @@ interface Events { * { version: vObserved }, or throws FS_NOT_OBSERVED if the actor is unset or * has not observed the target. Does NOT call next(): one decision. @mode waterfall */ - 'fs/edit-expectation'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> + '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). Listeners MUST be - * synchronous, side-effect-only recorders (`dsh-file-context`'s is a WeakMap + * synchronous, side-effect-only recorders (`dsh-fs-policy`'s is a WeakMap * write); the tool does not guard the emit, so a throwing listener surfaces as * the tool's isError result. No listener ⇒ nothing recorded. * @mode emit @@ -110,7 +110,7 @@ The `fs/*` decision events are **unbound waterfalls dispatched by the tool** (li ## Tool contract (`dsh-tool-fs`) -The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte unchanged) and prompt sections. The prompt guidance stays policy-first because a deployment loading the fs tools is expected to also load `dsh-file-context`: the model is still told to read before overwriting or editing, and any wording that says the "backend" requires that should be corrected to say the file-context policy requires it. The bare-provider fallback does not change the prompt stance. +The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte unchanged) and prompt sections. The prompt guidance stays policy-first because a deployment loading the fs tools is expected to also load `dsh-fs-policy`: the model is still told to read before overwriting or editing, and any wording that says the "backend" requires that should be corrected to say the fs-policy plugin requires it. The bare-provider fallback does not change the prompt stance. `dsh-tool-fs` gains the executor responsibilities relocated from the old `fileContext` method service, including **read rendering** (`read-render.ts`: `buildWindow` + `formatReadOutput`, `READ_MAX_BYTES`, `READ_MAX_LINE_LENGTH`, `FileReadOutcome`/`FileTextLine`, plus `STREAM_MIN_SIZE` in `read.ts`), which is the tool's rendering detail now that the tool owns the read. Those read-rendering types and helpers move into `dsh-tool-fs`; the policy plugin must not remain a type dependency for the tool. @@ -119,34 +119,34 @@ The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte un `stat` budget is minimized by letting the waterfall produce the expectation lazily — the bare default returns `undefined` (no guard) and never stats: - **read** — one `stat` (type + size routing + version), then `readText`/`streamText`, then `buildWindow`, then an `emit('fs/observed', target, info.version, exec)`. The post-read confirming `stat` from the old `fileContext.read` is dropped; a writer racing between the routing stat and the read can at worst make a *later* guarded edit spuriously `FS_STALE_VERSION` (fail-closed: the model re-reads, never writes against the wrong version, since `editText` re-checks in its lock). -- **write** — `expectation = await ctx.waterfall('fs/write-expectation', target, exec, () => undefined)`, then `ctx.fs.writeText(target, content, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** with or without `dsh-file-context`. -- **edit** — `expectation = await ctx.waterfall('fs/edit-expectation', target, exec, () => undefined)`, then `ctx.fs.editText(target, edit, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** in both cases: the bare default is `undefined` (unconditional edit), so the tool never stats to manufacture a basis. If the target is absent, the provider reports `FS_STALE_VERSION` even on the unguarded path. +- **write** — `expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`, then `ctx.fs.writeText(target, content, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** with or without `dsh-fs-policy`. +- **edit** — `expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`, then `ctx.fs.editText(target, edit, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** in both cases: the bare default is `undefined` (unconditional edit), so the tool never stats to manufacture a basis. If the target is absent, the provider reports `FS_STALE_VERSION` even on the unguarded path. -The tool passes `exec` (the tool-execution context) as the `actor` argument on every dispatch, so `dsh-file-context` can derive its observed-state owner. The tool does not know whether the policy plugin is present: it always provides the bare default behavior in the `next` thunk, and `dsh-file-context` short-circuits the thunk before it runs in the default deployment. +The tool passes `exec` (the tool-execution context) as the `actor` argument on every dispatch, so `dsh-fs-policy` can derive its observed-state owner. The tool does not know whether the policy plugin is present: it always provides the bare default behavior in the `next` thunk, and `dsh-fs-policy` short-circuits the thunk before it runs in the default deployment. -**`fs/observed` fires AFTER the mutation already succeeded**, via a plain `ctx.emit`. The event contract is intentionally narrow: an `fs/observed` listener MUST be synchronous and side-effect-only — `dsh-file-context`'s listener is a `WeakMap.set`, which cannot throw under normal operation and returns no promise. The tool does not guard the emit, so a listener that violates the contract by throwing would surface as the tool's `isError` result ([tools/index.ts](../../../../packages/core/tools/src/index.ts) — `ToolRegistry.execute` catches a tool throw into an error result) — reporting failure for a write/edit that actually happened. That is the price of keeping the event a plain fire-and-forget recorder: cordis `emit` does not await listener promises, so async or fallible audit/telemetry/listener work does not belong on this event. If layered or async observation is ever wanted, that is a new event with its own dispatch story. +**`fs/observed` fires AFTER the mutation already succeeded**, via a plain `ctx.emit`. The event contract is intentionally narrow: an `fs/observed` listener MUST be synchronous and side-effect-only — `dsh-fs-policy`'s listener is a `WeakMap.set`, which cannot throw under normal operation and returns no promise. The tool does not guard the emit, so a listener that violates the contract by throwing would surface as the tool's `isError` result ([tools/index.ts](../../../../packages/core/tools/src/index.ts) — `ToolRegistry.execute` catches a tool throw into an error result) — reporting failure for a write/edit that actually happened. That is the price of keeping the event a plain fire-and-forget recorder: cordis `emit` does not await listener promises, so async or fallible audit/telemetry/listener work does not belong on this event. If layered or async observation is ever wanted, that is a new event with its own dispatch story. -## Policy plugin contract (`dsh-file-context`) +## Policy plugin contract (`dsh-fs-policy`) -`dsh-file-context` is a plugin, not a service. It does not register `ctx.fileContext`, has no public method surface, and exposes no `read`/`write`/`edit`/`resolve` methods. It attaches three listeners via `ctx.on()` registrations (each returning a disposer for HMR). It keeps the observed-state `WeakMap>` and the structural owner derivation (narrowing the event's opaque `object` actor to its own `{ agent?: { session? } }` shape), but does not inject `fs` — every handler operates only on its own `WeakMap`, never on `ctx.fs`. +`dsh-fs-policy` is a plugin, not a service. It does not register `ctx.fileContext`, has no public method surface, and exposes no `read`/`write`/`edit`/`resolve` methods. It attaches three listeners via `ctx.on()` registrations (each returning a disposer for HMR). It keeps the observed-state `WeakMap>` and the structural owner derivation (narrowing the event's opaque `object` actor to its own `{ agent?: { session? } }` shape), but does not inject `fs` — every handler operates only on its own `WeakMap`, never on `ctx.fs`. -- `fs/write-expectation` listener: `prior = getObserved(owner, key)`; return `prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }`. It does NOT call `next()`: it fully owns the single decision slot. -- `fs/edit-expectation` listener: `prior = getObserved(owner, key)`; if no `owner` or no `prior`, throw `FS_NOT_OBSERVED`; else return `{ version: prior.version }`. Also does not call `next()`. +- `fs/write-intent` listener: `prior = getObserved(owner, key)`; return `prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }`. It does NOT call `next()`: it fully owns the single decision slot. +- `fs/edit-intent` listener: `prior = getObserved(owner, key)`; if no `owner` or no `prior`, throw `FS_NOT_OBSERVED`; else return `{ version: prior.version }`. Also does not call `next()`. - `fs/observed` listener: `record(owner, key, version)`. An observed-state entry is the **prior-observation record**: a successful `read`, `write`, OR `edit` all emit `fs/observed` and record `{ version }`, so the entry's presence means "this owner has observed this target at this version", not narrowly "has read it". 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, so the next edit's basis is the version it just produced. `FS_NOT_OBSERVED` rejects only an edit with NO prior observation of any kind. The owner is derived structurally from `{ agent?: { session? } }`; disposal drops all state (HMR safety). -`dsh-file-context` is now a pure policy/recording plugin with no service surface — it influences the world only through the event seam. That is what removes the method coupling from `dsh-tool-fs`. +`dsh-fs-policy` is now a pure policy/recording plugin with no service surface — it influences the world only through the event seam. That is what removes the method coupling from `dsh-tool-fs`. -## Bare-provider behavior (no `dsh-file-context`) +## Bare-provider behavior (no `dsh-fs-policy`) -This is not the intended deployment stance — a config loading the fs tools is expected to also load `dsh-file-context`. It is the unconstrained provider floor that exists once the tool is no longer coupled to a policy method service. With `dsh-file-context` absent, every `fs/*` waterfall falls through to its `undefined` default and `fs/observed` has no listener: +This is not the intended deployment stance — a config loading the fs tools is expected to also load `dsh-fs-policy`. It is the unconstrained provider floor that exists once the tool is no longer coupled to a policy method service. With `dsh-fs-policy` absent, every `fs/*` waterfall falls through to its `undefined` default and `fs/observed` has no listener: - **read** is identical (it never needed policy; it only emits a now-unheard `fs/observed`). - **write** unconditionally creates-or-overwrites: `expected` is `undefined`, so `writeText` writes whether or not the file exists and whatever its current version. No read-first requirement, no version check. - **edit** unconditionally replaces literal text in the file's current content: `expected` is `undefined`, so `editText` matches and rewrites without a version guard or a read-first requirement (`FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` still apply — those are about the literal match, not freshness). A missing target still reports `FS_STALE_VERSION`, matching the guarded edit path's "cannot edit this target now" code. -Both mutations are still atomic (the backend's per-target lock is unconditional). What is simply *absent*, not lost, is the policy `dsh-file-context` would add: observed-state, read-before-edit, and version-guarded write/edit. Loading `dsh-file-context` layers those constraints on by having its listeners return guarded `expected` values instead of `undefined`; nothing in the bare provider changes. +Both mutations are still atomic (the backend's per-target lock is unconditional). What is simply *absent*, not lost, is the policy `dsh-fs-policy` would add: observed-state, read-before-edit, and version-guarded write/edit. Loading `dsh-fs-policy` layers those constraints on by having its listeners return guarded `expected` values instead of `undefined`; nothing in the bare provider changes. ## Supersedes @@ -154,15 +154,15 @@ This amends — does not reverse — [the split-fs-seam RFC](../simplification/2 ## Acceptance Criteria -- The `dsh-tool-fs` root plugin injects `fs` (+ `tools`/`systemPrompt`), not `fileContext`; it calls `ctx.fs` directly and dispatches the `fs/write-expectation`/`fs/edit-expectation` waterfalls (passing `exec` as the actor) and the `fs/observed` emit. Read rendering lives in `dsh-tool-fs`. (No subpath plugins — see the Tool contract above.) +- The `dsh-tool-fs` root plugin injects `fs` (+ `tools`/`systemPrompt`), not `fileContext`; it calls `ctx.fs` directly and dispatches the `fs/write-intent`/`fs/edit-intent` waterfalls (passing `exec` as the actor) and the `fs/observed` emit. Read rendering lives in `dsh-tool-fs`. (No subpath plugins — see the Tool contract above.) - `dsh-fs` declares the three events with `@mode` tags and an opaque `object` actor argument (no agent/session structure leaks into the provider vocabulary); the generated cordis catalog is regenerated. -- `dsh-file-context` is a plugin, not a service: it does not register `ctx.fileContext`, has no public `read`/`write`/`edit`/`resolve` methods, and does not inject `fs`; it registers the three listeners, keeps observed-state, and has HMR/disposal coverage (dispose the fiber, assert the gate no longer rewrites). -- **Bare-provider test**: a config WITHOUT `dsh-file-context` boots the `dsh-tool-fs` root plugin, and `read`/`write`(create AND overwrite)/`edit` work against the real `dsh-fs-local`; an `edit` of an unread existing file and an overwrite of an existing unread file both succeed (unconditional bare-provider behavior), proving the tool carries no `fileContext` dependency. A bare-provider edit of a missing target reports `FS_STALE_VERSION`. With `dsh-file-context` present, the same unread `edit` is rejected `FS_NOT_OBSERVED` and the same unread overwrite uses `createIfAbsent` (rejected on an existing file). -- **Single-slot semantics**: a test registers a second `fs/edit-expectation` listener AFTER `dsh-file-context` and asserts it is NOT reached (first-wins short-circuit), and documents in a comment that a decider registered before/`prepend`ed would instead win — the slot is first-wins by convention, not an enforced invariant. +- `dsh-fs-policy` is a plugin, not a service: it does not register `ctx.fileContext`, has no public `read`/`write`/`edit`/`resolve` methods, and does not inject `fs`; it registers the three listeners, keeps observed-state, and has HMR/disposal coverage (dispose the fiber, assert the gate no longer rewrites). +- **Bare-provider test**: a config WITHOUT `dsh-fs-policy` boots the `dsh-tool-fs` root plugin, and `read`/`write`(create AND overwrite)/`edit` work against the real `dsh-fs-local`; an `edit` of an unread existing file and an overwrite of an existing unread file both succeed (unconditional bare-provider behavior), proving the tool carries no `fileContext` dependency. A bare-provider edit of a missing target reports `FS_STALE_VERSION`. With `dsh-fs-policy` present, the same unread `edit` is rejected `FS_NOT_OBSERVED` and the same unread overwrite uses `createIfAbsent` (rejected on an existing file). +- **Single-slot semantics**: a test registers a second `fs/edit-intent` listener AFTER `dsh-fs-policy` and asserts it is NOT reached (first-wins short-circuit), and documents in a comment that a decider registered before/`prepend`ed would instead win — the slot is first-wins by convention, not an enforced invariant. - **Fire-and-forget recording**: `fs/observed` is emitted via a plain `ctx.emit` after the mutation succeeds; a listener is contractually synchronous and side-effect-only, so the tool does not guard it. -- `dsh-fs` `writeText`/`editText` make `expected` optional (omit ⇒ unconditional); the `FsWriteExpectation` union is unchanged, and `dsh-file-context`'s guarded paths (`createIfAbsent`/`replaceIfVersion`/`{ version }`) behave exactly as today. A bare-provider test exercises an unconditional overwrite, an unconditional edit, and a missing-target edit reporting `FS_STALE_VERSION`. -- Freshness is enforced by provider CAS when guarded: an edit after a stale read reports `FS_STALE_VERSION` (regression test); `dsh-file-context` performs no `stat`. -- `stat` budget: read = 1, write = 0, edit = 0 — in the tool, with or without `dsh-file-context` (the bare default returns `undefined`, never stats). A test asserts neither write nor edit stats in the tool on either path. +- `dsh-fs` `writeText`/`editText` make `expected` optional (omit ⇒ unconditional); the `FsWriteIntent` union is unchanged, and `dsh-fs-policy`'s guarded paths (`createIfAbsent`/`replaceIfVersion`/`{ version }`) behave exactly as today. A bare-provider test exercises an unconditional overwrite, an unconditional edit, and a missing-target edit reporting `FS_STALE_VERSION`. +- Freshness is enforced by provider CAS when guarded: an edit after a stale read reports `FS_STALE_VERSION` (regression test); `dsh-fs-policy` performs no `stat`. +- `stat` budget: read = 1, write = 0, edit = 0 — in the tool, with or without `dsh-fs-policy` (the bare default returns `undefined`, never stats). A test asserts neither write nor edit stats in the tool on either path. - Model-facing schemas stay byte-for-byte unchanged; snapshot transcript goldens are unaffected (or the diff is reviewed and re-recorded with justification). - Docs/artifacts updated in the same change: `docs/architecture.md`, fs package READMEs, `docs/core-data-structures/filesystem.md`, the split-fs-seam RFC's now-amended description, type-equiv blocks + manifest, cordis catalog, module graph. Gates green: `doc-sync`, `knip`, `test:coverage` (100% per-file). @@ -170,6 +170,6 @@ This amends — does not reverse — [the split-fs-seam RFC](../simplification/2 - **Event indirection over a method call.** A waterfall + emit is less direct than `await ctx.fileContext.edit(...)`. The payoff is removing the tool-to-policy method dependency while keeping the default policy plugin; the cost is one more event vocabulary to learn. Mitigated by keeping the three events narrow and documenting the default-thunk semantics on each. - **Policy events in the storage seam.** `dsh-fs` gains two version-decision events plus a recording event though it is "just storage". This is the price of decoupling (the emitter cannot depend on the policy plugin). The events carry only `dsh-fs` vocabulary plus an opaque `object` actor and no model-facing concepts, so the seam stays free of line-window/observation policy types and of the agent/session owner structure. -- **Single policy occupant, first-wins by convention.** The `fs/write-expectation`/`fs/edit-expectation` slots hold exactly one decider; the first-registered (or `prepend`ed) listener wins and the rest are short-circuited. `dsh-file-context` owning the slot is a deployment convention, not an event-enforced invariant — a second decider registered first would bypass it. This is acceptable because a second fs-version-policy decider is a misconfiguration, not a feature. If a future need for *layered* fs version policy appears, it is a new RFC (a composable value-passing seam), not a silent second listener on these events. Layered permission/audit/sandbox interception already has its home on `tools/execute`. +- **Single policy occupant, first-wins by convention.** The `fs/write-intent`/`fs/edit-intent` slots hold exactly one decider; the first-registered (or `prepend`ed) listener wins and the rest are short-circuited. `dsh-fs-policy` owning the slot is a deployment convention, not an event-enforced invariant — a second decider registered first would bypass it. This is acceptable because a second fs-version-policy decider is a misconfiguration, not a feature. If a future need for *layered* fs version policy appears, it is a new RFC (a composable value-passing seam), not a silent second listener on these events. Layered permission/audit/sandbox interception already has its home on `tools/execute`. - **Dropping the post-read confirming stat** makes a follow-up *guarded* edit occasionally fail-closed (`FS_STALE_VERSION` → re-read) under a read/write race. This is a UX nicety lost, never a correctness hole; the provider lock still prevents wrong-version writes. -- **The bare provider does no read-before-write/edit and no version check.** A deployment without `dsh-file-context` lets the model overwrite or edit any existing file unconditionally. This is the deliberate meaning of keeping the tool independent of a policy service: the safety disciplines live in the `dsh-file-context` plugin. A deployment that omits it is opting into an unconstrained filesystem on purpose; that is not the intended stance for a config that ships the fs tools. +- **The bare provider does no read-before-write/edit and no version check.** A deployment without `dsh-fs-policy` lets the model overwrite or edit any existing file unconditionally. This is the deliberate meaning of keeping the tool independent of a policy service: the safety disciplines live in the `dsh-fs-policy` plugin. A deployment that omits it is opting into an unconstrained filesystem on purpose; that is not the intended stance for a config that ships the fs tools. diff --git a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md index 6e510c69b1..217c60cf63 100644 --- a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md +++ b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -[The filesystem capability-seam RFC](../architecture/2026-06-17-filesystem-capability-seam.md) defines the filesystem capability seam (`ctx.fs`), the three-package split (`dsh-fs`, `dsh-fs-local`, `dsh-tool-fs`), and the observed-file/stale-version policy for read-before-write/edit checks. The remaining decision for the first filesystem tool delivery is the model-facing schema surface: what arguments the model sees for `read`, `write`, and `edit`. +[The filesystem capability-seam RFC](../architecture/2026-06-17-filesystem-capability-seam.md) defines the filesystem capability seam (`ctx.fs`), the package split (`dsh-fs`, `dsh-fs-local`, `dsh-tool-fs`, plus the `dsh-fs-policy` policy plugin), and the observed-file/stale-version policy for read-before-write/edit checks — which the [split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) and [event-gate](../architecture/2026-06-26-file-context-as-event-gate.md) RFCs moved off `ctx.fs` into the `dsh-fs-policy` plugin on the `fs/*` event gate. The remaining decision for the first filesystem tool delivery is the model-facing schema surface: what arguments the model sees for `read`, `write`, and `edit`. The schema should be small enough to implement in the first `dsh-tool-fs` pass, but stable enough that future local/remote/sandboxed filesystem backends do not require model-facing churn. It should also avoid importing every option from reference systems. Claude Code and OpenCode expose similar core file tools but differ in naming style and extra flags; this RFC chooses the minimal shared surface for the prototype. @@ -15,10 +15,10 @@ The schema should be small enough to implement in the first `dsh-tool-fs` pass, | Tool | Our schema | Claude Code | OpenCode | Notes | Part of prototype | |---|---|---|---|---|---| | `read` | `read(file_path, offset?, limit?)` | `Read(file_path, offset?, limit?, pages?)` | `read(filePath, offset?, limit?)` | Files only; 1-indexed `offset`; no image/PDF/multimodal support in the first pass. | YES | -| `write` | `write(file_path, content)` | `Write(file_path, content)` | `write(content, filePath)` | Creates or overwrites UTF-8 text. Updates to existing files require prior observation through `ctx.fs`; new-file creates do not. | YES | -| `edit` | `edit(file_path, old_string, new_string, replace_all?)` | `Edit(file_path, old_string, new_string, replace_all?)` | `edit(filePath, oldString, newString, replaceAll?)` | Literal string replacement; unique match required by default; requires prior full observation through `ctx.fs`. | YES | +| `write` | `write(file_path, content)` | `Write(file_path, content)` | `write(content, filePath)` | Creates or overwrites UTF-8 text. Under the default fs-policy, updates to existing files require a prior observation; new-file creates do not. | YES | +| `edit` | `edit(file_path, old_string, new_string, replace_all?)` | `Edit(file_path, old_string, new_string, replace_all?)` | `edit(filePath, oldString, newString, replaceAll?)` | Literal string replacement; unique match required by default; under the default fs-policy requires a prior observation (any windowed read counts). | YES | -The schema uses snake_case field names (`file_path`, `old_string`, `new_string`, `replace_all`) to align with Claude Code and with existing DeepSeek Harness tool-schema examples. The consumer package translates these model-facing names into internal `ctx.fs` requests. +The schema uses snake_case field names (`file_path`, `old_string`, `new_string`, `replace_all`) to align with Claude Code and with existing DeepSeek Harness tool-schema examples. The consumer package translates these model-facing names into `ctx.fs` calls and `fs/*` event dispatches. ## Tool schemas @@ -47,9 +47,9 @@ Arguments: - `file_path: string` — required. Path to write, resolved by `ctx.fs`. - `content: string` — required. Full UTF-8 text content to write. -For existing files, `write` requires prior full file state derived from a previous read in the same execution context. `ctx.fs` derives the file-state owner and uses the recorded version as the stale guard. Creating a new file does not require prior state or an owner. +Under the default fs-policy, updating an existing file with `write` requires a prior observation (a read/write/edit) of that file by the same execution context; the `dsh-fs-policy` plugin supplies the observed version as the stale guard on `fs/write-intent`. Creating a new file does not require a prior observation. With the policy plugin absent, `write` is an unconditional bare-provider create-or-overwrite. -The schema does not expose `expected_hash`, `expected_version`, or `create_only` as model-facing parameters. Stale-version checks are driven by `ctx.fs` file state and backend-produced versions, not by asking the model to copy version tokens through the schema. +The schema does not expose `expected_hash`, `expected_version`, or `create_only` as model-facing parameters. Stale-version checks are driven by backend-produced versions and the policy plugin's observed state, not by asking the model to copy version tokens through the schema. ### `edit` @@ -62,7 +62,7 @@ Arguments: - `new_string: string` — required. Literal replacement text; an empty string deletes the match. - `replace_all?: boolean` — optional. Defaults to false. When false, `old_string` must identify exactly one match. -`edit` requires a prior observation of the file in the same execution context (any windowed read counts — authorization is version freshness, not a full-view requirement), or a prior write/edit by that context. The `dsh-file-context` policy plugin derives the owner and supplies the recorded version as the stale guard; the provider's mutation lock enforces it. +`edit` requires a prior observation of the file in the same execution context (any windowed read counts — authorization is version freshness, not a full-view requirement), or a prior write/edit by that context. The `dsh-fs-policy` policy plugin derives the owner and supplies the recorded version as the stale guard; the provider's mutation lock enforces it. The first pass rejects Codex-style patch grammars and multi-mode edit APIs. It uses one strict literal replacement mode so the model-facing contract stays simple and the backend can own exact-match, duplicate-match, line-ending, and stale-version semantics. @@ -99,15 +99,15 @@ The following are deliberately out of scope for the first filesystem schema pass - `write` requires `file_path` and `content`. - `edit` requires `file_path`, `old_string`, and `new_string`, accepts optional boolean `replace_all`, rejects empty `old_string`, and defaults `replace_all` to false. - The registered JSON schemas use the snake_case field names in this RFC. -- The tool descriptions accurately describe that existing-file `write` and `edit` require a prior full read in the same execution context, while new-file `write` does not. +- The tool descriptions accurately describe that, under the default fs-policy, existing-file `write` and `edit` require a prior observation (any windowed read counts) in the same execution context, while new-file `write` does not. - The `tool-fs` root plugin registers all three schemas. -Integration tests should execute `read`, `write`, and `edit` through `ctx.tools.execute()` with a fake or local `ctx.fs` provider and verify that model arguments are translated into the expected `ctx.fs` calls. +Integration tests should execute `read`, `write`, and `edit` through `ctx.tools.execute()` against the real `dsh-fs-local` provider and verify that model arguments are translated into the expected `ctx.fs` calls and `fs/*` dispatches. ## Risks **The first schema is intentionally smaller than Claude Code's.** Dropping PDF pages, multimodal read, rich grep/list flags, and expected hash fields keeps the first implementation focused, but users may ask for those quickly. They should be added as separate RFCs or focused follow-ups rather than overloaded into the initial schema. -**No explicit model-facing stale guard in v1.** The schema does not ask the model to provide an expected hash/version. That is intentional: stale checks come from backend-produced versions and `ctx.fs` observed-file state, not from fragile model-copied tokens. Filesystem safety failures surface through structured `FsError` codes owned by `dsh-fs`, not through model-supplied version fields. +**No explicit model-facing stale guard in v1.** The schema does not ask the model to provide an expected hash/version. That is intentional: stale checks come from backend-produced versions and the `dsh-fs-policy` plugin's observed state, not from fragile model-copied tokens. Filesystem safety failures surface through structured `FsError` codes owned by `dsh-fs`, not through model-supplied version fields. **Naming becomes public surface.** Once shipped, changing `file_path` to `filePath` or `old_string` to `oldString` would churn prompts, examples, and downstream clients. This RFC chooses snake_case up front and treats it as the stable model-facing contract. diff --git a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md index 26bbb65056..736b65df08 100644 --- a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md +++ b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md @@ -1,4 +1,4 @@ -# RFC: Split the filesystem seam — provider text mutations plus policy `ctx.fileContext` +# RFC: Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin Status: implemented @@ -13,7 +13,7 @@ That makes every future backend reimplement model-facing read semantics and obse This also creates a real UX dead-end: a windowed read records `view: partial`, and partial views cannot authorize `edit`. A model that reads lines 100-150 of a large file therefore cannot edit line 120 unless it first gets a `full` read, which may be impossible for a file past the read cap. Literal edit only needs freshness: the bytes being matched must still be from the version the model read. -The old RFC already deferred a separate `@deepseek-ai/dsh-file-context` package. This RFC builds that layer and keeps `ctx.fs` close to fsspec-style storage primitives (`info`/`cat`/`open`), without turning it into full fsspec. +The old RFC already deferred a separate `@deepseek-ai/dsh-fs-policy` package. This RFC builds that layer and keeps `ctx.fs` close to fsspec-style storage primitives (`info`/`cat`/`open`), without turning it into full fsspec. ## Decision @@ -21,14 +21,14 @@ Split the stack into four layers: ```text tool dsh-tool-fs model-facing schemas + read windowing + text rendering; the EXECUTOR (reads/writes/edits via ctx.fs, dispatches the fs/* events) -policy dsh-file-context observed-state + read-before-edit + write/edit freshness, contributed through the fs/* event gate (no service) +policy dsh-fs-policy observed-state + read-before-edit + write/edit freshness, contributed through the fs/* event gate (no service) provider seam dsh-fs ctx.fs: text IO + atomic mutation primitives (optional version guard) provider dsh-fs-local local implementation of ctx.fs ``` -`dsh-tool-fs` keeps the same model-facing `read`/`write`/`edit` schemas. It injects `fs` (not a policy service) and reaches `ctx.fs` directly, dispatching the `fs/*` policy events so `dsh-file-context` can gate and record. +`dsh-tool-fs` keeps the same model-facing `read`/`write`/`edit` schemas. It is the executor: it injects `fs` (not a policy service) and reaches `ctx.fs` directly, owns read windowing, and dispatches the `fs/*` events so `dsh-fs-policy` can gate and record. -The tool↔policy COUPLING below was reworked by [the file-context event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md): `dsh-file-context` is now a gate PLUGIN that participates through the `fs/*` events (no `ctx.fileContext` service), and read windowing + the fs I/O moved up into `dsh-tool-fs`. The four-layer split, the provider contract, and the freshness *policy* this RFC decided are unchanged. Read the "`ctx.fileContext.read`/`write`/`edit`" method descriptions below as the policy DECISIONS the gate plugin now makes on the `fs/*` events, and the provider's version guard as optional (omit = unconditional bare provider). +This RFC decided the four-layer split, the provider contract, and the freshness policy. The tool↔policy COUPLING was then refined by [the event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md): `dsh-fs-policy` is a gate PLUGIN that participates through the `fs/*` events rather than a `ctx.fileContext` method service, so the tool is not method-coupled to it and read windowing + the fs I/O live in `dsh-tool-fs`. This document describes that landed event-gate shape; the provider's version guard is optional (omit = unconditional bare provider). ## Provider Contract @@ -39,7 +39,7 @@ abstract resolve(path: string): Promise abstract stat(target: FsTarget, signal?: AbortSignal): Promise abstract readText(target: FsTarget, signal?: AbortSignal): Promise abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> -abstract writeText(target: FsTarget, content: string, expected: FsWriteExpectation, signal?: AbortSignal): Promise +abstract writeText(target: FsTarget, content: string, expected: FsWriteIntent, signal?: AbortSignal): Promise abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise interface FsInfo { @@ -48,18 +48,18 @@ interface FsInfo { size?: number } -type FsWriteExpectation = +type FsWriteIntent = | { kind: 'createIfAbsent' } | { kind: 'replaceIfVersion'; version: FsVersion } ``` -`stat` returns metadata, not content. `version` is the freshness token; `type` lets the policy reject directories/special files before reading; `size` lets `ctx.fileContext.read` choose `readText` vs `streamText` without probing by failure. `undefined` means absent. +`stat` returns metadata, not content. `version` is the freshness token; `type` lets the executor reject directories/special files before reading; `size` lets the `read` tool choose `readText` vs `streamText` without probing by failure. `undefined` means absent. `readText` reads the whole regular text file. `streamText` streams the same text semantics for large files. Both provider primitives own regular-file checks, UTF-8 decoding, binary/NUL rejection, and `FS_NOT_TEXT`; the policy layer never handles raw bytes or reimplements cross-chunk decoding. `readText` is the small-file/direct whole-file primitive, while large model-facing reads use `streamText`. `writeText` is atomic temp-file + rename with an explicit write expectation. `createIfAbsent` creates a missing target and rejects an existing target with `FS_NOT_OBSERVED`; it is the path used when the owner has no prior read. `replaceIfVersion` replaces only when the target exists at the observed version; a missing target or version mismatch throws `FS_STALE_VERSION`. -`editText` is a provider-level guarded text mutation. It first verifies the target still exists at `expected.version`, then reads the current text, applies literal replacement, and writes atomically. The stale check must happen before literal matching so an edit based on an old read reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND` or `FS_AMBIGUOUS_EDIT` from matching against newer content. Keeping this primitive on the provider seam also preserves backend-local locking and lets a future remote backend implement native compare-and-edit without forcing `ctx.fileContext` to pull the whole file through the policy layer. +`editText` is a provider-level guarded text mutation. When guarded it first verifies the target still exists at `expected.version`, then reads the current text, applies literal replacement, and writes atomically. The stale check must happen before literal matching so an edit based on an old read reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND` or `FS_AMBIGUOUS_EDIT` from matching against newer content. Keeping this primitive on the provider seam preserves backend-local locking and lets a future remote backend implement native compare-and-edit without forcing the policy layer to pull the whole file through it. This is a *text-storage* seam, deliberately half a level above byte-level fsspec (`cat`/`open` hand back raw bytes). UTF-8 decoding, binary/NUL rejection, guarded full-file writes, and guarded literal text edits live in the provider so the policy layer never touches raw bytes, reimplements cross-chunk decoding, or separates stale checks from the mutation critical section. Model-facing concepts still stay out of the provider: no line windows, numbered lines, rendered footers, or observed-state store leak down. @@ -67,23 +67,25 @@ Deleted from `dsh-fs`: `readPage`, `FsExpectation`, `FsView`, `FsStateSource`, ` ## Policy Contract -`@deepseek-ai/dsh-file-context` registers concrete service `ctx.fileContext` and injects `fs`. It is a concrete service, not a seam: it owns the read-windowing and write/edit freshness policy that does not belong on the `FileSystem` provider base class (where a sandboxed/remote backend would otherwise inherit model-facing observation policy it has no business carrying). +`@deepseek-ai/dsh-fs-policy` is a plugin, not a service: it registers no `ctx.*` key and injects nothing. It owns the write/edit freshness policy and observed-state that do not belong on the `FileSystem` provider base class (where a sandboxed/remote backend would otherwise inherit model-facing observation policy it has no business carrying). It contributes that policy through the `fs/*` event gate the executor dispatches. (This RFC originally proposed a concrete `ctx.fileContext` service with `read`/`write`/`edit` methods; [the event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md) refined it into the plugin described here so the tool is never method-coupled to the policy.) -Observed state lives here as `WeakMap>`. An entry exists iff the owner has read that target through `ctx.fileContext.read`, so its presence *is* the read record — there is no separate `hasRead` flag. The owner is still derived structurally from `{ agent?: { session? } }`, but that shape no longer belongs to `dsh-fs`. +Observed state lives here as `WeakMap>`. 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 separate `hasRead` flag. The owner is derived structurally from the opaque event actor (`{ agent?: { session? } }`), a shape that lives in `dsh-fs-policy`, not `dsh-fs`. -`read(target, request, exec?, signal?)` is the only read path used by the model-facing `read` tool. It stats the target, rejects absent/non-regular targets, chooses `readText` or `streamText` from `FsInfo`, builds the requested line window from text chunks, records `{ version: info.version }`, and returns the structured outcome that the tool renders. +The plugin decides three `fs/*` events: -`write(target, content, exec?, signal?)` uses freshness policy: no recorded read calls `writeText({ kind: 'createIfAbsent' })`, so only new files can be created blindly; a recorded read calls `writeText({ kind: 'replaceIfVersion', version: vObserved })`, so existing files are replaced only if they are unchanged since the read. A successful write refreshes recorded state from the returned outcome or a post-write `stat`. +- `fs/write-intent` — no prior observation ⇒ `{ kind: 'createIfAbsent' }` (only new files can be created blindly); a prior observation ⇒ `{ kind: 'replaceIfVersion', version: vObserved }` (existing files replaced only if unchanged since the observation). Single-slot decision; does not call `next()`. +- `fs/edit-intent` — requires a prior observation by the owner (else `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. It does not implement literal replacement — it authorizes and supplies the version, and the provider's mutation critical section applies the guard, so concurrent edits based on the same observed version remain one-wins/one-stale. +- `fs/observed` — records `{ version }` for this owner+target after a successful read/write/edit. Synchronous, side-effect-only `WeakMap.set`. -`edit(target, edit, exec?, signal?)` requires a recorded read at `vObserved`, then calls `ctx.fs.editText(target, edit, { version: vObserved })` and refreshes recorded state from the returned version. `ctx.fileContext` does not implement literal replacement itself; it authorizes the operation and passes the observed version to the provider. The provider owns the mutation critical section, so concurrent edits based on the same observed version remain one-wins/one-stale rather than being merged or re-applied. If a backend needs a defensive whole-file edit cap, it should surface that as the same filesystem error taxonomy, but large model-facing reads should stream instead of failing just because the file is large. +The 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 — the plugin only supplies `vObserved` as the basis. ## Tool Contract -`dsh-tool-fs` keeps the same schemas and prompt surface. `read` still exposes `file_path`, `offset`, and `limit`; `write` and `edit` are unchanged. +`dsh-tool-fs` keeps the same schemas and prompt surface. `read` still exposes `file_path`, `offset`, and `limit`; `write` and `edit` are unchanged. It is the executor: it validates model args, reads/writes/edits through `ctx.fs` directly, owns line windowing and result rendering (`N: text`, footer, `/` envelope), and dispatches the `fs/*` events. -The tool package only validates model args, calls `ctx.fileContext`, and renders results (`N: text`, footer, `/` envelope). The no-bypass rule is part of the contract: a model-facing `read` must call `ctx.fileContext.read`, never `ctx.fs.readText` or `ctx.fs.streamText`, so every successful read records observed-state before rendering. +Each mutation dispatches its intent waterfall with an `undefined` bare-provider default, then calls `ctx.fs`, then emits `fs/observed`: e.g. `write` does `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` → `ctx.fs.writeText(target, content, intent)` → `ctx.emit('fs/observed', …)`. A `read` stats once, reads/streams, builds the window, and emits `fs/observed`. Passing `exec` as the actor lets `dsh-fs-policy` derive the owner without the tool reaching into the policy. -Direct `ctx.fs` calls are still allowed for non-tool consumers. They are explicit escape hatches: a direct `ctx.fs.readText` records no observed-state, so a later `ctx.fileContext.edit` rejects with `FS_NOT_OBSERVED` until the file is read through `ctx.fileContext`. +Because the policy is contributed through events with an `undefined` default, `dsh-tool-fs` is not method-coupled to `dsh-fs-policy`: with the plugin absent, every intent waterfall falls through to `undefined` (unconditional bare-provider write/edit) and `fs/observed` has no listener. Loading the plugin back layers the read-before-write/edit policy on. ## Concurrency Boundary @@ -97,7 +99,7 @@ Cross-process writes are best-effort freshness plus atomic replacement: `mtime:s This RFC reverses two decisions from [filesystem-capability-seam](../../implemented/architecture/2026-06-17-filesystem-capability-seam.md) and narrows a third: -- Read-before-write/edit policy moves out of `ctx.fs` and into `ctx.fileContext`. +- Read-before-write/edit policy moves out of `ctx.fs` and into the `dsh-fs-policy` plugin (on the `fs/*` event gate). - Text reads no longer return backend-numbered line records or `full`/`partial` views; authorization is based on version freshness, so a windowed read can authorize edit when the file is unchanged. - Literal edit no longer sits behind the old `applyEdit` API that mixed backend mutation with seam-owned observation policy. It remains a provider primitive as `editText`, because version guard + literal match + atomic rewrite must stay inside the provider's mutation critical section. @@ -105,8 +107,8 @@ It keeps the interface/implementation/consumer discipline, consumer-never-import ## Acceptance Criteria -- `dsh-fs` exposes exactly `resolve`/`stat`/`readText`/`streamText`/`writeText`/`editText`; `stat` returns `FsInfo | undefined`; `writeText` uses `FsWriteExpectation` (`createIfAbsent` or `replaceIfVersion`); removed types/primitives are gone, and the old `applyEdit` API is replaced by `editText`. -- `dsh-file-context` adds the observed-state + `read`/`write`/`edit` freshness policy and has HMR/disposal coverage. (It does so as a gate PLUGIN on the `fs/*` events with no `ctx.fileContext` service, per [the event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md) — the original service form this RFC proposed was reworked.) +- `dsh-fs` exposes exactly `resolve`/`stat`/`readText`/`streamText`/`writeText`/`editText`; `stat` returns `FsInfo | undefined`; `writeText` uses `FsWriteIntent` (`createIfAbsent` or `replaceIfVersion`); removed types/primitives are gone, and the old `applyEdit` API is replaced by `editText`. +- `dsh-fs-policy` adds the observed-state + `read`/`write`/`edit` freshness policy and has HMR/disposal coverage. (It does so as a gate PLUGIN on the `fs/*` events with no `ctx.fileContext` service, per [the event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md) — the original service form this RFC proposed was reworked.) - `dsh-tool-fs` reaches the policy decisions and model-facing schemas stay byte-for-byte unchanged; the observation contract (a read records observed-state; a direct `ctx.fs` read does not) is documented and tested. (The tool injects `fs` and dispatches the `fs/*` events rather than injecting a `fileContext` service, per the event-gate RFC.) - Windowed read authorizing edit is shown to fail on the pre-refit code and pass after the refit. Existing version-CAS behavior is preserved with a regression test; it is not claimed as a pre-refit failure. An edit based on a stale read must report `FS_STALE_VERSION` before attempting literal matching. - `dsh-fs-local` carries no line, view, or `formatReadBody` logic; it does carry provider-level `editText` logic. @@ -116,7 +118,7 @@ It keeps the interface/implementation/consumer discipline, consumer-never-import ## Risks - Adds a fourth fs package and a new service. This is intentional: it is the previously deferred policy layer, not a second abstract backend seam. -- Direct `ctx.fs` use can surprise callers who later use `ctx.fileContext`. The failure is explicit (`FS_NOT_OBSERVED`) and documented. -- Large-file line windowing moves from the backend to `ctx.fileContext.read`; text decoding and binary rejection stay in `ctx.fs.streamText`, so this is relocation of windowing only, not a second text-IO implementation. +- Direct `ctx.fs` use bypasses the policy: a direct `ctx.fs.readText` emits no `fs/observed`, so under the default policy a later `edit` rejects with `FS_NOT_OBSERVED` until the file is read through the `read` tool. The failure is explicit and documented. +- Large-file line windowing moves from the backend to the `read` tool in `dsh-tool-fs`; text decoding and binary rejection stay in `ctx.fs.streamText`, so this is relocation of windowing only, not a second text-IO implementation. - Keeping `editText` in the provider seam means every backend must implement the literal replacement contract. This is intentional: the operation is not pure storage, but stale guard + literal match + atomic rewrite is the unit that must stay together for correct error attribution and concurrency behavior. The contract should stay narrow and text-only so future backends can implement it natively or by whole-file rewrite. - Freshness permits full-file `write` after a windowed read. That is weaker than the old view check, but avoids making large files impossible to edit; prompt guidance should still discourage blind full replaces. diff --git a/packages/README.md b/packages/README.md index 1ecdb8c9eb..04776937eb 100644 --- a/packages/README.md +++ b/packages/README.md @@ -38,7 +38,7 @@ dsh-bash-local ← dsh-bash (BashExecutor impl) dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas) dsh-fs ← dsh-llm, dsh-brand (filesystem provider seam + fs/* events) dsh-fs-local ← dsh-fs (FileSystem impl) -dsh-file-context ← dsh-fs (observed-state + freshness policy gate, no service) +dsh-fs-policy ← dsh-fs (observed-state + freshness policy gate, no service) dsh-tool-fs ← dsh-fs, dsh-tools (file tools + executor) dsh-llm-deepseek ← dsh-llm (DeepSeek adapter) dsh-llm-pi-ai ← dsh-llm (pi-ai-backed adapter) @@ -78,7 +78,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop | `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) | | `fs/` | `fs` | Filesystem provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` events | `ctx.fs` | | `fs-local/` | `fs` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | -| `file-context/` | `fs` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit via the `fs/*` event gate | (no service — `fs/*` listeners) | +| `fs-policy/` | `fs` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit via the `fs/*` event gate | (no service — `fs/*` listeners) | | `tool-fs/` | `fs` | Model-facing `read`/`write`/`edit` tools + executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) | | `compact/` | `compact` | Abstract compaction seam + `compact/*` events + `CompactionResult` | `ctx.compact` | | `compact-basic/` | `compact` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | diff --git a/packages/fs/README.md b/packages/fs/README.md index 0fbce830e3..985a9f3ad6 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -6,7 +6,7 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona |---|---|---| | `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`) | -| `file-context/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) | +| `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 (`file-context/`) 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. +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. diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 60ad805938..ca5a7bb09e 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -6,7 +6,7 @@ The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepse 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-file-context for the +// 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. ``` diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 61cc3fee1a..b24b7e6b89 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -5,7 +5,7 @@ * * 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-file-context`. Large files + * 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. * @@ -35,6 +35,16 @@ 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' } @@ -119,6 +129,10 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise { const type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other' return { version: versionOf(info), mode: info.mode & 0o777, type, size: info.size } } catch (error: unknown) { - /* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; surface it. */ - if (!isENOENT(error)) throw error + // 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 } } diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 9f769bc7cf..7a65148a99 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -21,7 +21,7 @@ import type { FsEditRequest, FsInfo, FsTarget, - FsWriteExpectation, + FsWriteIntent, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' import { @@ -120,7 +120,7 @@ export class LocalFileSystem extends FileSystem { override async writeText( target: FsTarget, content: string, - expected?: FsWriteExpectation, + expected?: FsWriteIntent, signal?: AbortSignal, ): Promise { return this.withLock(target.targetKey, async () => { diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 4119188f1b..d149e741b0 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -3,7 +3,7 @@ * 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-file-context`, so it is not exercised here. + * `dsh-fs-policy`, so it is not exercised here. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 33f26f797a..6f28d54402 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -2,7 +2,7 @@ * 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-file-context`, so it is not tested here. + * policy and lives in `dsh-fs-policy`, so it is not tested here. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -21,7 +21,7 @@ import { writeFileAtomic, } from '@deepseek-ai/dsh-fs-local' import type { LocalTarget } from '@deepseek-ai/dsh-fs-local' -import { FsTargetKey } from '@deepseek-ai/dsh-fs' +import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs' let dir: string beforeEach(async () => { @@ -88,6 +88,16 @@ describe('resolveLocalTarget', () => { 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', () => { @@ -128,6 +138,11 @@ describe('probe', () => { await new Promise((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('readWholeText', () => { diff --git a/packages/fs/file-context/README.md b/packages/fs/fs-policy/README.md similarity index 60% rename from packages/fs/file-context/README.md rename to packages/fs/fs-policy/README.md index b543722055..ad912bfc95 100644 --- a/packages/fs/file-context/README.md +++ b/packages/fs/fs-policy/README.md @@ -1,10 +1,10 @@ -# @deepseek-ai/dsh-file-context +# @deepseek-ai/dsh-fs-policy -The **file-context 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.fileContext` 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. +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 FileContext from '@deepseek-ai/dsh-file-context' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' declare const ctx: Context @@ -12,8 +12,8 @@ declare const ctx: Context // 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/*-expectation slots. -await ctx.plugin(FileContext) +// listener should be the first decider registered for the fs/*-intent slots. +await ctx.plugin(FsPolicy) ``` ## The four-layer split @@ -21,7 +21,7 @@ await ctx.plugin(FileContext) | 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-file-context` (this) | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) | +| 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` | @@ -31,8 +31,8 @@ Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek | Event | This plugin's listener | |---|---| -| `fs/write-expectation` | No prior observation → `{ kind: 'createIfAbsent' }`; a prior observation → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. | -| `fs/edit-expectation` | 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/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 @@ -41,7 +41,7 @@ Observed state is a `WeakMap>`. An entry exists ## Single-slot, first-wins -The `fs/write-expectation`/`fs/edit-expectation` 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`. +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 diff --git a/packages/fs/file-context/package.json b/packages/fs/fs-policy/package.json similarity index 95% rename from packages/fs/file-context/package.json rename to packages/fs/fs-policy/package.json index 16ee567305..c3f2a07982 100644 --- a/packages/fs/file-context/package.json +++ b/packages/fs/fs-policy/package.json @@ -1,5 +1,5 @@ { - "name": "@deepseek-ai/dsh-file-context", + "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, diff --git a/packages/fs/file-context/src/index.ts b/packages/fs/fs-policy/src/index.ts similarity index 79% rename from packages/fs/file-context/src/index.ts rename to packages/fs/fs-policy/src/index.ts index 5e0488ea0e..4d5c7964b7 100644 --- a/packages/fs/file-context/src/index.ts +++ b/packages/fs/fs-policy/src/index.ts @@ -1,10 +1,10 @@ /** - * The file-context policy PLUGIN: observed-state, read-before-edit, and + * 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.fileContext` service and exposes no + * 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-expectation`/`fs/edit-expectation` waterfalls and + * 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 @@ -33,22 +33,22 @@ * * ## Single-slot, first-wins * - * The `fs/write-expectation`/`fs/edit-expectation` listeners do NOT call + * 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-file-context + * @module @deepseek-ai/dsh-fs-policy */ import type { Context } from 'cordis' import { FsError } from '@deepseek-ai/dsh-fs' -import type { FsTarget, FsVersion, FsWriteExpectation } from '@deepseek-ai/dsh-fs' -import type { FileContextExec } from './types.ts' +import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs' +import type { FsPolicyExec } from './types.ts' -export type { FileContextExec } from './types.ts' +export type { FsPolicyExec } from './types.ts' /** * Per-context observed-file state and the three `fs/*` decisions over it. One @@ -69,7 +69,7 @@ class ObservedStateGate { * the write/edit prior-observation policy. */ private owner(actor: object | undefined): object | undefined { - return (actor as FileContextExec | undefined)?.agent?.session + return (actor as FsPolicyExec | undefined)?.agent?.session } private get(owner: object, targetKey: string): FsVersion | undefined { @@ -91,11 +91,11 @@ class ObservedStateGate { } /** - * Decide the write expectation: no prior observation ⇒ `createIfAbsent` (only + * 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). */ - writeExpectation(target: FsTarget, actor: object | undefined): FsWriteExpectation { + 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' } @@ -105,7 +105,7 @@ class ObservedStateGate { * Decide the edit version guard: requires a prior observation by this owner * (else `FS_NOT_OBSERVED`); returns the observed version as the CAS basis. */ - editExpectation(target: FsTarget, actor: object | undefined): { version: FsVersion } { + 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) { @@ -122,7 +122,7 @@ class ObservedStateGate { } /** Cordis plugin name used by loader diagnostics. */ -export const name = 'file-context' +export const name = 'fs-policy' /** * Register the three `fs/*` listeners. No `inject` — this plugin reads no @@ -138,21 +138,22 @@ export function apply(ctx: Context): void { // (HMR safety). The WeakMap itself would be GC'd, but replacing it makes the // release observable and immediate for tests. gate.clear() - }, 'file-context observed-state teardown') + }, 'fs-policy observed-state teardown') - // fs/write-expectation: occupy the single decision slot — do NOT call next(). + // 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-expectation', (target, actor) => Promise.resolve().then(() => gate.writeExpectation(target, actor))) + ctx.on('fs/write-intent', (target, actor) => Promise.resolve().then(() => gate.writeIntent(target, actor))) - // fs/edit-expectation: occupy the single decision slot — do NOT call next(). + // 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-expectation', (target, actor) => Promise.resolve().then(() => gate.editExpectation(target, actor))) + ctx.on('fs/edit-intent', (target, actor) => Promise.resolve().then(() => gate.editIntent(target, actor))) - // fs/observed: synchronous, side-effect-only WeakMap write (cannot throw under - // normal operation); the tool contains any throw so a record bug never fails - // the already-completed mutation. + // 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) }) diff --git a/packages/fs/file-context/src/types.ts b/packages/fs/fs-policy/src/types.ts similarity index 86% rename from packages/fs/file-context/src/types.ts rename to packages/fs/fs-policy/src/types.ts index b3157cc2e9..9ee742a7f7 100644 --- a/packages/fs/file-context/src/types.ts +++ b/packages/fs/fs-policy/src/types.ts @@ -1,5 +1,5 @@ /** - * Vocabulary for the file-context policy plugin: the minimal execution-context + * 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. * @@ -7,7 +7,7 @@ * re-used from `@deepseek-ai/dsh-fs`; this package owns only the observed-state * owner structure on top of it. * - * @module @deepseek-ai/dsh-file-context/types + * @module @deepseek-ai/dsh-fs-policy/types */ /** @@ -20,7 +20,7 @@ * 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 FileContextExec { +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. */ diff --git a/packages/fs/file-context/tests/policy.spec.ts b/packages/fs/fs-policy/tests/policy.spec.ts similarity index 56% rename from packages/fs/file-context/tests/policy.spec.ts rename to packages/fs/fs-policy/tests/policy.spec.ts index 63808f1610..02bb934cfd 100644 --- a/packages/fs/file-context/tests/policy.spec.ts +++ b/packages/fs/fs-policy/tests/policy.spec.ts @@ -1,5 +1,5 @@ /** - * Tests for the file-context policy PLUGIN: it registers no service, only the + * 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 @@ -7,87 +7,87 @@ * 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 expectations and records versions on its own WeakMap. + * 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, FsWriteExpectation } from '@deepseek-ai/dsh-fs' -import * as FileContext from '@deepseek-ai/dsh-file-context' -import type { FileContextExec } from '@deepseek-ai/dsh-file-context' +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 { inputPath: path, targetKey: FsTargetKey(path), displayPath: path } } -const ownerExec = (session: object): FileContextExec => ({ agent: { session } }) +const ownerExec = (session: object): FsPolicyExec => ({ agent: { session } }) -/** Dispatch the write-expectation waterfall with the bare default thunk. */ -function writeExpectation(ctx: Context, t: FsTarget, actor: object | undefined): Promise { - return ctx.waterfall('fs/write-expectation', t, actor, () => undefined) +/** Dispatch the write-intent waterfall with the bare default thunk. */ +function writeIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise { + return ctx.waterfall('fs/write-intent', t, actor, () => undefined) } -/** Dispatch the edit-expectation waterfall with the bare default thunk. */ -function editExpectation(ctx: Context, t: FsTarget, actor: object | undefined): Promise<{ version: FsVersion } | undefined> { - return ctx.waterfall('fs/edit-expectation', 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(FileContext) + const fiber = await ctx.plugin(FsPolicy) return { ctx, fiber } } describe('registration / disposal', () => { - it('registers no service surface (it is a plugin, not ctx.fileContext)', async () => { + it('registers no service surface (it is a plugin, not ctx.fsPolicy)', async () => { const { ctx } = await setup() - expect((ctx as Context & { fileContext?: unknown }).fileContext).toBeUndefined() + 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(FileContext) + await ctx.plugin(FsPolicy) // The listener is live: an unobserved write decides createIfAbsent. - expect(await writeExpectation(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' }) + expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' }) }) }) -describe('write-expectation decision', () => { +describe('write-intent decision', () => { it('an unobserved target decides createIfAbsent', async () => { const { ctx } = await setup() - expect(await writeExpectation(ctx, target('a.txt'), ownerExec({}))).toEqual({ kind: 'createIfAbsent' }) + 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 writeExpectation(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' }) - expect(await writeExpectation(ctx, target('a.txt'), {})).toEqual({ kind: 'createIfAbsent' }) + expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' }) + expect(await writeIntent(ctx, target('a.txt'), {})).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 writeExpectation(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' }) + expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' }) }) }) -describe('edit-expectation decision', () => { +describe('edit-intent decision', () => { it('rejects an unread edit with FS_NOT_OBSERVED', async () => { const { ctx } = await setup() - await expect(editExpectation(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + 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(editExpectation(ctx, target('a.txt'), undefined)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + await expect(editIntent(ctx, target('a.txt'), undefined)).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 editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' }) + expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' }) }) }) @@ -96,7 +96,7 @@ describe('observed-state is the prior-observation record', () => { const { ctx } = await setup() const exec = ownerExec({}) ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) // a read - expect(await writeExpectation(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v0' }) + 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 () => { @@ -104,17 +104,17 @@ describe('observed-state is the prior-observation record', () => { 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 editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v1' }) + 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 editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v2' }) + 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(editExpectation(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) }) }) @@ -124,8 +124,8 @@ describe('multi-owner isolation', () => { const a = ownerExec({}) const b = ownerExec({}) ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) - await expect(editExpectation(ctx, target('a.txt'), b)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) - expect(await editExpectation(ctx, target('a.txt'), a)).toEqual({ version: 'v0' }) + 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 () => { @@ -134,8 +134,8 @@ describe('multi-owner isolation', () => { 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 writeExpectation(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' }) - expect(await writeExpectation(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' }) + expect(await writeIntent(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' }) + expect(await writeIntent(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' }) }) }) @@ -143,27 +143,27 @@ 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 expectation = await ctx.waterfall('fs/write-expectation', target('a.txt'), ownerExec({}), () => { + const intent = await ctx.waterfall('fs/write-intent', target('a.txt'), ownerExec({}), () => { defaultRan = true return undefined }) - expect(expectation).toEqual({ kind: 'createIfAbsent' }) + expect(intent).toEqual({ kind: 'createIfAbsent' }) expect(defaultRan).toBe(false) }) - it('a SECOND decider registered AFTER file-context is not reached (first-wins short-circuit)', async () => { + 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 file-context, so it dispatches second; file-context does + // 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-expectation', () => { + 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 editExpectation(ctx, target('a.txt'), exec) + await editIntent(ctx, target('a.txt'), exec) expect(secondRan).toBe(false) }) }) @@ -172,21 +172,21 @@ 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(FileContext) + const fiber = await ctx.plugin(FsPolicy) ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) - expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' }) + expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' }) await fiber.dispose() - await ctx.plugin(FileContext) + await ctx.plugin(FsPolicy) // Same owner object, but state was released on disposal. - await expect(editExpectation(ctx, target('a.txt'), exec)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + 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(FileContext) + const fiber = await ctx.plugin(FsPolicy) await fiber.dispose() // With no listener, the waterfall falls through to the bare default. - expect(await writeExpectation(ctx, target('a.txt'), ownerExec({}))).toBeUndefined() + expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toBeUndefined() }) }) diff --git a/packages/fs/file-context/tsconfig.json b/packages/fs/fs-policy/tsconfig.json similarity index 100% rename from packages/fs/file-context/tsconfig.json rename to packages/fs/fs-policy/tsconfig.json diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index fd2307dec5..917b660cfa 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -7,7 +7,7 @@ This package is the provider-seam layer of the four-layer filesystem stack, spli | 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-file-context` | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) | +| 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 | @@ -23,22 +23,21 @@ A backend subclasses `FileSystem` and implements six primitives. | `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). | -| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteExpectation` (`createIfAbsent`/`replaceIfVersion`) to guard. | +| `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 [catalog](../../../docs/cordis-catalog/events-and-services.md)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-file-context`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-expectation` and `fs/edit-expectation` 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. +This package declares three events (see the generated [catalog](../../../docs/cordis-catalog/events-and-services.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-file-context`) ADDS by supplying the optional guard — not provider behavior — so a sandboxed/remote backend inherits no model-facing observation policy. +`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. `FsWriteExpectation` 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_TEXT`, `FS_NOT_REGULAR_FILE`, `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. - +`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_TEXT`, `FS_NOT_REGULAR_FILE`, `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. diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 395816dbf9..813cb04e16 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-fs", - "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service, and the read-before-write/edit file-state contract", + "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", diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index f25a521301..e4aef709d0 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -2,7 +2,7 @@ * 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 expectation, and apply a guarded literal edit — without saying HOW. + * 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 @@ -20,7 +20,7 @@ * 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-file-context`) adds through the `fs/*` + * 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. * @@ -41,14 +41,14 @@ * 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-file-context`) adds on top by supplying the guard. + * 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-expectation`, `fs/edit-expectation`, and + * 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-file-context`; the events live in the one package both + * `@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 @@ -64,7 +64,7 @@ import type { FsInfo, FsTarget, FsVersion, - FsWriteExpectation, + FsWriteIntent, FsWriteOutcome, } from './types.ts' @@ -79,7 +79,7 @@ export type { FsErrorCode, FsInfo, FsTarget, - FsWriteExpectation, + FsWriteIntent, FsWriteOutcome, } from './types.ts' @@ -90,11 +90,11 @@ declare module 'cordis' { interface Events { /** - * Single-slot decision: produce the write expectation for the next + * 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-file-context` policy listener returns `createIfAbsent` + * `@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 @@ -102,23 +102,23 @@ declare module 'cordis' { * not layering. `actor` is the opaque tool-execution context, never read here. * @mode waterfall */ - 'fs/write-expectation'(target: FsTarget, actor: object | undefined, next: () => FsWriteExpectation | undefined | Promise): Promise + 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise /** * 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-file-context` policy listener returns + * `@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-expectation'}). + * first-wins (see {@link Events.'fs/write-intent'}). * @mode waterfall */ - 'fs/edit-expectation'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> + '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-file-context`'s + * 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 @@ -147,7 +147,7 @@ declare module 'cordis' { * binary/NUL rejection, and `FS_NOT_TEXT`. * - {@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 FsWriteExpectation} to guard the write. + * 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 @@ -188,7 +188,7 @@ export abstract class FileSystem extends Service { * unconditional create-or-overwrite (the bare provider — no version guard, no * read-first requirement). Atomic either way. */ - abstract writeText(target: FsTarget, content: string, expected?: FsWriteExpectation, signal?: AbortSignal): Promise + abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise /** * Apply a literal edit to an existing UTF-8 text file. When `expected` is diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index 258c7a1e8b..15a58ee93b 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -1,12 +1,12 @@ /** * Vocabulary for the filesystem provider seam (`ctx.fs`): the opaque - * target/version identities, the metadata `stat` returns, the write-expectation + * 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-file-context`). They are deliberately a *text-storage* + * (`@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 @@ -14,7 +14,7 @@ * * 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-file-context`). + * (`@deepseek-ai/dsh-tool-fs` / `@deepseek-ai/dsh-fs-policy`). * * @module @deepseek-ai/dsh-fs/types */ @@ -91,7 +91,7 @@ export interface FsInfo { * is expressed by omission, so the write and edit mutations share one symmetric * shape (`expected?`: omit = unconditional, present = guarded). */ -export type FsWriteExpectation = +export type FsWriteIntent = | { kind: 'createIfAbsent' } | { kind: 'replaceIfVersion'; version: FsVersion } diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index 7091746dc3..789ed7fdac 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -1,7 +1,7 @@ /** * 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-file-context`; this seam owns only the + * 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. */ @@ -13,7 +13,7 @@ import type { FsEditRequest, FsInfo, FsTarget, - FsWriteExpectation, + FsWriteIntent, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' @@ -38,7 +38,7 @@ class FakeFileSystem extends FileSystem { const content = await this.readText(target) return (async function* () { yield content })() } - override async writeText(target: FsTarget, content: string, _expected?: FsWriteExpectation): Promise { + override async writeText(target: FsTarget, content: string, _expected?: FsWriteIntent): Promise { const existed = this.files.has(target.targetKey) this.files.set(target.targetKey, content) return { operation: existed ? 'update' : 'create', version: FsVersion('v2') } diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index bcc5c5cdf1..bc38a5ee64 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -1,15 +1,15 @@ # @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-file-context`](../file-context)) through the `fs/*` event gate; the tool is not method-coupled to it. +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(FileContext) // @deepseek-ai/dsh-file-context (policy gate) +await ctx.plugin(FsPolicy) // @deepseek-ai/dsh-fs-policy (policy gate) await ctx.plugin(ToolFs) // this package — registers read/write/edit ``` -`@deepseek-ai/dsh-file-context` 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. +`@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. ## Tools (schemas per [the filesystem tool schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md)) @@ -26,13 +26,13 @@ Field names are snake_case to match Claude Code and existing harness tool schema The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve()`, then: - **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits a contained `fs/observed`. (1 stat.) -- **write** — `ctx.waterfall('fs/write-expectation', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, expectation)`, then `fs/observed`. (0 stat.) -- **edit** — `ctx.waterfall('fs/edit-expectation', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, expectation)`, then `fs/observed`. (0 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-file-context` 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. +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-file-context`'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. +`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. diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 5323bbadcf..f92966f515 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -30,7 +30,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-file-context": "workspace:^", + "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index f1eab5e319..efe2e5f53b 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -1,10 +1,10 @@ /** * 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-expectation` waterfall to obtain the optional + * 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-file-context`) + * — 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`. @@ -52,7 +52,7 @@ 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 file-context policy requires it), unless you just created or edited it in this session.', + 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({ @@ -70,11 +70,11 @@ export function applyEditTool(ctx: Context): void { // 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 expectation = await ctx.waterfall('fs/edit-expectation', target, exec, () => undefined) + 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 }, - expectation, + intent, exec.signal, ) // Record the observed version (a no-op when no policy plugin listens). diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index 285299e352..e9f384a96c 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -8,15 +8,16 @@ * 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-expectation`/`fs/edit-expectation`) to obtain the OPTIONAL version - * guard, and after every read/write/edit it emits a contained `fs/observed`. A - * policy plugin (`@deepseek-ai/dsh-file-context`, loaded by the default product - * config) occupies the decision slot and listens for `fs/observed` to add - * observed-state + read-before-edit + version-guarded write/edit. 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. + * (`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 */ diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 31d31424cb..b7e0d43772 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -3,7 +3,7 @@ * 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-file-context`) can record the read. With + * 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 diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 564d99ddd6..ed9143f32a 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -1,10 +1,10 @@ /** * The model-facing `write` tool: create or fully replace a UTF-8 text file. The - * tool is the executor: it dispatches the `fs/write-expectation` waterfall to + * 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-file-context`) occupies the single decision slot and + * (`@deepseek-ai/dsh-fs-policy`) occupies the single decision slot and * returns `createIfAbsent`/`replaceIfVersion` instead. The tool stats ZERO * times either way. * @@ -39,7 +39,7 @@ 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 file-context policy requires it) and prefer edit for targeted changes.', + 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({ @@ -54,8 +54,8 @@ export function applyWriteTool(ctx: Context): void { const target = await ctx.fs.resolve(input.filePath) // Single-slot decision: the policy plugin produces createIfAbsent/ // replaceIfVersion; the bare default is undefined (unconditional). No stat. - const expectation = await ctx.waterfall('fs/write-expectation', target, exec, () => undefined) - const outcome = await ctx.fs.writeText(target, input.content, expectation, exec.signal) + 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) return [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }] diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 087227f790..238909ff98 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -3,7 +3,7 @@ * 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-file-context` policy gate plugin: read-before- + * - 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 @@ -22,7 +22,7 @@ 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 FileContext from '@deepseek-ai/dsh-file-context' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' let dir: string @@ -53,14 +53,14 @@ afterEach(async () => { // -------------------------------------------------------------------------- // DEFAULT deployment: the policy gate plugin is loaded. // -------------------------------------------------------------------------- -describe('default deployment (with dsh-file-context)', () => { +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(FileContext) + await ctx.plugin(FsPolicy) fiber = await ctx.plugin(ToolFs) }) @@ -228,7 +228,7 @@ describe('default deployment (with dsh-file-context)', () => { // -------------------------------------------------------------------------- // BARE deployment: the tool suite WITHOUT the policy gate. // -------------------------------------------------------------------------- -describe('bare provider (no dsh-file-context)', () => { +describe('bare provider (no dsh-fs-policy)', () => { beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-bare-')) ctx = new Context() diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 5ca6947354..4a161a272b 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -1,6 +1,6 @@ /** * Consumer-surface tests for the filesystem tools as the EXECUTOR. They run the - * REAL `@deepseek-ai/dsh-file-context` gate plugin (the genuine policy + * 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 @@ -19,10 +19,10 @@ import type { FsEditRequest, FsInfo, FsTarget, - FsWriteExpectation, + FsWriteIntent, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' -import * as FileContext from '@deepseek-ai/dsh-file-context' +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' @@ -31,8 +31,8 @@ import type { FileReadOutcome } from '@deepseek-ai/dsh-tool-fs' class FakeFs extends FileSystem { files = new Map() rejectWith?: FsError - writeExpectations: (FsWriteExpectation | undefined)[] = [] - editExpectations: ({ version: FsVersion } | undefined)[] = [] + writeIntents: (FsWriteIntent | undefined)[] = [] + editIntents: ({ version: FsVersion } | undefined)[] = [] private throwIfArmed(): void { if (this.rejectWith) throw this.rejectWith @@ -54,16 +54,16 @@ class FakeFs extends FileSystem { const content = this.files.get(target.targetKey) ?? '' return (async function* () { yield content })() } - override async writeText(target: FsTarget, content: string, expected?: FsWriteExpectation): Promise { + override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise { this.throwIfArmed() - this.writeExpectations.push(expected) + this.writeIntents.push(expected) const existed = this.files.has(target.targetKey) this.files.set(target.targetKey, content) return { operation: existed ? 'update' : 'create', version: FsVersion('v2') } } override async editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }): Promise { this.throwIfArmed() - this.editExpectations.push(expected) + this.editIntents.push(expected) const content = this.files.get(target.targetKey) ?? '' this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString)) return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3') } @@ -75,7 +75,7 @@ async function setup() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(FakeFs) - await ctx.plugin(FileContext) + await ctx.plugin(FsPolicy) await ctx.plugin(ToolFs) const fs = ctx.fs as FakeFs return { ctx, fs } @@ -122,11 +122,16 @@ describe('registration', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(FakeFs) - await ctx.plugin(FileContext) + 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(['tool:edit', 'tool:read', 'tool:write']) await fiber.dispose() expect(ctx.tools.schemas()).toHaveLength(0) + expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0) }) }) @@ -174,7 +179,7 @@ describe('read tool', () => { 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.editExpectations).toEqual([{ version: 'v1' }]) + expect(fs.editIntents).toEqual([{ version: 'v1' }]) }) it('propagates FS_NOT_FOUND for an absent file', async () => { @@ -257,7 +262,7 @@ describe('write tool', () => { const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }, { session: {} }) expect(result.isError).toBe(false) expect(text(result)).toContain('Created file') - expect(fs.writeExpectations).toEqual([{ kind: 'createIfAbsent' }]) + expect(fs.writeIntents).toEqual([{ kind: 'createIfAbsent' }]) }) it('rejects a blank file_path', async () => { diff --git a/packages/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json index 7c03431ee4..6af16400c0 100644 --- a/packages/fs/tool-fs/tsconfig.json +++ b/packages/fs/tool-fs/tsconfig.json @@ -12,6 +12,6 @@ { "path": "../../core/tools" }, { "path": "../../core/system-prompt" }, { "path": "../fs" }, - { "path": "../file-context" } + { "path": "../fs-policy" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39fdcbd628..58ea6d9bfe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -278,18 +278,6 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/fs/file-context: - devDependencies: - '@deepseek-ai/dsh-fs': - specifier: workspace:^ - version: link:../fs - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/fs/fs: devDependencies: '@deepseek-ai/dsh-brand': @@ -318,20 +306,32 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/fs/fs-policy: + devDependencies: + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../fs + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/fs/tool-fs: devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-file-context': - specifier: workspace:^ - version: link:../file-context '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../fs-local + '@deepseek-ai/dsh-fs-policy': + specifier: workspace:^ + version: link:../fs-policy '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 68933a2a13..8d84eff57a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -80,10 +80,9 @@ const LINK_MAP: Record = { FsInfo: 'filesystem.md', FsTarget: 'filesystem.md', FsVersion: 'filesystem.md', - FsWriteExpectation: 'filesystem.md', + FsWriteIntent: 'filesystem.md', FsWriteOutcome: 'filesystem.md', - FileContextExec: 'filesystem.md', - FileReadRequest: 'filesystem.md', + FsPolicyExec: 'filesystem.md', FileReadOutcome: 'filesystem.md', } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 6d584bc6c6..9a6fa4cab8 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -46,12 +46,12 @@ { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTargetKey", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsInfo", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteExpectation", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteIntent", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteOutcome", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditRequest", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditOutcome", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsErrorCode", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileContextExec", "source": "packages/fs/file-context/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsPolicyExec", "source": "packages/fs/fs-policy/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/tool-fs/src/read-render.ts" }, { "doc": "docs/core-data-structures/compaction.md", "symbol": "CompactionResult", "source": "packages/compact/compact/src/types.ts" }, diff --git a/tsconfig.build.json b/tsconfig.build.json index a3334bb397..442d187a38 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -30,7 +30,7 @@ { "path": "./packages/bash/tool-bash" }, { "path": "./packages/fs/fs" }, { "path": "./packages/fs/fs-local" }, - { "path": "./packages/fs/file-context" }, + { "path": "./packages/fs/fs-policy" }, { "path": "./packages/fs/tool-fs" }, { "path": "./packages/support/invariants" }, { "path": "./packages/ui/acp" }, diff --git a/tsconfig.json b/tsconfig.json index a192e9319e..00a3a21460 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -39,7 +39,7 @@ { "path": "./packages/bash/tool-bash" }, { "path": "./packages/fs/fs" }, { "path": "./packages/fs/fs-local" }, - { "path": "./packages/fs/file-context" }, + { "path": "./packages/fs/fs-policy" }, { "path": "./packages/fs/tool-fs" }, { "path": "./packages/compact/compact" }, { "path": "./packages/compact/compact-basic" }, From eda2983b001010e3ea480e0500b6881f1f17b028 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:16:17 +0800 Subject: [PATCH 38/46] =?UTF-8?q?fix(docs):=20address=20Codex=20review=20?= =?UTF-8?q?=E2=80=94=20document=20the=20subagent=5Ffork=20alias,=20harden?= =?UTF-8?q?=20dispose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 Codex review findings on the tool-schema catalog: (A) The shipped coding-agent / acp-agent configs load dsh-tool-subagent twice — as `subagent` (spawn backend) and `subagent_fork` (fork backend) — so the model sees a `subagent_fork` tool the catalog never mentioned, while the intro claimed to list "the exact name the model receives". The registered name is the plugin's load-time `toolName` config, not a package fact, so rather than bake an example-app config into a packages-scoped generator, add a per-package deployment `note`: the subagent entry now records the `subagent_fork` alias and points at the leaf configs. Intro and RFC scope reworded to state the unit is the package (at its default config), with aliases noted — no longer overclaiming. A test asserts the note names `subagent_fork`, covering the config-driven-name path. (B) collectToolCatalog only disposed the context on the success path; a throw from mount/schemas() after earlier plugins mounted would leak the fiber. Move `ctx.fiber.dispose()` into a `finally` per the repo's dispose-to-quiescence rule. --- .../process/2026-07-02-tool-schema-catalog.md | 4 ++- docs/tool-catalog/tools.md | 6 ++-- .../core/tools/tests/gen-tool-catalog.spec.ts | 12 +++++++ scripts/gen-tool-catalog.ts | 36 ++++++++++++++----- 4 files changed, 46 insertions(+), 12 deletions(-) diff --git a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md index 9af018d2d6..96355941b3 100644 --- a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md +++ b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md @@ -31,7 +31,9 @@ The boot manifest (`TOOL_PACKAGES`) is a hand-written list — in tension with t ### Scope -Shipped product tools under `packages/*/tool-*` only: `dsh-tool-bash` (`bash`, `bash_output`, `bash_kill`), `dsh-tool-todo` (`todo_write`), `dsh-tool-subagent` (`subagent`). The `examples/` demo tools (`echo`) are excluded, matching the cordis catalog's packages-only scope — a demo tool is not part of the product surface a reader is cataloguing. +Shipped product tool PACKAGES under `packages/*/tool-*`, each booted with its default config: `dsh-tool-bash` (`bash`, `bash_output`, `bash_kill`), `dsh-tool-todo` (`todo_write`), `dsh-tool-subagent` (`subagent`). The `examples/` demo tools (`echo`) are excluded, matching the cordis catalog's packages-only scope — a demo tool is not part of the product surface a reader is cataloguing. + +The unit is the PACKAGE, not the deployed tool instance. A package's registered tool name can be a load-time config — `tool-subagent`'s `toolName` — so the same package surfaces as `subagent` (spawn backend) AND `subagent_fork` (fork backend) in the shipped `coding-agent` / `acp-agent` configs, with an identical schema. The generator boots each package once at its default and records such shipped aliases in a per-package note, rather than enumerating every deployment permutation. Cataloguing at the package level keeps the source of truth the package (what a plugin author reads) and avoids leaking example-app `cordis.yml` config into a packages-scoped generator; the note keeps the doc honest about the names a reader will actually see the model receive. The design deliberately does not attempt to catalog "every configured tool instance across every leaf config" — that is a deployment inventory, a different (and unbounded) surface. ### A plain `json` fence diff --git a/docs/tool-catalog/tools.md b/docs/tool-catalog/tools.md index 9e625d17ad..97400f8223 100644 --- a/docs/tool-catalog/tools.md +++ b/docs/tool-catalog/tools.md @@ -3,11 +3,11 @@ # Tool Schema Catalog -Every model-facing tool a shipped plugin contributes to `ctx.tools`: the exact `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [cordis events & services catalog](../cordis-catalog/events-and-services.md) (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered. +Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [cordis events & services catalog](../cordis-catalog/events-and-services.md) (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered. This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator's boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](../rfc/implemented/process/2026-07-02-tool-schema-catalog.md). -Scope: shipped product tools under `packages/*/tool-*`. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog's packages-only scope. +Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog's packages-only scope. ## `@deepseek-ai/dsh-tool-bash` @@ -119,6 +119,8 @@ Delegate a self-contained task to a subagent (a separate agent that works in its Source: [`packages/subagent/tool-subagent/src/index.ts`](../../packages/subagent/tool-subagent/src/index.ts) +The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. + ## `@deepseek-ai/dsh-tool-todo` ### `todo_write` diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 57d6b6ccc4..eba74c833d 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -60,6 +60,18 @@ describe('gen-tool-catalog collectToolCatalog', () => { const bash = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-bash') expect(bash?.source).toBe('packages/bash/tool-bash/src/index.ts') }) + + it('records the shipped `subagent_fork` alias in a note (config-driven tool name)', async () => { + // `tool-subagent`'s registered name is the load-time `toolName` config, so + // the shipped agents surface this one package as both `subagent` and + // `subagent_fork`. Booting yields only the default name; the note is how a + // reader learns the fork alias the model also sees. Without it the catalog + // would silently under-report the shipped tool surface. + const catalog = await collectToolCatalog() + const subagent = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-subagent') + expect(subagent?.schemas.map(s => s.name)).toEqual(['subagent']) + expect(subagent?.note).toMatch(/subagent_fork/) + }) }) describe('gen-tool-catalog assertManifestComplete', () => { diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 8e62277713..be318f753c 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -72,6 +72,14 @@ interface ToolPackage { /** Plug the injected seams + the tool plugin onto a context that already * carries `systemPrompt` + `tools`. */ mount: (ctx: Context) => Promise + /** + * A deployment note rendered after the package's tools, for a fact that + * booting the package alone cannot show. The registered tool NAME can be a + * load-time config (`tool-subagent`'s `toolName`), so one package may surface + * under several names across deployments — the boot yields the package + * DEFAULT, and this note records the shipped alternatives the model sees. + */ + note?: string } /** @@ -99,6 +107,8 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(SubagentMock, { name: 'mock' }) await ctx.plugin(ToolSubagent, { provider: 'mock' }) }, + note: + 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.', }, { pkg: '@deepseek-ai/dsh-tool-todo', @@ -115,6 +125,8 @@ interface CatalogPackage { pkg: string source: string schemas: ToolSchema[] + /** A deployment note (see {@link ToolPackage.note}), rendered after the tools. */ + note?: string } /** The whole catalog: one entry per booted tool package, in manifest order. */ @@ -153,13 +165,18 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES const catalog: ToolCatalog = [] for (const entry of packages) { const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await entry.mount(ctx) - // Copy the schemas out before the context is torn down. - const schemas = ctx.tools.schemas().sort((a, b) => a.name.localeCompare(b.name)) - await ctx.fiber.dispose() - catalog.push({ pkg: entry.pkg, source: entry.source, schemas }) + // Dispose in `finally` so a throw from `mount`/`schemas()` after earlier + // plugins mounted still tears the context down (no leaked executor/provider + // fiber) — the repo's "dispose must reach quiescence" rule. + try { + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await entry.mount(ctx) + const schemas = ctx.tools.schemas().sort((a, b) => a.name.localeCompare(b.name)) + catalog.push({ pkg: entry.pkg, source: entry.source, schemas, ...entry.note !== undefined ? { note: entry.note } : {} }) + } finally { + await ctx.fiber.dispose() + } } return catalog } @@ -182,16 +199,17 @@ export function render(catalog: ToolCatalog): string { '', '# Tool Schema Catalog', '', - 'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the exact `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [cordis events & services catalog](../cordis-catalog/events-and-services.md) (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.', + 'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [cordis events & services catalog](../cordis-catalog/events-and-services.md) (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.', '', 'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](../rfc/implemented/process/2026-07-02-tool-schema-catalog.md).', '', - 'Scope: shipped product tools under `packages/*/tool-*`. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.', + 'Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.', '', ] for (const entry of catalog) { lines.push(`## \`${entry.pkg}\``, '') for (const schema of entry.schemas) lines.push(...renderTool(schema, entry.source)) + if (entry.note) lines.push(entry.note, '') } return lines.join('\n') } From 6c88b380ea8a4a65862000f782bd8ce00b77e184 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:30:04 +0800 Subject: [PATCH 39/46] docs(tool-catalog): register dsh-tool-fs in the boot manifest Master's new tool-schema catalog boots every tool-* package and hard-errors if one is absent from the manifest. Add the dsh-tool-fs entry (boot dsh-fs-local to satisfy the injected `fs`, harvest read/write/edit), note that dsh-fs-policy adds the read-before-write/edit gate without changing schemas, and regenerate docs/tool-catalog/tools.md. Update the collectToolCatalog test's expected tool set to include the fs tools. --- docs/tool-catalog/tools.md | 94 +++++++++++++++++++ .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- scripts/gen-tool-catalog.ts | 16 ++++ 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/docs/tool-catalog/tools.md b/docs/tool-catalog/tools.md index 97400f8223..9f00a66d75 100644 --- a/docs/tool-catalog/tools.md +++ b/docs/tool-catalog/tools.md @@ -91,6 +91,100 @@ Read new output from a background bash task started with `bash` + `run_in_backgr Source: [`packages/bash/tool-bash/src/index.ts`](../../packages/bash/tool-bash/src/index.ts) +## `@deepseek-ai/dsh-tool-fs` + +### `edit` + +Edit an existing UTF-8 text file by replacing literal text. + +```json +{ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "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." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] +} +``` + +Source: [`packages/fs/tool-fs/src/index.ts`](../../packages/fs/tool-fs/src/index.ts) + +### `read` + +Read a UTF-8 text file and return line-numbered content. + +```json +{ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "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 2000." + } + }, + "required": [ + "file_path" + ] +} +``` + +Source: [`packages/fs/tool-fs/src/index.ts`](../../packages/fs/tool-fs/src/index.ts) + +### `write` + +Create or fully replace a UTF-8 text file. + +```json +{ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + } + }, + "required": [ + "file_path", + "content" + ] +} +``` + +Source: [`packages/fs/tool-fs/src/index.ts`](../../packages/fs/tool-fs/src/index.ts) + +The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. + ## `@deepseek-ai/dsh-tool-subagent` ### `subagent` diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index eba74c833d..591e57b721 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -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', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index be318f753c..0efc4354b7 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -40,9 +40,11 @@ import type { ToolSchema } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import SubagentService from '@deepseek-ai/dsh-subagent' import * as SubagentMock from '@deepseek-ai/dsh-subagent-mock' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' @@ -97,6 +99,20 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolBash) }, }, + { + pkg: '@deepseek-ai/dsh-tool-fs', + dir: 'tool-fs', + source: 'packages/fs/tool-fs/src/index.ts', + async mount(ctx) { + // The tool injects `fs`; boot the local backend to satisfy it. The schemas + // do not depend on the policy plugin (an event gate that changes behavior, + // not tool shape), so the bare provider is enough to harvest them. + await ctx.plugin(LocalFileSystem) + await ctx.plugin(ToolFs) + }, + note: + 'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.', + }, { pkg: '@deepseek-ai/dsh-tool-subagent', dir: 'tool-subagent', From b06f1bb60d7aea7448d72f59a6953577c5f2001b Mon Sep 17 00:00:00 2001 From: "tn.shen" Date: Thu, 2 Jul 2026 12:55:01 +0800 Subject: [PATCH 40/46] fix(acp): enable filesystem tools in demo --- examples/acp-agent/README.md | 4 +-- examples/acp-agent/cordis.snapshot.yml | 28 +++++++++++++++------ examples/acp-agent/cordis.yml | 35 +++++++++++++++++++------- 3 files changed, 49 insertions(+), 18 deletions(-) diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 58ba92cd0e..2ca4de690b 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -6,7 +6,7 @@ The DeepSeek Harness coding agent exposed as an **Agent Client Protocol (ACP)** pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) ``` -This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent) app (which bundles the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, JSONL session persistence, and the `@deepseek-ai/dsh-acp` bridge — with **no pre-created agents**, since ACP `session/new` creates them on demand), the swappable DeepSeek and bash backends, and the optional model-facing `subagent`/`subagent_fork`/`todo_write` tool entries. The app package bakes in the no-stdout-logger cluster, so a leaf has no logger entry to get wrong by default — keeping stdout pure for JSON-RPC. +This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent) app (which bundles the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, JSONL session persistence, and the `@deepseek-ai/dsh-acp` bridge — with **no pre-created agents**, since ACP `session/new` creates them on demand), the swappable DeepSeek, bash, and filesystem backends, and the model-facing `read`/`write`/`edit`/`subagent`/`subagent_fork`/`todo_write` tool entries. The app package bakes in the no-stdout-logger cluster, so a leaf has no logger entry to get wrong by default — keeping stdout pure for JSON-RPC. ## stdout is the protocol @@ -28,7 +28,7 @@ Add to your Zed `settings.json` under `agent_servers`: } ``` -The editor sets each session's `cwd` to the project it opens; the agent's bash tools run there (see the per-session `cwd` note in `packages/ui/acp`), so launch the server from the harness repo with `pnpm --dir …` and let ACP carry the workspace path per session. +The editor sets each session's `cwd` to the project it opens; the agent's bash tools run there (see the per-session `cwd` note in `packages/ui/acp`). The filesystem tools in this demo use the local filesystem backend and resolve relative paths from the server launch directory, so launch the server from the harness repo with `pnpm --dir …` when using `read`/`write`/`edit` against this checkout. ## Snapshot tests (record-once / replay-deterministic) diff --git a/examples/acp-agent/cordis.snapshot.yml b/examples/acp-agent/cordis.snapshot.yml index f03cc49223..8a6f17d012 100644 --- a/examples/acp-agent/cordis.snapshot.yml +++ b/examples/acp-agent/cordis.snapshot.yml @@ -18,7 +18,7 @@ # Local bash executor for agent-core's tool-bash schema. # FIXME(config-comments): keep this executor note from implying bash is the -# whole tool set; subagent and todo_write are loaded below. +# whole tool set; filesystem, subagent, and todo_write are loaded below. - id: bash name: '@deepseek-ai/dsh-bash-local' config: @@ -33,12 +33,13 @@ systemPrompt: | You are a coding assistant driven over the Agent Client Protocol. - Your tools are bash (plus bash_output/bash_kill for background tasks) - and subagent. Do ALL file operations through bash: read with - cat/sed/head, search with grep, write with heredocs (cat <<'EOF' > - file), edit with sed or a rewrite. Each bash call runs in a fresh - shell — pass workdir instead of cd. Check the [exit code: N] marker; - verify your work. Keep answers brief and factual. + Your tools are read/write/edit for file operations, bash (plus + bash_output/bash_kill for background tasks), and subagent. Use read to + inspect UTF-8 text files, write to create or replace files, and edit for + targeted literal replacements. Use bash for shell commands, tests, + searches, and operations that are not ordinary file reads or edits. Each + bash call runs in a fresh shell — pass workdir instead of cd. Check the + [exit code: N] marker; verify your work. Keep answers brief and factual. Use the subagent tool to delegate a focused, self-contained subtask to a fresh child agent (it works in its own context and returns only its @@ -85,3 +86,16 @@ # replayed todo_write tool call resolves to a real tool during snapshot replay. - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' + +# Filesystem capability stack — identical to cordis.yml's wiring, so replayed +# read/write/edit tool calls resolve to the real tools during snapshot replay. +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 31d4d5429a..35c3176f34 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -1,8 +1,9 @@ # The acp-agent plugin tree: the ACP server. Also the snapshot RECORD config # (the dsh-acp-agent bin selects it for DSH_SNAPSHOT=record): a real llm-deepseek # run whose persisted log the snapshot harness harvests. The swappable DeepSeek -# adapter and local bash executor, the ACP server app (@deepseek-ai/dsh-acp-agent), -# and the optional model-facing subagent/todo tools loaded below. +# adapter, local bash/filesystem executors, the ACP server app +# (@deepseek-ai/dsh-acp-agent), and the optional model-facing fs/subagent/todo +# tools loaded below. # # CRITICAL: this tree loads NO stdout logger and NO hmr — stdout is reserved for # the ACP JSON-RPC protocol (see packages/ui/acp). That guarantee is now a @@ -24,7 +25,7 @@ # Local bash executor for agent-core's tool-bash schema. # FIXME(config-comments): keep this executor note from implying bash is the -# whole tool set; subagent and todo_write are loaded below. +# whole tool set; filesystem, subagent, and todo_write are loaded below. - id: bash name: '@deepseek-ai/dsh-bash-local' config: @@ -41,12 +42,13 @@ systemPrompt: | You are a coding assistant driven over the Agent Client Protocol. - Your tools are bash (plus bash_output/bash_kill for background tasks) - and subagent. Do ALL file operations through bash: read with - cat/sed/head, search with grep, write with heredocs (cat <<'EOF' > - file), edit with sed or a rewrite. Each bash call runs in a fresh - shell — pass workdir instead of cd. Check the [exit code: N] marker; - verify your work. Keep answers brief and factual. + Your tools are read/write/edit for file operations, bash (plus + bash_output/bash_kill for background tasks), and subagent. Use read to + inspect UTF-8 text files, write to create or replace files, and edit for + targeted literal replacements. Use bash for shell commands, tests, + searches, and operations that are not ordinary file reads or edits. Each + bash call runs in a fresh shell — pass workdir instead of cd. Check the + [exit code: N] marker; verify your work. Keep answers brief and factual. Use the subagent tool to delegate a focused, self-contained subtask to a fresh child agent (it works in its own context and returns only its @@ -95,3 +97,18 @@ # session log (todo/write), surfaced to the ACP client as a `plan` update. - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' + +# Filesystem capability stack: local provider, read-before-write/edit policy +# gate, then the model-facing read/write/edit tools. Relative filesystem paths +# resolve from the server launch cwd; the documented Zed setup launches this +# demo from the harness checkout with `pnpm --dir`. +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' From 743eb9ea09a3cbc879d34a33c8477b20e5af8ea5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:29:29 +0800 Subject: [PATCH 41/46] fix(fs): resolve paths against the caller's session cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ACP bridge gives each session its own workspace (SessionHeader.cwd), and dsh-tool-bash already resolves a bash workdir against it. But ctx.fs.resolve(path) took no caller context and dsh-fs-local resolved every relative path against a fixed config.cwd (process.cwd() at plugin load) — so in the ACP demo `write foo.txt` and `bash cat foo.txt` hit different directories the moment an editor opens any project other than the server's launch dir. Thread the session cwd into resolution, mirroring dsh-tool-bash: widen FileSystem.resolve to resolve(path, opts?: { cwd?: string }); dsh-fs-local bases a relative path on opts.cwd ?? config.cwd (absolute paths ignore it); the read/write/edit tools derive it via a shared sessionCwd(exec) helper (exec.agent?.session.header.cwd). The provider stays free of dsh-agent/dsh-session — the tool projects exec → cwd and hands over a plain string, per the explicit-at-seams convention. Backward compatible (the arg is optional). Tests: fs-local resolve(path,{cwd}) bases relative on the passed cwd / ignores it for absolute; tool integration writes/reads/edits in a session cwd != config.cwd and verifies the file on disk (proven to fail on the pre-fix no-cwd path). Fakes that stood in a bare {session:{}} now carry a header so sessionCwd doesn't throw. RFC in docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md. --- docs/cordis-catalog/events-and-services.md | 2 +- docs/rfc/README.md | 1 + .../2026-07-02-fs-per-session-cwd.md | 30 ++++++++++ packages/fs/fs-local/README.md | 2 +- packages/fs/fs-local/src/index.ts | 4 +- packages/fs/fs-local/tests/filesystem.spec.ts | 24 ++++++++ packages/fs/fs/README.md | 2 +- packages/fs/fs/src/index.ts | 10 +++- packages/fs/tool-fs/README.md | 4 +- packages/fs/tool-fs/src/edit.ts | 4 +- packages/fs/tool-fs/src/read.ts | 4 +- packages/fs/tool-fs/src/session-cwd.ts | 24 ++++++++ packages/fs/tool-fs/src/write.ts | 4 +- packages/fs/tool-fs/tests/integration.spec.ts | 55 ++++++++++++++++++- packages/fs/tool-fs/tests/tools.spec.ts | 8 +-- 15 files changed, 161 insertions(+), 17 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md create mode 100644 packages/fs/tool-fs/src/session-cwd.ts diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 5a0f203e23..c0fb5bd86b 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -444,7 +444,7 @@ Semantics every backend must honor: - 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`). ```ts cordis-catalog -abstract resolve(path: string): Promise +abstract resolve(path: string, opts?: { cwd?: string }): Promise abstract stat(target: FsTarget, signal?: AbortSignal): Promise abstract readText(target: FsTarget, signal?: AbortSignal): Promise abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 66794ed471..59516c24c5 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -124,6 +124,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | | [Extract example apps into packages](implemented/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | | [Make `dsh-fs-policy` an event-gate plugin, not a method interface](implemented/architecture/2026-06-26-file-context-as-event-gate.md) | 2026-06-26 | +| [Resolve filesystem paths against the caller's session cwd](implemented/architecture/2026-07-02-fs-per-session-cwd.md) | 2026-07-02 | ### Process diff --git a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md new file mode 100644 index 0000000000..5669ecd98f --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md @@ -0,0 +1,30 @@ +# RFC: Resolve filesystem paths against the caller's session cwd + +Status: implemented + +## Problem + +The ACP bridge gives every session its own workspace: `session/new` records the editor's project directory as `SessionHeader.cwd`, and `dsh-tool-bash` defaults each bash call's `workdir` to the calling agent's `session.header.cwd` (see [the per-session cwd RFC work in `packages/ui/acp`](../../../../packages/ui/acp) and `resolveWorkdir` in `dsh-tool-bash`). So a bash command in session A runs in A's project, and in session B runs in B's — one server process, N workspaces. + +The filesystem tools did NOT honor this. `ctx.fs.resolve(path)` took no caller context, and `dsh-fs-local` resolved every relative path against a single `config.cwd` fixed at plugin load (`process.cwd()`). In the ACP demo that means `write foo.txt` and `bash cat foo.txt` resolve `foo.txt` against **different** directories — the fs tools against the server's launch dir, bash against the session's project dir. The two tools disagree about what "the current directory" is, which is a correctness bug the moment an editor opens any project other than the server's launch dir. It only appeared to work in the snapshot harness because that harness launches the child process in the same temp dir it passes as the session cwd, so the two coincide. + +## Decision + +Thread the caller's session cwd into path resolution, exactly as `dsh-tool-bash` already does for `workdir`. The **caller** (the tool) supplies the cwd; the provider does not read a session or agent. + +- `FileSystem.resolve` widens to `resolve(path: string, opts?: { cwd?: string }): Promise`. `opts.cwd` is the base a RELATIVE `path` resolves against; an absolute `path` ignores it; omitting `opts.cwd` uses the backend's own default. An options object (not a positional `cwd?`) leaves room for future resolution hints without another signature change. +- `dsh-fs-local.resolve` uses `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)`. `config.cwd` stays the default for a caller that supplies none (non-ACP / no-session use, and the single-session stdio demo where `process.cwd()` IS the workspace). +- `dsh-tool-fs`'s `read`/`write`/`edit` derive the session cwd through a shared `sessionCwd(exec)` helper (`exec.agent?.session.header.cwd`, mirroring bash's `resolveWorkdir`) and pass it to `resolve`. A non-agent / headerless caller yields `undefined`, so the backend applies its default. + +## Why the caller supplies the cwd (not the provider) + +The provider seam must not depend on `dsh-agent` / `dsh-session` — it is a text-storage backend that a sandboxed or remote implementation also satisfies, and those have no notion of an "agent session". The tool already receives the `ToolExecution` (`exec`), which carries the agent, so the tool is the right place to project `exec → cwd` and hand the provider a plain string. This is the "explicit > implicit at package seams" convention: the base directory arrives as an explicit argument the provider acts on, not smuggled in by having the provider reach into a session it should not know about. It also matches `dsh-tool-bash` one-to-one, so the two model-facing file surfaces resolve paths identically. + +The default lives in ONE place — the provider's `config.cwd`. `sessionCwd` returns `undefined` rather than `process.cwd()` when there is no session, so the tool never manufactures a base the provider would otherwise choose. + +## Consequences + +- In the ACP demo the fs tools and bash now agree on each session's workspace; an editor can open any project folder and both tool families act on it. +- No change to `FsTarget` identity: `targetKey` is still the realpath of the resolved absolute path, so observed-state keying and symlink identity are unaffected — a correct per-session cwd produces the same key bash targets. +- Backward compatible: every existing `resolve(path)` call (all in tests) keeps working; the new argument is optional. +- The single-session stdio demo is unaffected: it supplies no session cwd (its agent's session has no `cwd`), so resolution falls back to `config.cwd = process.cwd()`, which is the workspace. diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index ca5a7bb09e..6fb75276e0 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -12,7 +12,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) ## Behavior -- **`resolve(path)`** — relative paths resolve from `config.cwd` (default `process.cwd()`). 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. +- **`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. - **`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`). diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 7a65148a99..97dda3c4dd 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -97,8 +97,8 @@ export class LocalFileSystem extends FileSystem { } } - override async resolve(path: string): Promise { - const local = await resolveLocalTarget(this.config.cwd, path) + override async resolve(path: string, opts?: { cwd?: string }): Promise { + const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path) return { inputPath: path, targetKey: local.targetKey, displayPath: local.displayPath } } diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index d149e741b0..0b96350c2d 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -51,6 +51,30 @@ describe('registration', () => { }) }) +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') diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 917b660cfa..3cea538ff7 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -19,7 +19,7 @@ A backend subclasses `FileSystem` and implements six primitives. | Member | Semantics | |---|---| -| `resolve(path)` | Resolve a path into a stable `FsTarget` (`inputPath`, opaque `targetKey`, `displayPath`). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. | +| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (`inputPath`, 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). | diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index e4aef709d0..e3a8a36b66 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -165,8 +165,16 @@ export abstract class FileSystem extends Service { * 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. */ - abstract resolve(path: string): Promise + abstract resolve(path: string, opts?: { cwd?: string }): Promise /** Return target metadata, or `undefined` when the target does not exist. */ abstract stat(target: FsTarget, signal?: AbortSignal): Promise diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index bc38a5ee64..dacef45590 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -23,9 +23,9 @@ Field names are snake_case to match Claude Code and existing harness tool schema ## 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()`, then: +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 a contained `fs/observed`. (1 stat.) +- **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.) diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index efe2e5f53b..c24692b012 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -18,6 +18,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { FsEditOutcome } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' +import { sessionCwd } from './session-cwd.ts' /** Validated `edit` arguments after defaulting. */ interface EditInput { @@ -66,7 +67,8 @@ export function applyEditTool(ctx: Context): void { }, async execute(args, exec): Promise { const input = parseEditArgs(args) - const target = await ctx.fs.resolve(input.filePath) + 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. diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index b7e0d43772..5befd8be92 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -20,6 +20,7 @@ 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. */ export const READ_LIMIT = 2000 @@ -68,7 +69,8 @@ export function applyReadTool(ctx: Context): void { }, async execute(args, exec): Promise { const input = parseReadArgs(args) - const target = await ctx.fs.resolve(input.filePath) + 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 diff --git a/packages/fs/tool-fs/src/session-cwd.ts b/packages/fs/tool-fs/src/session-cwd.ts new file mode 100644 index 0000000000..b7774fb201 --- /dev/null +++ b/packages/fs/tool-fs/src/session-cwd.ts @@ -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 +} diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index ed9143f32a..8cb91ececd 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -17,6 +17,7 @@ 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 { 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 } { @@ -51,7 +52,8 @@ export function applyWriteTool(ctx: Context): void { }, async execute(args, exec): Promise { const input = parseWriteArgs(args) - const target = await ctx.fs.resolve(input.filePath) + 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) diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 238909ff98..e8019234f0 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -28,8 +28,10 @@ import * as ToolFs from '@deepseek-ai/dsh-tool-fs' let dir: string let ctx: Context let fiber: Awaited> -// A stable session object stands in for an agent session (the file-state owner). -const session = {} +// 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) { @@ -287,3 +289,52 @@ describe('bare provider (no dsh-fs-policy)', () => { 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') + }) +}) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 4a161a272b..fb76f9215f 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -174,7 +174,7 @@ describe('read tool', () => { it('records observed state so a follow-up edit by the same session is authorized', async () => { const { ctx, fs } = await setup() - const session = {} + 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 }) @@ -259,7 +259,7 @@ describe('formatReadOutput footer variants', () => { 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: {} }) + 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' }]) @@ -284,7 +284,7 @@ describe('write tool', () => { describe('edit tool', () => { it('formats a single-replacement success after a read', async () => { const { ctx, fs } = await setup() - const session = {} + 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 }) @@ -315,7 +315,7 @@ describe('edit tool', () => { 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: {} }) + 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' }) }) From bd7fb31ae36f87d9922749687a1d549889a6cca2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:36:17 +0800 Subject: [PATCH 42/46] feat(tool-fs): editor-facing presentation for read/write/edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fs tools rendered as generic cards (title = tool name, raw file content) in an ACP editor. Give them tool-owned presentation like bash/subagent have: - read → title "Read ", kind read, offset/limit as rawInput - write → title "Write ", kind edit - edit → title "Edit ", kind edit, a clipped old→new rawInput summary Add a provider-neutral `locations: { path, line? }[]` to ToolCallPresentation — the files a call reads/modifies — so a capable editor can follow along / jump to the file (read carries its offset as the line). The ACP bridge forwards it onto the wire `tool_call` (ResolvedCallPresentation + call() + the tool_call build in streamSessionEventUpdate). This flips the `locations` cell in the ACP feature matrix to supported. The SDK already carries `tool_call.locations` (ToolCallLocation `{ path, line? }`), so no ACP types leak into dsh-tools. presentResult is intentionally omitted: it only receives `{ content, isError }`, not the write/edit outcome, so titling by create-vs-overwrite or replacement count would mean parsing the model-facing text — the static title stays. Tests: pure presentCall assertions for all three tools incl. locations and the edit rawInput clip; a bridge test drives the REAL fs tools through ToolPresenter and asserts locations reaches the wire tool_call (proven to fail without the forwarding line). New withFs harness option + dsh-fs devDeps on dsh-acp. --- docs/cordis-catalog/events-and-services.md | 2 +- docs/core-data-structures/tools.md | 2 +- packages/core/tools/README.md | 2 +- packages/core/tools/src/index.ts | 10 +++++ packages/fs/tool-fs/src/edit.ts | 13 +++++++ packages/fs/tool-fs/src/read.ts | 15 ++++++++ packages/fs/tool-fs/src/write.ts | 7 ++++ packages/fs/tool-fs/tests/tools.spec.ts | 40 ++++++++++++++++++++ packages/ui/acp/README.md | 4 +- packages/ui/acp/acp-feature-support.md | 4 +- packages/ui/acp/package.json | 3 ++ packages/ui/acp/src/index.ts | 4 ++ packages/ui/acp/tests/harness.ts | 17 +++++++++ packages/ui/acp/tests/stream-update.spec.ts | 41 ++++++++++++++++++++- pnpm-lock.yaml | 9 +++++ 15 files changed, 164 insertions(+), 9 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index c0fb5bd86b..22b6dc791b 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -547,7 +547,7 @@ async execute(exec: ToolExecution): Promise Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:277`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:287`](../../packages/core/tools/src/index.ts) ## Inherited tier (cordis core + loader/hmr/timer) diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index f255fdbc32..1c1744c45c 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -105,7 +105,7 @@ A waterfall listener receives `(exec, next)`: call `next()` to proceed (possibly ## Tool-presentation UI vocabulary -How a tool wants its call shown in a UI (an editor tool-call card, a CLI log line), provider-neutral so a tool describes itself without depending on any client protocol. `presentCall` returns a `ToolCallPresentation` (pending state: `title`, `kind`, `rawInput`, `content`, optional `terminal`); `presentResult` returns a `ToolResultPresentation` (completed state: replacement `title`, reformatted `content`, terminal `output`/exit). `ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon. A `ToolTerminal` asks a capable UI to render the call as a terminal card (cwd header, output, exit-status pill). +How a tool wants its call shown in a UI (an editor tool-call card, a CLI log line), provider-neutral so a tool describes itself without depending on any client protocol. `presentCall` returns a `ToolCallPresentation` (pending state: `title`, `kind`, `rawInput`, `content`, `locations` — `{ path, line? }[]` files the call reads/modifies, for editor follow-along — and optional `terminal`); `presentResult` returns a `ToolResultPresentation` (completed state: replacement `title`, reformatted `content`, terminal `output`/exit). `ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon. A `ToolTerminal` asks a capable UI to render the call as a terminal card (cwd header, output, exit-status pill). > These shapes carry a `FIXME(tool-presentation)` in source: they grew incrementally and the call-vs-result terminal split is muddy. Before more tools/UIs depend on them, they will be redesigned (a tagged union over card kinds) and pinned in an RFC, migrating `dsh-tool-bash` and the ACP bridge together. Treat the field-level shapes here as provisional; the source is authoritative. diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 102645ad82..01a2e09d3b 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -72,7 +72,7 @@ See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, an 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: -- `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`). +- `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), an optional `locations` (`{ path, line? }[]` — the files this call reads/modifies, so a capable UI can follow along / jump to them; the ACP bridge forwards them as `tool_call.locations`), 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`. 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` 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. diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 5a17aa2b0c..c2f324e87b 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -109,6 +109,16 @@ export interface ToolCallPresentation { * {@link terminal} block (if any) as a terminal card. */ content?: ContentBlock[] + /** + * Files this call reads or modifies, so a capable UI can "follow along" — + * highlight or jump to the file (and line) as the tool runs. Provider-neutral + * `{ path, line? }` pairs; a UI bridge maps them to its own affordance (the ACP + * bridge forwards them 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). Omit for a call that touches no file (e.g. + * `bash`). + */ + locations?: { path: string; line?: number }[] /** * 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 diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index c24692b012..1fb76ffd3c 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -83,5 +83,18 @@ export function applyEditTool(ctx: Context): void { ctx.emit('fs/observed', target, outcome.version, exec) return [{ type: 'text', text: formatEditOutput(target.displayPath, outcome) }] }, + // Pure display: `edit` kind, a location for editor follow-along, and a short + // old→new summary as rawInput (truncated so a large replacement stays a + // readable card). The replacement COUNT is not available here — presentResult + // only sees `{ content, isError }`, not the outcome — so the title is static. + presentCall(args) { + const clip = (s: string): string => (s.length > 40 ? `${s.slice(0, 40)}…` : s) + return { + title: `Edit ${args.file_path}`, + kind: 'edit', + rawInput: `${JSON.stringify(clip(args.old_string))} → ${JSON.stringify(clip(args.new_string))}`, + locations: [{ path: args.file_path }], + } + }, })) } diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 5befd8be92..17fa7aa7ab 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -101,5 +101,20 @@ export function applyReadTool(ctx: Context): void { ctx.emit('fs/observed', target, info.version, exec) return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }] }, + // Pure display: a UI card titled by the file, `read` kind (icon), and a + // location so an editor can follow along to the file (and the read's offset + // line). `rawInput` surfaces offset/limit when the model narrowed the read. + presentCall(args) { + const detail = [ + ...args.offset !== undefined ? [`offset ${args.offset}`] : [], + ...args.limit !== undefined ? [`limit ${args.limit}`] : [], + ].join(', ') + return { + title: `Read ${args.file_path}`, + kind: 'read', + locations: [{ path: args.file_path, ...args.offset !== undefined ? { line: args.offset } : {} }], + ...detail.length > 0 ? { rawInput: detail } : {}, + } + }, })) } diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 8cb91ececd..97098bd78d 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -62,5 +62,12 @@ export function applyWriteTool(ctx: Context): void { ctx.emit('fs/observed', target, outcome.version, exec) return [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }] }, + // Pure display: `edit` kind (an editor treats create/replace as an edit) and + // a location so the UI can follow along to the written file. The create-vs- + // overwrite fact lives in the model-facing result text; `presentResult` only + // sees `{ content, isError }` (not the outcome), so the title stays static. + presentCall(args) { + return { title: `Write ${args.file_path}`, kind: 'edit', locations: [{ path: args.file_path }] } + }, })) } diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index fb76f9215f..364d944b02 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -320,3 +320,43 @@ describe('edit tool', () => { 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: titles by file, read kind, location with the offset line', async () => { + expect(await presentCall('read', { file_path: 'src/a.ts', offset: 12, limit: 40 })).toEqual({ + title: 'Read src/a.ts', kind: 'read', rawInput: 'offset 12, limit 40', + locations: [{ path: 'src/a.ts', line: 12 }], + }) + }) + + it('read: omits rawInput and the location line when offset/limit are unset', async () => { + expect(await presentCall('read', { file_path: 'a.txt' })).toEqual({ + title: 'Read a.txt', kind: 'read', locations: [{ path: 'a.txt' }], + }) + }) + + it('write: titles by file, edit kind, location', async () => { + expect(await presentCall('write', { file_path: 'out.txt', content: 'x' })).toEqual({ + title: 'Write out.txt', kind: 'edit', locations: [{ path: 'out.txt' }], + }) + }) + + it('edit: titles by file, edit kind, an old→new rawInput summary, location', async () => { + expect(await presentCall('edit', { file_path: 'a.txt', old_string: 'foo', new_string: 'bar' })).toEqual({ + title: 'Edit a.txt', kind: 'edit', rawInput: '"foo" → "bar"', locations: [{ path: 'a.txt' }], + }) + }) + + it('edit: clips a long old/new string in the rawInput summary', async () => { + const long = 'a'.repeat(60) + const p = await presentCall('edit', { file_path: 'a.txt', old_string: long, new_string: 'b' }) + expect((p as { rawInput: string }).rawInput).toBe(`${JSON.stringify(`${'a'.repeat(40)}…`)} → ${JSON.stringify('b')}`) + }) +}) diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index ad50383542..8d0444de70 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -28,7 +28,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline ` | `session/load` | `ctx.agents.resume(...)` | replays the persisted event log to the client as `session/update` — the USER side (`user/message` → `user_message_chunk`), assistant text/reasoning (`assistant/chunk`), and tool calls/results (`tool/call` + `tool/result`). Re-loading an already-live id is rejected; the id's load slot is reserved (`loadingIds`) BEFORE the async resume so a pipelined load of the SAME id can't leak a second agent (distinct ids load concurrently). The resumed session keeps its PERSISTED header `cwd`, so its bash tools run in the original workspace; the requested `cwd` must be absolute and match the persisted `cwd`. After the async resume a `closed` re-check refuses to install a record if the bridge tore down mid-load | | `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) | | `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) | -| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (title/kind/rawInput/content owned by the TOOL via `presentCall`/`presentResult` — see Tool-call presentation) | +| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (title/kind/rawInput/content/locations owned by the TOOL via `presentCall`/`presentResult` — see Tool-call presentation) | ## Multi-session @@ -42,7 +42,7 @@ Each session runs in its own workspace, recorded as the session's `SessionHeader ## Tool-call presentation -How a tool call renders in the editor is owned by the TOOL, not the bridge — the bridge never special-cases tool names. Each tool may declare `presentCall(args)` (pending state: a human-readable `title`, a `kind` for the icon, the salient `rawInput` to show in a detail view, and optional `content` blocks shown alongside) and `presentResult(args, result)` (completed state: an optional replacement `title` and reformatted `content`) on its `dsh-tools` definition. The bridge looks the definition up by name in `ctx.tools` and maps the neutral `ToolCallPresentation`/`ToolResultPresentation` to the ACP `tool_call`/`tool_call_update` wire shapes. A tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind inferred from the name). For example `dsh-tool-bash` sets the title to the exact `command` ("ls -la src"), `kind: 'execute'`, the `command` as `rawInput`, the model `description` as a `content` text block, and wraps the completed output in a fenced ` ```console ` block. (The command is the title because an editor hides `rawInput` for execute-kind cards — Zed renders it only for non-terminal tools — and the reference adapters likewise use the command as an execute tool's title.) +How a tool call renders in the editor is owned by the TOOL, not the bridge — the bridge never special-cases tool names. Each tool may declare `presentCall(args)` (pending state: a human-readable `title`, a `kind` for the icon, the salient `rawInput` to show in a detail view, optional `content` blocks shown alongside, and optional `locations` — `{ path, line? }[]` files the call reads/modifies, forwarded as `tool_call.locations` so an editor can follow along) and `presentResult(args, result)` (completed state: an optional replacement `title` and reformatted `content`) on its `dsh-tools` definition. The bridge looks the definition up by name in `ctx.tools` and maps the neutral `ToolCallPresentation`/`ToolResultPresentation` to the ACP `tool_call`/`tool_call_update` wire shapes. A tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind inferred from the name). For example `dsh-tool-bash` sets the title to the exact `command` ("ls -la src"), `kind: 'execute'`, the `command` as `rawInput`, the model `description` as a `content` text block, and wraps the completed output in a fenced ` ```console ` block; the `dsh-tool-fs` `read`/`write`/`edit` tools set a `Read/Write/Edit ` title, a `read`/`edit` kind, and a `locations` entry for the file. (The command is the title because an editor hides `rawInput` for execute-kind cards — Zed renders it only for non-terminal tools — and the reference adapters likewise use the command as an execute tool's title.) The `tool/result` session event carries only `{ callId, content, isError }` — not the tool name or args — so to call a tool's `presentResult` the bridge keeps a small per-session map from `callId` to the in-flight call's `(name, args)`, populated on `tool/call` and removed as each result is presented (it holds only currently-in-flight calls, never finished ones). This is bridge-local state — NOT a change to the event schema or a core service. The map lives on the `SessionRecord`, so two concurrent sessions never cross their in-flight tool state; a `session/load` replay uses a throwaway presenter that pairs each `tool/call` with its `tool/result` as the log replays in order, so replayed tool cards render identically to live ones. diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index 429c862b27..c39b518aba 100644 --- a/packages/ui/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -101,7 +101,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult | `content` blocks | S | ✅ | ✅ | ✅ | Text content; the description renders above the card. | | `diff` content | S | ❌ | ✅ | ✅ | No structured diff rendering for edits (would need a diffing edit tool + presenter). | | `terminal` content | S | ✅ | ✅ | ✅ | Via the Zed `_meta` terminal convention (see below), not the spec `terminal/*` sub-protocol. | -| `locations` (follow-along) | S | ❌ | ✅ | ✅ | No file-location hints emitted. | +| `locations` (follow-along) | S | ✅ | ✅ | ✅ | The `read`/`write`/`edit` tools emit `{ path, line? }` file-location hints via `presentCall`. | | `rawInput` | S | ✅ | ⚠️ | ✅ | Parsed tool args surfaced as `rawInput`. | | `rawOutput` | S | ❌ | ⚠️ | ✅ | Not emitted. | @@ -147,7 +147,7 @@ Ranked by how commonly the reference adapters ship them and how much UX they unl 5. **Slash commands** (`available_commands_update`). 6. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`). 7. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path). -8. **Diff + location tool rendering** — `diff` content and `locations` for edit tools. +8. **Diff tool rendering** — structured `diff` content for edit tools (the `locations` follow-along hint already ships on `read`/`write`/`edit`). 9. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`). 10. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access. diff --git a/packages/ui/acp/package.json b/packages/ui/acp/package.json index 52080be092..b4c27f25e2 100644 --- a/packages/ui/acp/package.json +++ b/packages/ui/acp/package.json @@ -38,12 +38,15 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.6" diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 754b1750be..255ca9c990 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -836,6 +836,7 @@ export function streamSessionEventUpdate( kind: present.kind, status: 'in_progress', ...present.rawInput !== undefined ? { rawInput: present.rawInput } : {}, + ...present.locations !== undefined ? { locations: present.locations } : {}, ...callContent.length > 0 ? { content: callContent } : {}, ...asTerminal ? { _meta: { terminal_info: { terminal_id: event.data.callId, cwd: terminalCwd(present.terminal, terminal.cwd) } } } @@ -926,6 +927,8 @@ interface ResolvedCallPresentation { rawInput?: unknown /** UI content shown on the pending call (e.g. a bash description text block above the card). */ content?: ContentBlock[] + /** Files this call reads/modifies (mapped to ACP `tool_call.locations`), for editor follow-along. */ + locations?: { path: string; line?: number }[] /** Tool's request to render as a terminal (the pending side carries the cwd). */ terminal?: ToolTerminal } @@ -1005,6 +1008,7 @@ export class ToolPresenter { kind: present.kind ?? 'other', rawInput: present.rawInput, ...present.content !== undefined ? { content: present.content } : {}, + ...present.locations !== undefined ? { locations: present.locations } : {}, ...present.terminal !== undefined ? { terminal: present.terminal } : {}, } } diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts index 9077d106d0..664ffbea5a 100644 --- a/packages/ui/acp/tests/harness.ts +++ b/packages/ui/acp/tests/harness.ts @@ -19,7 +19,10 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import { ClientSideConnection, @@ -165,6 +168,15 @@ export async function makeBridgeHarness(options: { * tool + the bridge's own todo/write→plan mapping, not a stand-in. */ withTodo?: boolean + /** + * Plug the REAL filesystem stack (`dsh-fs-local` + `dsh-fs-policy` + + * `dsh-tool-fs`) so a test can drive `read`/`write`/`edit` through the bridge + * and assert their tool-owned presentation (title/kind/`locations`) on the + * wire — the shipping tools, not a stand-in. `fsCwd` sets the local backend's + * base directory (default: `storageDir`). + */ + withFs?: boolean + fsCwd?: string } = { storageDir: '' }): Promise { const adapter = new MockAdapter(options.script ?? []) @@ -183,6 +195,11 @@ export async function makeBridgeHarness(options: { if (options.withTodo) { await ctx.plugin(ToolTodo) } + if (options.withFs) { + await ctx.plugin(LocalFileSystem, { cwd: options.fsCwd ?? options.storageDir }) + await ctx.plugin(FsPolicy) + await ctx.plugin(ToolFs) + } ctx.llm.registerAdapter(['mock'], adapter) // Two identity byte pipes cross-wired into the two ndJsonStreams: bytes the diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index 89d68fd1c0..9e1578c8c1 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -1,8 +1,13 @@ import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionNotification } from '@agentclientprotocol/sdk' -import type { ToolDefinition, ToolRegistry } from '@deepseek-ai/dsh-tools' +import type { ToolDefinition, ToolRegistry as ToolRegistryType } from '@deepseek-ai/dsh-tools' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import FsLocal from '@deepseek-ai/dsh-fs-local' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { streamSessionEventUpdate, agentOptions, todosToPlan, ToolPresenter } from '../src/index.ts' /** Collect the updates a single event produces (no presenter → generic fallback). */ @@ -20,7 +25,7 @@ function liveUpdatesFor(event: SessionEvent): SessionNotification['update'][] { } /** A tiny tool registry stub exposing just `get` for {@link ToolPresenter}. */ -function registryOf(...tools: ToolDefinition[]): Pick { +function registryOf(...tools: ToolDefinition[]): Pick { const map = new Map(tools.map(t => [t.name, t])) return { get: name => map.get(name) } } @@ -330,6 +335,38 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () => expect(updates[0]).toMatchObject({ sessionUpdate: 'tool_call', title: 'boom' }) expect(updates[1]).toMatchObject({ sessionUpdate: 'tool_call_update', content: [{ type: 'content', content: { type: 'text', text: 'raw' } }] }) }) + + it('forwards a tool-owned `locations` onto the wire tool_call (REAL fs read/edit tools)', async () => { + // Use the SHIPPING fs tools (not a stand-in), booted through their real + // plugins, so the wire tool_call carries the actual presentCall output — + // including `locations` for editor follow-along. (AGENTS.md "prefer the real + // implementation over a mock".) + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FsLocal) + await ctx.plugin(ToolFs) + const presenter = new ToolPresenter(ctx.tools) + + const [readCall] = updatesWith(presenter, evt('tool/call', { + turn: 1, step: 1, callId: CallId('r1'), name: 'read', + arguments: JSON.stringify({ file_path: 'src/a.ts', offset: 12 }), + })) + expect(readCall).toMatchObject({ + sessionUpdate: 'tool_call', toolCallId: 'r1', title: 'Read src/a.ts', kind: 'read', + rawInput: 'offset 12', locations: [{ path: 'src/a.ts', line: 12 }], + }) + + const [editCall] = updatesWith(presenter, evt('tool/call', { + turn: 1, step: 1, callId: CallId('e1'), name: 'edit', + arguments: JSON.stringify({ file_path: 'src/b.ts', old_string: 'x', new_string: 'y' }), + })) + expect(editCall).toMatchObject({ + sessionUpdate: 'tool_call', toolCallId: 'e1', title: 'Edit src/b.ts', kind: 'edit', + locations: [{ path: 'src/b.ts' }], + }) + await ctx.fiber.dispose() + }) }) describe('terminal-card mapping (capability-gated)', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58ea6d9bfe..380cfb3b4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -739,6 +739,12 @@ importers: '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../bash/bash-local + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../fs/fs-local + '@deepseek-ai/dsh-fs-policy': + specifier: workspace:^ + version: link:../../fs/fs-policy '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -757,6 +763,9 @@ importers: '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../bash/tool-bash + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:^ + version: link:../../fs/tool-fs '@deepseek-ai/dsh-tool-todo': specifier: workspace:^ version: link:../../todo/tool-todo From a334395f0c004f9c0999ac0b0676244c3dbac5ce Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:46:59 +0800 Subject: [PATCH 43/46] feat(coding-agent): wire the filesystem tools into the demo Load dsh-fs-local + dsh-fs-policy + dsh-tool-fs after tool-todo (mirroring the acp-agent wiring), and steer the system prompt to prefer read/write/edit for file ops with bash for shell/tests/search. Update the welcome line and the FIXME(config-comments) bash note. Doc sweep now that both demos ship the fs tools and the seam resolves per-session cwd: architecture.md and the event-gate RFC no longer say the demos do file ops through bash / that no config wires the tools; the coding-agent + examples READMEs and the AGENTS.md layout blurb list the fs tools; the acp-agent README drops the launch-dir caveat (per-session cwd now works, so the server can launch anywhere). (stdio-agent is single-session, so fs-local's cwd = process.cwd() is the workspace. Keyless boot smoke is blocked locally by an unrelated inotify watcher-limit ENOSPC that also hits demo:echo; the config parses and the same fs stack boots green in the acp-agent snapshot tier.) --- AGENTS.md | 9 +++--- docs/architecture.md | 2 +- .../2026-06-26-file-context-as-event-gate.md | 2 +- examples/README.md | 2 +- examples/acp-agent/README.md | 2 +- examples/coding-agent/README.md | 5 +-- examples/coding-agent/cordis.yml | 31 ++++++++++++++----- 7 files changed, 35 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d53750222e..4bdeb727fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,10 +115,11 @@ examples/ Runnable demos (not workspaces; see examples/AGENTS.md). Each is a teaching plugins. The app package bundles the agent-core spine + front-door cluster + boot glue (a bin). No start.ts. echo-agent = mock model + echo tool on dsh-stdio-agent (pnpm run demo:echo, no - key). coding-agent = the real thing: DeepSeek V4 + bash tools + - subagent + todo_write on the same app (pnpm run demo:coding, needs - DEEPSEEK_API_KEY). acp-agent = the coding agent as an ACP server on - dsh-acp-agent (pnpm run demo:acp, needs DEEPSEEK_API_KEY). + key). coding-agent = the real thing: DeepSeek V4 + fs tools + (read/write/edit) + bash tools + subagent + todo_write on the same + app (pnpm run demo:coding, needs DEEPSEEK_API_KEY). acp-agent = the + coding agent as an ACP server on dsh-acp-agent (pnpm run demo:acp, + needs DEEPSEEK_API_KEY). cordis.snapshot.yml = the acp leaf with llm-replay for keyless snapshot replay. docs/ architecture.md — the design doc. module-graph.md — generated diff --git a/docs/architecture.md b/docs/architecture.md index eddb1ea235..1f8901b7f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,7 +78,7 @@ Swappable capabilities are split into **three packages** so each part evolves in The LLM seam has the same topology folded differently: `dsh-llm` carries the interface (`LlmAdapter`) AND the consumer surface (`ctx.llm.stream()`), with adapters as implementation packages — there the consumer is the loop itself, not a swappable schema surface. Use the full three-package split when the consumer is independently replaceable; keep interface + consumer together when they are one concern. Don't split preemptively: a capability with one conceivable implementation and one consumer stays one package until proven otherwise. -The filesystem capability follows the bash topology with a fourth layer, but the policy is contributed through an **event gate**, not a method service: `dsh-fs` owns the abstract `ctx.fs` provider seam (text IO + atomic mutation primitives whose version guard is optional) and the `fs/*` policy event vocabulary, `dsh-fs-local` provides the local backend, `dsh-tool-fs` is the model-facing `read`/`write`/`edit` tools AND the executor (it reads/writes/edits through `ctx.fs` directly, owns read windowing, dispatches the `fs/*` events), and `dsh-fs-policy` is a policy PLUGIN (no service) that decides the `fs/write-intent`/`fs/edit-intent` waterfalls and records on `fs/observed` to add observed-state + read-before-edit + version-guarded write/edit. Because the tool is not method-coupled to the policy, dropping `dsh-fs-policy` gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool at a service-injection boundary. The fs tools are not wired into any default/example config yet (the demo agents do file ops through bash); a deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit. See [the fs-policy event-gate RFC](rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md). +The filesystem capability follows the bash topology with a fourth layer, but the policy is contributed through an **event gate**, not a method service: `dsh-fs` owns the abstract `ctx.fs` provider seam (text IO + atomic mutation primitives whose version guard is optional) and the `fs/*` policy event vocabulary, `dsh-fs-local` provides the local backend, `dsh-tool-fs` is the model-facing `read`/`write`/`edit` tools AND the executor (it reads/writes/edits through `ctx.fs` directly, owns read windowing, dispatches the `fs/*` events), and `dsh-fs-policy` is a policy PLUGIN (no service) that decides the `fs/write-intent`/`fs/edit-intent` waterfalls and records on `fs/observed` to add observed-state + read-before-edit + version-guarded write/edit. Because the tool is not method-coupled to the policy, dropping `dsh-fs-policy` gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool at a service-injection boundary. The demo agents (`coding-agent`, `acp-agent`) wire the full stack — `dsh-fs-local` + `dsh-fs-policy` + `dsh-tool-fs` — so `read`/`write`/`edit` are the default file surface (bash stays for shell/tests/search); the tools resolve a relative path against the caller's session cwd, matching bash ([the per-session cwd RFC](rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)). See [the fs-policy event-gate RFC](rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md). > **"Capability" — two unrelated meanings.** (1) The *seam pattern* above ("one plugin provides a capability, another needs it") is realized by plain Cordis **services + `inject`**: a provider registers a service (`ctx.bash`, declared in `interface Context`); a consumer declares `inject: ['bash']` and its fiber stays pending until the service exists, tearing down via HMR if it later vanishes. No extra library is needed. (2) `@cordisjs/plugin-capability` is a different axis entirely — a **permission/capability-security** service (named permissions with inheritance/dependency, tested against a session via `ctx.capability.test`). It is a candidate for the deferred permissions/sandbox work (the `tools/execute` veto seam), NOT a mechanism for swapping implementations. diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md index 3d5e67ce20..9f7b8a48c6 100644 --- a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -29,7 +29,7 @@ provider seam dsh-fs ctx.fs: text IO + ATOMIC mutation primitives who provider dsh-fs-local local implementation of ctx.fs ``` -The model is **additive, 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. There is no "先读后写", no version check, nothing to remove; the bare provider just does the I/O atomically. `dsh-fs-policy` is a plugin that *adds* constraints on top: observed-state, read-before-edit, and "write/edit must be based on the version you read". So removing `dsh-fs-policy` does not break `dsh-tool-fs` at the service-injection boundary; it removes the policy gate and leaves the bare provider behavior. The intended deployment stance is that a config loading the fs tools also loads `dsh-fs-policy`, so the user-facing behavior and prompt discipline are read-before-write/edit (no default/example config wires the fs tools yet — the demo agents do file ops through bash). The bare-provider mode exists because the tool should not be method-coupled to the policy plugin, not because an unconstrained filesystem is the normal product stance. +The model is **additive, 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. There is no "先读后写", no version check, nothing to remove; the bare provider just does the I/O atomically. `dsh-fs-policy` is a plugin that *adds* constraints on top: observed-state, read-before-edit, and "write/edit must be based on the version you read". So removing `dsh-fs-policy` does not break `dsh-tool-fs` at the service-injection boundary; it removes the policy gate and leaves the bare provider behavior. The intended deployment stance is that a config loading the fs tools also loads `dsh-fs-policy`, so the user-facing behavior and prompt discipline are read-before-write/edit (the `coding-agent` and `acp-agent` demos wire the full stack). The bare-provider mode exists because the tool should not be method-coupled to the policy plugin, not because an unconstrained filesystem is the normal product stance. `dsh-tool-fs` no longer injects `fileContext`. It injects `fs` and `tools`/`systemPrompt`. diff --git a/examples/README.md b/examples/README.md index 887bc18beb..a95814bbbd 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,7 +15,7 @@ Run with: `pnpm run demo:echo`. When prompted, type "echo " to trigge ## coding-agent -The real thing: DeepSeek V4 + the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the same `@deepseek-ai/dsh-stdio-agent` app. Where echo-agent proves the skeleton with mocks, this is a usable coding assistant. +The real thing: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the same `@deepseek-ai/dsh-stdio-agent` app. Where echo-agent proves the skeleton with mocks, this is a usable coding assistant. Run with: `pnpm run demo:coding` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details. diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 2ca4de690b..e9a38f2313 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -28,7 +28,7 @@ Add to your Zed `settings.json` under `agent_servers`: } ``` -The editor sets each session's `cwd` to the project it opens; the agent's bash tools run there (see the per-session `cwd` note in `packages/ui/acp`). The filesystem tools in this demo use the local filesystem backend and resolve relative paths from the server launch directory, so launch the server from the harness repo with `pnpm --dir …` when using `read`/`write`/`edit` against this checkout. +The editor sets each session's `cwd` to the project it opens; both the agent's bash tools and the `read`/`write`/`edit` filesystem tools resolve relative paths against that per-session workspace (see the per-session `cwd` note in `packages/ui/acp` and [the per-session cwd RFC](../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), so the server can be launched anywhere and each session still acts on its own project directory. ## Snapshot tests (record-once / replay-deterministic) diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index 6a156dcc48..d8b764483d 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -1,6 +1,6 @@ # coding-agent -The real stdio coding-agent wiring: DeepSeek V4 + the bash tool suite + subagent delegation + `todo_write` + stdio chat + JSONL persistence, loaded from `cordis.yml`. Where echo-agent proves the skeleton with mocks, this example is a usable coding assistant. +The real stdio coding-agent wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + `todo_write` + stdio chat + JSONL persistence, loaded from `cordis.yml`. Where echo-agent proves the skeleton with mocks, this example is a usable coding assistant. ## Run it @@ -11,7 +11,7 @@ The real stdio coding-agent wiring: DeepSeek V4 + the bash tool suite + subagent pnpm run demo:coding ``` -Type a coding task. The agent works through `bash` (+ `bash_output` / `bash_kill` for background tasks): file reads, writes, searches, and test runs all happen through shell commands, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). It can also delegate with `subagent`/`subagent_fork` and track multi-step work with `todo_write` (a whole-list task tracker rendered as a checklist). Reasoning streams dimmed; tool calls/results render inline. +Type a coding task. The agent works through the `read`/`write`/`edit` filesystem tools for ordinary file operations and `bash` (+ `bash_output` / `bash_kill` for background tasks) for shell commands, searches, and test runs, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Both the fs tools and bash resolve relative paths against the session workspace. It can also delegate with `subagent`/`subagent_fork` and track multi-step work with `todo_write` (a whole-list task tracker rendered as a checklist). Reasoning streams dimmed; tool calls/results render inline. ``` > fix the failing test in /path/to/project @@ -44,6 +44,7 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads | `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix | | `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) | | `tool-todo` | the model-facing `todo_write` tool; writes the whole task list to the session log and renders as a checklist in stdio | +| `fs-local`, `fs-policy`, `tool-fs` | the filesystem stack: the local `ctx.fs` provider, the read-before-write/edit policy gate (on the `fs/*` event gate), and the model-facing `read`/`write`/`edit` tools. Relative paths resolve against the session workspace | ## End-to-end tests (`pnpm run test:e2e`, key-gated) diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index bc3c3f2ff7..625fa0c9b9 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -30,7 +30,7 @@ # Local bash executor for agent-core's tool-bash schema. # FIXME(config-comments): keep this executor note from implying bash is the -# whole tool set; subagent and todo_write are loaded below. +# whole tool set; filesystem, subagent, and todo_write are loaded below. - id: bash name: '@deepseek-ai/dsh-bash-local' config: @@ -46,16 +46,17 @@ # under ./.sessions); unset starts a fresh session each run. resumeSessionId: !!js process.env.RESUME_SESSION_ID persistenceRoot: './.sessions' - welcome: 'coding-agent ready. Give it a coding task (its tools are bash, subagent, and todo_write).' + welcome: 'coding-agent ready. Give it a coding task (its tools are read, write, edit, bash, subagent, and todo_write).' systemPrompt: | You are coding-agent, a CLI coding assistant. - Your tools are bash (plus bash_output/bash_kill for background - tasks) and subagent. Do ALL file operations through bash: read with - cat/sed/head, search with grep, write with heredocs (cat <<'EOF' > - file), edit with sed or a rewrite. Each bash call runs in a fresh - shell — pass workdir instead of cd, and never rely on shell state - between calls. + Your tools are read/write/edit for file operations, bash (plus + bash_output/bash_kill for background tasks), and subagent. Use read to + inspect UTF-8 text files, write to create or replace files, and edit for + targeted literal replacements. Use bash for shell commands, tests, + searches, and operations that are not ordinary file reads or edits. Each + bash call runs in a fresh shell — pass workdir instead of cd, and never + rely on shell state between calls. Use the subagent tool to delegate a focused, self-contained subtask to a fresh child agent (it works in its own context and returns only @@ -123,3 +124,17 @@ # session log (todo/write), rendered as a stdio checklist / ACP plan. - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' + +# Filesystem capability stack: local provider, read-before-write/edit policy +# gate, then the model-facing read/write/edit tools. stdio-agent is a single +# session, so relative paths resolve from the process cwd (the workspace). +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' From 94cbec816259db02b28d7940ff528dfcb4fc4216 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:09:09 +0800 Subject: [PATCH 44/46] test(acp): snapshot scenarios for the filesystem tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five recorded ACP snapshot scenarios exercising read/write/edit end-to-end through the real acp-agent subprocess, replayed keyless in CI: - fs-read — read a seeded file (read tool + presentation + observed-state) - fs-write — create a file (write, no prior version guard) - fs-edit — read then literal-replace (read-before-edit authorization) - fs-write-overwrite — read then rewrite (replaceIfVersion after a read) - fs-read-window — read lines 5-8 with offset/limit (windowing + the offset surfaced as the tool_call location line) The goldens confirm the tools render with their new presentation — Read/Write/ Edit titles, read/edit kinds, and `locations` (fs-read-window carries `{path, line:5}`) — and that the prompts steered the model to the fs tools, not bash (zero bash calls in any golden). Recorded against the real API, filtered to the new scenarios so no existing fixture churned. --- examples/acp-agent/tests/acp.snapshot.ts | 5 + .../tests/snapshots/fs-edit/input.json | 7 + .../tests/snapshots/fs-edit/session.jsonl | 147 ++++++++++++++++++ .../snapshots/fs-edit/stdout.golden.jsonl | 76 +++++++++ .../snapshots/fs-edit/workspace/config.txt | 2 + .../tests/snapshots/fs-read-window/input.json | 7 + .../snapshots/fs-read-window/session.jsonl | 102 ++++++++++++ .../fs-read-window/stdout.golden.jsonl | 59 +++++++ .../fs-read-window/workspace/big.txt | 10 ++ .../tests/snapshots/fs-read/input.json | 7 + .../tests/snapshots/fs-read/session.jsonl | 93 +++++++++++ .../snapshots/fs-read/stdout.golden.jsonl | 61 ++++++++ .../snapshots/fs-read/workspace/greeting.txt | 1 + .../snapshots/fs-write-overwrite/input.json | 7 + .../fs-write-overwrite/session.jsonl | 132 ++++++++++++++++ .../fs-write-overwrite/stdout.golden.jsonl | 71 +++++++++ .../fs-write-overwrite/workspace/data.txt | 1 + .../tests/snapshots/fs-write/input.json | 7 + .../tests/snapshots/fs-write/session.jsonl | 95 +++++++++++ .../snapshots/fs-write/stdout.golden.jsonl | 55 +++++++ 20 files changed, 945 insertions(+) create mode 100644 examples/acp-agent/tests/snapshots/fs-edit/input.json create mode 100644 examples/acp-agent/tests/snapshots/fs-edit/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-edit/workspace/config.txt create mode 100644 examples/acp-agent/tests/snapshots/fs-read-window/input.json create mode 100644 examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-read-window/workspace/big.txt create mode 100644 examples/acp-agent/tests/snapshots/fs-read/input.json create mode 100644 examples/acp-agent/tests/snapshots/fs-read/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-read/workspace/greeting.txt create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite/input.json create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite/workspace/data.txt create mode 100644 examples/acp-agent/tests/snapshots/fs-write/input.json create mode 100644 examples/acp-agent/tests/snapshots/fs-write/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 94f18e64d3..7d19f44bb5 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -54,6 +54,11 @@ const SCENARIOS: Scenario[] = [ { name: 'tool-call-turn', hasModelTurn: true, recorded: true }, { name: 'todo-plan', hasModelTurn: true, recorded: true }, { name: 'workspace-edit', hasModelTurn: true, recorded: true }, + { name: 'fs-read', hasModelTurn: true, recorded: true }, + { name: 'fs-write', hasModelTurn: true, recorded: true }, + { name: 'fs-edit', hasModelTurn: true, recorded: true }, + { name: 'fs-write-overwrite', hasModelTurn: true, recorded: true }, + { name: 'fs-read-window', hasModelTurn: true, recorded: true }, { name: 'multi-turn', hasModelTurn: true, recorded: true }, { name: 'error-finish', hasModelTurn: true, recorded: false }, { name: 'cancel', hasModelTurn: true, recorded: false }, diff --git a/examples/acp-agent/tests/snapshots/fs-edit/input.json b/examples/acp-agent/tests/snapshots/fs-edit/input.json new file mode 100644 index 0000000000..1455aa373c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-edit/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "First use the read tool to read config.txt in the current directory. Then use the edit tool (NOT bash) to replace the literal text DEBUG with RELEASE in that file. Then reply with exactly the single word DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl new file mode 100644 index 0000000000..0e6b253480 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl @@ -0,0 +1,147 @@ +{"type":"session","version":0,"id":"2d43b6e7-859c-4e20-9145-3bcfe4c29836","createdAt":1782993777165,"cwd":"/tmp/acp-snap-cwd-yl8qhJ"} +{"type":"turn/start","seq":0,"time":1782993777170,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782993777170,"data":{"content":[{"type":"text","text":"First use the read tool to read config.txt in the current directory. Then use the edit tool (NOT bash) to replace the literal text DEBUG with RELEASE in that file. Then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1782993777171,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782993777573,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782993777573,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":5,"time":1782993777707,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":6,"time":1782993777734,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} +{"type":"assistant/chunk","seq":7,"time":1782993777734,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":8,"time":1782993777735,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} +{"type":"assistant/chunk","seq":9,"time":1782993777735,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":10,"time":1782993777735,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" config"}}} +{"type":"assistant/chunk","seq":11,"time":1782993777762,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":12,"time":1782993777762,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":13,"time":1782993777762,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":14,"time":1782993777762,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" see"}}} +{"type":"assistant/chunk","seq":15,"time":1782993777763,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" its"}}} +{"type":"assistant/chunk","seq":16,"time":1782993777763,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" contents"}}} +{"type":"assistant/chunk","seq":17,"time":1782993777789,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":18,"time":1782993777845,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":19,"time":1782993777846,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":20,"time":1782993777873,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":21,"time":1782993777873,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":22,"time":1782993777873,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":23,"time":1782993777873,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":24,"time":1782993777904,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":25,"time":1782993777904,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":26,"time":1782993777904,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":27,"time":1782993777904,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"config"}}} +{"type":"assistant/chunk","seq":28,"time":1782993777931,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":29,"time":1782993777931,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":30,"time":1782993777960,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":31,"time":1782993777989,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Let me start by reading the config.txt file to see its contents."}}}} +{"type":"assistant/chunk","seq":32,"time":1782993777989,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}}}} +{"type":"assistant/chunk","seq":33,"time":1782993777989,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":59,"cacheReadTokens":2176,"reasoningTokens":14}}}} +{"type":"assistant/chunk","seq":34,"time":1782993777989,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":35,"time":1782993777991,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Let me start by reading the config.txt file to see its contents."},{"type":"tool-call","id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}],"usage":{"inputTokens":123,"outputTokens":59,"cacheReadTokens":2176,"reasoningTokens":14}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"} +{"type":"tool/call","seq":36,"time":1782993777991,"data":{"turn":1,"step":1,"callId":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}} +{"type":"tool/result","seq":37,"time":1782993777996,"data":{"turn":1,"step":1,"callId":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","content":[{"type":"text","text":"/tmp/acp-snap-cwd-yl8qhJ/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}],"isError":false},"sourceEventSeqs":[36],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1782993777996,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":39,"time":1782993777996,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":40,"time":1782993778611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":41,"time":1782993778611,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":42,"time":1782993778711,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":43,"time":1782993778739,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}} +{"type":"assistant/chunk","seq":44,"time":1782993778739,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":45,"time":1782993778739,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"mode"}}} +{"type":"assistant/chunk","seq":46,"time":1782993778740,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"="}}} +{"type":"assistant/chunk","seq":47,"time":1782993778767,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"DEBUG"}}} +{"type":"assistant/chunk","seq":48,"time":1782993778767,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":49,"time":1782993778768,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" on"}}} +{"type":"assistant/chunk","seq":50,"time":1782993778768,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" line"}}} +{"type":"assistant/chunk","seq":51,"time":1782993778768,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":52,"time":1782993778768,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":53,"time":1782993778795,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":54,"time":1782993778795,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":55,"time":1782993778795,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":56,"time":1782993778796,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":57,"time":1782993778796,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":58,"time":1782993778796,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" replace"}}} +{"type":"assistant/chunk","seq":59,"time":1782993778822,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":60,"time":1782993778823,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" literal"}}} +{"type":"assistant/chunk","seq":61,"time":1782993778823,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" text"}}} +{"type":"assistant/chunk","seq":62,"time":1782993778851,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":63,"time":1782993778851,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"DEBUG"}}} +{"type":"assistant/chunk","seq":64,"time":1782993778851,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":65,"time":1782993778851,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":66,"time":1782993778851,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":67,"time":1782993778852,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"RE"}}} +{"type":"assistant/chunk","seq":68,"time":1782993778878,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"LEASE"}}} +{"type":"assistant/chunk","seq":69,"time":1782993778878,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":70,"time":1782993778878,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":71,"time":1782993778878,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":72,"time":1782993778878,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} +{"type":"assistant/chunk","seq":73,"time":1782993778878,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":74,"time":1782993778905,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":75,"time":1782993778962,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":76,"time":1782993778962,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":77,"time":1782993778989,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":78,"time":1782993778989,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":79,"time":1782993778989,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":80,"time":1782993778989,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":81,"time":1782993779022,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":82,"time":1782993779022,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":83,"time":1782993779022,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":84,"time":1782993779022,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"config"}}} +{"type":"assistant/chunk","seq":85,"time":1782993779044,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":86,"time":1782993779044,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":87,"time":1782993779073,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":88,"time":1782993779073,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":89,"time":1782993779073,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"old"}}} +{"type":"assistant/chunk","seq":90,"time":1782993779073,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"_string"}}} +{"type":"assistant/chunk","seq":91,"time":1782993779100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":92,"time":1782993779100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":93,"time":1782993779100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":94,"time":1782993779100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"DEBUG"}}} +{"type":"assistant/chunk","seq":95,"time":1782993779128,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":96,"time":1782993779156,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":97,"time":1782993779156,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":98,"time":1782993779157,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"new"}}} +{"type":"assistant/chunk","seq":99,"time":1782993779157,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"_string"}}} +{"type":"assistant/chunk","seq":100,"time":1782993779157,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":101,"time":1782993779186,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":102,"time":1782993779186,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":103,"time":1782993779186,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"RE"}}} +{"type":"assistant/chunk","seq":104,"time":1782993779186,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"LEASE"}}} +{"type":"assistant/chunk","seq":105,"time":1782993779213,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":106,"time":1782993779213,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":107,"time":1782993779276,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file contains \"mode=DEBUG\" on line 1. Now I need to replace the literal text \"DEBUG\" with \"RELEASE\" using the edit tool."}}}} +{"type":"assistant/chunk","seq":108,"time":1782993779276,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}}}} +{"type":"assistant/chunk","seq":109,"time":1782993779276,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":246,"outputTokens":114,"cacheReadTokens":2176,"reasoningTokens":34}}}} +{"type":"assistant/chunk","seq":110,"time":1782993779276,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":111,"time":1782993779276,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file contains \"mode=DEBUG\" on line 1. Now I need to replace the literal text \"DEBUG\" with \"RELEASE\" using the edit tool."},{"type":"tool-call","id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}],"usage":{"inputTokens":246,"outputTokens":114,"cacheReadTokens":2176,"reasoningTokens":34}},"sourceEventSeqs":[40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110],"surfaceOp":"append"} +{"type":"tool/call","seq":112,"time":1782993779276,"data":{"turn":1,"step":2,"callId":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}} +{"type":"tool/result","seq":113,"time":1782993779282,"data":{"turn":1,"step":2,"callId":"call_00_OwPhDMqS06VEbY7rO4Rv1204","content":[{"type":"text","text":"The file /tmp/acp-snap-cwd-yl8qhJ/config.txt has been updated successfully."}],"isError":false},"sourceEventSeqs":[112],"surfaceOp":"append"} +{"type":"step/end","seq":114,"time":1782993779282,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":115,"time":1782993779282,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":116,"time":1782993779871,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":117,"time":1782993779871,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":118,"time":1782993779945,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} +{"type":"assistant/chunk","seq":119,"time":1782993779978,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":120,"time":1782993779979,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" successful"}}} +{"type":"assistant/chunk","seq":121,"time":1782993779979,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":122,"time":1782993779979,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":123,"time":1782993779979,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":124,"time":1782993780001,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":125,"time":1782993780001,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":126,"time":1782993780001,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":127,"time":1782993780002,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":128,"time":1782993780002,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":129,"time":1782993780029,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":130,"time":1782993780030,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":131,"time":1782993780030,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":132,"time":1782993780030,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":133,"time":1782993780030,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":134,"time":1782993780030,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":135,"time":1782993780063,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":136,"time":1782993780063,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":137,"time":1782993780063,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":138,"time":1782993780063,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":139,"time":1782993780063,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The edit was successful. The user asked me to reply with exactly the single word DONE."}}}} +{"type":"assistant/chunk","seq":140,"time":1782993780064,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":141,"time":1782993780064,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":263,"outputTokens":22,"cacheReadTokens":2304,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":142,"time":1782993780064,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":143,"time":1782993780064,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The edit was successful. The user asked me to reply with exactly the single word DONE."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":263,"outputTokens":22,"cacheReadTokens":2304,"reasoningTokens":19}},"sourceEventSeqs":[116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142],"surfaceOp":"append"} +{"type":"step/end","seq":144,"time":1782993780064,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":145,"time":1782993780064,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl new file mode 100644 index 0000000000..8f5d02632d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl @@ -0,0 +1,76 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" start"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reading"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" config"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" see"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" its"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contents"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","title":"Read config.txt","kind":"read","status":"in_progress","locations":[{"path":"config.txt"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contains"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"mode"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"="}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"DEBUG"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" on"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" line"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" replace"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" literal"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" text"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"DEBUG"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"RE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LEASE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" edit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_OwPhDMqS06VEbY7rO4Rv1204","title":"Edit config.txt","kind":"edit","status":"in_progress","rawInput":"\"DEBUG\" → \"RELEASE\"","locations":[{"path":"config.txt"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_OwPhDMqS06VEbY7rO4Rv1204","status":"completed","content":[{"type":"content","content":{"type":"text","text":"The file {{cwd}}/config.txt has been updated successfully."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" edit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" successful"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/workspace/config.txt b/examples/acp-agent/tests/snapshots/fs-edit/workspace/config.txt new file mode 100644 index 0000000000..267876a5af --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-edit/workspace/config.txt @@ -0,0 +1,2 @@ +mode=DEBUG +level=info diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/input.json b/examples/acp-agent/tests/snapshots/fs-read-window/input.json new file mode 100644 index 0000000000..a2f42ac808 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read-window/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the read tool (NOT bash) with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl new file mode 100644 index 0000000000..f9433a8725 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl @@ -0,0 +1,102 @@ +{"type":"session","version":0,"id":"b9dfbc86-c33f-45ca-869a-49b62a94ea77","createdAt":1782993880851,"cwd":"/tmp/acp-snap-cwd-2yWjlu"} +{"type":"turn/start","seq":0,"time":1782993880856,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782993880856,"data":{"content":[{"type":"text","text":"Use the read tool (NOT bash) with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1782993880857,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782993881466,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782993881466,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":5,"time":1782993881583,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":6,"time":1782993881612,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":7,"time":1782993881613,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" lines"}}} +{"type":"assistant/chunk","seq":8,"time":1782993881613,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":9,"time":1782993881614,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"5"}}} +{"type":"assistant/chunk","seq":10,"time":1782993881638,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-"}}} +{"type":"assistant/chunk","seq":11,"time":1782993881669,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"8"}}} +{"type":"assistant/chunk","seq":12,"time":1782993881670,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":13,"time":1782993881670,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" big"}}} +{"type":"assistant/chunk","seq":14,"time":1782993881670,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":15,"time":1782993881670,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":16,"time":1782993881671,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":17,"time":1782993881697,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":18,"time":1782993881697,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":19,"time":1782993881697,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":20,"time":1782993881697,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" offset"}}} +{"type":"assistant/chunk","seq":21,"time":1782993881698,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":22,"time":1782993881724,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"5"}}} +{"type":"assistant/chunk","seq":23,"time":1782993881724,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":24,"time":1782993881724,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" limit"}}} +{"type":"assistant/chunk","seq":25,"time":1782993881724,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":26,"time":1782993881724,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} +{"type":"assistant/chunk","seq":27,"time":1782993881725,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":28,"time":1782993881808,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":29,"time":1782993881808,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":30,"time":1782993881838,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":31,"time":1782993881839,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":32,"time":1782993881839,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":33,"time":1782993881839,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":34,"time":1782993881839,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":35,"time":1782993881863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":36,"time":1782993881863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":37,"time":1782993881863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"big"}}} +{"type":"assistant/chunk","seq":38,"time":1782993881863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":39,"time":1782993881891,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1782993881919,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":41,"time":1782993881920,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1782993881920,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"offset"}}} +{"type":"assistant/chunk","seq":43,"time":1782993881920,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1782993881920,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":45,"time":1782993881946,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"5"}}} +{"type":"assistant/chunk","seq":46,"time":1782993882002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":47,"time":1782993882002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":48,"time":1782993882002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"limit"}}} +{"type":"assistant/chunk","seq":49,"time":1782993882002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":50,"time":1782993882002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":51,"time":1782993882029,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"4"}}} +{"type":"assistant/chunk","seq":52,"time":1782993882058,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":53,"time":1782993882087,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Let me read lines 5-8 of big.txt using the read tool with offset 5 and limit 4."}}}} +{"type":"assistant/chunk","seq":54,"time":1782993882087,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}}}} +{"type":"assistant/chunk","seq":55,"time":1782993882087,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":119,"outputTokens":101,"cacheReadTokens":2176,"reasoningTokens":24}}}} +{"type":"assistant/chunk","seq":56,"time":1782993882087,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":57,"time":1782993882089,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Let me read lines 5-8 of big.txt using the read tool with offset 5 and limit 4."},{"type":"tool-call","id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}],"usage":{"inputTokens":119,"outputTokens":101,"cacheReadTokens":2176,"reasoningTokens":24}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56],"surfaceOp":"append"} +{"type":"tool/call","seq":58,"time":1782993882089,"data":{"turn":1,"step":1,"callId":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}} +{"type":"tool/result","seq":59,"time":1782993882094,"data":{"turn":1,"step":1,"callId":"call_00_0htYNlUzC9b8aH2gHN8h2706","content":[{"type":"text","text":"/tmp/acp-snap-cwd-2yWjlu/big.txt\nfile\n\n5: line five\n6: line six\n7: line seven\n8: line eight\n\n(Showing lines 5-8 of 10. Use offset=9 to continue.)\n"}],"isError":false},"sourceEventSeqs":[58],"surfaceOp":"append"} +{"type":"step/end","seq":60,"time":1782993882095,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":61,"time":1782993882095,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":62,"time":1782993882552,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":63,"time":1782993882552,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":64,"time":1782993882625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":65,"time":1782993882653,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":66,"time":1782993882653,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":67,"time":1782993882653,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":68,"time":1782993882653,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":69,"time":1782993882653,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" lines"}}} +{"type":"assistant/chunk","seq":70,"time":1782993882654,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":71,"time":1782993882680,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"5"}}} +{"type":"assistant/chunk","seq":72,"time":1782993882680,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-"}}} +{"type":"assistant/chunk","seq":73,"time":1782993882681,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"8"}}} +{"type":"assistant/chunk","seq":74,"time":1782993882681,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":75,"time":1782993882707,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" big"}}} +{"type":"assistant/chunk","seq":76,"time":1782993882708,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":77,"time":1782993882708,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":78,"time":1782993882708,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":79,"time":1782993882708,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":80,"time":1782993882735,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":81,"time":1782993882735,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":82,"time":1782993882735,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":83,"time":1782993882735,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":84,"time":1782993882735,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":85,"time":1782993882736,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":86,"time":1782993882767,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":87,"time":1782993882767,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ve"}}} +{"type":"assistant/chunk","seq":88,"time":1782993882767,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" done"}}} +{"type":"assistant/chunk","seq":89,"time":1782993882767,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":90,"time":1782993882790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":91,"time":1782993882818,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":92,"time":1782993882818,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":93,"time":1782993882818,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":94,"time":1782993882819,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to read lines 5-8 of big.txt and then reply with exactly \"DONE\". I've done that."}}}} +{"type":"assistant/chunk","seq":95,"time":1782993882819,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":96,"time":1782993882819,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":303,"outputTokens":31,"cacheReadTokens":2176,"reasoningTokens":28}}}} +{"type":"assistant/chunk","seq":97,"time":1782993882819,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":98,"time":1782993882820,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user asked me to read lines 5-8 of big.txt and then reply with exactly \"DONE\". I've done that."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":303,"outputTokens":31,"cacheReadTokens":2176,"reasoningTokens":28}},"sourceEventSeqs":[62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97],"surfaceOp":"append"} +{"type":"step/end","seq":99,"time":1782993882820,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":100,"time":1782993882820,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl new file mode 100644 index 0000000000..577f8adaa6 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl @@ -0,0 +1,59 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" lines"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"5"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"8"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" of"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" big"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" offset"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"5"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" limit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"4"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_0htYNlUzC9b8aH2gHN8h2706","title":"Read big.txt","kind":"read","status":"in_progress","rawInput":"offset 5, limit 4","locations":[{"path":"big.txt","line":5}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_0htYNlUzC9b8aH2gHN8h2706","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/big.txt\nfile\n\n5: line five\n6: line six\n7: line seven\n8: line eight\n\n(Showing lines 5-8 of 10. Use offset=9 to continue.)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" lines"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"5"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"8"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" of"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" big"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'ve"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" done"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/workspace/big.txt b/examples/acp-agent/tests/snapshots/fs-read-window/workspace/big.txt new file mode 100644 index 0000000000..ae121a6980 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read-window/workspace/big.txt @@ -0,0 +1,10 @@ +line one +line two +line three +line four +line five +line six +line seven +line eight +line nine +line ten diff --git a/examples/acp-agent/tests/snapshots/fs-read/input.json b/examples/acp-agent/tests/snapshots/fs-read/input.json new file mode 100644 index 0000000000..c8097b7246 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the read tool (NOT bash) to read the file greeting.txt in the current directory, then reply with exactly the single word DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl new file mode 100644 index 0000000000..c8e5d936d8 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl @@ -0,0 +1,93 @@ +{"type":"session","version":0,"id":"01de71a7-68ef-469f-8a73-de9c1d7c55cf","createdAt":1782993863844,"cwd":"/tmp/acp-snap-cwd-WE9Cx4"} +{"type":"turn/start","seq":0,"time":1782993863849,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782993863849,"data":{"content":[{"type":"text","text":"Use the read tool (NOT bash) to read the file greeting.txt in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1782993863850,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782993864293,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782993864294,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782993864378,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782993864407,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1782993864408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1782993864408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1782993864408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":10,"time":1782993864408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":11,"time":1782993864435,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" greeting"}}} +{"type":"assistant/chunk","seq":12,"time":1782993864435,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":13,"time":1782993864464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":14,"time":1782993864464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":15,"time":1782993864464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":16,"time":1782993864464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":17,"time":1782993864464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":18,"time":1782993864465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":19,"time":1782993864493,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":20,"time":1782993864494,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":21,"time":1782993864519,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":22,"time":1782993864520,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":23,"time":1782993864548,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":24,"time":1782993864548,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":25,"time":1782993864549,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":26,"time":1782993864549,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":27,"time":1782993864577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":28,"time":1782993864577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":29,"time":1782993864577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":30,"time":1782993864577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":31,"time":1782993864662,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":32,"time":1782993864663,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":33,"time":1782993864691,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":34,"time":1782993864692,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":35,"time":1782993864692,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":36,"time":1782993864692,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":37,"time":1782993864692,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":38,"time":1782993864692,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":39,"time":1782993864720,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1782993864721,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"gre"}}} +{"type":"assistant/chunk","seq":41,"time":1782993864721,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"eting"}}} +{"type":"assistant/chunk","seq":42,"time":1782993864721,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":43,"time":1782993864755,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1782993864755,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":45,"time":1782993864807,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read the greeting.txt file using the read tool, not bash, and then reply with exactly \"DONE\"."}}}} +{"type":"assistant/chunk","seq":46,"time":1782993864807,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}}}} +{"type":"assistant/chunk","seq":47,"time":1782993864807,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":106,"outputTokens":73,"cacheReadTokens":2176,"reasoningTokens":27}}}} +{"type":"assistant/chunk","seq":48,"time":1782993864808,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":49,"time":1782993864810,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read the greeting.txt file using the read tool, not bash, and then reply with exactly \"DONE\"."},{"type":"tool-call","id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"usage":{"inputTokens":106,"outputTokens":73,"cacheReadTokens":2176,"reasoningTokens":27}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48],"surfaceOp":"append"} +{"type":"tool/call","seq":50,"time":1782993864810,"data":{"turn":1,"step":1,"callId":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} +{"type":"tool/result","seq":51,"time":1782993864815,"data":{"turn":1,"step":1,"callId":"call_00_6cBhaXfexPCkwewPFfJd4624","content":[{"type":"text","text":"/tmp/acp-snap-cwd-WE9Cx4/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[50],"surfaceOp":"append"} +{"type":"step/end","seq":52,"time":1782993864816,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":53,"time":1782993864816,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":54,"time":1782993866091,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":55,"time":1782993866091,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":56,"time":1782993866187,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":57,"time":1782993866215,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}} +{"type":"assistant/chunk","seq":58,"time":1782993866216,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":59,"time":1782993866216,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"hello"}}} +{"type":"assistant/chunk","seq":60,"time":1782993866244,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":61,"time":1782993866244,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" on"}}} +{"type":"assistant/chunk","seq":62,"time":1782993866244,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" line"}}} +{"type":"assistant/chunk","seq":63,"time":1782993866244,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":64,"time":1782993866277,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":65,"time":1782993866277,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":66,"time":1782993866277,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":67,"time":1782993866277,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":68,"time":1782993866277,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":69,"time":1782993866302,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":70,"time":1782993866303,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":71,"time":1782993866303,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":72,"time":1782993866330,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":73,"time":1782993866330,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":74,"time":1782993866330,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":75,"time":1782993866331,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":76,"time":1782993866331,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":77,"time":1782993866358,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":78,"time":1782993866359,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":79,"time":1782993866359,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":80,"time":1782993866359,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":81,"time":1782993866359,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":82,"time":1782993866387,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":83,"time":1782993866387,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":84,"time":1782993866388,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":85,"time":1782993866388,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file contains \"hello\" on line 1. The user asked me to read it and then reply with exactly \"DONE\"."}}}} +{"type":"assistant/chunk","seq":86,"time":1782993866388,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":87,"time":1782993866388,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":239,"outputTokens":30,"cacheReadTokens":2176,"reasoningTokens":27}}}} +{"type":"assistant/chunk","seq":88,"time":1782993866388,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":89,"time":1782993866388,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file contains \"hello\" on line 1. The user asked me to read it and then reply with exactly \"DONE\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":239,"outputTokens":30,"cacheReadTokens":2176,"reasoningTokens":27}},"sourceEventSeqs":[54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88],"surfaceOp":"append"} +{"type":"step/end","seq":90,"time":1782993866388,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":91,"time":1782993866389,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl new file mode 100644 index 0000000000..1d19e735e4 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl @@ -0,0 +1,61 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" greeting"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" not"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_6cBhaXfexPCkwewPFfJd4624","title":"Read greeting.txt","kind":"read","status":"in_progress","locations":[{"path":"greeting.txt"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_6cBhaXfexPCkwewPFfJd4624","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contains"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"hello"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" on"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" line"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/workspace/greeting.txt b/examples/acp-agent/tests/snapshots/fs-read/workspace/greeting.txt new file mode 100644 index 0000000000..ce01362503 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read/workspace/greeting.txt @@ -0,0 +1 @@ +hello diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/input.json b/examples/acp-agent/tests/snapshots/fs-write-overwrite/input.json new file mode 100644 index 0000000000..585ec3ebed --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly the single line: replaced. Then reply with exactly the single word DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl new file mode 100644 index 0000000000..ac97558243 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl @@ -0,0 +1,132 @@ +{"type":"session","version":0,"id":"2b08a4bd-62f1-4846-b57f-7c62d4101673","createdAt":1782993794495,"cwd":"/tmp/acp-snap-cwd-X0UUW6"} +{"type":"turn/start","seq":0,"time":1782993794499,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782993794499,"data":{"content":[{"type":"text","text":"First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly the single line: replaced. Then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1782993794500,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782993794915,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782993794915,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782993795030,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782993795057,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1782993795057,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1782993795057,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1782993795058,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":10,"time":1782993795087,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":11,"time":1782993795088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" data"}}} +{"type":"assistant/chunk","seq":12,"time":1782993795088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":13,"time":1782993795088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":14,"time":1782993795088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":15,"time":1782993795113,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" replace"}}} +{"type":"assistant/chunk","seq":16,"time":1782993795114,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" its"}}} +{"type":"assistant/chunk","seq":17,"time":1782993795114,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" entire"}}} +{"type":"assistant/chunk","seq":18,"time":1782993795141,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" contents"}}} +{"type":"assistant/chunk","seq":19,"time":1782993795141,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":20,"time":1782993795141,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":21,"time":1782993795168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"re"}}} +{"type":"assistant/chunk","seq":22,"time":1782993795168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"placed"}}} +{"type":"assistant/chunk","seq":23,"time":1782993795168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\","}}} +{"type":"assistant/chunk","seq":24,"time":1782993795168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":25,"time":1782993795198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":26,"time":1782993795198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":27,"time":1782993795198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":28,"time":1782993795198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":29,"time":1782993795198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":30,"time":1782993795199,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":31,"time":1782993795230,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":32,"time":1782993795313,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":33,"time":1782993795314,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":34,"time":1782993795343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":35,"time":1782993795343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":36,"time":1782993795343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":37,"time":1782993795343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":38,"time":1782993795343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1782993795343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":40,"time":1782993795372,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":41,"time":1782993795373,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"data"}}} +{"type":"assistant/chunk","seq":42,"time":1782993795373,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":43,"time":1782993795404,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1782993795404,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":45,"time":1782993795466,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to first read data.txt, then replace its entire contents with \"replaced\", and then reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":46,"time":1782993795466,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}}}} +{"type":"assistant/chunk","seq":47,"time":1782993795466,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":73,"cacheReadTokens":2176,"reasoningTokens":28}}}} +{"type":"assistant/chunk","seq":48,"time":1782993795466,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":49,"time":1782993795468,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to first read data.txt, then replace its entire contents with \"replaced\", and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}],"usage":{"inputTokens":123,"outputTokens":73,"cacheReadTokens":2176,"reasoningTokens":28}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48],"surfaceOp":"append"} +{"type":"tool/call","seq":50,"time":1782993795468,"data":{"turn":1,"step":1,"callId":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}} +{"type":"tool/result","seq":51,"time":1782993795473,"data":{"turn":1,"step":1,"callId":"call_00_GtqlR9riew6wgdQzLbbu6019","content":[{"type":"text","text":"/tmp/acp-snap-cwd-X0UUW6/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[50],"surfaceOp":"append"} +{"type":"step/end","seq":52,"time":1782993795473,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":53,"time":1782993795473,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":54,"time":1782993796122,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":55,"time":1782993796122,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Now"}}} +{"type":"assistant/chunk","seq":56,"time":1782993796250,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":57,"time":1782993796281,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":58,"time":1782993796281,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":59,"time":1782993796282,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" replace"}}} +{"type":"assistant/chunk","seq":60,"time":1782993796282,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":61,"time":1782993796309,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" entire"}}} +{"type":"assistant/chunk","seq":62,"time":1782993796309,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contents"}}} +{"type":"assistant/chunk","seq":63,"time":1782993796309,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":64,"time":1782993796310,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" data"}}} +{"type":"assistant/chunk","seq":65,"time":1782993796338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":66,"time":1782993796338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":67,"time":1782993796338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":68,"time":1782993796339,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":69,"time":1782993796367,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":70,"time":1782993796367,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" line"}}} +{"type":"assistant/chunk","seq":71,"time":1782993796368,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":72,"time":1782993796368,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"re"}}} +{"type":"assistant/chunk","seq":73,"time":1782993796368,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"placed"}}} +{"type":"assistant/chunk","seq":74,"time":1782993796368,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":75,"time":1782993796452,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":76,"time":1782993796452,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":77,"time":1782993796480,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":78,"time":1782993796480,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":79,"time":1782993796480,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":80,"time":1782993796480,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":81,"time":1782993796480,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":82,"time":1782993796508,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":83,"time":1782993796508,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":84,"time":1782993796508,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"data"}}} +{"type":"assistant/chunk","seq":85,"time":1782993796508,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":86,"time":1782993796536,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":87,"time":1782993796565,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":88,"time":1782993796565,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":89,"time":1782993796566,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"content"}}} +{"type":"assistant/chunk","seq":90,"time":1782993796566,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":91,"time":1782993796566,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":92,"time":1782993796593,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":93,"time":1782993796593,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"re"}}} +{"type":"assistant/chunk","seq":94,"time":1782993796593,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"placed"}}} +{"type":"assistant/chunk","seq":95,"time":1782993796621,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":96,"time":1782993796621,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":97,"time":1782993796681,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Now I need to replace the entire contents of data.txt with exactly the single line \"replaced\"."}}}} +{"type":"assistant/chunk","seq":98,"time":1782993796681,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}}}} +{"type":"assistant/chunk","seq":99,"time":1782993796681,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":254,"outputTokens":82,"cacheReadTokens":2176,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":100,"time":1782993796681,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":101,"time":1782993796681,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Now I need to replace the entire contents of data.txt with exactly the single line \"replaced\"."},{"type":"tool-call","id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}],"usage":{"inputTokens":254,"outputTokens":82,"cacheReadTokens":2176,"reasoningTokens":20}},"sourceEventSeqs":[54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100],"surfaceOp":"append"} +{"type":"tool/call","seq":102,"time":1782993796681,"data":{"turn":1,"step":2,"callId":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}} +{"type":"tool/result","seq":103,"time":1782993796688,"data":{"turn":1,"step":2,"callId":"call_00_CkV4RzKjuERcr4NdJbtY3226","content":[{"type":"text","text":"/tmp/acp-snap-cwd-X0UUW6/data.txt\nfile\n\nUpdated file\n"}],"isError":false},"sourceEventSeqs":[102],"surfaceOp":"append"} +{"type":"step/end","seq":104,"time":1782993796689,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":105,"time":1782993796689,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":106,"time":1782993797188,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":107,"time":1782993797189,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Done"}}} +{"type":"assistant/chunk","seq":108,"time":1782993797260,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":109,"time":1782993797289,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":110,"time":1782993797290,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":111,"time":1782993797290,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":112,"time":1782993797290,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":113,"time":1782993797317,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":114,"time":1782993797317,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":115,"time":1782993797317,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":116,"time":1782993797354,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":117,"time":1782993797355,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":118,"time":1782993797355,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":119,"time":1782993797355,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":120,"time":1782993797355,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":121,"time":1782993797384,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":122,"time":1782993797384,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":123,"time":1782993797385,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":124,"time":1782993797385,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Done. I need to reply with exactly the single word DONE."}}}} +{"type":"assistant/chunk","seq":125,"time":1782993797385,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":126,"time":1782993797385,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":254,"outputTokens":17,"cacheReadTokens":2304,"reasoningTokens":14}}}} +{"type":"assistant/chunk","seq":127,"time":1782993797385,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":128,"time":1782993797386,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"Done. I need to reply with exactly the single word DONE."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":254,"outputTokens":17,"cacheReadTokens":2304,"reasoningTokens":14}},"sourceEventSeqs":[106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127],"surfaceOp":"append"} +{"type":"step/end","seq":129,"time":1782993797386,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":130,"time":1782993797386,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl new file mode 100644 index 0000000000..1d234c2dd4 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl @@ -0,0 +1,71 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" data"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" replace"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" its"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" entire"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contents"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"re"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"placed"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_GtqlR9riew6wgdQzLbbu6019","title":"Read data.txt","kind":"read","status":"in_progress","locations":[{"path":"data.txt"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_GtqlR9riew6wgdQzLbbu6019","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" replace"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" entire"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contents"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" of"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" data"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" line"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"re"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"placed"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_CkV4RzKjuERcr4NdJbtY3226","title":"Write data.txt","kind":"edit","status":"in_progress","locations":[{"path":"data.txt"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_CkV4RzKjuERcr4NdJbtY3226","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/data.txt\nfile\n\nUpdated file\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Done"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/workspace/data.txt b/examples/acp-agent/tests/snapshots/fs-write-overwrite/workspace/data.txt new file mode 100644 index 0000000000..b2745f6f48 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/workspace/data.txt @@ -0,0 +1 @@ +original contents diff --git a/examples/acp-agent/tests/snapshots/fs-write/input.json b/examples/acp-agent/tests/snapshots/fs-write/input.json new file mode 100644 index 0000000000..1512e93735 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the write tool (NOT bash) to create a file named notes.txt in the current directory containing exactly the single line: hello world. Then reply with exactly the single word DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl new file mode 100644 index 0000000000..533ec83084 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -0,0 +1,95 @@ +{"type":"session","version":0,"id":"5475c102-9aaa-4952-8a48-d5c3444eb322","createdAt":1782993761947,"cwd":"/tmp/acp-snap-cwd-v8qbp7"} +{"type":"turn/start","seq":0,"time":1782993761951,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782993761952,"data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create a file named notes.txt in the current directory containing exactly the single line: hello world. Then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1782993761953,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782993762528,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782993762529,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782993762648,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782993762676,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1782993762676,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1782993762677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1782993762677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" create"}}} +{"type":"assistant/chunk","seq":10,"time":1782993762677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":11,"time":1782993762677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":12,"time":1782993762704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" named"}}} +{"type":"assistant/chunk","seq":13,"time":1782993762731,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" notes"}}} +{"type":"assistant/chunk","seq":14,"time":1782993762732,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":15,"time":1782993762732,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":16,"time":1782993762773,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":17,"time":1782993762773,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" content"}}} +{"type":"assistant/chunk","seq":18,"time":1782993762787,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":19,"time":1782993762788,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"hello"}}} +{"type":"assistant/chunk","seq":20,"time":1782993762788,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" world"}}} +{"type":"assistant/chunk","seq":21,"time":1782993762788,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":22,"time":1782993762788,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":23,"time":1782993762788,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":24,"time":1782993762815,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":25,"time":1782993762815,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":26,"time":1782993762815,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":27,"time":1782993762815,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":28,"time":1782993762843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":29,"time":1782993762843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":30,"time":1782993762843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":31,"time":1782993762843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":32,"time":1782993762843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":33,"time":1782993762844,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":34,"time":1782993762871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":35,"time":1782993762926,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":36,"time":1782993762927,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":37,"time":1782993762953,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":38,"time":1782993762954,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1782993762954,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":40,"time":1782993762954,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":41,"time":1782993762987,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1782993762988,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":43,"time":1782993762988,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1782993762988,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"notes"}}} +{"type":"assistant/chunk","seq":45,"time":1782993763013,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":46,"time":1782993763013,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":47,"time":1782993763040,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":48,"time":1782993763041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":49,"time":1782993763041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"content"}}} +{"type":"assistant/chunk","seq":50,"time":1782993763041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":51,"time":1782993763068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":52,"time":1782993763069,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":53,"time":1782993763069,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"hello"}}} +{"type":"assistant/chunk","seq":54,"time":1782993763069,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":" world"}}} +{"type":"assistant/chunk","seq":55,"time":1782993763097,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":56,"time":1782993763097,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":57,"time":1782993763155,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, and then reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":58,"time":1782993763155,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}}}} +{"type":"assistant/chunk","seq":59,"time":1782993763155,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":115,"outputTokens":93,"cacheReadTokens":2176,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":60,"time":1782993763155,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":61,"time":1782993763157,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}],"usage":{"inputTokens":115,"outputTokens":93,"cacheReadTokens":2176,"reasoningTokens":31}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"} +{"type":"tool/call","seq":62,"time":1782993763157,"data":{"turn":1,"step":1,"callId":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}} +{"type":"tool/result","seq":63,"time":1782993763164,"data":{"turn":1,"step":1,"callId":"call_00_OxAUP9dIc6I1B6Coo5vs8586","content":[{"type":"text","text":"/tmp/acp-snap-cwd-v8qbp7/notes.txt\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[62],"surfaceOp":"append"} +{"type":"step/end","seq":64,"time":1782993763164,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":65,"time":1782993763165,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":66,"time":1782993763769,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":67,"time":1782993763769,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":68,"time":1782993763841,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":69,"time":1782993763869,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":70,"time":1782993763869,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" created"}}} +{"type":"assistant/chunk","seq":71,"time":1782993763869,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":72,"time":1782993763869,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":73,"time":1782993763869,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":74,"time":1782993763900,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":75,"time":1782993763901,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":76,"time":1782993763901,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":77,"time":1782993763901,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":78,"time":1782993763901,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":79,"time":1782993763930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":80,"time":1782993763931,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":81,"time":1782993763931,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":82,"time":1782993763931,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":83,"time":1782993763931,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":84,"time":1782993763957,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":85,"time":1782993763957,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":86,"time":1782993763958,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":87,"time":1782993763958,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created successfully. Now I just need to reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":88,"time":1782993763958,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":89,"time":1782993763958,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":255,"outputTokens":20,"cacheReadTokens":2176,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":90,"time":1782993763958,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":91,"time":1782993763958,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file was created successfully. Now I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":255,"outputTokens":20,"cacheReadTokens":2176,"reasoningTokens":17}},"sourceEventSeqs":[66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90],"surfaceOp":"append"} +{"type":"step/end","seq":92,"time":1782993763958,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":93,"time":1782993763959,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl new file mode 100644 index 0000000000..daa26245bb --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl @@ -0,0 +1,55 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" create"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" named"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" notes"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" content"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"hello"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" world"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_OxAUP9dIc6I1B6Coo5vs8586","title":"Write notes.txt","kind":"edit","status":"in_progress","locations":[{"path":"notes.txt"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_OxAUP9dIc6I1B6Coo5vs8586","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/notes.txt\nfile\n\nCreated file\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" created"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" successfully"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} From b3f8b4c9c69185363204e9e4871222718ce5e6e6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:38:06 +0800 Subject: [PATCH 45/46] test(fs): close abort/concurrency/observed coverage gaps + with-key e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavioral gaps from the coverage audit (line coverage was already 100%; these close BEHAVIOR gaps): - fs-local: service-level writeText/editText pre-abort → FS_ABORTED (file unchanged); concurrent guarded-write race and mixed write-vs-edit race (one wins, one FS_STALE_VERSION, locks released); edit→edit version refresh at the provider; the replaceIfVersion post-write version matches a fresh stat. fsio: a mid-stream abort → FS_ABORTED (previously only pre-abort was covered). - fs-policy: the agent-without-session owner rung ({agent:{}} → no owner → createIfAbsent / FS_NOT_OBSERVED); fs/write-intent first-wins (symmetric to the existing edit-intent test). - tool-fs: abort-through-the-tool for read/write/edit (isError FS_ABORTED, file unchanged); a deterministic tool-tier concurrent-edit race via a shared read; the throwing-fs/observed contract (a throwing listener surfaces as isError but the mutation already hit disk); the replace_all edit message; parseReadArgs rejects fractional/NaN offset and zero/negative limit. - dsh-fs: FsError chains a cause through ErrorOptions. New with-key e2e (packages/fs/tool-fs/tests/fs-tools.e2e.ts, self-skips without DEEPSEEK_API_KEY): a real model drives the real read/write/edit tools to create → read → edit a file, verified on disk; a second test proves a relative path resolves against the per-session cwd (factory meta.cwd) not config.cwd. Booted via a plain tests/harness.ts. Added dsh-agent-loop + dsh-llm-deepseek devDeps. --- packages/fs/fs-local/tests/filesystem.spec.ts | 70 ++++++++++++++++ packages/fs/fs-local/tests/fsio.spec.ts | 16 ++++ packages/fs/fs-policy/tests/policy.spec.ts | 23 +++++ packages/fs/fs/tests/service.spec.ts | 7 ++ packages/fs/tool-fs/package.json | 2 + packages/fs/tool-fs/tests/fs-tools.e2e.ts | 84 +++++++++++++++++++ packages/fs/tool-fs/tests/harness.ts | 47 +++++++++++ packages/fs/tool-fs/tests/integration.spec.ts | 70 ++++++++++++++++ packages/fs/tool-fs/tests/tools.spec.ts | 23 +++++ pnpm-lock.yaml | 6 ++ 10 files changed, 348 insertions(+) create mode 100644 packages/fs/tool-fs/tests/fs-tools.e2e.ts create mode 100644 packages/fs/tool-fs/tests/harness.ts diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 0b96350c2d..03c751a538 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -196,6 +196,41 @@ describe('writeText', () => { .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', () => { @@ -297,6 +332,41 @@ describe('editText', () => { 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.replacements).toBe(1) + 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', () => { diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 6f28d54402..0b16e9e2c8 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -222,6 +222,22 @@ describe('streamWholeText', () => { 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 => { + 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', () => { diff --git a/packages/fs/fs-policy/tests/policy.spec.ts b/packages/fs/fs-policy/tests/policy.spec.ts index 02bb934cfd..2ea61ffcd1 100644 --- a/packages/fs/fs-policy/tests/policy.spec.ts +++ b/packages/fs/fs-policy/tests/policy.spec.ts @@ -64,6 +64,13 @@ describe('write-intent decision', () => { 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({}) @@ -83,6 +90,11 @@ describe('edit-intent decision', () => { 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({}) @@ -166,6 +178,17 @@ describe('single-slot, first-wins', () => { 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)', () => { diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index 789ed7fdac..a0032afdee 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -108,4 +108,11 @@ describe('FsError', () => { 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') + }) }) diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index f92966f515..080b9a8f78 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -30,10 +30,12 @@ }, "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:^", diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts new file mode 100644 index 0000000000..5e13e229fb --- /dev/null +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -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) + // 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', systemPrompt: SYSTEM }) + + 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) + 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', systemPrompt: SYSTEM }, + }) + 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) +}) diff --git a/packages/fs/tool-fs/tests/harness.ts b/packages/fs/tool-fs/tests/harness.ts new file mode 100644 index 0000000000..0a492c509e --- /dev/null +++ b/packages/fs/tool-fs/tests/harness.ts @@ -0,0 +1,47 @@ +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. + */ +export async function fsHarness(fsCwd: string): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + 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 { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index e8019234f0..c0973197eb 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -338,3 +338,73 @@ describe('per-session cwd', () => { 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') + }) +}) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 364d944b02..12f373753e 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -158,6 +158,20 @@ describe('read tool', () => { 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 }) @@ -291,6 +305,15 @@ describe('edit tool', () => { 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' }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 380cfb3b4e..d87855145f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -323,6 +323,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs @@ -335,6 +338,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../../llm/llm-deepseek '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session From 3aa6b3c77a92eaada8f76366816047b35697f3eb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:47:20 +0800 Subject: [PATCH 46/46] chore(knip): register tool-fs e2e tests as knip entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new packages/fs/tool-fs/tests/*.e2e.ts (+ its harness.ts) need an explicit knip workspace entry — mirroring the other e2e-bearing packages — so knip follows them and does not flag the files or their dsh-agent-loop/dsh-llm-deepseek devDeps as unused. --- knip.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/knip.json b/knip.json index 67d99a861d..9e5829317d 100644 --- a/knip.json +++ b/knip.json @@ -44,6 +44,10 @@ "packages/subagent/subagent-acp": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/mock-acp-server.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] + }, + "packages/fs/tool-fs": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] } } }