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).
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@ Source: [`packages/core/tools/src/index.ts:43`](../../packages/core/tools/src/in
|
||||
|
||||
## Services
|
||||
|
||||
The 9 `ctx.<key>` 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.<key>` 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<FsTarget>
|
||||
async read(target: FsTarget, request: FileReadRequest, exec?: FileContextExec, signal?: AbortSignal): Promise<FileReadOutcome>
|
||||
async write(target: FsTarget, content: string, exec?: FileContextExec, signal?: AbortSignal): Promise<FsWriteOutcome>
|
||||
async edit(target: FsTarget, edit: FsEditRequest, exec?: FileContextExec, signal?: AbortSignal): Promise<FsEditOutcome>
|
||||
```
|
||||
|
||||
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<FsTarget>
|
||||
abstract readPage(target: FsTarget, request: FsReadRequest, signal?: AbortSignal): Promise<FsReadOutcome>
|
||||
abstract createOrReplace(target: FsTarget, content: string, expected: FsExpectation, signal?: AbortSignal): Promise<FsWriteOutcome>
|
||||
abstract applyEdit(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
|
||||
owner(exec?: FsExecContext): object | undefined
|
||||
async read(target: FsTarget, request: FsReadRequest, exec?: FsExecContext, signal?: AbortSignal): Promise<FsReadOutcome>
|
||||
async write(target: FsTarget, content: string, exec?: FsExecContext, signal?: AbortSignal): Promise<FsWriteOutcome>
|
||||
async edit(target: FsTarget, edit: FsEditRequest, exec?: FsExecContext, signal?: AbortSignal): Promise<FsEditOutcome>
|
||||
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
|
||||
abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
|
||||
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
|
||||
abstract writeText(target: FsTarget, content: string, expected: FsWriteExpectation, signal?: AbortSignal): Promise<FsWriteOutcome>
|
||||
abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
|
||||
```
|
||||
|
||||
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`
|
||||
|
||||
|
||||
@@ -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<owner, Map<targetKey, { version }>>` 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).
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<FsTarget>
|
||||
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
|
||||
abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
|
||||
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
|
||||
abstract writeText(target: FsTarget, content: string, expected: FsWriteExpectation, signal?: AbortSignal): Promise<FsWriteOutcome>
|
||||
abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
|
||||
|
||||
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<owner, Map<targetKey, { version }>>`. 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, `<path>/<content>` 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.
|
||||
Reference in New Issue
Block a user