refactor(fs): make dsh-file-context an event-gate plugin, not a method service

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.
This commit is contained in:
Dudu-0223
2026-06-28 13:49:02 +08:00
parent d612ebaef1
commit 90dceea0e4
35 changed files with 1229 additions and 793 deletions

View File

@@ -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<ContentBlock[]> {
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

View File

@@ -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 {

View File

@@ -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)}`)
}
}

View File

@@ -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<ContentBlock[]> {
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

View File

@@ -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
}

View File

@@ -0,0 +1,139 @@
/**
* 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-tool-fs/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<string>` (a chunked `streamText`) or an
* `Iterable<string>` (a whole-file `readText` wrapped as `[text]`), so one code
* path serves both. Scans for newlines with a capped line buffer (a newline-free
* giant line is truncated, never buffered past {@link READ_MAX_LINE_LENGTH}),
* enforces the byte cap, and throws `FS_NOT_FOUND` for an offset past EOF.
*/
export async function buildWindow(
chunks: AsyncIterable<string> | Iterable<string>,
request: ReadWindow,
displayPath: string,
): Promise<WindowResult> {
const acc = newAccumulator()
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)
}

View File

@@ -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<ContentBlock[]> {
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