Files
deepseek-harness/docs/core-data-structures/filesystem.md
T
kingwl 52aedf226d Merge remote-tracking branch 'origin/master' into cross-family-fs-sandbox
# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/event-producer-consumer.md
#	docs/persistence-catalog.md
#	examples/acp-agent/cordis.yml
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/permission-switching/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	packages/bash/bash-sandbox/src/index.ts
#	packages/bash/bash/src/types.ts
#	packages/bash/tool-bash/src/index.ts
#	packages/fs/fs/src/index.ts
#	packages/sandbox/sandbox-policy/src/session-mode.ts
#	packages/ui/permission/src/index.ts
#	packages/ui/permission/tests/permission.spec.ts
#	scripts/doc-budgets.manifest.json
2026-07-14 21:25:36 +08:00

9.5 KiB

Filesystem

The optional filesystem capability has four parts: dsh-fs owns ctx.fs and atomic text operations with optional version guards, dsh-fs-local implements local disk, dsh-fs-policy adds observed-state and freshness rules through events rather than a service, and dsh-tool-fs directly executes model-facing read/write/edit calls and renders windows. It is outside the agent-loop spine; alternate backends do not change policy or 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-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 and packages/fs/fs/src/index.ts. Policy source: packages/fs/fs-policy/src/types.ts. Read-rendering source: packages/fs/tool-fs/src/read-render.ts.

Target identity and metadata (provider seam)

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.

interface FsTarget {
  targetKey: FsTargetKey
  displayPath: string
}

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.

type FsTargetKey = Branded<'FsTargetKey'>
type FsVersion = Branded<'FsVersion'>

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.

interface FsInfo {
  version: FsVersion
  type: 'file' | 'directory' | 'other'
  size?: number
}

listDir returns direct child entries in stable name order. Each entry carries the child basename, type, resolved target, and cheap metadata when the backend can report it. It must not read file contents, so size is only for regular files and version is metadata-derived. Broken or disappeared children may be returned as other without metadata; permission or backend I/O failures while listing or resolving child metadata fail the whole listing with FS_PERMISSION_DENIED or FS_IO_ERROR.

interface FsDirEntry {
  name: string
  type: 'file' | 'directory' | 'other'
  target: FsTarget
  version?: FsVersion
  size?: number
}

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 FsWriteIntentcreateIfAbsent 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.

type FsWriteIntent =
  | { kind: 'createIfAbsent' }
  | { kind: 'replaceIfVersion'; version: FsVersion }
interface FsWriteOutcome {
  operation: 'create' | 'update'
  version: FsVersion
  before: string | null
  after: string
}

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.

interface FsEditRequest {
  oldString: string
  newString: string
  replaceAll: boolean
}
interface FsEditOutcome {
  version: FsVersion
  before: string
  after: string
}

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-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-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.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-fs-policy import the tool, agent, or session packages.

interface FsPolicyExec {
  agent?: {
    session?: object
  }
}

Read outcome (consumer / read rendering)

A text read is bounded by line window, byte cap, and backend limits. The outcome the model-facing read tool renders is purely presentational; there is no full/partial view — authorization is freshness-based (the tool emits fs/observed with the stat's version directly), 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.

interface FileReadOutcome {
  offset: number
  lines: FileTextLine[]
  totalLines: number
  truncatedByBytes?: true
}

Observed-file state (policy plugin)

Observed state is a WeakMap<owner, Map<targetKey, { version }>> 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)

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.

type FsErrorCode =
  | 'FS_NOT_FOUND'
  | 'FS_NOT_DIRECTORY'
  | 'FS_NOT_TEXT'
  | 'FS_NOT_REGULAR_FILE'
  | 'FS_PERMISSION_DENIED'
  | 'FS_SANDBOX_DENIED'
  | 'FS_IO_ERROR'
  | 'FS_STALE_VERSION'
  | 'FS_NOT_OBSERVED'
  | 'FS_AMBIGUOUS_EDIT'
  | 'FS_EDIT_NOT_FOUND'
  | 'FS_ABORTED'

FS_NOT_DIRECTORY, FS_PERMISSION_DENIED, and FS_IO_ERROR are used by directory listing to distinguish an existing non-directory target, a denied listing, and an unexpected backend I/O failure. FS_SANDBOX_DENIED is a POLICY refusal from a sandbox-enforcing backend (dsh-fs-sandbox) — the mode fence denied a write/edit — distinct from FS_PERMISSION_DENIED (the host kernel refusing). 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 service and the plugin

FileSystem (ctx.fs, abstract) owns the provider primitives: resolve, stat, readText, streamText, listDir, 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 services.md.