Add filesystem capability seam and tools

This commit is contained in:
Dudu-0223
2026-06-22 10:48:41 +08:00
parent 6b4dc48fbd
commit 5e01564afb
36 changed files with 3515 additions and 1 deletions

256
packages/fs/fs/src/index.ts Normal file
View File

@@ -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<object, Map<string, FileState>>()
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<FsTarget>
/** Read a bounded UTF-8 text page from a target. */
abstract readPage(target: FsTarget, request: FsReadRequest, signal?: AbortSignal): Promise<FsReadOutcome>
/**
* 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<FsWriteOutcome>
/**
* 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<FsEditOutcome>
// --- 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<FsReadOutcome> {
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<FsWriteOutcome> {
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<FsEditOutcome> {
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

194
packages/fs/fs/src/types.ts Normal file
View File

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