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" },