Merge master into compact-tool-result-prune

This commit is contained in:
Tianyi Cui
2026-07-20 16:41:59 +08:00
807 changed files with 15501 additions and 3177 deletions

View File

@@ -6,9 +6,9 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md
- **Optional services use `ctx.get(name)`.** Reserve `ctx.<name>` for declared injections; the property proxy is topology-sensitive, while strict `ctx.get` reads the global service store ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)).
- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md).
- **Typed same-process service and plugin calls are contracts, not serialization boundaries.** Prefer readonly borrowed values; materialize or defensively validate only at parser/config, queued, model/tool JSON, durable/file, worker, process, or wire boundaries.
- **Initiator-owned private chains derive, then capture.** Under `ctx.agents.withInitiator()`, recover the Agent at each orchestration entry, derive `agent.session`, and let operation-local helpers close over it. Keep `Agent` and `Session` explicit at lifecycle, session-log, service, authority, worker/process, persistence, and wire interfaces; do not widen a leaf helper from `Session` to `Context` merely to hide a parameter ([rationale](../docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
- **Initiator-owned private chains derive, then capture.** Under `ctx.agents.withInitiator()`, recover the Agent at each orchestration entry, derive `agent.session`, and let operation-local helpers close over it. Keep `Agent` and `Session` explicit at lifecycle, session-log, service, authority, worker/process, persistence, and wire interfaces; do not widen a leaf helper from `Session` to `Context` merely to hide a parameter ([rationale](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence.
- **Shape capability interfaces around all current consumers.** Keep tool-schema, Loader, UI, transport, and backend-specific behavior in the consumer or adapter; do not let one consumer dictate the interface ([capability-seam rationale](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
- **Shape capability interfaces around all current consumers.** Keep tool-schema, Loader, UI, transport, and backend-specific behavior in the consumer or adapter; do not let one consumer dictate the interface ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)).
- **Require a current owner and need.** Tie each abstraction, state machine, option, defensive copy, and compatibility path to a current contract or production consumer, and keep behavior in its owning plugin or service.
- **Require evidence for public choices.** Configurability does not justify an unsupported default, public operation set, format, or imported external concept. Use current-consumer evidence or relevant prior art; otherwise require an explicit value or defer the choice.
- **Write model-facing contracts from the model's perspective.** Prompts, tool schemas, results, and diagnostics contain only task-relevant concepts, not UI, transport, or implementation vocabulary. Pin stable model-visible text verbatim and dynamic behavior through snapshots or end-to-end coverage.
@@ -22,5 +22,5 @@ Naming notes:
- `src/types.ts` contains only types — no runtime code.
- Tests live at package level under `tests/`, not `src/__tests__/`.
- A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code.
- Package READMEs document model/token effects using the [canonical Model Experience format](../docs/cookbook/adding-a-package.md#4-write-the-package-readme).
- Package READMEs put durable consumer gaps and non-obvious maintainer constraints under `## Known Limitations and Deferred Work`; ordinary cleanup stays in its TODO or RFC. Packages with none use a justified [allowlist entry](../scripts/verify-package-readme-limitations.ts) ([rationale](../docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md)).
- Package READMEs document model, token, and KV-cache effects using the [canonical Model Experience format](../docs/cookbook/adding-a-package.md#4-write-the-package-readme).
- Package READMEs put durable consumer gaps and non-obvious maintainer constraints under `## Known Limitations and Deferred Work`; ordinary cleanup stays in its TODO or Agent Note. Packages with none use a justified [allowlist entry](../scripts/verify-package-readme-limitations.ts) ([rationale](../.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.md)).

View File

@@ -25,7 +25,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface |
| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface |
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval: logical corpus, bounded reads, lineage, and event relationships | Product — stable surface |
@@ -41,6 +41,6 @@ Groups distinguish product API from support infrastructure. New packages join an
The dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI).
The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-spine-demo`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-spine-demo`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)).
Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or use its [allowlist](../scripts/verify-package-readme-limitations.ts).

View File

@@ -1,6 +1,6 @@
# bash/ — bash capability family
The canonical three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, concrete implementations, and the model-facing tool that consumes it. All **product** packages.
The canonical three-package capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, concrete implementations, and the model-facing tool that consumes it. All **product** packages.
| Package | Role | ctx key |
|---|---|---|

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-bash-local
Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c <command>` per call in its own process group, collects bounded output with full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group.
Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c <command>` per call in its own process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group.
The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package.
@@ -14,7 +14,8 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
timeoutMs: 120000 # default foreground timeout
maxTimeoutMs: 600000 # cap for per-call overrides
maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk
graceMs: 3000 # SIGTERM→SIGKILL escalation grace on kills
maxSpillBytes: 67108864 # per-stream full-output spill cap
graceMs: 3000 # kill escalation and post-exit pipe-drain grace
```
## Behavior (and where it came from)
@@ -22,21 +23,25 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices:
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
- **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file.
- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names, then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's ordinary `env` is merged after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment RFC](../../../docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). After the main shell exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the command open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
- **Tail-keep truncation + bounded spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. A stream larger than `maxSpillBytes` discards its now-incomplete spill and returns only the marked truncated tail. If the final spill close reports a delayed writeback failure, the executor likewise withholds the path rather than advertising an incomplete file.
- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names, then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's ordinary `env` is merged after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), the handle's `readOutput()` is incremental with whole-stream byte offsets, and disposal kills every running process and awaits its exit. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
## Model Experience
Indirectly, through `dsh-tool-bash`, which renders this executor's bounded stdout/stderr tails, background-process deltas, spill-file paths, and infrastructure failures.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`.
- **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them.
- **POSIX-only** — the `bash` binary, detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported.
- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
- **Spill files are never deleted** — full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them.
- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind.
The raw process handling lives in `src/run.ts`; `src/index.ts` is the service wiring.

View File

@@ -10,7 +10,7 @@ import z from 'schemastery'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { DEFAULT_GRACE_MS, runBash } from './run.ts'
import { DEFAULT_GRACE_MS, DEFAULT_MAX_SPILL_BYTES, runBash } from './run.ts'
import type { RunInternals, RunningBash } from './run.ts'
/** Plugin config (all optional — `static Config` supplies the defaults). */
@@ -23,7 +23,9 @@ export interface Config {
maxTimeoutMs?: number
/** Per-stream in-memory output cap; overflow spills to a temp file. */
maxOutputBytes?: number
/** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
maxSpillBytes?: number
/** Grace period for kill escalation and for inherited pipes after shell exit. */
graceMs?: number
}
@@ -46,6 +48,7 @@ export class LocalBashExecutor extends BashExecutor {
timeoutMs: z.number().default(120_000),
maxTimeoutMs: z.number().default(600_000),
maxOutputBytes: z.number().default(64_000),
maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES),
graceMs: z.number().default(DEFAULT_GRACE_MS),
})
@@ -64,6 +67,7 @@ export class LocalBashExecutor extends BashExecutor {
assertPositiveFinite('timeoutMs', this.config.timeoutMs)
assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
assertPositiveFinite('graceMs', this.config.graceMs)
ctx.effect(() => async () => {
// Await closure so even a TERM-trapping child cannot outlive the fiber.
@@ -120,6 +124,7 @@ export class LocalBashExecutor extends BashExecutor {
cwd: spec.workdir,
stdoutMaxBytes: spec.stdoutMaxBytes,
stderrMaxBytes: this.config.maxOutputBytes,
maxSpillBytes: this.config.maxSpillBytes,
graceMs: this.config.graceMs,
signal: d.signal,
stdin: spec.stdin,
@@ -139,6 +144,7 @@ export class LocalBashExecutor extends BashExecutor {
cwd: spec.workdir,
stdoutMaxBytes: this.config.maxOutputBytes,
stderrMaxBytes: this.config.maxOutputBytes,
maxSpillBytes: this.config.maxSpillBytes,
graceMs: this.config.graceMs,
signal: spec.signal,
stdin: spec.stdin,

View File

@@ -8,7 +8,7 @@
import { type ChildProcessByStdio, spawn } from 'node:child_process'
import type { Readable, Writable } from 'node:stream'
import { randomBytes } from 'node:crypto'
import { closeSync, mkdtempSync, openSync, writeSync } from 'node:fs'
import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
@@ -72,7 +72,9 @@ export interface SpawnSpec {
stdoutMaxBytes: number
/** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */
stderrMaxBytes: number
/** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
maxSpillBytes: number
/** Grace period for kill escalation and for inherited pipes after shell exit. */
graceMs: number
/**
* Abort signal — kills the process group when it fires. The executor owns
@@ -119,6 +121,9 @@ export interface RunInternals {
/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */
export const DEFAULT_GRACE_MS = 3_000
/** Default per-stream spill cap (the `maxSpillBytes` config). */
export const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
let spillCounter = 0
let defaultSpillDir: string | undefined
@@ -133,9 +138,9 @@ function privateSpillDir(): string {
}
/**
* Collects one stream with a bounded in-memory tail. The FULL stream is
* always recoverable: on first overflow a spill file is created and every
* chunk (including those already collected) is appended there.
* Collects one stream with a bounded in-memory tail. On first overflow a
* spill file is created and every chunk (including those already collected)
* is appended there while the full stream remains within `maxSpillBytes`.
*
* Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
* end of command output; the spill file covers the head.
@@ -146,11 +151,13 @@ export class OutputCollector {
private dropped = false
private spillFd: number | undefined
private spillFile: string | undefined
private spillDisabled = false
/** Total bytes ever pushed (not just retained). */
private total = 0
constructor(
private readonly maxBytes: number,
private readonly maxSpillBytes: number,
private readonly label: string,
private readonly spillDir: string,
) {}
@@ -166,7 +173,7 @@ export class OutputCollector {
push(chunk: Buffer): void {
this.total += chunk.length
const overflows = this.bytes + chunk.length > this.maxBytes
if (overflows || this.spillFd !== undefined) this.spillAll(chunk)
if (!this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(chunk)
this.chunks.push(chunk)
this.bytes += chunk.length
while (this.bytes > this.maxBytes && this.chunks.length > 1) {
@@ -188,6 +195,10 @@ export class OutputCollector {
/** Open the spill file lazily and append `chunk` (and any prior chunks once). */
private spillAll(chunk: Buffer): void {
if (this.total > this.maxSpillBytes) {
this.discardSpill()
return
}
if (this.spillFd === undefined) {
// Random suffix + O_EXCL + no-follow-equivalent ('wx' fails on any
// existing path, symlink or not) + owner-only mode: defeats spill-path
@@ -202,6 +213,30 @@ export class OutputCollector {
writeSync(this.spillFd, chunk)
}
/** Stop spilling and remove the file once it can no longer hold the complete stream. */
private discardSpill(): void {
const fd = this.spillFd
const file = this.spillFile
this.spillFd = undefined
this.spillFile = undefined
this.spillDisabled = true
if (fd !== undefined) {
try {
closeSync(fd)
} catch {
// Retain the descriptor so finalize can retry the failed close.
this.spillFd = fd
}
}
if (file !== undefined) {
try {
unlinkSync(file)
} catch {
// A failed unlink leaves at most maxSpillBytes behind, never an unbounded file.
}
}
}
/**
* Incremental read in whole-stream byte coordinates: returns everything
* pushed since `fromByte`. When `fromByte` has already slid out of the
@@ -301,8 +336,8 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
: spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
const stdout = new OutputCollector(spec.stdoutMaxBytes, 'stdout', spillDir)
const stderr = new OutputCollector(spec.stderrMaxBytes, 'stderr', spillDir)
const stdout = new OutputCollector(spec.stdoutMaxBytes, spec.maxSpillBytes, 'stdout', spillDir)
const stderr = new OutputCollector(spec.stderrMaxBytes, spec.maxSpillBytes, 'stderr', spillDir)
child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) })
child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) })
@@ -328,12 +363,13 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
}
const done = new Promise<SpawnOutcome>((resolve, reject) => {
child.on('error', (error) => {
// No meaningful close outcome follows a spawn failure.
cleanup()
reject(error)
})
child.on('close', (exitCode, signal) => {
let settled = false
let pipeDrainTimer: NodeJS.Timeout | undefined
const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
if (settled) return
settled = true
child.stdout.destroy()
child.stderr.destroy()
cleanup()
resolve({
exitCode,
@@ -341,9 +377,20 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
stdout: stdout.finalize(),
stderr: stderr.finalize(),
})
}
child.on('error', (error) => {
// No meaningful close outcome follows a spawn failure.
settled = true
cleanup()
reject(error)
})
child.on('exit', (exitCode, signal) => {
pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) }, spec.graceMs)
})
child.on('close', settle)
function cleanup(): void {
if (graceTimer !== undefined) clearTimeout(graceTimer)
if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer)
spec.signal?.removeEventListener('abort', onAbort)
}
})

View File

@@ -66,6 +66,7 @@ describe('LocalBashExecutor.run', () => {
await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/)
await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/)
await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/)
await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/)
await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/)
const { bash } = await setup()

View File

@@ -1,4 +1,4 @@
import { mkdtempSync, readFileSync, statSync } from 'node:fs'
import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
@@ -6,7 +6,10 @@ import type { DshEnvironment } from '@deepseek-ai/dsh-bash'
import { killGroup, OutputCollector, runBash } from '../src/run.ts'
import type { RunningBash } from '../src/run.ts'
const { failNextClose } = vi.hoisted(() => ({ failNextClose: { value: false } }))
const { failNextClose, failNextUnlink } = vi.hoisted(() => ({
failNextClose: { value: false },
failNextUnlink: { value: false },
}))
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>()
return {
@@ -18,6 +21,13 @@ vi.mock('node:fs', async (importOriginal) => {
}
actual.closeSync(fd)
},
unlinkSync(path: Parameters<typeof actual.unlinkSync>[0]): void {
if (failNextUnlink.value) {
failNextUnlink.value = false
throw Object.assign(new Error('simulated EIO on unlink'), { code: 'EIO' })
}
actual.unlinkSync(path)
},
}
})
@@ -29,6 +39,7 @@ function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]>
cwd: process.cwd(),
stdoutMaxBytes: 64_000,
stderrMaxBytes: 64_000,
maxSpillBytes: 64 * 1024 * 1024,
graceMs: 3_000,
...overrides,
}
@@ -173,6 +184,22 @@ describe('runBash', () => {
const result = await running.done
expect(result.signal).toBe('SIGTERM')
})
it('bounds inherited-pipe draining after the shell exits', async () => {
const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`)
const started = Date.now()
const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 }))
const descendant = await waitForPidFile(pidFile)
try {
const result = await running.done
expect(Date.now() - started).toBeLessThan(1_000)
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('shell-done\n')
} finally {
process.kill(descendant, 'SIGKILL')
await waitGone(descendant)
}
})
})
describe('stdin and extra env (set by in-process plugins)', () => {
@@ -282,7 +309,7 @@ describe('output truncation and spill', () => {
describe('OutputCollector', () => {
it('keeps the tail of a single oversized chunk', () => {
const collector = new OutputCollector(10, 'test', spillDir)
const collector = new OutputCollector(10, 100, 'test', spillDir)
collector.push(Buffer.from('0123456789abcdef'))
const out = collector.finalize()
expect(out.text).toBe('6789abcdef')
@@ -291,7 +318,7 @@ describe('OutputCollector', () => {
})
it('readFrom returns increments and flags lossy reads', () => {
const collector = new OutputCollector(10, 'test', spillDir)
const collector = new OutputCollector(10, 100, 'test', spillDir)
collector.push(Buffer.from('aaaaa'))
const first = collector.readFrom(0)
expect(first.text).toBe('aaaaa')
@@ -312,7 +339,7 @@ describe('OutputCollector', () => {
})
it('contains close failures and drops the spill path', () => {
const collector = new OutputCollector(4, 'closefail', spillDir)
const collector = new OutputCollector(4, 100, 'closefail', spillDir)
collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbb'))
expect(collector.readFrom(0).spillPath).toBeDefined()
@@ -326,6 +353,46 @@ describe('OutputCollector', () => {
expect(out!.truncated).toBe(true)
expect(out!.spillPath).toBeUndefined()
})
it('discards a spill that exceeds its configured cap', () => {
const collector = new OutputCollector(4, 8, 'bounded', spillDir)
collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbb'))
const spillPath = collector.readFrom(0).spillPath!
expect(readFileSync(spillPath, 'utf8')).toBe('aaaabbbb')
collector.push(Buffer.from('c'))
collector.push(Buffer.from('dddd'))
const out = collector.finalize()
expect(out.text).toBe('dddd')
expect(out.truncated).toBe(true)
expect(out.spillPath).toBeUndefined()
expect(() => readFileSync(spillPath)).toThrow()
})
it('does not create a spill when the first overflowing chunk exceeds the cap', () => {
const collector = new OutputCollector(4, 4, 'no-spill', spillDir)
collector.push(Buffer.from('abcdefgh'))
const out = collector.finalize()
expect(out.text).toBe('efgh')
expect(out.truncated).toBe(true)
expect(out.spillPath).toBeUndefined()
})
it('contains cleanup failures while disabling an oversize spill', () => {
const collector = new OutputCollector(4, 8, 'cleanup-fail', spillDir)
collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbb'))
const spillPath = collector.readFrom(0).spillPath!
failNextClose.value = true
failNextUnlink.value = true
expect(() => { collector.push(Buffer.from('c')) }).not.toThrow()
expect(failNextClose.value).toBe(false)
expect(failNextUnlink.value).toBe(false)
expect(collector.finalize().spillPath).toBeUndefined()
unlinkSync(spillPath)
})
})
describe('killGroup', () => {

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-bash-sandbox
Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) and a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (which owns the default mode + workspace root, shared with the sandboxed filesystem) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; quoting and result-classification helpers stay internal.
@@ -16,7 +16,7 @@ Semantics:
- **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI).
- **Runner failures are sandbox failures, never command failures.** Foreground execution throws `SANDBOX_UNAVAILABLE`; a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting.
- **Config-time default, per-call policy.** The DEFAULT mode is fixed by this entry's config for the executor's lifetime; `resolve()` stamps it onto every spec, and an explicit request-level `sandboxMode` override — set by the tool layer only for a call whose wider mode a human granted through `ctx.approval` ([the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)) — makes THAT call run, classify, and report under its own mode while every neighbor keeps the default (background facts are stamped per task at settle). The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt.
- **Deployment default, per-call policy.** The DEFAULT mode + workspace root are owned by [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (one home both enforcing families read), not this executor's config; `resolve()` stamps the default onto every spec, and an explicit request-level `sandboxMode` override — set by the tool layer only for a call whose wider mode a human granted through `ctx.approval` ([the sandbox Agent Note § Escalation](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)) — makes THAT call run, classify, and report under its own mode while every neighbor keeps the default (background facts are stamped per task at settle). The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt.
- **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce.
- Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
@@ -25,11 +25,13 @@ Deny-only at the seam: a denial is a reported fact, and this executor never nego
```yaml
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
- id: bash
name: '@deepseek-ai/dsh-bash-sandbox'
- id: sandbox-policy
name: '@deepseek-ai/dsh-sandbox-policy'
config:
mode: read-only
workspaceRoot: !!js process.cwd()
- id: bash
name: '@deepseek-ai/dsh-bash-sandbox'
```
The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent); see [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo.
@@ -38,21 +40,45 @@ The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landloc
### Bash tool schema, indirectly
**What the model sees**: The generated [`dsh-tool-bash` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash) are the baseline. By advertising a confining `sandboxMode`, this backend augments `bash` with `sandbox_permissions` using enum `workspace-write` | `danger-full-access` and with `justification`. The backend adds no prompt prose, and the session's effective mode remains unstated.
#### What the model sees
**Token effect**: Small fixed schema increment on requests where `bash` is visible; mode switches add no context tokens.
The generated [`dsh-tool-bash` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash) are the baseline. By advertising a confining `sandboxMode`, this backend augments `bash` with `sandbox_permissions` using enum `workspace-write` | `danger-full-access` and with `justification`. The backend adds no prompt prose, and the session's effective mode remains unstated.
#### Token effect
Small fixed schema increment on requests where `bash` is visible; mode switches add no context tokens.
#### KV Cache effect
Prefix-stable while the executor advertises the same sandbox capabilities. Changing those capabilities alters the `bash` schema and may invalidate reuse from that definition; per-session mode switches do not.
### Bash tool result, indirectly
**What the model sees**: After ordinary bounded output, a denied call appends exactly `[sandbox: file access denied under <mode> mode]`. When escalation is available it next appends `[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]`. A settled background runner failure instead appends `[sandbox: the sandbox runner itself failed under <mode> mode — the command did not run; this is a sandbox problem, not a command failure]`.
#### What the model sees
**Token effect**: Zero additional tokens on an unremarkable allowed run beyond ordinary output. Denial or failure adds the quoted conditional marker, retained until compaction.
After ordinary bounded output, a denied call appends exactly `[sandbox: file access denied under <mode> mode]`. When escalation is available it next appends `[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]`. A settled background runner failure instead appends `[sandbox: the sandbox runner itself failed under <mode> mode — the command did not run; this is a sandbox problem, not a command failure]`.
#### Token effect
Zero additional tokens on an unremarkable allowed run beyond ordinary output. Denial or failure adds the quoted conditional marker, retained until compaction.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Bash tool error, indirectly
**What the model sees**: If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). For an execution-time runner failure, this backend supplies the first stderr line as its detail.
#### What the model sees
**Token effect**: Conditional error text is visible for that call and retained in history until compaction.
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). For an execution-time runner failure, this backend supplies the first stderr line as its detail.
#### Token effect
Conditional error text is visible for that call and retained in history until compaction.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work

View File

@@ -25,16 +25,15 @@
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-bash-local": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"node-addon-landlock-run": "0.0.0-test.0",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -7,52 +7,39 @@
* @module @deepseek-ai/dsh-bash-sandbox
*/
import { resolve } from 'node:path'
import { Context } from 'cordis'
import z from 'schemastery'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } from './helpers.ts'
/**
* Plugin config: the local executor's knobs plus the sandbox policy. All
* optional — `static Config` supplies the defaults (`mode: 'read-only'` is the
* fail-safe default; an example that wants a workspace-writable agent opts in
* explicitly). The runner choice is not configured here: which platform
* backend confines the command is the `ctx.sandbox` provider's config.
* Plugin config: the local executor's knobs, verbatim. The sandbox policy
* the default mode and the `workspace-write` boundary root — is NOT here: it
* lives on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), the one
* home both enforcing families read, so bash and fs can never confine to
* different roots. The runner choice is likewise the `ctx.sandbox` provider's
* config, not this executor's.
*/
export interface Config extends LocalConfig {
/** File-sandbox mode commands run under (default: `read-only`). */
mode?: SandboxMode
/**
* Root directory `workspace-write` mode may write under (default: the
* executor's default working directory — `cwd`, else `process.cwd()`).
*/
workspaceRoot?: string
}
export type Config = LocalConfig
/**
* Registers as `ctx.bash` in place of the local executor and requires a
* `ctx.sandbox` provider; the tool layer is unchanged. The configured mode is
* the fallback, while a session override or approved one-shot escalation may
* select each call's mode. The prompt does not state the standing mode;
* `result.sandbox` reports the mode and enforcement actually used.
* `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is
* unchanged. The policy default (mode + workspace root) is the fallback,
* while a session override or approved one-shot escalation may select each
* call's mode. The prompt does not state the standing mode; `result.sandbox`
* reports the mode and enforcement actually used.
*/
export class SandboxBashExecutor extends LocalBashExecutor {
static inject = ['sandbox']
static inject = ['sandbox', 'sandboxPolicy']
// The sandbox-specific fields intersect the local executor's Config as an
// inline schema call: the config catalog walks `static Config` statically.
static override Config: z<Config> = z.intersect([
LocalBashExecutor.Config,
z.object({
mode: z.union(['read-only', 'workspace-write', 'danger-full-access'] as const).default('read-only'),
workspaceRoot: z.string(),
}),
])
// No own Config: the sandbox default (mode + workspaceRoot) moved to
// ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config
// verbatim (the config catalog walks the inherited static).
private readonly mode: SandboxMode
private readonly workspaceRoot: string
@@ -71,9 +58,11 @@ export class SandboxBashExecutor extends LocalBashExecutor {
constructor(ctx: Context, config: Config) {
super(ctx, config)
// Schemastery fills mode before construction; workspaceRoot and cwd retain runtime fallbacks.
this.mode = config.mode as SandboxMode
this.workspaceRoot = resolve(config.workspaceRoot ?? config.cwd ?? process.cwd())
// The sandbox default (mode + workspaceRoot) is the one shared policy home
// both enforcing families read; injecting sandboxPolicy guarantees it is
// constructed first. workspaceRoot arrives already resolved absolute.
this.mode = ctx.sandboxPolicy.defaultMode
this.workspaceRoot = ctx.sandboxPolicy.workspaceRoot
}
/** The configured default mode — the capability fact the tool layer reads. */

View File

@@ -6,6 +6,7 @@ import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
@@ -40,7 +41,8 @@ async function tempDir(base: string): Promise<string> {
async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise<SandboxBashExecutor> {
ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
return ctx.bash as SandboxBashExecutor
}

View File

@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { launcherPath } from 'node-addon-landlock-run'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
/**
@@ -45,7 +46,8 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false }
await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
return ctx.bash as SandboxBashExecutor
}

View File

@@ -12,7 +12,8 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts'
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
@@ -39,7 +40,11 @@ const passthrough = (argv: readonly string[]): ConfinedArgv =>
* Boot a context with a recording fake `ctx.sandbox` (behavior injectable
* per test) and the executor under test on top of it.
*/
async function setup(config: Config = {}, behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough) {
async function setup(
config: { mode?: SandboxMode; workspaceRoot?: string } & Config = {},
behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough,
) {
const { mode, workspaceRoot, ...execConfig } = config
const calls: ConfineCall[] = []
class FakeSandboxProvider extends SandboxProvider {
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
@@ -49,7 +54,11 @@ async function setup(config: Config = {}, behavior: (argv: readonly string[], po
}
const ctx = new Context()
await ctx.plugin(FakeSandboxProvider)
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...config })
await ctx.plugin(SandboxPolicyService, {
...mode !== undefined ? { mode } : {},
...workspaceRoot !== undefined ? { workspaceRoot } : {},
})
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...execConfig })
const bash = ctx.bash as SandboxBashExecutor
bash.internals = { spillDir }
return { ctx, bash, calls }
@@ -84,14 +93,14 @@ describe('the provider hand-off', () => {
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
})
it('workspace-write rides the policy, workspaceRoot falling back to cwd when not configured', async () => {
const { bash, calls } = await setup({ mode: 'workspace-write', cwd: tmpdir() })
it('workspace-write rides the policy, workspaceRoot falling back to process.cwd() when not configured', async () => {
const { bash, calls } = await setup({ mode: 'workspace-write' })
const result = await bash.run(bash.resolve({ command: 'true' }))
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(tmpdir()) })
expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(process.cwd()) })
})
it('an explicit workspaceRoot wins over cwd', async () => {
it('an explicit workspaceRoot on the policy wins', async () => {
const { calls, bash } = await setup({ mode: 'workspace-write', workspaceRoot: '/ws', cwd: tmpdir() })
await bash.run(bash.resolve({ command: 'true' }))
expect(calls[0]?.policy.workspaceRoot).toBe(resolve('/ws'))

View File

@@ -6,6 +6,7 @@ import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
@@ -39,7 +40,8 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' }
await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
return ctx.bash as SandboxBashExecutor
}

View File

@@ -14,9 +14,6 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/brand"
},
@@ -26,6 +23,9 @@
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../sandbox/sandbox-policy"
},
{
"path": "../../bash/bash"
},

View File

@@ -29,15 +29,19 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp
`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxMode) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxMode` is optional on the request and required-but-nullable on the resolved spec: it carries an approved one-shot escalation or the session's standing override; a sandboxing executor stamps its configured default when absent, while a non-sandboxing executor carries the field and confines nothing.
The seam also owns the per-session mode override vocabulary: the log-only `'bash/sandbox-mode'` session event, the pure `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path. `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md).
The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md).
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, reject those names in ordinary `env`, then merge `dshEnv`, so an omitted current fact cannot fall back to stale ambient state. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment RFC](../../../docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, reject those names in ordinary `env`, then merge `dshEnv`, so an omitted current fact cannot fall back to stale ambient state. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
## Model Experience
Indirectly, through `dsh-tool-bash`, which turns executor output and sandbox facts into guidance and retained tool-result tokens.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **No interactive-input vocabulary** — `stdin` is written once at spawn and closed; the seam has no channel to feed a running task and no PTY session concept.
- **Foreground timeouts are always executor-owned** — a caller-owned-deadline mode on the seam is explicitly deferred by [the tool-call timeout-policy RFC](../../../docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md).
- **Foreground timeouts are always executor-owned** — a caller-owned-deadline mode on the seam is explicitly deferred by [the tool-call timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md).

View File

@@ -23,12 +23,10 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -10,7 +10,6 @@ import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from './types.ts'
export { DSH_ENV_PREFIX } from './types.ts'
export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
export type {
BashExecRequest,
BashExecSpec,

View File

@@ -1,52 +0,0 @@
/**
* Per-session sandbox-mode override stored as log-only events. Folding the log
* isolates sessions and survives replay; the tool stamps the override onto
* each call unless an approved one-shot escalation outranks it, and the
* executor default applies when neither exists. The model receives neither the
* event nor a standing-mode notice; denial results name the effective mode.
* @module dsh-bash/session-mode
*/
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* Durable log-only sandbox-mode override; never a surface event or model
* message. Execution and ACP option reporting fold the latest event through
* {@link effectiveSandboxMode} without adding a prompt notice.
*/
'bash/sandbox-mode': { mode: SandboxMode }
}
}
/** Every {@link SandboxMode}, for option advertisement and runtime validation of untrusted mode strings. */
export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access']
/**
* The session's sandbox-mode override: the last `bash/sandbox-mode` event in
* the log, or undefined when the session never switched and callers should use
* the executor default. Replay needs no separate catch-up state.
* @param events - session events in log order (other event types are skipped).
* @returns the mode of the last switch event, or undefined without one.
*/
export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMode | undefined {
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index] as SessionEvent
if (event.type === 'bash/sandbox-mode') return event.data.mode
}
return undefined
}
/**
* Append one `bash/sandbox-mode` event as the only override write path.
* Execution and ACP option reporting fold it on read; prompt assembly does not
* consume it.
* @param session - the session the override belongs to.
* @param mode - the mode every subsequent bash call in this session runs
* under (until the next switch).
*/
export function setSandboxMode(session: Session, mode: SandboxMode): void {
session.append('bash/sandbox-mode', { mode })
}

View File

@@ -131,7 +131,7 @@ export interface BashRunResult {
* short. Mutually exclusive with {@link aborted}: one fused deadline drives
* both the timeout and the caller's cancellation, so a timeout and an abort
* racing before process close report the single first-abort cause, not both
* (see the [timeout-library RFC](../../../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
* (see the [timeout-library Agent Note](../../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
*/
timedOut: boolean
/**

View File

@@ -16,9 +16,6 @@
},
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../core/session"
}
]
}

View File

@@ -57,58 +57,98 @@ The tool owns its `presentCall`/`presentResult` render intent. A foreground call
## The tool builds its request from named args only
The `BashExecRequest` seam carries optional `stdoutMaxBytes`, `stdin`, ordinary `env`, and managed `dshEnv`, used by trusted in-process plugins and this tool's environment registry. The model-facing tool exposes none of `stdoutMaxBytes`, `stdin`, or `env`: it builds requests from named command/workdir/timeout/signal/sandbox fields plus the registry-collected `dshEnv`. Extra model keys are ignored and cannot replace managed values. Shell syntax provides equivalent command-level behavior, while the local executor scrubs ambient credentials and stale `DSH_*` values. See the [stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
The `BashExecRequest` seam carries optional `stdoutMaxBytes`, `stdin`, ordinary `env`, and managed `dshEnv`, used by trusted in-process plugins and this tool's environment registry. The model-facing tool exposes none of `stdoutMaxBytes`, `stdin`, or `env`: it builds requests from named command/workdir/timeout/signal/sandbox fields plus the registry-collected `dshEnv`. Extra model keys are ignored and cannot replace managed values. Shell syntax provides equivalent command-level behavior, while the local executor scrubs ambient credentials and stale `DSH_*` values. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
## Permissions and escalation
Commands run with the executor's full authority unless a sandboxing executor ([`dsh-bash-sandbox`](../bash-sandbox/)) confines them — the deny-only sandbox reports denials as result facts, rendered here as the denial marker; per-call allow/deny/ask policy is the `tools/pre-execute` waterfall (see docs/architecture.md).
Escalating bash calls resolve `ctx.approval` before execution. `allowed-once` applies the requested mode only to that call; rejection, cancellation, unavailability, or missing approval context executes nothing and returns a distinct error. On a real denial, the model may retry the same command once in the same turn with the narrowest sufficient mode and justification; the approval prompt itself is the consent step. Escalation is never speculative, and a disabled or rejected approval is final. The [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) owns the rationale.
Escalating bash calls resolve `ctx.approval` before execution. `allowed-once` applies the requested mode only to that call; rejection, cancellation, unavailability, or missing approval context executes nothing and returns a distinct error. On a real denial, the model may retry the same command once in the same turn with the narrowest sufficient mode and justification; the approval prompt itself is the consent step. Escalation is never speculative, and a disabled or rejected approval is final. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns the rationale.
## Per-session mode switching
For sandboxing executors, each call resolves mode as one-shot escalation, then session override, then executor default. Non-sandboxing and agent-less calls carry no session override. Neither the prompt nor a switch notice announces the standing mode; denial results report the effective mode when the boundary matters. See the [`dsh-bash` fold](../bash/README.md) and [sandbox switching contract](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).
For sandboxing executors, each call resolves mode as one-shot escalation, then session override, then executor default. Non-sandboxing and agent-less calls carry no session override. Neither the prompt nor a switch notice announces the standing mode; denial results report the effective mode when the boundary matters. See the [`dsh-bash` fold](../bash/README.md) and [sandbox switching contract](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
## Model Experience
### System prompt
**What the model sees**: Every request in this plugin's registration scope contains the bash guidance below. A sandboxing executor adds no mode statement or switch notice. Scoped tool restrictions can hide the schemas without removing this independently registered section.
#### What the model sees
**Token effect**: Small fixed input cost per request while the plugin is active, unchanged by sandbox mode or mode switches.
Every request in this plugin's registration scope contains the bash guidance below. A sandboxing executor adds no mode statement or switch notice. Scoped tool restrictions can hide the schemas without removing this independently registered section.
#### Bash guidance
##### Bash guidance
```markdown
Check the [exit code: N] marker on every bash result; investigate failures before moving on.
```
#### Token effect
Small fixed input cost per request while the plugin is active, unchanged by sandbox mode or mode switches.
#### KV Cache effect
Prefix-stable while the registration scope and prompt text are unchanged. Plugin activation or disposal may invalidate reuse from this prompt section; sandbox mode switches do not.
### Tool schemas
**What the model sees**: The model sees the generated [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash). `run_in_background` appears only when this producer enables it; `sandbox_permissions` and `justification` appear only when the mounted executor advertises sandboxing. Agent-scoped tool restrictions can remove the definition for that agent.
#### What the model sees
**Token effect**: Fixed schema cost on every request where the tools are visible; sandbox support adds the escalation fields and its conditional description paragraph.
The model sees the generated [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash). `run_in_background` appears only when this producer enables it; `sandbox_permissions` and `justification` appear only when the mounted executor advertises sandboxing. Agent-scoped tool restrictions can remove the definition for that agent.
#### Token effect
Fixed schema cost on every request where the tools are visible; sandbox support adds the escalation fields and its conditional description paragraph.
#### KV Cache effect
Prefix-stable while visibility, background support, and executor sandbox capabilities are unchanged. A restriction, config change, or executor change may invalidate reuse from the first changed tool definition.
### Foreground result
**What the model sees**: The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. With no output it emits exactly `(no output)`. Conditional lines are exactly `[output truncated; full output: <path-or-(unavailable)>]`, `[sandbox: file access denied under <mode> mode]`, `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]`; the sandbox escalation and runner-failure lines are quoted in [`dsh-bash-sandbox`](../bash-sandbox/README.md).
#### What the model sees
**Token effect**: Zero result tokens before a call. Output is bounded per stream, while each emitted line remains in history until compaction.
The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. With no output it emits exactly `(no output)`. Conditional lines are exactly `[output truncated; full output: <path-or-(unavailable)>]`, `[sandbox: file access denied under <mode> mode]`, `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]`; the sandbox escalation and runner-failure lines are quoted in [`dsh-bash-sandbox`](../bash-sandbox/README.md).
#### Token effect
Zero result tokens before a call. Output is bounded per stream, while each emitted line remains in history until compaction.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Background task context and results
**What the model sees**: Start returns exactly `started background task <taskId>`. This producer supplies incremental process output, optional `[some output was dropped from memory; full output: <paths-or-(unavailable)>]`, sandbox facts, and terminal detail such as `exit code: <exitCode>` or `signal: <signal>` to the generic task runtime. [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md) owns the visible status line, completion notice, listing, and cancellation response.
#### What the model sees
**Token effect**: The start acknowledgement is small and retained; collected output is data-dependent and bounded by the executor's stream buffers. Consuming reads do not repeat prior output.
Start returns exactly `started background task <taskId>`. This producer supplies incremental process output, optional `[some output was dropped from memory; full output: <paths-or-(unavailable)>]`, sandbox facts, and terminal detail such as `exit code: <exitCode>` or `signal: <signal>` to the generic task runtime. [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md) owns the visible status line, completion notice, listing, and cancellation response.
#### Token effect
The start acknowledgement is small and retained; collected output is data-dependent and bounded by the executor's stream buffers. Consuming reads do not repeat prior output.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Tool errors
**What the model sees**: Validation and policy failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`.
#### What the model sees
**Token effect**: Only the failing call adds these retained tokens; a rejected escalation does not add command output because the command does not run.
Validation and policy failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`.
#### Token effect
Only the failing call adds these retained tokens; a rejected escalation does not add command output because the command does not run.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work
- **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay; a display-only known residual.
- **The `bash` tool opts out of `timeout-policy` budgets** — it keeps the executor-owned `BASH_TIMEOUT` path, per [the tool-call timeout-policy RFC](../../../docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md).
- **The `bash` tool opts out of `timeout-policy` budgets** — it keeps the executor-owned `BASH_TIMEOUT` path, per [the tool-call timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md).
- **Background processes have no executor timeout** — callers must use `task_kill`, or rely on owner/service disposal, when work no longer matters.

View File

@@ -23,15 +23,16 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-home": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
@@ -47,6 +48,7 @@
"@deepseek-ai/dsh-home": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
@@ -54,6 +56,7 @@
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -15,12 +15,13 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-session-persistence'
import { assertNever } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tasks'
import type {} from '@deepseek-ai/dsh-user-approval'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { DSH_ENV_PREFIX, effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home'
import { processOutcome } from './background.ts'
@@ -226,24 +227,11 @@ function validateBashArgs(args: BashToolArgs): void {
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
}
if (args.sandbox_permissions !== undefined && args.justification === undefined) {
throw new Error('invalid escalation: sandbox_permissions requires a justification')
}
if (args.justification !== undefined && args.sandbox_permissions === undefined) {
throw new Error('invalid escalation: justification is only valid together with sandbox_permissions')
}
if (args.justification !== undefined && args.justification.trim().length === 0) {
throw new Error('invalid justification: expected a non-empty sentence')
}
// The escalation pairing (sandbox_permissions ⇔ justification, non-empty) is
// the shared rule both enforcing families validate identically.
validateEscalationArgs(args.sandbox_permissions, args.justification)
}
const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
'read-only': ['workspace-write', 'danger-full-access'],
'workspace-write': ['danger-full-access'],
}
const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access']
function bashDescription(backgroundEnabled: boolean, escalationModes: readonly SandboxMode[]): string {
const background = backgroundEnabled
? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.'
@@ -346,35 +334,32 @@ export function apply(ctx: Context, config: Config = {}): void {
const sessionOverride = (exec: ToolExecution): SandboxMode | undefined =>
defaultMode === undefined || exec.agent === undefined ? undefined : effectiveSandboxMode(exec.agent.session.events)
const approveEscalation = async (mode: string, justification: string, exec: ToolExecution): Promise<SandboxMode> => {
/**
* Resolve a sandbox-escalation request through `ctx.approval` BEFORE
* anything executes, delegating the shared fail-closed sequence (strict
* widening, channel resolution, outcome mapping) to
* {@link approveEscalation}. This tool contributes only the composition
* guard (the fields are unadvertised without a sandboxing executor, yet
* schema validation checks advertised keys only, so an unadvertised
* `sandbox_permissions` still reaches execute) and the approval ingredients
* — the seam is consumed opportunistically (`ctx.get`) so a deployment
* without it degrades per call.
*/
const approveBashEscalation = (mode: string, justification: string, exec: ToolExecution): Promise<SandboxMode> => {
if (escalationModes.length === 0) {
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
}
const effectiveMode = (sessionOverride(exec) ?? defaultMode) as SandboxMode
if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) {
throw new Error(`sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`)
}
const approval = ctx.get('approval')
if (approval === undefined) {
throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval service is composed`)
}
if (exec.agent === undefined) {
throw new Error(`sandbox escalation to "${mode}" requires approval, but the call has no agent to route it through`)
}
const outcome = await approval.request({
agent: exec.agent,
toolName: 'bash',
callId: exec.callId,
reason: `escalate sandbox to ${mode}: ${justification}`,
...exec.signal ? { signal: exec.signal } : {},
})
switch (outcome) {
case 'allowed-once': return mode as SandboxMode
case 'rejected': throw new Error(`the user rejected escalating this command to "${mode}"`)
case 'cancelled': throw new Error(`approval for escalating to "${mode}" was cancelled`)
case 'unavailable': throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval channel is available`)
default: return assertNever(outcome, 'ApprovalOutcome')
}
return approveEscalation(
{ requestedMode: mode, justification, effectiveMode, subject: 'command' },
{
approver: ctx.get('approval'),
agent: exec.agent,
callId: exec.callId,
toolName: 'bash',
...exec.signal ? { signal: exec.signal } : {},
},
)
}
// Cross-call guidance belongs in the prompt rather than one-call schema prose.
@@ -417,7 +402,7 @@ export function apply(ctx: Context, config: Config = {}): void {
validateBashArgs(args)
// Description is display metadata; workdir defaults to the caller's session.
const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined
? await approveEscalation(args.sandbox_permissions, args.justification, exec)
? await approveBashEscalation(args.sandbox_permissions, args.justification, exec)
: sessionOverride(exec)
const workdir = resolveWorkdir(args.workdir, exec)
const dshEnv = bashEnv.collect(exec)

View File

@@ -6,6 +6,7 @@
import type { BashProcessRead, BashRunResult, BashSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox'
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
function streamText(output: CollectedOutput): string {
@@ -42,10 +43,10 @@ export function renderResult(
const markers: string[] = []
// Keep the exit marker last because parseExitStatus anchors there.
if (result.sandbox?.denied) {
markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
markers.push(sandboxDenialMarker(result.sandbox.mode))
// Hint only when the composition exposes escalation, before the final exit marker.
if (escalationModes.length > 0) {
markers.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
markers.push(escalationHintMarker('command'))
}
}
// A command may trap SIGTERM and exit 0 after timeout; still report interruption.
@@ -84,9 +85,9 @@ export function renderProcessRead(
if (sandbox?.runnerFailed) {
notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`)
} else if (sandbox?.denied) {
notices.push(`[sandbox: file access denied under ${sandbox.mode} mode]`)
notices.push(sandboxDenialMarker(sandbox.mode))
if (escalationModes.length > 0) {
notices.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
notices.push(escalationHintMarker('command'))
}
}
if (notices.length === 0) return read.delta

View File

@@ -181,7 +181,7 @@ async function setupSandboxed(withApproval = false) {
function sandboxAgent(mode?: 'read-only' | 'workspace-write' | 'danger-full-access', ctx?: Context): Agent {
const events: Array<{ type: string; data?: Record<string, unknown> }> = [{ type: 'turn/start' }]
if (mode !== undefined) events.push({ type: 'bash/sandbox-mode', data: { mode } })
if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } })
const id = SessionId('sandbox-session')
return {
id,
@@ -287,7 +287,7 @@ describe('bash tool', () => {
})
// Type and required-key violations are rejected by the harness
// (defineTool validates against the SchemaSpec — the arg-validation RFC) before execute.
// (defineTool validates against the SchemaSpec — the arg-validation Agent Note) before execute.
it.each([
[{}, /missing required property "command"/],
[{ command: 42, description: 'd' }, /"command" must be a string/],
@@ -553,7 +553,7 @@ describe('sandbox escalation through the generic task producer', () => {
const malformed = sandboxAgent()
;(malformed.session.events as unknown as Array<{ type: string; data: { mode: string } }>).push({
type: 'bash/sandbox-mode',
type: 'sandbox/mode',
data: { mode: 'unknown-mode' },
})
expect(text(await call(ctx, 'bash', escalate, malformed))).toContain('not strictly wider')
@@ -610,7 +610,7 @@ describe('sandbox escalation through the generic task producer', () => {
const { ctx } = await setupSandboxed(true)
ctx.approval.request = () => Promise.resolve('rogue' as ApprovalOutcome)
const result = await call(ctx, 'bash', escalate, sandboxAgent())
expect(text(result)).toContain('unreachable variant in ApprovalOutcome')
expect(text(result)).toContain('unreachable variant in EscalationOutcome')
})
})
@@ -946,7 +946,7 @@ describe('the model-facing bash tool builds its request from named args only (no
* model input into the post-scrub `env` merge or per-run capture budget — NOT
* to defend a trust boundary
* (the credential scrub in dsh-bash-local is the security control; see the
* bash-stdin-env RFC). Foreground `run()` returns a canned result; `start()`
* bash-stdin-env Agent Note). Foreground `run()` returns a canned result; `start()`
* hands back an already-settled fake handle so the task registration completes.
*/
class RecordingBashExecutor extends BashExecutor {

View File

@@ -46,6 +46,9 @@
},
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../sandbox/sandbox-policy"
}
]
}

View File

@@ -1,6 +1,6 @@
# code-runtime/ — code-execution capability family
The code-execution capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's [Code Mode](../core/tools/README.md) (`tools: { mode: code }` — the `run_code` tool and the generated TypeScript SDK); design in the [Code Mode RFC](../../docs/rfc/implemented/feature/2026-06-15-code-mode.md). **Product** packages.
The code-execution capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's [Code Mode](../core/tools/README.md) (`tools: { mode: code }` — the `run_code` tool and the generated TypeScript SDK); design in the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). **Product** packages.
| Package | Role | ctx key |
|---|---|---|

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-code-runtime-worker
Worker-thread implementation of the [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam: `WorkerCodeRuntime` runs each program in ONE fresh Node `worker_threads.Worker` — TypeScript in, type-stripped host-side, bindings bridged over the message port, `{ value, logs, error? }` out. **Containment, not a security boundary**: trust posture is bash-equivalent by design (the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md) § Trust posture), with containment bash does not have — separate isolate, empty environment, heap cap, hard termination.
Worker-thread implementation of the [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam: `WorkerCodeRuntime` runs each program in ONE fresh Node `worker_threads.Worker` — TypeScript in, type-stripped host-side, bindings bridged over the message port, `{ value, logs, error? }` out. **Containment, not a security boundary**: trust posture is bash-equivalent by design (the [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) § Trust posture), with containment bash does not have — separate isolate, empty environment, heap cap, hard termination.
## Config
@@ -37,6 +37,10 @@ The SDK surface is the default/named `WorkerCodeRuntime` class plus `Config`. Th
Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), which renders this worker's capped printed or returned data and exact `[dsh-code-runtime-worker] log capture truncated at <maxLogBytes> bytes` and `… [truncated]` markers into a retained `run_code` result. Binding traffic and worker internals stay outside context.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **OS processes a program spawns survive termination** — `worker.terminate()` ends the thread only, weaker than bash-local's process-group kill; orphan cleanup is a deployment concern until a container backend exists.

View File

@@ -2,7 +2,7 @@
The **code-execution seam**: an abstract `CodeRuntime` service (`ctx.codeRuntime`) defining WHAT a code runtime does — run one model-written program against a set of host-provided async bindings and report `{ value, logs, error? }` — without saying HOW.
This package is the interface third of the capability (the bash trio is the template — see [capability seams](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): implementations subclass `CodeRuntime` and register the service; the consumer is the tool registry's Code Mode, which generates the model-facing SDK and bridges tool dispatch — both specified in the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md), whose first implementation is a Node worker-thread backend. The runtime knows nothing about tools or sessions: it is handed named async functions and a program string, and everything tool-shaped stays with the consumer.
This package is the interface third of the capability (the bash trio is the template — see [capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): implementations subclass `CodeRuntime` and register the service; the consumer is the tool registry's Code Mode, which generates the model-facing SDK and bridges tool dispatch — both specified in the [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), whose first implementation is a Node worker-thread backend. The runtime knows nothing about tools or sessions: it is handed named async functions and a program string, and everything tool-shaped stays with the consumer.
## Service API (`ctx.codeRuntime`)
@@ -22,8 +22,12 @@ Semantics every implementation must honor (contract details in the class JSDoc):
Indirectly, through Code Mode in `dsh-tools`, which exposes `run_code` and returns program logs, values, or failures as retained tool-result tokens.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **`run()` is one-shot** — `logs` arrive only on the resolved `CodeRunResult`; the seam exposes no streaming-log or progress surface for a live program's output.
- **A persistent REPL-style kernel is recorded future work** — the no-state-between-runs contract stands until a persistent-kernel backend brings its own logging story ([Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md)).
- **A persistent REPL-style kernel is recorded future work** — the no-state-between-runs contract stands until a persistent-kernel backend brings its own logging story ([Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)).
- **Only the worker-thread backend ships** — `'process'`/`'container'` are declared well-known `isolation` values with no implementation; a hard security boundary awaits a container backend.

View File

@@ -1,6 +1,6 @@
# compact/ — compaction capability family
A compaction capability family (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract interface, a summarizing backend, a model-free tool-result pruning companion, and a deferred model-facing consumer. All **product** packages.
A compaction capability family (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract interface, a summarizing backend, a model-free tool-result pruning companion, and a deferred model-facing consumer. All **product** packages.
| Package | Role | ctx key |
|---|---|---|
@@ -9,4 +9,4 @@ A compaction capability family (see [capability seams](../../docs/rfc/implemente
| `compact-tool-result-prune/` | Optional model-free head/middle/tail rewriting before summary compaction | `ctx.toolResultPrune` |
| `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) |
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`, and deterministic pruning at `compact/compact-tool-result-prune/`. Unlike the bash seam, the interface depends on `dsh-session` and `dsh-llm` because its verbs are defined over a `Session` and its output uses `ContentBlock`. That deviation is recorded in the [compaction capability-seam RFC](../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement remains a reusable LLM-family service; a template- or model-backed compactor can replace `compact-basic` without changing the meter, pruner, or callers.
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`, and deterministic pruning at `compact/compact-tool-result-prune/`. Unlike the bash seam, the interface depends on `dsh-session` and `dsh-llm` because its verbs are defined over a `Session` and its output uses `ContentBlock`. That deviation is recorded in the [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement remains a reusable LLM-family service; a template- or model-backed compactor can replace `compact-basic` without changing the meter, pruner, or callers.

View File

@@ -2,7 +2,7 @@
The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with reusable `ctx.tokenMeter` pressure, token-budget retention, and summarization as a direct one-shot `ctx.llm.stream()` call (interceptable at `llm/stream`).
This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design.
This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design.
## What it owns
@@ -57,29 +57,45 @@ Loading the plugin registers `ctx.compact`. Add [`dsh-compact-tool-result-prune`
### Conversation history
**What the model sees**: After a successful step crosses the threshold, oversized tool results are first rewritten when the optional pruner is loaded. If summarization remains necessary, the next request receives the checkpoint preamble below, a blank line, `<compacted-summary>`, the data-dependent summary, and `</compacted-summary>`. Overflow recovery rebuilds the immediate retry from whatever replacement advanced the surface.
#### What the model sees
**Token effect**: Model-free pruning can avoid the auxiliary call entirely; otherwise it reduces that call's transcript before the summary replaces an older range. A summary remains until a later compaction replaces it, while an indivisible non-tool unit can still exceed the budget.
After a successful step crosses the threshold, oversized tool results are first rewritten when the optional pruner is loaded. If summarization remains necessary, the next request receives the checkpoint preamble below, a blank line, `<compacted-summary>`, the data-dependent summary, and `</compacted-summary>`. Overflow recovery rebuilds the immediate retry from whatever replacement advanced the surface. A checkpoint replaces the selected older range and is followed by the retained recent units.
#### Conversation checkpoint preamble
##### Conversation checkpoint preamble
```markdown
This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.
```
#### Token effect
Model-free pruning can avoid the auxiliary call entirely; otherwise it reduces that call's transcript before the summary replaces an older range. The replacement reduces future input history rather than appending a second copy. A summary remains until a later compaction replaces it, while an indivisible non-tool unit can still exceed the budget.
#### KV Cache effect
Replacing rather than append-only. Each checkpoint invalidates reuse from the first replaced history token; the unchanged request prefix before that range remains reusable.
### Auxiliary summarizer user message
**What the model sees**: The summarization model receives exactly `Summarize this conversation history:` followed by a blank line, the data-dependent [`renderTranscript()`](../compact/README.md) output, another blank line, and `Summary:`. The conversation model never sees this private request or its reasoning; only returned text is stored.
#### What the model sees
**Token effect**: This is a separate model call with data-dependent input and `maxTokens`-capped output. Convergence retries can pay this cost more than once.
The summarization model receives exactly `Summarize this conversation history:` followed by a blank line, the data-dependent [`renderTranscript()`](../compact/README.md) output, another blank line, and `Summary:`. The conversation model never sees this private request or its reasoning; only returned text is stored.
#### Token effect
This is a separate model call with data-dependent input and `maxTokens`-capped output. Convergence retries can pay this cost more than once.
#### KV Cache effect
Independent of the conversation request cache. An auxiliary call can reuse an exact transcript prefix, while a different selected range or rendering invalidates reuse from its first changed token.
### Auxiliary summarizer system prompt
**What the model sees**: The summarization model receives the checkpoint-writing instruction below.
#### What the model sees
**Token effect**: Fixed auxiliary input cost plus the data-dependent transcript on every summarization attempt.
The summarization model receives the checkpoint-writing instruction below.
#### Auxiliary summarizer system prompt
##### Auxiliary summarizer system prompt
```markdown
You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.
@@ -117,6 +133,14 @@ Rules:
- If the transcript already contains a <compacted-summary> block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.
```
#### Token effect
Fixed auxiliary input cost plus the data-dependent transcript on every summarization attempt.
#### KV Cache effect
Prefix-stable for auxiliary calls while this instruction and the summarizer route are unchanged. Changing either starts a different prefix; transcript changes occur after the instruction.
## Known Limitations and Deferred Work
- **Meter accuracy follows the fixed heuristic** — missing reusable provider usage falls back to character count plus structural overhead rather than exact tokenization.
@@ -124,4 +148,4 @@ Rules:
- **Some indivisible-unit and envelope-only overflow remains outside surface compaction** — recovery cannot shrink system/tools/prefix, split an indivisible non-tool node, or repair a tool unit whose non-prunable remainder still exceeds the window. The optional pruner can shrink text-bearing tool-result bulk inside an otherwise indivisible pair.
- **`compactRegion` requires an open turn** — a manual call on a fully-closed session throws ("no open turn") rather than compacting.
- **Summarization failure preserves the latest durable surface** — before any replacement, the auto path logs a warning and proceeds with full over-budget history. If pruning already landed, a later summarization failure proceeds from that durable pruned surface. Summarization truncation at `maxTokens`, which hidden reasoning tokens can consume, follows the same rule.
- **The summarization call has no transcript-snapshot coverage** — `dsh-llm-replay` derives calls from `assistant/chunk` events, so this chunk-less direct `ctx.llm.stream()` call cannot replay (named deferred replay infrastructure in [the seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)).
- **The summarization call has no transcript-snapshot coverage** — `dsh-llm-replay` derives calls from `assistant/chunk` events, so this chunk-less direct `ctx.llm.stream()` call cannot replay (named deferred replay infrastructure in [the seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)).

View File

@@ -39,9 +39,17 @@ export function apply(ctx: Context): void {
### Pruned tool result
**What the model sees**: Once a compaction trigger qualifies, future requests see the retained head, `\n\n[... tool result middle pruned ...]\n\n`, and retained tail in place of the removed text. Rich blocks keep their order. The model does not see a second copy of the original.
#### What the model sees
**Token effect**: Each rewritten tool result has at most `thresholdChars` text code points. Pruning itself makes no model call; compact-basic skips summarization when the remeasured request falls below pressure, otherwise the summarizer reads the pruned surface.
Once a compaction trigger qualifies, future requests see the retained head, `\n\n[... tool result middle pruned ...]\n\n`, and retained tail in place of the removed text. Rich blocks keep their order. The model does not see a second copy of the original.
#### Token effect
Each rewritten tool result has at most `thresholdChars` text code points. Pruning itself makes no model call; compact-basic skips summarization when the remeasured request falls below pressure, otherwise the summarizer reads the pruned surface.
#### KV Cache effect
Replacing an earlier result invalidates the reusable request prefix from that result onward; subsequent requests reuse the new pruned prefix until another surface replacement occurs.
## Known Limitations and Deferred Work

View File

@@ -10,7 +10,7 @@ This package is the interface tier of the compaction capability, split so each c
| `@deepseek-ai/dsh-compact-basic` | a backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization |
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md).
Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md).
## Service API (`ctx.compact`)
@@ -61,15 +61,31 @@ Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and
### Conversation history, when a backend is invoked
**What the model sees**: A successful implementation replaces an older surface range with one user-role summary checkpoint; the raw events stay logged but stop appearing in derived model messages. The seam itself performs no rewrite.
#### What the model sees
**Token effect**: Zero direct tokens from this interface. A backend trades many retained history tokens for one summary and leaves the recent tail unchanged.
A successful implementation replaces an older surface range with one user-role summary checkpoint; the raw events stay logged but stop appearing in derived model messages. The seam itself performs no rewrite.
#### Token effect
Zero direct tokens from this interface. A backend trades many retained history tokens for one summary and leaves the recent tail unchanged.
#### KV Cache effect
A successful backend replacement invalidates reuse from the first shadowed history token; the seam itself does not alter a request.
### Transcript supplied to a compaction consumer
**What the model sees**: `renderTranscript()` joins entries with one blank line and renders them exactly as `User: <content>`, `Assistant: <content>`, `Tool result (call <callId>): <content>`, `Tool error (call <callId>): <content>`, `[Context: <content>]`, or `[Steering: <content>]`. Non-text blocks render exactly as `[reasoning: <text>]`, `[tool-call: <name>(<arguments>)]`, `[tool-result: <content>]`, `[tool-result]`, or `[<block-type>]`.
#### What the model sees
**Token effect**: Data-dependent input tokens are paid only by the auxiliary model or consumer that requests this transcript; the conversation model does not receive a duplicate transcript.
`renderTranscript()` joins entries with one blank line and renders them exactly as `User: <content>`, `Assistant: <content>`, `Tool result (call <callId>): <content>`, `Tool error (call <callId>): <content>`, `[Context: <content>]`, or `[Steering: <content>]`. Non-text blocks render exactly as `[reasoning: <text>]`, `[tool-call: <name>(<arguments>)]`, `[tool-result: <content>]`, `[tool-result]`, or `[<block-type>]`.
#### Token effect
Data-dependent input tokens are paid only by the auxiliary model or consumer that requests this transcript; the conversation model does not receive a duplicate transcript.
#### KV Cache effect
No conversation-cache invalidation. A consumer's auxiliary request can reuse only the exact prefix produced by this rendering; changed or compacted entries invalidate reuse from their first difference.
## Known Limitations and Deferred Work

View File

@@ -3,7 +3,7 @@
* compact and replace a history range with one summary node by subclassing
* {@link CompactService}. This interface necessarily depends on session and LLM
* vocabulary; the rationale is in the
* [compaction RFC](../../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md).
* [compaction Agent Note](../../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md).
* @module @deepseek-ai/dsh-compact
*/

View File

@@ -3,7 +3,7 @@
* Those declaration-merged events are log-only lock/provenance markers, not
* surface events; a separate replacement `user/message` carries the summary.
* Backend packages own configuration and retention policy; see
* `docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md`.
* `.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md`.
* @module @deepseek-ai/dsh-compact/types
*/
@@ -30,7 +30,7 @@ declare module '@deepseek-ai/dsh-session' {
* The model that wrote the summary — the summarize call's envelope,
* reported by the backend that made the call, logged so the one-shot
* request is reconstructable from log + code and "which model wrote
* this summary" has a durable answer (the reconstructability RFC).
* this summary" has a durable answer (the reconstructability Agent Note).
*/
model: string
/** The generation cap the summarize call sent, when one applied. */

View File

@@ -7,4 +7,4 @@ Product plugins that add model-visible request context without defining a tool o
| `time-context/` | Durable per-step current time and elapsed-time context | (none) |
| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/session-prefix` + `tools/post-execute`) |
The [`workspace-context` decision record](../../docs/rfc/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split.
The [`workspace-context` decision record](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split.

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-time-context
Opt-in durable context with the current zoned time and elapsed time sampled during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the durable time-context RFC](../../../docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.md).
Opt-in durable context with the current zoned time and elapsed time sampled during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the durable time-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md).
## Config
@@ -32,24 +32,32 @@ The time reading stays in derived conversation history until a later compaction
### Preparation-time temporal context
**What the model sees**: On each preparation attempt that injects, one source-tagged context message containing the two lines below. `<timestamp>` is an ISO-shaped local timestamp with numeric offset and IANA zone; durations use compact whole-second units. Positive intervals can leave an attempted step without a new reading.
#### What the model sees
**Token effect**: Each injected two-line message accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one for every eligible preparation attempt.
On each preparation attempt that injects, one source-tagged context message containing the two lines below. `<timestamp>` is an ISO-shaped local timestamp with numeric offset and IANA zone; durations use compact whole-second units. Positive intervals can leave an attempted step without a new reading.
#### First step
##### First step
```markdown
Time sampled while preparing turn <turn>, step 1: <timestamp>
Elapsed since the preceding model-visible message: <duration-or-unavailable>.
```
#### Later steps
##### Later steps
```markdown
Time sampled while preparing turn <turn>, step <step>: <timestamp>
Elapsed since the preceding step context: <duration-or-unavailable>.
```
#### Token effect
Each injected two-line message accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one for every eligible preparation attempt.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work
- **Whole-second display** — timestamps and durations omit sub-second precision even though durable event times retain milliseconds.

View File

@@ -78,11 +78,11 @@ Instruction content is read through `streamText()` under `maxSourceBytes`, even
### Baseline session prefix
**What the model sees**: At the first request of each loop instance, the model receives one user-role prefix message containing the bounded user-global and project instruction chain in broad-to-specific order.
#### What the model sees
**Token effect**: The rendered baseline is frozen and resent on every request in that loop instance. `maxBytes` bounds the complete message, broader files are omitted before the most-specific file is truncated, and an empty chain contributes zero tokens.
At the first request of each loop instance, the model receives one user-role prefix message containing the bounded user-global and project instruction chain in broad-to-specific order.
#### Baseline instruction template
##### Baseline instruction template
```markdown
<system-reminder>
@@ -98,13 +98,21 @@ Instructions from: AGENTS.md
</system-reminder>
```
#### Token effect
The rendered baseline is frozen and resent on every request in that loop instance. `maxBytes` bounds the complete message, broader files are omitted before the most-specific file is truncated, and an empty chain contributes zero tokens.
#### KV Cache effect
Prefix-stable within one loop instance because the baseline is frozen. A new or resumed instance recomposes it, so instruction, precedence, cwd, candidate, or byte-budget changes may invalidate reuse from the first changed baseline token.
### Newly discovered scope context
**What the model sees**: After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained raw `context/message` with the newly applicable instruction file.
#### What the model sees
**Token effect**: Each discovered scope adds bounded history tokens until compaction. Unchanged content is suppressed by visible session state plus version/digest comparison, and Code Mode defers the same message until after the outer `run_code` result.
After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained raw `context/message` with the newly applicable instruction file.
#### Additional instruction template
##### Additional instruction template
```markdown
<system-reminder>
@@ -116,13 +124,21 @@ These instructions apply to work under `packages/app`. Use them as guidance when
</system-reminder>
```
#### Token effect
Each discovered scope adds bounded history tokens until compaction. Unchanged content is suppressed by visible session state plus version/digest comparison, and Code Mode defers the same message until after the outer `run_code` result.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Changed or removed instruction context
**What the model sees**: A changed file produces `Updated instructions from: <path>` plus its replacement content; a candidate switch also names the previous path. A removed final candidate produces the removal notice below.
#### What the model sees
**Token effect**: Each confirmed change or removal is one retained history message bounded by `maxBytes`. Provider failures add no message, and an update omitted by the budget remains eligible for a later filesystem touch.
A changed file produces `Updated instructions from: <path>` plus its replacement content; a candidate switch also names the previous path. A removed final candidate produces the removal notice below.
#### Removal notice
##### Removal notice
```markdown
<system-reminder>
@@ -132,6 +148,14 @@ The previously loaded instructions from this file no longer apply.
</system-reminder>
```
#### Token effect
Each confirmed change or removal is one retained history message bounded by `maxBytes`. Provider failures add no message, and an update omitted by the budget remains eligible for a later filesystem touch.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work
- **Discovery follows structured fs tools, not shell navigation** — a `bash` command that changes directories does not trigger nested instruction discovery because shell syntax and per-call shell state are not a reliable filesystem seam.

View File

@@ -1,6 +1,6 @@
# packages/cordis — the self-referential runtime toolset
Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service surface, mount model-written plugins, and dispose them again. Design home: [the toolset RFC](../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service surface, mount model-written plugins, and dispose them again. Design home: [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
| Package | Role | ctx key |
|---|---|---|

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-tool-cordis
The self-referential cordis toolset: three model-facing tools over the live runtime the agent runs inside. Design home — sandbox semantics, mount lifecycle, cross-mount composition, the generated API catalog, standing decisions: [the toolset RFC](../../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
The self-referential cordis toolset: three model-facing tools over the live runtime the agent runs inside. Design home — sandbox semantics, mount lifecycle, cross-mount composition, the generated API catalog, standing decisions: [the toolset Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
## What it does
@@ -12,7 +12,7 @@ Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-cata
## Trust stance
The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Treat this toolset like bash access; see the [design and trust stance](../../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Treat this toolset like bash access; see the [design and trust stance](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
## Config
@@ -36,21 +36,45 @@ Namespace plugin: named exports `name` / `inject` / `Config` / `apply`, no defau
### Tool schemas
**What the model sees**: The conversation model sees the generated [`cordis_inspect`, `cordis_mount`, and `cordis_unmount` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) whenever this plugin is visible.
#### What the model sees
**Token effect**: Fixed schema cost on every request in that tool view.
The conversation model sees the generated [`cordis_inspect`, `cordis_mount`, and `cordis_unmount` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) whenever this plugin is visible.
#### Token effect
Fixed schema cost on every request in that tool view.
#### KV Cache effect
Prefix-stable while this tool view is unchanged. Scoping or plugin lifecycle changes that hide these definitions may invalidate reuse from the first changed schema token.
### Tool-call history and results
**What the model sees**: Inspect joins selected sections exactly as `## <section>` then a newline and the data-dependent body, with one blank line between sections. Its broad API/event reports omit JSDoc; `name` with `what: "api"` or `what: "events"` returns one exact target with its original JSDoc. Mount returns `mounted <id> (plugin "<name>", state: <state>)`, optionally inserting ` — waiting for service(s): <names> (activates when provided)` before the closing parenthesis. Unmount returns `unmounted <id> (plugin "<name>")`; an unknown id becomes `Error: no dynamic plugin with id "<id>" (list mounts with cordis_inspect what:"dynamic")`. The submitted mount program remains in the assistant tool-call history.
#### What the model sees
**Token effect**: Inspect output and mount code are data-dependent and resent until compaction; lifecycle acknowledgements are small.
Inspect joins selected sections exactly as `## <section>` then a newline and the data-dependent body, with one blank line between sections. Its broad API/event reports omit JSDoc; `name` with `what: "api"` or `what: "events"` returns one exact target with its original JSDoc. Mount returns `mounted <id> (plugin "<name>", state: <state>)`, optionally inserting ` — waiting for service(s): <names> (activates when provided)` before the closing parenthesis. Unmount returns `unmounted <id> (plugin "<name>")`; an unknown id becomes `Error: no dynamic plugin with id "<id>" (list mounts with cordis_inspect what:"dynamic")`. The submitted mount program remains in the assistant tool-call history.
#### Token effect
Inspect output and mount code are data-dependent and resent until compaction; lifecycle acknowledgements are small.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Later requests after a mount
**What the model sees**: A mounted plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; unmount removes those contributions after quiescence.
#### What the model sees
**Token effect**: Indirect token impact equals the mounted plugin's contributions and lasts only for the mount lifetime.
A mounted plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; unmount removes those contributions after quiescence.
#### Token effect
Indirect token impact equals the mounted plugin's contributions and lasts only for the mount lifetime.
#### KV Cache effect
Mounting or unmounting a prompt or tool contribution changes later request prefixes and may invalidate reuse from the first changed contribution; an unchanged mount set remains prefix-stable.
## Known Limitations and Deferred Work

View File

@@ -241,12 +241,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
jsDoc: '/**\n * List direct children of a directory in stable name order. Returns resolved\n * child targets plus cheap metadata only; never reads file contents.\n * @param target - the resolved directory target.\n * @param signal - aborts the listing.\n * @returns one entry per direct child, in stable name order.\n */',
},
{
signature: 'abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>',
jsDoc: '/**\n * Atomically create or replace UTF-8 text. `expected` guards intent and\n * staleness; omission allows unconditional overwrite.\n * @param target - the resolved target to write.\n * @param content - the full new file content.\n * @param expected - the write intent guarding the write; omit for unconditional.\n * @param signal - aborts before the atomic rename takes effect.\n * @returns the outcome, including the version the write produced.\n */',
signature: 'abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise<FsWriteOutcome>',
jsDoc: '/**\n * Atomically create or replace UTF-8 text. `expected` guards intent and\n * staleness; omission allows unconditional overwrite.\n * @param target - the resolved target to write.\n * @param content - the full new file content.\n * @param expected - the write intent guarding the write; omit for unconditional.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxMode - the per-call sandbox mode this write runs under; a\n * sandboxing backend fences the write by it, the bare backend ignores it.\n * Omit to leave the backend its own default.\n * @returns the outcome, including the version the write produced.\n */',
},
{
signature: 'abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>',
jsDoc: '/**\n * Atomically edit literal text. When supplied, the version guard is checked\n * before matching so stale content reports `FS_STALE_VERSION`; omission edits\n * the current content without a freshness precondition.\n * @param target - the resolved target to edit.\n * @param edit - the literal search/replace request.\n * @param expected - the version guard; omit for an unconditional edit.\n * @param signal - aborts before the atomic rename takes effect.\n * @returns the outcome, including the version the edit produced.\n */',
signature: 'abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise<FsEditOutcome>',
jsDoc: '/**\n * Atomically edit literal text. When supplied, the version guard is checked\n * before matching so stale content reports `FS_STALE_VERSION`; omission edits\n * the current content without a freshness precondition.\n * @param target - the resolved target to edit.\n * @param edit - the literal search/replace request.\n * @param expected - the version guard; omit for an unconditional edit.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxMode - the per-call sandbox mode this edit runs under; a\n * sandboxing backend fences the edit by it, the bare backend ignores it.\n * Omit to leave the backend its own default.\n * @returns the outcome, including the version the edit produced.\n */',
},
],
},
@@ -304,6 +304,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'sandboxPolicy',
summary: 'The sandbox-policy service (`ctx.sandboxPolicy`).',
methods: [],
},
{
key: 'sessionPersistence',
summary: 'Durable append-only session storage.',
@@ -768,7 +773,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'llm/stream',
mode: 'waterfall',
signature: '\'llm/stream\'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>',
jsDoc: '/**\n * Waterfall around every streaming model call (retry, replay, routing).\n * Bound to the {@link LlmService}; call `next()` to reach the resolved\n * adapter\'s stream, or yield your own chunks to short-circuit.\n * @param options - the full request. A LOOP-built request arrives\n * deep-frozen (mutation throws): its content is a pure function of the\n * session log (the reconstructability RFC), so listeners read it, never\n * rewrite it. A hand-built one-shot (compaction summarize) is the\n * caller\'s own object and stays mutable here.\n * @mode waterfall\n */',
jsDoc: '/**\n * Waterfall around every streaming model call (retry, replay, routing).\n * Bound to the {@link LlmService}; call `next()` to reach the resolved\n * adapter\'s stream, or yield your own chunks to short-circuit.\n * @param options - the full request. A LOOP-built request arrives\n * deep-frozen (mutation throws): its content is a pure function of the\n * session log (the reconstructability Agent Note), so listeners read it, never\n * rewrite it. A hand-built one-shot (compaction summarize) is the\n * caller\'s own object and stays mutable here.\n * @mode waterfall\n */',
summary: 'Waterfall around every streaming model call (retry, replay, routing).',
},
{

View File

@@ -26,7 +26,7 @@ export interface Config {
/**
* Milliseconds the SYNCHRONOUS portion of mount code may run in the vm
* before evaluation is aborted (default 5000). An async body escapes this
* bound — see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md for the trust stance.
* bound — see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md for the trust stance.
*/
vmTimeoutMs?: number
}

View File

@@ -19,7 +19,7 @@ Each agent and its session share one caller-chosen `SessionId`, assumed globally
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — programmatic create under the caller-supplied shared id. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), register the agent under that same id, reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`.
- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), register the agent under that same id, reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`.
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code.
@@ -72,25 +72,49 @@ Everything that goes beyond "call the model, run the tools, repeat" belongs to p
### Complete conversation request
**What the model sees**: For each step, the loop sends the rendered per-agent system prompt, visible tool schemas, the frozen session prefix, and the session's derived messages. It supplies `model` and `cwd` variable values but no additional fixed prose.
#### What the model sees
**Token effect**: System text, schemas, and prefix are paid again on every step. Per-agent scoping chooses the initial contributions, while the authoritative assembly waterfall can alter the final request and makes its listener responsible for protocol coherence.
For each step, the loop sends the rendered per-agent system prompt, visible tool schemas, the frozen session prefix, and the session's derived messages. It supplies `model` and `cwd` variable values but no additional fixed prose.
#### Token effect
System text, schemas, and prefix are paid again on every step. Per-agent scoping chooses the initial contributions, while the authoritative assembly waterfall can alter the final request and makes its listener responsible for protocol coherence.
#### KV Cache effect
Append-only only while system text, schemas, session prefix, and earlier history remain byte-identical under the same provider and model route. A token-bearing assembly rewrite or composition change may invalidate reuse from the first altered request token.
### Retained message history
**What the model sees**: Accepted user messages, assistant messages, tool calls and results, injected context, and steering are logged and sent on later steps. Raw stream chunks, lifecycle boundaries, and other log-only events are excluded.
#### What the model sees
**Token effect**: Input grows with every surface message until a compaction replacement shadows older nodes; a multi-step tool turn resends the accumulated prefix and history each step.
Accepted user messages, assistant messages, tool calls and results, injected context, and steering are logged and sent on later steps. Raw stream chunks, lifecycle boundaries, and other log-only events are excluded.
#### Token effect
Input grows with every surface message until a compaction replacement shadows older nodes; a multi-step tool turn resends the accumulated prefix and history each step.
#### KV Cache effect
Ordinary history growth is append-only and preserves reusable entries. A surface replacement or compaction invalidates reuse from the first shadowed history token.
### Undispatched calls after cancellation
**What the model sees**: If a later request replays an aborted step, each tool call that cancellation prevented from dispatching has the error result text `Error: tool call skipped because the step was aborted before execution`.
#### What the model sees
**Token effect**: One fixed error result per skipped call remains in history until compaction shadows it.
If a later request replays an aborted step, each tool call that cancellation prevented from dispatching has the error result text `Error: tool call skipped because the step was aborted before execution`.
#### Token effect
One fixed error result per skipped call remains in history until compaction shadows it.
#### KV Cache effect
Append-only; each synthetic result follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work
- **Classification is unary** — calls whose safety depends on comparing siblings or resources must remain exclusive ([rationale](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md)).
- **Classification is unary** — calls whose safety depends on comparing siblings or resources must remain exclusive ([rationale](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)).
- **Config labels are fresh by default** — omitting `sessionId` creates a fresh `${id}-session-<uuid>` on every startup; exact resume-or-create behavior requires an explicit stable `sessionId`, while `resumeSessionId` requires existing persisted history.
- **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona/tool composition is available only through the programmatic `ctx.agents.create()` / `resume()` factory options.
- **No built-in turn budget** — the default continuation is `continue` whenever a step had tool calls or steering; bounding a runaway turn requires an `agent/turn-continuation` force-stop plugin.

View File

@@ -1,7 +1,7 @@
/**
* Drives one agent across queued durable turns. Turn failures are contained so
* later work can run; the session log, not this driver, owns conversation state.
* See docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md.
* See .agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md.
* @module dsh-agent-loop/loop
*/

View File

@@ -235,7 +235,7 @@ describe('disposed vs aborted branching', () => {
})
})
describe('structured tool error propagation (the runtime-validation RFC, part 2)', () => {
describe('structured tool error propagation (the runtime-validation Agent Note, part 2)', () => {
it('forwards a tool HarnessError onto the tool/result session event', async () => {
const { HarnessError } = await import('@deepseek-ai/dsh-llm')
// First model turn calls the tool; second turn (after the tool result is

View File

@@ -1,6 +1,6 @@
/**
* Property-based tests for the agent loop's inbox/turn scheduling (the
* property-testing RFC). Deterministic by construction: schedules are driven
* property-testing Agent Note). Deterministic by construction: schedules are driven
* through the `agent/status` settle signal (no wall-clock sleeps), so a flake
* is a finding, not timing noise.
*

View File

@@ -14,7 +14,7 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
* multi-step tool turn (plus a follow-up turn) against the live DeepSeek API must report
* `cacheReadTokens > 0` on every request after the first — the adapter maps the provider's
* `prompt_cache_hit_tokens`, and the per-step usage recorded on `assistant/message` events is
* the production observable for cache behavior (the reconstructability RFC's measurement
* the production observable for cache behavior (the reconstructability Agent Note's measurement
* layer: prefix stability is corollary #1). Mocks establish append-extension;
* this key-gated test establishes a real provider cache hit.
*/

View File

@@ -75,7 +75,7 @@ function throwUnknown(value: unknown): never {
throw value
}
describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
describe('the session-persistence Agent Note: AgentLoop factory create/resume', () => {
it('normalizes a non-Error resume publication failure for rollback and rethrows it', async () => {
const sessionId = SessionId('unknown-resume-failure-s')
const root = await persistSession(sessionId)

View File

@@ -1,6 +1,6 @@
/**
* Exercises scheduler ordering and cancellation with deterministic gated tools.
* ACP goldens own transcript-facing coverage.
* ACP expected outputs own transcript-facing coverage.
*/
import { describe, expect, it } from 'vitest'

View File

@@ -26,7 +26,7 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-
- `ctx.agents.withInitiator(agent, operation)` — run with one exact Agent and preserve the operation's exact synchronous value or Promise.
- `ctx.agents.withoutInitiator(operation)` — hide an inherited initiator for unrelated process-local work.
The scope carries the `Agent` itself and is process-local. Ambient presence is neither liveness proof nor authorization; explicit Agent fields remain authoritative at service, worker, process, persistence, and wire boundaries. Teardown rejects new boundaries, lets injected dependents and returned-Promise boundaries drain, then disables the underlying `AsyncLocalStorage`; unreturned work remains owned by the subsystem that detached it. If a boundary's inherited async chain starts an owning Cordis fiber's unload, that nested boundary chain is released from the drain so the unload cannot wait on itself; its continuations observe the disposed service after teardown. The [initiator-scope decision](../../../docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md) owns the detailed boundary and teardown contract.
The scope carries the `Agent` itself and is process-local. Ambient presence is neither liveness proof nor authorization; explicit Agent fields remain authoritative at service, worker, process, persistence, and wire boundaries. Teardown rejects new boundaries, lets injected dependents and returned-Promise boundaries drain, then disables the underlying `AsyncLocalStorage`; unreturned work remains owned by the subsystem that detached it. If a boundary's inherited async chain starts an owning Cordis fiber's unload, that nested boundary chain is released from the drain so the unload cannot wait on itself; its continuations observe the disposed service after teardown. The [initiator-scope decision](../../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md) owns the detailed boundary and teardown contract.
#### Factory seam (creation)
@@ -34,7 +34,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
- `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose.
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>` — create a session and agent, await optional setup while unpublished, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered.
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured.
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — load a persisted session ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured.
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, `await`s its exit plus every outstanding idle-injection flush (not just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started `session/flush` before the session is detached and keeps scoped listeners alive through those checkpoints. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber.
@@ -44,7 +44,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: a retry opens a new numbered step after the failed step closes. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way).
Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: a retry opens a new numbered step after the failed step closes. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way).
`PromptDecision.additionalContexts` is an array so every injected context keeps its own source, envelope, and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source.
@@ -56,7 +56,7 @@ The handle every plugin programs against:
- `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content).
- `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle
- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. `options.envelope` defaults to the canonical `<context>` framing and may be `'raw'` when the caller owns a complete familiar frame; `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)).
- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. `options.envelope` defaults to the canonical `<context>` framing and may be `'raw'` when the caller owns a complete familiar frame; `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)).
- `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op.
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
- `agent.session`, `agent.status`, `agent.options`, `agent.id`
@@ -71,15 +71,31 @@ The handle every plugin programs against:
### User, steering, and injected messages
**What the model sees**: `send`, `steer`, and `inject` feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself.
#### What the model sees
**Token effect**: Accepted content becomes retained history or a repeated session prefix; blocked content contributes no request tokens. Size is caller- and plugin-dependent.
`send`, `steer`, and `inject` feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself.
#### Token effect
Accepted content becomes retained history or a repeated session prefix; blocked content contributes no request tokens. Size is caller- and plugin-dependent.
#### KV Cache effect
Accepted history and steering are append-only; a blocked submission sends no request. A session prefix remains stable within its loop instance, while a new or resumed instance may establish a different prefix.
### Agent-scoped request composition
**What the model sees**: Registrations through `agent.ctx` can shadow prompt sections or tools and can install agent-only interceptors during unpublished setup.
#### What the model sees
**Token effect**: The package adds zero tokens itself; scoped contributions affect only that agent and disappear on disposal.
Registrations through `agent.ctx` can shadow prompt sections or tools and can install agent-only interceptors during unpublished setup.
#### Token effect
The package adds zero tokens itself; scoped contributions affect only that agent and disappear on disposal.
#### KV Cache effect
Prefix-stable while an agent's scoped registrations are unchanged. Setup or reload that changes prompt sections, tool definitions, or request listeners may invalidate reuse from the first affected request token.
## Known Limitations and Deferred Work
@@ -87,6 +103,6 @@ The handle every plugin programs against:
- **Ambient identity may outlive liveness** — consumers still check `agent.status`, cancellation, and the owning capability contract before lifecycle-sensitive work.
- **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam.
- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead.
- **No public step-only abort** — `cancel()` clears ALL pending work (queued + steering + in-flight); an abort that preserves queued prompts returns only with a named consumer ([stop-surface RFC](../../../docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).
- **No public step-only abort** — `cancel()` clears ALL pending work (queued + steering + in-flight); an abort that preserves queued prompts returns only with a named consumer ([stop-surface Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).
- **`HookContext` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable.
- **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`).

View File

@@ -15,7 +15,7 @@ Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis c
## Design contract
The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the [agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) for rationale and security non-goals.
The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the [agent-scope Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) for rationale and security non-goals.
Handing out a scoped context hands out the minting plugin's service-resolution surface (resolution walks the minting fiber's dependency chain, not the holder's) — mint it from the plugin whose dependencies the scoped registrations need to resolve.

View File

@@ -22,7 +22,7 @@ Use the split lifecycle only when teardown must be ordered with another resource
- `enter(session)` performs the collision check, publishes without announcing, and returns an entry-bound idempotent detach. Concurrent same-id preparations are allowed, but only one entry succeeds; a stale detach cannot remove its replacement.
- `announce(session)` emits the single creation edge and rejects repeat or reentrant announcements. Detach during that dispatch is deferred and later emits the paired disposal edge; an unannounced entry emits neither lifecycle edge.
`dsh-agent-loop` uses this split so final loop flush precedes session detach; see the [ownership RFC](../../../docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md).
`dsh-agent-loop` uses this split so final loop flush precedes session detach; see the [ownership Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md).
### Live service events
@@ -54,7 +54,7 @@ Durable values need one accepted representation, not a check followed by a secon
### Request-header reconstruction (`request-header.ts`)
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. `messagePrefix` remains separate from derived history. See the [reconstructable-requests RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md).
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. `messagePrefix` remains separate from derived history. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
`context/message` defaults to the canonical tagged context projection. A producer may set `envelope: 'raw'` when its `content` already contains the complete model-facing frame, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`.
@@ -85,25 +85,49 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
### Derived message history
**What the model sees**: The model receives projections of `user/message`, `assistant/message`, and `tool/result` surface entries verbatim. A `context/message` is a user-role message containing exactly `<context source="<source-kind>">`, its content blocks, and `</context>`; `steering/message` uses the identical `<steering source="<source-kind>">` / `</steering>` wrapper. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
#### What the model sees
**Token effect**: Appended surface entries are resent on later steps. A `replace` surface operation removes the shadowed entries from future inputs without deleting their raw log records.
The model receives projections of `user/message`, `assistant/message`, and `tool/result` surface entries verbatim. A `context/message` is a user-role message containing exactly `<context source="<source-kind>">`, its content blocks, and `</context>`; `steering/message` uses the identical `<steering source="<source-kind>">` / `</steering>` wrapper. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
#### Token effect
Appended surface entries are resent on later steps. A `replace` surface operation removes the shadowed entries from future inputs without deleting their raw log records.
#### KV Cache effect
Appended surface entries preserve reusable prefixes. A `replace` operation invalidates reuse from the first shadowed message even though the underlying event log stays append-only.
### Crash-repair result
**What the model sees**: If a persisted turn ended with unanswered tool calls, each synthetic error result contains exactly `Tool call interrupted by a crash; no result was recorded.`
#### What the model sees
**Token effect**: Zero tokens in an intact session. Each repaired call adds this retained error text on resume.
If a persisted turn ended with unanswered tool calls, each synthetic error result contains exactly `Tool call interrupted by a crash; no result was recorded.`
#### Token effect
Zero tokens in an intact session. Each repaired call adds this retained error text on resume.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Logged request header
**What the model sees**: The session reconstructs the system prompt, tool schemas, call config, and session prefix that the loop actually sent. Header events do not add a second copy to message history; the prefix is prepended outside `deriveMessages()`.
#### What the model sees
**Token effect**: Zero duplicate tokens from logging. The reconstructed prefix, system text, and schemas still incur their normal per-request cost.
The session reconstructs the system prompt, tool schemas, call config, and session prefix that the loop actually sent. Header events do not add a second copy to message history; the prefix is prepended outside `deriveMessages()`.
#### Token effect
Zero duplicate tokens from logging. The reconstructed prefix, system text, and schemas still incur their normal per-request cost.
#### KV Cache effect
Logging causes no invalidation, and exact reconstruction preserves request-prefix identity. A later header with changed prefix, prompt, or schemas may invalidate reuse from its first difference.
## Known Limitations and Deferred Work
- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`.
- **`fork()` cuts only at closed-turn boundaries of live sessions** — the boundary must be a `turn/end` event and the source must be in the store; forking a persisted-but-unloaded session is excluded from the [fork API](../../../docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md).
- **`fork()` cuts only at closed-turn boundaries of live sessions** — the boundary must be a `turn/end` event and the source must be in the store; forking a persisted-but-unloaded session is excluded from the [fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md).
- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no compatibility implied: a backend rejects any other version, and no migration path exists until the first release ([policy](../../../AGENTS.md)).
- **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them.

View File

@@ -496,7 +496,7 @@ export class Session {
* The per-node pure function {@link deriveMessages} folds over the surface;
* an external reconstructor (or the dev invariant) folds the same function
* over a log prefix's surface to rebuild the exact messages any request was
* built from (the reconstructability RFC). The returned message wrapper is
* built from (the reconstructability Agent Note). The returned message wrapper is
* fresh; its content reuses the logged event's already deep-frozen durable
* data, so changing the wrapper cannot rewrite the log and changing content
* throws.

View File

@@ -1,5 +1,5 @@
/**
* Property-based tests for the Session event log (the property-testing RFC).
* Property-based tests for the Session event log (the property-testing Agent Note).
*
* Generates arbitrary event logs and asserts the derivation invariants the
* agent loop and replay depend on: deriveMessages is deterministic and

View File

@@ -7,7 +7,7 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem
| Key | Default | Meaning |
|---|---|---|
| `persona` | `''` | The global deployment-persona default: the ONE config-authored prompt fragment, rendered as the order-0 `deployment:persona` section unless an agent-scoped contribution shadows it. A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. |
| `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `'<unlisted-tools>'` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, unlisted tools land at the rest entry in lexicographic name order. Absent ⇒ plain lexicographic name order. Applied to the collected tools BEFORE the `system-prompt/assemble` waterfall — like the sections' `order` sort, it canonicalizes what the registry contributed (registration order is a plugin-load artifact), and a waterfall listener that mutates the list owns the determinism of what it emits. Misconfiguration fails loud: a list without exactly one rest entry, or with duplicates, throws at load; a listed name with no registered tool rejects every `assemble()`; a tool provider returning the reserved rest-entry name also rejects. Under the shipped loop the turn fails before any model request. Why a central list and not per-plugin weights: [Explicit model-facing tool order](../../../docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md). |
| `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `'<unlisted-tools>'` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, unlisted tools land at the rest entry in lexicographic name order. Absent ⇒ plain lexicographic name order. Applied to the collected tools BEFORE the `system-prompt/assemble` waterfall — like the sections' `order` sort, it canonicalizes what the registry contributed (registration order is a plugin-load artifact), and a waterfall listener that mutates the list owns the determinism of what it emits. Misconfiguration fails loud: a list without exactly one rest entry, or with duplicates, throws at load; a listed name with no registered tool rejects every `assemble()`; a tool provider returning the reserved rest-entry name also rejects. Under the shipped loop the turn fails before any model request. Why a central list and not per-plugin weights: [Explicit model-facing tool order](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md). |
## Service: `SystemPrompt` (ctx key: `systemPrompt`)
@@ -38,27 +38,43 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `Asse
- Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically.
- The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller.
Design rationale: [the prompt-variables RFC](../../../docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md).
Design rationale: [the prompt-variables Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md).
## Model Experience
### System prompt
**What the model sees**: Every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The final `system-prompt/assemble` waterfall result is authoritative, so an expert listener's changes determine the delivered prompt and tool schemas.
#### What the model sees
**Token effect**: Identity is a fixed per-request cost. Persona and plugin text are repeated per request and scale with their rendered content.
Every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The final `system-prompt/assemble` waterfall result is authoritative, so an expert listener's changes determine the delivered prompt and tool schemas.
#### Harness identity
##### Harness identity
```markdown
You are an AI agent powered by the DeepSeek Harness SDK.
```
#### Token effect
Identity is a fixed per-request cost. Persona and plugin text are repeated per request and scale with their rendered content.
#### KV Cache effect
Prefix-stable while identity, persona, variables, section text, and order render identically. Any change may invalidate reuse from the first changed system-prompt token.
### Tool schemas
**What the model sees**: For shipped tools, the model receives the per-agent-visible subset of the [generated tool schemas](../../../docs/tool-catalog.md#tool-package-map), ordered by configuration or lexicographically after restrictions and assembly interception. Extensions can contribute additional definitions through the same registry. Sections and schema providers are separate assembly inputs, so a tool restriction does not remove independently registered guidance.
#### What the model sees
**Token effect**: Schema tokens repeat on every request. Restricting a tool removes its entire schema cost for that agent but not a separate prompt section; reordering changes cache shape but not semantic content.
For shipped tools, the model receives the per-agent-visible subset of the [generated tool schemas](../../../docs/tool-catalog.md#tool-package-map), ordered by configuration or lexicographically after restrictions and assembly interception. Extensions can contribute additional definitions through the same registry. Sections and schema providers are separate assembly inputs, so a tool restriction does not remove independently registered guidance.
#### Token effect
Schema tokens repeat on every request. Restricting a tool removes its entire schema cost for that agent but not a separate prompt section; reordering changes cache shape but not semantic content.
#### KV Cache effect
Prefix-stable while the visible schema set, rendering, and order are unchanged. Registration, restriction, or reordering may invalidate reuse from the first changed schema token.
## Known Limitations and Deferred Work

View File

@@ -16,9 +16,9 @@ tools:
### Public API
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. `timeoutMs`, when present, must be positive and finite. Disposed with the calling fiber.
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)).
- `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
- `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body; around wrappers may replace only `signal`.
- `ctx.tools.executionMode(exec)` returns `parallel` only when the visible definition's `isConcurrencySafe(exec.arguments)` classifier returns exactly `true`; unknown, hidden, undeclared, invalid, or throwing classifications are exclusive.
@@ -88,7 +88,7 @@ See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, an
Optional `timeoutMs` must be positive and finite; it is policy metadata, not model-visible schema.
Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. Exact `true` permits concurrent dispatch/body execution; invalid input and all other outcomes remain exclusive. Opted-in bodies do not mutate parent-owned state, and shared-state races must commute or fail closed. The [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md) owns the full safety contract.
Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. Exact `true` permits concurrent dispatch/body execution; invalid input and all other outcomes remain exclusive. Opted-in bodies do not mutate parent-owned state, and shared-state races must commute or fail closed. The [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md) owns the full safety contract.
### Structured-output schema subset
@@ -101,11 +101,11 @@ Tools optionally own pure `presentCall()` and `presentResult()` render intents,
- Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`.
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, or `{ card: 'diff', title?, diffs }`.
Returning `undefined` selects generic fallback. Presenters depend only on their arguments because UIs call them during live streaming and log replay. Result presentation may read JSON-serializable `result.meta`, which persists with the result; `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [render-intent RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns the rationale.
Returning `undefined` selects generic fallback. Presenters depend only on their arguments because UIs call them during live streaming and log replay. Result presentation may read JSON-serializable `result.meta`, which persists with the result; `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns the rationale.
### Code Mode
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only program output re-enters model context. Each JSON-normalized binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials reject that binding, ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; failures surface as `CodeRunFailedError`. See the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md) and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only program output re-enters model context. Each JSON-normalized binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials reject that binding, ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; failures surface as `CodeRunFailedError`. See the [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is total: constructs outside the `defineTool` subset degrade to `unknown`, never throw.
- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `<parent>:code:<n>`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/envelope/meta even when the program later fails.
@@ -113,23 +113,31 @@ Under `code` or `both`, the registry exposes the reserved `run_code` transport a
### Parallel execution
The agent loop groups consecutive `parallel` calls into a bounded rolling pool and treats each `exclusive` call as an ordering barrier. Only dispatch/body overlaps; policy, durable results, and context retain model order. Code Mode bindings remain serial. The [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md) owns the shipped declarations and rationale.
The agent loop groups consecutive `parallel` calls into a bounded rolling pool and treats each `exclusive` call as an ordering barrier. Only dispatch/body overlaps; policy, durable results, and context retain model order. Code Mode bindings remain serial. The [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md) owns the shipped declarations and rationale.
## Model Experience
### Normal tool schemas
**What the model sees**: In normal mode the model sees each visible definition's exact name, description, and JSON schema; the shipped definitions are recorded in the generated [tool package map and schema sections](../../../docs/tool-catalog.md#tool-package-map). Agent-scoped restrictions, shadows, and extension registrations change that agent's end-tool set.
#### What the model sees
**Token effect**: Fixed per-request cost proportional to the visible definitions. Restrictions that hide tools remove their entire schema cost for that agent.
In normal mode the model sees each visible definition's exact name, description, and JSON schema; the shipped definitions are recorded in the generated [tool package map and schema sections](../../../docs/tool-catalog.md#tool-package-map). Agent-scoped restrictions, shadows, and extension registrations change that agent's end-tool set.
#### Token effect
Fixed per-request cost proportional to the visible definitions. Restrictions that hide tools remove their entire schema cost for that agent.
#### KV Cache effect
Prefix-stable while visible definitions and their order are unchanged. Registration, disposal, or scoped restriction may invalidate reuse from the first changed schema token.
### Code Mode schema and system prompt
**What the model sees**: Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact `declare const tools` block. `both` exposes normal schemas and this Code Mode surface.
#### What the model sees
**Token effect**: Fixed per-request cost proportional to the visible definitions. Code Mode trades end-tool schemas for generated SDK text plus one transport schema rather than promising a universal reduction.
Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact `declare const tools` block. `both` exposes normal schemas and this Code Mode surface.
#### Code Mode SDK instructions
##### Code Mode SDK instructions
```markdown
## Writing code for run_code
@@ -144,18 +152,34 @@ Pass `run_code` the body of an async TypeScript function (erasable syntax only
The available tools:
```
#### Token effect
Fixed per-request cost proportional to the visible definitions. Code Mode trades end-tool schemas for generated SDK text plus one transport schema rather than promising a universal reduction.
#### KV Cache effect
Prefix-stable while the Code Mode selection, generated SDK, transport schema, and visible tool set are unchanged. Mode or filter changes may invalidate reuse from the first changed prompt or schema token.
### Tool-call history and results
**What the model sees**: The loop retains model-emitted arguments and the registry's final content. Any thrown or denied call becomes exactly `Error: <message>`. Code Mode returns only the outer program's printed lines and rendered return value, `(run_code completed with no output)` when both are empty, or `Error: code run failed (<kind>): <message>` followed conditionally by `Captured output:` and the captured lines. Inner dispatch events stay log-only; post-execute listeners may append source-attributed context after the result.
#### What the model sees
**Token effect**: Arguments, results, and additional context are data-dependent and resent until compaction. Restrictions that hide tools also remove their schemas before the model can call them.
The loop retains model-emitted arguments and the registry's final content. Any thrown or denied call becomes exactly `Error: <message>`. Code Mode returns only the outer program's printed lines and rendered return value, `(run_code completed with no output)` when both are empty, or `Error: code run failed (<kind>): <message>` followed conditionally by `Captured output:` and the captured lines. Inner dispatch events stay log-only; post-execute listeners may append source-attributed context after the result.
#### Token effect
Arguments, results, and additional context are data-dependent and resent until compaction. Restrictions that hide tools also remove their schemas before the model can call them.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work
- **Concurrency policy is not an event seam** — `executionMode()` reads the resolved tool definition directly; plugins can only declare a classifier on definitions they own.
- **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md).
- **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md).
- **`defineTool`'s schema DSL is a deliberate subset** — string/number/boolean/object/array with string-only `enum`; `validateArgs` tolerates extra keys and preserves `default` as a model-visible JSON Schema annotation without applying it during validation; dynamic Cordis mounts may supply defaults even though first-party definitions do not, while raw-registered JSON-Schema tools validate their own input.
- **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper.
- **Code Mode is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only.
- **Code Mode bindings return text only** — non-text content blocks in a sub-call result collapse to `[<type> content]` placeholders.
- **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md).
- **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md).

View File

@@ -139,7 +139,7 @@ export interface ToolDefinition extends ToolSchema {
* Opted-in executions must not mutate parent-owned state. Shared state must
* tolerate concurrent dispatch; recorder races are permitted only when they
* commute or fail closed. See the
* [parallel-tool-call RFC](../../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md)
* [parallel-tool-call Agent Note](../../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)
* for the full contract.
* @param args - parsed arguments; `defineTool` validates before calling.
* @returns Whether this call may join a parallel group.

View File

@@ -75,7 +75,7 @@ export function jsonSchemaToTs(schema: unknown, indent = 0): string {
}
}
/** The fixed model-facing usage contract rendered above the declarations (see the Code Mode RFC's "What the model sees"). */
/** The fixed model-facing usage contract rendered above the declarations (see the Code Mode Agent Note's "What the model sees"). */
const SDK_INSTRUCTIONS = `## Writing code for run_code
Pass \`run_code\` the body of an async TypeScript function (erasable syntax only — no \`enum\` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:

View File

@@ -13,7 +13,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEventMap } from '@deepseek-ai/dsh-session'
/**
* Code Mode unit tier (per the RFC's plan): provider contribution per mode,
* Code Mode unit tier (per the Agent Note's plan): provider contribution per mode,
* misconfiguration rejections, the run_code dispatch bridge (serialization,
* abort, JSON normalization, error mapping, events, quiescence), and HMR
* safety — all against an in-repo fake runtime, exactly the

View File

@@ -49,6 +49,19 @@ describe('gen-tool-catalog collectToolCatalog', () => {
expect(bash?.source).toBe('packages/bash/tool-bash/src/index.ts')
})
it('harvests search tools without depending on the generator process PATH', async () => {
const oldPath = process.env.PATH
try {
process.env.PATH = ''
const catalog = await collectToolCatalog()
const search = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-fs-search')
expect(search?.schemas.map(s => s.name).sort()).toEqual(['glob', 'grep'])
} finally {
if (oldPath === undefined) delete process.env.PATH
else process.env.PATH = oldPath
}
})
it('records the shipped `subagent_fork` alias in a note (config-driven tool name)', async () => {
// `tool-subagent`'s registered name is the load-time `toolName` config, so the shipped
// agents surface this one package as both `subagent` and `subagent_fork`.

View File

@@ -1,8 +1,8 @@
/**
* Property-based tests for the tool-schema DSL (the property-testing RFC), including
* Property-based tests for the tool-schema DSL (the property-testing Agent Note), including
* the the property-testing ↔ runtime-validation composition composition: generated args that satisfy a SchemaSpec must
* pass validateArgs, and targeted corruptions must be rejected. This closes the
* validator/InferArgs drift risk noted in the arg-validation RFC.
* validator/InferArgs drift risk noted in the arg-validation Agent Note.
*/
import { describe, expect, it } from 'vitest'

View File

@@ -1174,7 +1174,7 @@ describe('ToolRegistry.get', () => {
})
})
describe('validateArgs (the runtime-validation RFC, part 1)', () => {
describe('validateArgs (the runtime-validation Agent Note, part 1)', () => {
it('returns [] for valid args and is total over malformed input', () => {
const spec = {
path: { type: 'string', required: true },
@@ -1274,7 +1274,7 @@ describe('validateArgs (the runtime-validation RFC, part 1)', () => {
})
})
describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
describe('defineTool validation (the runtime-validation Agent Note, part 1)', () => {
it('returns an isError result with the violations when the model sends bad args', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-acp-demo
The **ACP server app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md)) with the front-door cluster an [Agent Client Protocol](../../ui/acp/README.md) server needs, and a `bin` that boots a leaf `cordis.yml` speaking ACP JSON-RPC on stdio.
The **ACP server app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)) with the front-door cluster an [Agent Client Protocol](../../ui/acp/README.md) server needs, and a `bin` that boots a leaf `cordis.yml` speaking ACP JSON-RPC on stdio.
It is the structured counterpart to [`@deepseek-ai/dsh-stdio-demo`](../stdio-demo/README.md): both consume the same spine, but this one bakes in the OPPOSITE front-door cluster.
@@ -55,6 +55,10 @@ All diagnostics go to **stderr** — stdout is the protocol.
Indirectly, through `dsh-agent-spine-demo` and `dsh-acp`, which compose each ACP agent's prompt, tools, and message history; this app bundle adds no model-bound content itself.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **JSONL persistence is baked in** — config chooses its root but cannot select a different backend; that requires a sibling entry or differently composed app package.

View File

@@ -53,7 +53,7 @@ export interface Config {
skills?: agentCore.SkillConfig
/** Model-facing bash tool config forwarded through agent-core. */
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task control-tool config forwarded through agent-core. */
/** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
}
@@ -75,7 +75,7 @@ export const Config: z<Config> = z.object({
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: agentCore.ToolTasksConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
})
/* jscpd:ignore-end */

View File

@@ -34,9 +34,9 @@ The spine is everything COMMON to every front door. The swappable and front-door
- **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`).
- **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl).
- **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings.
- **presentation + per-app infra** — the terminal (`dsh-tui` / `dsh-stdio`) or ACP front door and `hmr`. These form the coupled front-door cluster that the app packages ([`dsh-stdio-demo`](../../examples/stdio-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)) bake in. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside.
- **presentation + per-app infra** — the terminal (`dsh-tui` / `dsh-stdio`) or ACP front door and `hmr`. These form the coupled front-door cluster that the app packages ([`dsh-stdio-demo`](../stdio-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) bake in. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside.
This is the [interface/implementation/consumer seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door.
This is the [interface/implementation/consumer seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door.
## Config
@@ -46,7 +46,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
```
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
## Why a code bundle, not a shared YAML include
@@ -56,7 +56,11 @@ A YAML include can deduplicate config but cannot own a bin or provide front-door
Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, and `dsh-tools`, which this bundle mounts without adding model-bound wrapper content.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **The spine set is fixed in code** — `apply()` mounts every child unconditionally (including `tool-bash`); no config excludes or replaces one, so swapping the loop or dropping a spine member means composing a different bundle.
- **Most of the spine set is fixed in code** — `apply()` always mounts the core services and `tool-bash`; config can omit the bundled skills and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle.
- **`dsh-invariants` mounts unconditionally** — this bundle has no toggle, so every composition using it pays the dev-mode relational assertions; Session's always-on validation and freezing are separate.

View File

@@ -31,6 +31,8 @@ export const name = 'agent-spine-demo'
/** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */
export interface SkillConfig {
/** Mount the bundled local skill provider and model-facing skill tool (default true). */
enabled?: boolean
/** Registry-level discovery cache settings. */
registry?: SkillRegistryConfig
/** Local filesystem skill provider settings. */
@@ -73,12 +75,13 @@ export interface Config {
skills?: SkillConfig
/** Model-facing bash tool config, including this producer's background opt-in. */
toolBash?: toolBash.Config
/** Generic background-task control-tool wait bounds. */
toolTasks?: toolTasks.Config
/** Generic background-task controls; set false to keep the task service without model-facing task tools. */
toolTasks?: toolTasks.Config | false
}
/** The skill config schema exported for app packages that forward `skills`. */
export const SkillConfigSchema: z<SkillConfig> = z.object({
enabled: z.boolean().default(true),
registry: SkillService.Config,
local: SkillLocal.Config,
tool: toolSkill.Config,
@@ -100,7 +103,7 @@ export const Config = z.intersect([
skills: SkillConfigSchema,
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
toolBash: ToolBashConfigSchema,
toolTasks: ToolTasksConfigSchema,
toolTasks: z.union([z.const(false), ToolTasksConfigSchema]),
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks'>>,
]) as unknown as z<Config>
@@ -150,8 +153,11 @@ export function apply(ctx: Context, config: Config): void {
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
})
ctx.plugin(ToolRegistry, config.tools ?? {})
ctx.plugin(SkillService, config.skills?.registry ?? {})
ctx.plugin(SkillLocal, Object.assign({}, config.skills?.local, { dshHome }))
const skillsEnabled = config.skills?.enabled ?? true
if (skillsEnabled) {
ctx.plugin(SkillService, config.skills?.registry ?? {})
ctx.plugin(SkillLocal, Object.assign({}, config.skills?.local, { dshHome }))
}
ctx.plugin(AgentRegistry)
ctx.plugin(TaskService)
ctx.plugin(invariants)
@@ -161,8 +167,8 @@ export function apply(ctx: Context, config: Config): void {
}
// Both plugins prepend session-prefix messages. Registration order is the
// rendered order, so workspace instructions must precede the skill catalog.
ctx.plugin(toolSkill, config.skills?.tool ?? {})
ctx.plugin(toolTasks, config.toolTasks ?? {})
if (skillsEnabled) ctx.plugin(toolSkill, config.skills?.tool ?? {})
if (config.toolTasks !== false) ctx.plugin(toolTasks, config.toolTasks ?? {})
ctx.plugin(AgentLoop, {
agents: config.agents ?? [],
...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {},

View File

@@ -344,6 +344,21 @@ describe('dsh-agent-spine-demo bundle', () => {
await ctx.fiber.dispose()
})
it('can omit skills and model-facing task controls for a foreground-only deployment', async () => {
const ctx = await mount({
workspaceContext: false,
skills: { enabled: false },
toolBash: { enableRunInBackground: false },
toolTasks: false,
}, true)
expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['bash'])
expect(ctx.get('skills')).toBeUndefined()
expect(ctx.get('tasks')).toBeDefined()
await ctx.fiber.dispose()
})
it('picks shared spine config without leaking front-door fields', () => {
const appConfig = {
model: 'front-door-only',
@@ -352,9 +367,9 @@ describe('dsh-agent-spine-demo bundle', () => {
tools: { mode: 'native' as const },
dshHome: '/tmp/dsh-home',
workspaceContext: false as const,
skills: {},
skills: { enabled: false },
toolBash: { enableRunInBackground: false },
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
toolTasks: false as const,
}
expect(agentCore.pickSpineConfig(appConfig)).toEqual({
@@ -363,7 +378,7 @@ describe('dsh-agent-spine-demo bundle', () => {
tools: appConfig.tools,
dshHome: appConfig.dshHome,
workspaceContext: false,
skills: {},
skills: appConfig.skills,
toolBash: appConfig.toolBash,
toolTasks: appConfig.toolTasks,
})

View File

@@ -55,9 +55,17 @@ The headless-agent leaf supplies local bash, filesystem, skill, subagent, workfl
### One-shot task turn
**What the model sees**: The positional task becomes one user message. Through `dsh-agent-spine-demo`, the top-level agent also receives configured workspace instructions and persona, the skill catalog, visible tool schemas, and retained tool results needed for later steps in the same turn.
#### What the model sees
**Token effect**: The task, prompt sections, tool schemas, assistant output, and tool results consume tokens on each model step. JSON event streaming and final rendering add no model tokens; delegated child work has its own model usage and is not included in the parent result's `usage` total.
The positional task becomes one user message. Through `dsh-agent-spine-demo`, the top-level agent also receives configured workspace instructions and persona, the skill catalog, visible tool schemas, and retained tool results needed for later steps in the same turn.
#### Token effect
The task, prompt sections, tool schemas, assistant output, and tool results consume tokens on each model step. JSON event streaming and final rendering add no model tokens; delegated child work has its own model usage and is not included in the parent result's `usage` total.
#### KV Cache effect
Tool-round history is append-only while the one-shot agent's prompt, schemas, model route, and session prefix remain fixed. Changing that composition establishes a different request prefix; JSON output mode has no cache effect.
## Known Limitations and Deferred Work

View File

@@ -61,7 +61,7 @@ export const Config: z<Config> = z.object({
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
tools: ToolRegistry.Config,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: agentCore.ToolTasksConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
})
/* jscpd:ignore-end */

View File

@@ -146,6 +146,21 @@ describe('dsh-cli-demo app composition', () => {
expect(wait).toHaveBeenCalledWith(id, 7, undefined, undefined)
})
it('accepts false to keep task services without model-facing task controls', async () => {
const ctx = await mount({
provider: 'mock',
model: 'mock',
skills: { enabled: false },
toolTasks: false,
workspaceContext: false,
})
expect(ctx.get('tasks')).toBeDefined()
expect(ctx.get('tools')?.get('task_output')).toBeUndefined()
expect(ctx.get('tools')?.get('task_list')).toBeUndefined()
expect(ctx.get('tools')?.get('task_kill')).toBeUndefined()
})
it('exposes the Loader-safe namespace plugin shape and schema', () => {
expect(cliDemo.name).toBe('cli-demo')
expect(cliDemo.Config).toBeDefined()

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-jsonrpc-demo
Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../../ui/jsonrpc/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. `lib/bin.js` is also the [single-executable runtime](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) entry.
Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../../ui/jsonrpc/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. `lib/bin.js` is also the [single-executable runtime](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) entry.
## Config discovery
@@ -10,7 +10,7 @@ A config without `dsh-jsonrpc` is valid and serves nothing; the bin does not des
## Exit lifecycle
stdin EOF and `SIGTERM` dispose the root to quiescence and exit 0; `SIGINT` exits 130 after the same disposal. EOF may cut off an in-flight turn as documented in the [distribution RFC](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). The `jsonrpc` plugin owns response-before-exit protocol shutdown; both paths are idempotent and safe to race.
stdin EOF and `SIGTERM` dispose the root to quiescence and exit 0; `SIGINT` exits 130 after the same disposal. EOF may cut off an in-flight turn as documented in the [distribution Agent Note](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). The `jsonrpc` plugin owns response-before-exit protocol shutdown; both paths are idempotent and safe to race.
## stdout is the protocol
@@ -20,6 +20,10 @@ stdout carries only JSON-RPC frames. The bin and boot guards diagnose on stderr,
Indirectly, through the plugins loaded from the external `cordis.yml`, which own every model-bound prompt, schema, message, and result; this bin adds none of its own.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **The bin cannot prove that the config serves JSON-RPC** — a valid config with no `dsh-jsonrpc` entry boots successfully and serves nothing.

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-stdio-demo
The **terminal chat app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md)) with JSONL persistence, human interaction, a pre-created `main` agent, and a TTY-selected pi-tui/readline front door. Its `bin` boots a leaf `cordis.yml`.
The **terminal chat app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)) with JSONL persistence, human interaction, a pre-created `main` agent, and a TTY-selected pi-tui/readline front door. Its `bin` boots a leaf `cordis.yml`.
It is the terminal counterpart to [`@deepseek-ai/dsh-acp-demo`](../acp-demo/README.md): both consume the same spine, while ACP reserves stdout for JSON-RPC and creates sessions from the client.
@@ -79,15 +79,31 @@ Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo —
### Composed terminal agent request
**What the model sees**: Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each terminal submission becomes a user message; submissions made while the agent runs steer the active turn.
#### What the model sees
**Token effect**: Child prompt and schema costs repeat per request; user input and tool history grow until compaction. Terminal banners, logger output, cards, and rendered transcripts add zero model tokens.
Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each terminal submission becomes a user message; submissions made while the agent runs steer the active turn.
#### Token effect
Child prompt and schema costs repeat per request; user input and tool history grow until compaction. Terminal banners, logger output, cards, and rendered transcripts add zero model tokens.
#### KV Cache effect
User and tool history is append-only while the composed prompt, schemas, child model route, and session prefix remain fixed. A composition change or compaction may invalidate reuse from its first changed token; terminal rendering has no cache effect.
### Human-answer result
**What the model sees**: Through `dsh-tool-ask-user`, successful terminal answers use that package's exact compact JSON shape. Interruption becomes exactly `Error: ask_user_question was interrupted before the user answered`; a closed stdin becomes `Error: ask_user_question cannot be answered because stdin is closed`.
#### What the model sees
**Token effect**: Only a completed or failed tool call adds retained result tokens; prompts printed while waiting are terminal-only.
Through `dsh-tool-ask-user`, successful terminal answers use that package's exact compact JSON shape. Interruption becomes exactly `Error: ask_user_question was interrupted before the user answered`; a closed stdin becomes `Error: ask_user_question cannot be answered because stdin is closed`.
#### Token effect
Only a completed or failed tool call adds retained result tokens; prompts printed while waiting are terminal-only.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work

View File

@@ -97,7 +97,7 @@ export interface Config {
skills?: agentCore.SkillConfig
/** Model-facing bash tool config forwarded through agent-core. */
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task control-tool config forwarded through agent-core. */
/** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/**
* If set, the pre-created agent RESUMES this persisted session id instead of
@@ -125,7 +125,7 @@ export const Config: z<Config> = z.object({
ui: UiConfigSchema,
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: agentCore.ToolTasksConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
resumeSessionId: z.string(),
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
})

View File

@@ -6,11 +6,12 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona
|---|---|---|
| `fs/` | Provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` policy events | `ctx.fs` |
| `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) |
| `fs-sandbox/` | Sandbox-enforcing `FileSystem`: extends `fs-local` and fences write/edit by the per-call sandbox mode (read-only denies, workspace-write contains to the workspace + temp roots), reads pass through | (registers `ctx.fs`) |
| `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) |
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) |
| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools, backed by fixed ripgrep commands through the bash seam (`ctx.bash`), NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) |
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); advertises the sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) |
| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools when `rg` is available on the bash executor `PATH`, backed by fixed ripgrep commands through `ctx.bash`, NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) |
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents).
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas`fs-sandbox` is the first such replacement (an in-process path fence over the shared sandbox mode; see [the cross-family fs sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)). The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. The mode fence and the read-before-edit gate are orthogonal and compose. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents).
## No timeouts on file IO

View File

@@ -12,7 +12,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
## Behavior
- **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. `opts.signal` is checked before and after local resolution, while a remote sibling backend may use it to abort its round-trip. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path.
- **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. `opts.signal` is checked before and after local resolution, while a remote sibling backend may use it to abort its round-trip. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path.
- **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence.
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing.
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`.
@@ -25,9 +25,13 @@ The package-root SDK surface is the default/named `LocalFileSystem` class plus `
Indirectly, through [`dsh-tool-fs`](../tool-fs/README.md), which renders this provider's line-windowed UTF-8 content, mutation acknowledgements, and exact provider messages in capped retained results while versions, atomic-write mechanics, and directory metadata remain internal.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **`config.cwd` is not a sandbox** — it is a resolution default, not containment: absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall ([capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences)).
- **`config.cwd` is not a sandbox** — it is a resolution default, not containment: absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall ([capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences)).
- **An overwrite reads the whole prior file into memory** — solely as the UI diff basis; bounding that pre-read above a size threshold is deferred (`TODO(overwrite-diff-bound)`).
- **Version tokens are `mtimeMs:size`** — an external change that preserves both within the filesystem's timestamp granularity defeats the stale guard.
- **`editText` holds the whole file (plus the edited copy) in memory** — streaming exists only on the read path.

View File

@@ -45,7 +45,7 @@ type ResolvedConfig = Required<Config>
/**
* The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd}
* (a resolution default, NOT a containment boundary — see the filesystem
* capability-seam RFC); enforce
* capability-seam Agent Note); enforce
* containment with a stricter backend or a `tools/execute` permission plugin.
*/
export class LocalFileSystem extends FileSystem {

View File

@@ -51,13 +51,21 @@ Because the plugin influences the world only through events, removing it does no
### Filesystem tool outcome
**What the model sees**: This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper; observation state is never shown.
#### What the model sees
**Token effect**: Zero tokens on allowed operations beyond the ordinary tool result. A denial adds the small retained error result and avoids any success payload.
This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper; observation state is never shown.
#### Token effect
Zero tokens on allowed operations beyond the ordinary tool result. A denial adds the small retained error result and avoids any success payload.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work
- **Observed state does not survive a session resume** — persistence of the `WeakMap` record is deferred, so a resumed session must re-read files before guarded writes/edits.
- **Actors without an agent session can never satisfy the policy** — their edits throw `FS_NOT_OBSERVED` and their writes always resolve `createIfAbsent`, so a non-agent caller cannot overwrite an existing file through the gate.
- **Direct `ctx.fs` reads emit no `fs/observed`** — a file read outside the `read` tool stays unobserved, and a later guarded edit rejects with `FS_NOT_OBSERVED` until the tool reads it.
- **Authorization is version freshness, not view completeness** — any windowed read authorizes a full-file overwrite of an unchanged file, deliberately weaker than a full-view rule ([seam-split RFC](../../../docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)).
- **Authorization is version freshness, not view completeness** — any windowed read authorizes a full-file overwrite of an unchanged file, deliberately weaker than a full-view rule ([seam-split Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)).

View File

@@ -0,0 +1,33 @@
# dsh-fs-sandbox — the sandbox-enforcing filesystem backend
`SandboxedFileSystem` extends [`LocalFileSystem`](../fs-local/README.md) and registers as `ctx.fs`. It inherits every text-storage mechanic verbatim (resolve, stat, read/stream, list, the atomic write, the read-match-write edit critical section) and adds only a per-call MODE fence on `writeText`/`editText`. Reads always pass through — every mode permits reading.
Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md), is the whole swap; the model-facing tools (`dsh-tool-fs`) are untouched. Injects `sandboxPolicy` for the default mode and the `workspace-write` boundary root — the SAME policy home bash reads, so the two families never confine to different roots.
## The fence
The per-call mode is the tool-stamped effective mode (session override or escalation grant), falling back to the deployment default:
- `read-only` — denies every mutation with the structured `FS_SANDBOX_DENIED`.
- `workspace-write` — allows a mutation only when the target canonicalizes under a writable root: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), the SAME set the Seatbelt profile grants, derived from the one [`writableRoots`](../../sandbox/README.md) function so the fs fence and the bash runner cannot drift. The target is re-canonicalized immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught.
- `danger-full-access` — delegates unfenced.
## Threat model: a policy fence, not a kernel boundary
The fence is a check in TRUSTED code over a MODEL-CONTROLLED path — the operations are the seam's own (open, rename), only the target path is untrusted, so canonicalize-then-contain is the complete answer to this surface. This mirrors the `code-runtime` stance: containment, not a security boundary. Kernel-grade isolation of untrusted CODE stays `ctx.bash`'s job ([`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)). The residual TOCTOU (an ancestor symlink swapped between the containment re-check and the syscall) is narrowed by re-canonicalizing immediately before the write and is accepted for this threat model; a kernel-tight boundary needs `openat2`-class primitives not worth their portability cost here.
A denial is a structured `FsError` (`FS_SANDBOX_DENIED`, carrying the effective mode) — no stderr text inference (unlike bash's kernel denials), because an in-process fence knows exactly what it refused. The model-facing `[sandbox: file access denied under <mode> mode]` marker and the one-approved-wider retry live in the tool layer (`dsh-tool-fs`), exactly as bash's do. See [the cross-family fs sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md).
## Model Experience
Indirectly, through `dsh-tool-fs`, which renders this backend's `FS_SANDBOX_DENIED` refusals as the `[sandbox: file access denied under <mode> mode]` marker plus the same-turn escalation hint.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **A policy fence, not a kernel boundary** — the check is trusted code over a model-controlled path, so the residual resolve-to-syscall TOCTOU is narrowed (by the in-place re-canonicalization) but not eliminated; adversarial host processes are out of scope. Kernel-grade isolation of untrusted code stays `ctx.bash`'s.
- **Fence-vs-runner parity is derived, not asserted** — the writable set comes from `writableRoots`, shared with the Seatbelt profile and pinned by a parity test; a runner profile that changed its writable set without that function would drift.
- **Requires `ctx.sandboxPolicy`** — the backend reads the default mode and workspace root from it and does not confine without it composed.

View File

@@ -0,0 +1,38 @@
{
"name": "@deepseek-ai/dsh-fs-sandbox",
"description": "Sandbox-enforcing implementation of the DeepSeek Harness filesystem seam: fences write/edit by the per-call sandbox mode (read-only denies mutation, workspace-write contains it to the workspace + temp roots) while reads pass through",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-fs": "^0.0.1",
"@deepseek-ai/dsh-fs-local": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,157 @@
/**
* `SandboxedFileSystem`: the sandbox-enforcing implementation of the
* `@deepseek-ai/dsh-fs` provider seam. It extends `LocalFileSystem` so all
* text-storage mechanics — resolve, stat, read/stream, list, the atomic
* write and the read-match-write edit critical section — are the local
* implementation's, verbatim; this package adds only the per-call MODE fence
* on the two mutations. Reads pass through untouched: every mode permits
* reading.
*
* The fence is a policy check in TRUSTED code over a MODEL-CONTROLLED path,
* NOT a kernel boundary — the operations are the seam's own (open, rename),
* and only the target path is untrusted, so canonicalize-then-contain is the
* complete answer to this surface. Kernel-grade isolation of untrusted CODE
* stays `ctx.bash`'s job (`@deepseek-ai/dsh-bash-sandbox`). This mirrors the
* `code-runtime` stance: containment, not a security boundary. The residual
* TOCTOU (an ancestor symlink swapped between the containment re-check and the
* syscall) is narrowed by re-canonicalizing immediately before delegating and
* is accepted for this threat model.
*
* Per-call mode: `read-only` denies every mutation; `workspace-write` allows a
* mutation only when the target canonicalizes under the workspace root or a
* platform temp area (the SAME writable-root set the Seatbelt profile grants,
* derived from the one `writableRoots` function so bash and fs cannot drift);
* `danger-full-access` delegates unfenced. A denial throws the structured
* `FS_SANDBOX_DENIED` — no text inference is needed (unlike bash's kernel
* stderr), because an in-process fence knows exactly what it refused. The
* escalation retry lives in the tool layer (`@deepseek-ai/dsh-tool-fs`),
* exactly as bash's does.
*
* @module @deepseek-ai/dsh-fs-sandbox
*/
import { sep } from 'node:path'
import { Context } from 'cordis'
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
import type { Config as LocalConfig } from '@deepseek-ai/dsh-fs-local'
import { FsError } from '@deepseek-ai/dsh-fs'
import type { FsEditOutcome, FsEditRequest, FsTarget, FsVersion, FsWriteIntent, FsWriteOutcome } from '@deepseek-ai/dsh-fs'
import { writableRoots } from '@deepseek-ai/dsh-sandbox'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
/**
* Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve
* base for relative paths). The sandbox default (mode + `workspace-write`
* boundary root) is NOT here — it lives on `ctx.sandboxPolicy`, the one home
* both enforcing families share.
*/
export type Config = LocalConfig
/** Whether `path` is `root` itself or lies beneath it (both already canonical). */
function isUnder(path: string, root: string): boolean {
if (path === root) return true
const prefix = root.endsWith(sep) ? root : root + sep
return path.startsWith(prefix)
}
/**
* Sandbox-enforcing filesystem backend. Registers as `ctx.fs` (loading it
* INSTEAD OF `dsh-fs-local`, together with a `ctx.sandboxPolicy`, is the whole
* swap — the model-facing tools are untouched). Its configured default mode is
* the fallback exposed by {@link sandboxMode}; `dsh-tool-fs` folds a session's
* `sandbox/mode` override and stamps the effective mode onto each mutation,
* while an approved escalation may stamp a strictly wider mode for one call.
*/
export class SandboxedFileSystem extends LocalFileSystem {
static inject = ['sandboxPolicy']
private readonly defaultMode: SandboxMode
/**
* The canonical roots a `workspace-write` mutation may land under, computed
* once (the workspace root and platform temp areas are fixed for the
* provider's lifetime): the same set {@link writableRoots} gives every
* enforcement dialect, so the fs fence and the bash runner agree.
*/
private readonly writableRoots: string[]
constructor(ctx: Context, config: Config) {
super(ctx, config)
this.defaultMode = ctx.sandboxPolicy.defaultMode
this.writableRoots = writableRoots({ mode: 'workspace-write', workspaceRoot: ctx.sandboxPolicy.workspaceRoot })
}
/** The deployment default mode — the capability fact the tool layer reads to advertise escalation. */
override get sandboxMode(): SandboxMode {
return this.defaultMode
}
/**
* Fence the write by the per-call mode, then delegate to the inherited
* atomic write. See {@link checkedTarget}.
* @param target - the resolved target to write.
* @param content - the full new file content.
* @param expected - the write intent guarding the write; omit for unconditional.
* @param signal - aborts before the atomic rename takes effect.
* @param sandboxMode - the per-call mode; omit to use the deployment default.
* @returns the write outcome from the inherited backend.
*/
override async writeText(
target: FsTarget,
content: string,
expected?: FsWriteIntent,
signal?: AbortSignal,
sandboxMode?: SandboxMode,
): Promise<FsWriteOutcome> {
return super.writeText(await this.checkedTarget(target, sandboxMode), content, expected, signal)
}
/**
* Fence the edit by the per-call mode, then delegate to the inherited
* atomic edit. See {@link checkedTarget}.
* @param target - the resolved target to edit.
* @param edit - the literal search/replace request.
* @param expected - the version guard; omit for an unconditional edit.
* @param signal - aborts before the atomic rename takes effect.
* @param sandboxMode - the per-call mode; omit to use the deployment default.
* @returns the edit outcome from the inherited backend.
*/
override async editText(
target: FsTarget,
edit: FsEditRequest,
expected?: { version: FsVersion },
signal?: AbortSignal,
sandboxMode?: SandboxMode,
): Promise<FsEditOutcome> {
return super.editText(await this.checkedTarget(target, sandboxMode), edit, expected, signal)
}
/**
* Enforce the per-call mode against `target` and return the EXACT target the
* mutation must use, so the checked identity is the mutated one (no
* check-here-write-there TOCTOU). `read-only` denies; `workspace-write`
* re-canonicalizes NOW (`resolve` realpaths the deepest existing ancestor,
* reflecting a concurrently swapped symlink), requires containment under a
* writable root, and returns THAT fresh target; `danger-full-access` returns
* the caller's target unfenced. Throws the structured `FS_SANDBOX_DENIED` on
* refusal — the tool layer maps it to the model-facing `[sandbox: …]` marker
* and the escalation hint.
*/
private async checkedTarget(target: FsTarget, sandboxMode?: SandboxMode): Promise<FsTarget> {
const mode = sandboxMode ?? this.defaultMode
if (mode === 'danger-full-access') return target
if (mode === 'read-only') {
throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, 'FS_SANDBOX_DENIED')
}
// workspace-write: containment on the FRESH canonical path (catches a
// symlink ancestor swapped since the tool resolved this target), and the
// mutation delegates with THIS fresh target — never the stale one.
const fresh = await this.resolve(target.displayPath)
if (!this.writableRoots.some(root => isUnder(fresh.targetKey, root))) {
throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, 'FS_SANDBOX_DENIED')
}
return fresh
}
}
export default SandboxedFileSystem

View File

@@ -0,0 +1,237 @@
/**
* Tests for the sandbox-enforcing filesystem backend: the per-call mode fence
* on write/edit (read-only denies, workspace-write contains, danger-full-access
* passes through), reads always passing through, the capability fact, and the
* containment matrix — `..` traversal, absolute paths outside, and symlink
* escapes (a symlinked directory inside the workspace pointing out, and a new
* file created under one). The fence is exercised on a real filesystem: a
* denied write leaves no file on disk.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs'
import type { FsTarget } from '@deepseek-ai/dsh-fs'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox'
let base: string
let workspace: string
let outside: string
let ctx: Context
let fs: SandboxedFileSystem
let fiber: Awaited<ReturnType<Context['plugin']>>
async function boot(mode: SandboxMode): Promise<void> {
ctx = new Context()
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
fiber = await ctx.plugin(SandboxedFileSystem, { cwd: workspace })
fs = ctx.fs as SandboxedFileSystem
}
beforeEach(async () => {
// Base under HOME, deliberately NOT tmpdir: `workspace-write` grants /tmp and
// os.tmpdir() (parity with the bash runner), so an "outside" dir under tmpdir
// would be legitimately writable. Sibling dirs under HOME are outside every
// grant, so containment failures are real denials. (The bwrap e2e roots its
// workspaces under HOME for the same reason.)
base = await mkdtemp(join(homedir(), '.dsh-fssbx-'))
workspace = join(base, 'ws')
outside = join(base, 'out')
await mkdir(workspace)
await mkdir(outside)
})
afterEach(async () => {
await fiber?.dispose()
await rm(base, { recursive: true, force: true })
})
/** Resolve a path through the backend and return its target. */
function target(path: string): Promise<FsTarget> {
return fs.resolve(path)
}
describe('the capability fact', () => {
it('reports the deployment default mode (what the tool layer advertises against)', async () => {
await boot('workspace-write')
expect(fs.sandboxMode).toBe('workspace-write')
})
})
describe('read-only', () => {
beforeEach(() => boot('read-only'))
it('denies write, leaving no file on disk', async () => {
const path = join(workspace, 'denied.txt')
await expect(fs.writeText(await target(path), 'x')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
expect(existsSync(path)).toBe(false)
})
it('denies edit of an existing file (the content is unchanged)', async () => {
const path = join(workspace, 'file.txt')
await writeFile(path, 'original')
await expect(fs.editText(await target(path), { oldString: 'original', newString: 'changed', replaceAll: false }))
.rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
expect(await readFile(path, 'utf8')).toBe('original')
})
it('allows reads (every mode permits reading)', async () => {
const path = join(workspace, 'readable.txt')
await writeFile(path, 'hello')
expect(await fs.readText(await target(path))).toBe('hello')
})
})
describe('workspace-write containment', () => {
beforeEach(() => boot('workspace-write'))
it('a write under the workspace lands', async () => {
const path = join(workspace, 'nested', 'ok.txt')
const outcome = await fs.writeText(await target(path), 'inside')
expect(outcome.operation).toBe('create')
expect(await readFile(path, 'utf8')).toBe('inside')
})
it('a write to the platform temp area lands (parity with the bash runner grant)', async () => {
const path = join(await mkdtemp(join(tmpdir(), 'dsh-fssbx-tmp-')), 'temp.txt')
await fs.writeText(await target(path), 'temp')
expect(await readFile(path, 'utf8')).toBe('temp')
})
it('an absolute path outside the workspace is denied, no file created', async () => {
const path = join(outside, 'escape.txt')
await expect(fs.writeText(await target(path), 'x')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
expect(existsSync(path)).toBe(false)
})
it('a `..` traversal out of the workspace is denied', async () => {
const path = join(workspace, '..', 'sibling-escape.txt')
await expect(fs.writeText(await target(path), 'x')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
expect(existsSync(join(workspace, '..', 'sibling-escape.txt'))).toBe(false)
})
it('a symlinked directory inside the workspace pointing OUT is denied (canonicalized before containment)', async () => {
// workspace/link -> outside ; writing workspace/link/f.txt would land in outside/f.txt.
await symlink(outside, join(workspace, 'link'))
const path = join(workspace, 'link', 'f.txt')
await expect(fs.writeText(await target(path), 'x')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
expect(existsSync(join(outside, 'f.txt'))).toBe(false)
})
it('a NEW file created under a symlinked-out directory is denied (deepest-ancestor realpath)', async () => {
await symlink(outside, join(workspace, 'link'))
const path = join(workspace, 'link', 'newdir', 'deep.txt')
await expect(fs.writeText(await target(path), 'x')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
expect(existsSync(join(outside, 'newdir'))).toBe(false)
})
it('an edit outside the workspace is denied; the original is untouched', async () => {
const path = join(outside, 'file.txt')
await writeFile(path, 'original')
await expect(fs.editText(await target(path), { oldString: 'original', newString: 'x', replaceAll: false }))
.rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
expect(await readFile(path, 'utf8')).toBe('original')
})
it('an edit inside the workspace lands', async () => {
const path = join(workspace, 'edit.txt')
await writeFile(path, 'original')
const outcome = await fs.editText(await target(path), { oldString: 'original', newString: 'changed', replaceAll: false })
expect(outcome.after).toBe('changed')
expect(await readFile(path, 'utf8')).toBe('changed')
})
it('mutates the freshly checked identity, not a stale outside targetKey (TOCTOU direction)', async () => {
// A target whose displayPath is inside the workspace but whose targetKey is
// a STALE outside path — as if an ancestor symlink pointed out at the tool's
// resolve() and was swapped in before the write. The fence re-resolves
// displayPath (now inside) AND delegates with that fresh target, so the byte
// lands inside and the stale outside path is never written.
const insidePath = join(workspace, 'landed.txt')
const staleTarget: FsTarget = { displayPath: insidePath, targetKey: FsTargetKey(join(outside, 'escaped.txt')) }
await fs.writeText(staleTarget, 'inside')
expect(await readFile(insidePath, 'utf8')).toBe('inside')
expect(existsSync(join(outside, 'escaped.txt'))).toBe(false)
})
it('the workspace root itself passes the fence (path equal to a writable root), failing only on file type', async () => {
// isUnder's path-equals-root branch: the fence allows the root, and the
// write then fails because the root is a directory, not a regular file.
await expect(fs.writeText(await target(workspace), 'x')).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
})
})
describe('workspace-write with the filesystem root as the workspace (a root ending in the path separator)', () => {
it('grants writes anywhere: containment against `/` allows any absolute path', async () => {
// A degenerate but valid config — workspaceRoot '/'. It exercises isUnder's
// separator-suffixed-root branch: `/` already ends in the separator, so the
// prefix stays `/` and every absolute path is contained.
const rootCtx = new Context()
await rootCtx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/' })
const rootFiber = await rootCtx.plugin(SandboxedFileSystem, { cwd: workspace })
const rootFs = rootCtx.fs as SandboxedFileSystem
try {
const path = join(base, 'anywhere.txt') // under HOME, outside /tmp — allowed only via the `/` root
await rootFs.writeText(await rootFs.resolve(path), 'anywhere')
expect(await readFile(path, 'utf8')).toBe('anywhere')
} finally {
await rootFiber.dispose()
}
})
})
describe('danger-full-access', () => {
beforeEach(() => boot('danger-full-access'))
it('writes anywhere, unfenced', async () => {
const path = join(outside, 'free.txt')
await fs.writeText(await target(path), 'free')
expect(await readFile(path, 'utf8')).toBe('free')
})
})
describe('the per-call mode override (escalation)', () => {
it('a workspace-write stamp on a read-only default lets a contained write land for that call only', async () => {
await boot('read-only')
const path = join(workspace, 'escalated.txt')
// Default read-only would deny; the per-call workspace-write stamp allows it (contained).
await fs.writeText(await target(path), 'granted', undefined, undefined, 'workspace-write')
expect(await readFile(path, 'utf8')).toBe('granted')
// A neighboring plain call still runs under the read-only default.
await expect(fs.writeText(await target(join(workspace, 'plain.txt')), 'x'))
.rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
})
it('a danger-full-access stamp bypasses the fence for that call', async () => {
await boot('read-only')
const path = join(outside, 'granted-full.txt')
await fs.writeText(await target(path), 'full', undefined, undefined, 'danger-full-access')
expect(await readFile(path, 'utf8')).toBe('full')
})
})
describe('registration and HMR safety', () => {
it('registers as ctx.fs and unregisters cleanly from a child fiber', async () => {
await boot('workspace-write')
expect(ctx.fs).toBeInstanceOf(SandboxedFileSystem)
await fiber.dispose()
expect(ctx.get('fs')).toBeUndefined()
// Re-mount below the disposed one to prove no lingering registration.
fiber = await ctx.plugin(SandboxedFileSystem, { cwd: workspace })
expect(ctx.fs).toBeInstanceOf(SandboxedFileSystem)
})
})
describe('FsError identity', () => {
it('the denial is a structured FsError distinct from a host permission error', async () => {
await boot('read-only')
const error = await fs.writeText(await target(join(workspace, 'x.txt')), 'x').catch((e: unknown) => e)
expect(error).toBeInstanceOf(FsError)
expect((error as FsError).code).toBe('FS_SANDBOX_DENIED')
})
})

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../fs"
},
{
"path": "../fs-local"
},
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../sandbox/sandbox-policy"
}
]
}

View File

@@ -2,7 +2,7 @@
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives a backend provides — resolve a path, stat metadata, no-follow path metadata, read/stream text, list directories, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
This package is the provider-seam layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam RFC](../../../docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate RFC](../../../docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
This package is the provider-seam layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
| Layer | Package | Role |
|---|---|---|
@@ -42,15 +42,19 @@ This package declares three events (see the generated [events catalog](../../../
## Vocabulary
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
## Model Experience
Indirectly, through `dsh-tool-fs`, which renders provider text and errors as bounded, retained filesystem tool results.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Text-only by contract** — backends reject binary/non-UTF-8 content with `FS_NOT_TEXT`; binary-safe operations are a deliberate deferral of [the tool-schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md).
- **Eight primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing RFC](../../../docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md).
- **Text-only by contract** — backends reject binary/non-UTF-8 content with `FS_NOT_TEXT`; binary-safe operations are a deliberate deferral of [the tool-schemas Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md).
- **Eight primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md).
- **No IO deadline** — the seam arms no timeout; cancellation is a best-effort optional `AbortSignal` per primitive (the deliberate [fs-family stance](../README.md)).
- **Resolve-then-operate costs a remote backend two round-trips per tool call** — folding or caching resolution is left to such a backend.

View File

@@ -24,11 +24,13 @@
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -7,6 +7,7 @@
*/
import { Context, Service } from 'cordis'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type {
FsDirEntry,
FsEditOutcome,
@@ -82,6 +83,23 @@ export abstract class FileSystem extends Service {
super(ctx, 'fs')
}
/**
/**
* The sandbox mode this backend enforces on mutations BY DEFAULT, or
* `undefined` when it does not confine at all — the capability fact the tool
* layer reads to advertise the escalation fields honestly (mirrors
* `BashExecutor.sandboxMode`). The base class and the bare local backend
* report `undefined`; a sandboxing backend (`@deepseek-ai/dsh-fs-sandbox`)
* overrides it with the deployment default. A session override may make the
* effective mode narrower or wider, so strict escalation widening is checked
* per call rather than encoded in this default-relative fact.
* @returns the configured default mode of a sandboxing backend; `undefined`
* for a backend that never confines.
*/
get sandboxMode(): SandboxMode | undefined {
return undefined
}
/**
* 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
@@ -152,9 +170,18 @@ export abstract class FileSystem extends Service {
* @param content - the full new file content.
* @param expected - the write intent guarding the write; omit for unconditional.
* @param signal - aborts before the atomic rename takes effect.
* @param sandboxMode - the per-call sandbox mode this write runs under; a
* sandboxing backend fences the write by it, the bare backend ignores it.
* Omit to leave the backend its own default.
* @returns the outcome, including the version the write produced.
*/
abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
abstract writeText(
target: FsTarget,
content: string,
expected?: FsWriteIntent,
signal?: AbortSignal,
sandboxMode?: SandboxMode,
): Promise<FsWriteOutcome>
/**
* Atomically edit literal text. When supplied, the version guard is checked
@@ -164,9 +191,18 @@ export abstract class FileSystem extends Service {
* @param edit - the literal search/replace request.
* @param expected - the version guard; omit for an unconditional edit.
* @param signal - aborts before the atomic rename takes effect.
* @param sandboxMode - the per-call sandbox mode this edit runs under; a
* sandboxing backend fences the edit by it, the bare backend ignores it.
* Omit to leave the backend its own default.
* @returns the outcome, including the version the edit produced.
*/
abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
abstract editText(
target: FsTarget,
edit: FsEditRequest,
expected?: { version: FsVersion },
signal?: AbortSignal,
sandboxMode?: SandboxMode,
): Promise<FsEditOutcome>
}
export default FileSystem

View File

@@ -168,6 +168,7 @@ export type FsErrorCode =
| 'FS_NOT_TEXT'
| 'FS_NOT_REGULAR_FILE'
| 'FS_PERMISSION_DENIED'
| 'FS_SANDBOX_DENIED'
| 'FS_IO_ERROR'
| 'FS_STALE_VERSION'
| 'FS_NOT_OBSERVED'

View File

@@ -9,6 +9,7 @@
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../util/brand" },
{ "path": "../../llm/llm" }
{ "path": "../../llm/llm" },
{ "path": "../../sandbox/sandbox" }
]
}

View File

@@ -1,20 +1,20 @@
# @deepseek-ai/dsh-tool-fs-search
The **model-facing filesystem discovery tools**`glob`, `grep` — backed by the **bash executor seam**, not by `ctx.fs` provider methods. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)``ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a bounded, workdir-relative result. The package injects `tools`, `systemPrompt`, and `bash` — deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
The **model-facing filesystem discovery tools**`glob`, `grep` — backed by the **bash executor seam**, not by `ctx.fs` provider methods. At load, the package probes `command -v rg` through `ctx.bash`; if the executor cannot find ripgrep on its `PATH`, it logs a warning and registers no tools or prompt sections. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)``ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a bounded, workdir-relative result. The package injects `tools`, `systemPrompt`, and `bash` — deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
```ts ignore-check
// Default deployment: a bash executor, then the discovery tools.
// Default deployment: a bash executor whose PATH includes rg, then the discovery tools.
await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local
await ctx.plugin(ToolFsSearch) // this package — registers glob/grep
await ctx.plugin(ToolFsSearch) // this package — conditionally registers glob/grep
// Optional: a spill backend makes capped results fully recoverable.
await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local
```
Why bash-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The bash executor owns request defaulting/capping, subprocess execution, process-group termination, environment scrubbing, raw output capture, and backend substitution (local, sandboxed, remote); this package owns schemas, argument validation, shell quoting, parsing, retention, formatted-result spill, and timeout declaration. The tools never call `ctx.bash.start()` and never expose a bash task id — the call returns only after `rg` exits, times out, is aborted, or fails.
## Deployment requirement: co-located bash + filesystem
## Deployment requirement: rg + co-located bash/filesystem
Returned paths are displayed relative to the resolved bash workdir (the calling agent's session cwd when present, else the executor's configured default) and are follow-up-readable with `read` only when the bash workdir and the filesystem root are the same workspace. v1 documents that requirement and performs no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend.
The mounted bash executor must be able to resolve `rg` from its `PATH` at plugin load; otherwise `glob` and `grep` are absent from the model-visible tool schema. Returned paths are displayed relative to the resolved bash workdir (the calling agent's session cwd when present, else the executor's configured default) and are follow-up-readable with `read` only when the bash workdir and the filesystem root are the same workspace. v1 documents that co-location requirement and performs no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend.
## Config
@@ -43,48 +43,80 @@ Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMax
## Errors
Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (missing `rg`, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors.
Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (runtime `rg` disappearance after registration, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors.
## Model Experience
### System prompt
**What the model sees**: Every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section.
#### What the model sees
**Token effect**: Fixed guidance cost per request while the plugin is active.
After the load-time `rg` probe succeeds, every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section.
#### Glob guidance
##### Glob guidance
```markdown
Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.
```
#### Grep guidance
##### Grep guidance
```markdown
Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context.
```
#### Token effect
Fixed guidance cost per request while the tools are registered.
#### KV Cache effect
Prefix-stable while the plugin scope and guidance text are unchanged. Activation or disposal may invalidate reuse from this prompt section.
### Tool schemas
**What the model sees**: The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) while this surface is visible.
#### What the model sees
**Token effect**: Fixed schema cost on every request where the tools are visible.
The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) after the load-time `rg` probe succeeds and while this surface is visible.
#### Token effect
Fixed schema cost on every request where the tools are visible.
#### KV Cache effect
Prefix-stable while tool visibility and definitions are unchanged. Registration lifecycle or scoped restrictions may invalidate reuse from the first changed schema token.
### Results and spill notices
**What the model sees**: `glob` returns one path per line; `grep` groups `Line <line>: <preview>` matches beneath each path. Empty searches return `No files found` or `No matches found`. A capped result ends with its omission count plus the spill locator and backend retrieval hint, or says the complete result could not be saved.
#### What the model sees
**Token effect**: Inline paths and matches are bounded by `globMaxResults`, `grepMaxMatches`, and `grepMaxLineBytes`; the call and retained result remain in history until compaction.
`glob` returns one path per line; `grep` groups `Line <line>: <preview>` matches beneath each path. Empty searches return `No files found` or `No matches found`. A capped result ends with its omission count plus the spill locator and backend retrieval hint, or says the complete result could not be saved.
#### Token effect
Inline paths and matches are bounded by `globMaxResults`, `grepMaxMatches`, and `grepMaxLineBytes`; the call and retained result remain in history until compaction.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Tool errors
**What the model sees**: Failures are normalized as `Error: <message>` with structured `SEARCH_INVALID_PATTERN`, `SEARCH_FAILED`, `SEARCH_RAW_OUTPUT_OVERFLOW`, or `SEARCH_ABORTED` metadata for callers.
#### What the model sees
**Token effect**: Only a failing call adds these retained tokens.
Failures are normalized as `Error: <message>` with structured `SEARCH_INVALID_PATTERN`, `SEARCH_FAILED`, `SEARCH_RAW_OUTPUT_OVERFLOW`, or `SEARCH_ABORTED` metadata for callers.
#### Token effect
Only a failing call adds these retained tokens.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work
- **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the bash workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation.
- **Ripgrep is a deployment dependency** — a missing or incompatible `rg` executable fails calls with `SEARCH_FAILED`; remote or virtual filesystems need a co-located executor or another search consumer.
- **Ripgrep is a deployment dependency** — a missing `rg` executable makes the package register no tools or guidance; an incompatible executable or one that disappears after registration fails calls with `SEARCH_FAILED`. Remote or virtual filesystems need a co-located executor or another search consumer.
- **The schemas expose one bounded page** — offset pagination, case-mode switches, alternate output modes, and provider-backed discovery remain outside this package; capped complete output requires a spill backend.

View File

@@ -1,6 +1,7 @@
/**
* The model-facing filesystem discovery tool suite (`glob`, `grep`) over the
* bash executor seam (`ctx.bash`). This single plugin registers both tools.
* bash executor seam (`ctx.bash`). This single plugin registers both tools
* only when the mounted bash executor can find `rg` on its `PATH`.
*
* ## Bash-backed, not a `ctx.fs` provider method
*
@@ -12,9 +13,11 @@
* parsing, retention, formatted-result spill, and timeout declaration; the
* bash executor owns request defaulting/capping, subprocess execution,
* process-group termination, environment scrubbing, raw output capture, and
* backend substitution. The package injects `tools`, `systemPrompt`, and
* `bash` — deliberately NOT `fs`, and `ctx.spillStore` is read opportunistically
* with `ctx.get()` because formatted-result spill is optional.
* backend substitution. At load, the package probes `command -v rg` through the
* same bash seam; if ripgrep is absent, `glob` / `grep` and their prompt
* sections are not registered. The package injects `tools`, `systemPrompt`,
* and `bash` — deliberately NOT `fs`, and `ctx.spillStore` is read
* opportunistically with `ctx.get()` because formatted-result spill is optional.
*
* Returned paths are displayed relative to the resolved bash workdir and are
* follow-up-readable only in co-located deployments where the bash workdir and
@@ -80,6 +83,9 @@ export const Config: z<Config> = z.object({
/** The shape after schemastery applied the defaults. */
type ResolvedConfig = Required<Config>
/** POSIX-shell builtin probe for the ripgrep binary in the bash executor environment. */
const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1'
/** Every search cap counts items/bytes/milliseconds — a positive integer, or retention and timeout arithmetic misbehaves silently. */
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 1) {
@@ -87,8 +93,38 @@ function assertPositiveInteger(name: string, value: number): void {
}
}
/** Register the `glob`/`grep` filesystem discovery tool suite. */
export function apply(ctx: Context, config: Config): void {
/**
* Check whether the mounted bash executor can find `rg`.
*
* Nonzero exit means "not available" and disables this optional tool suite.
* Infrastructure failures stay loud: a deployment with a broken bash executor
* should not silently lose tools in a way that looks like a deliberate skip.
*
* @param ctx - plugin context whose `bash` service is the executor the tools will use.
* @returns true when `command -v rg` exits 0, false when it exits nonzero.
*/
async function ripgrepAvailable(ctx: Context): Promise<boolean> {
const spec = ctx.bash.resolve({ command: RG_PROBE_COMMAND })
let result
try {
result = await ctx.bash.run(spec)
} catch (error: unknown) {
throw new Error(`tool-fs-search: ripgrep availability probe could not start: ${String(error)}`, { cause: error })
}
if (result.aborted || result.timedOut || result.signal !== null || result.exitCode === null) {
throw new Error('tool-fs-search: ripgrep availability probe did not complete')
}
return result.exitCode === 0
}
/**
* Register the `glob`/`grep` filesystem discovery tool suite when `rg` exists.
*
* @param ctx - plugin context; registrations are effects scoped to this plugin.
* @param config - resolved plugin configuration from schemastery.
* @returns when ripgrep is unavailable, resolves without registering any tools.
*/
export async function apply(ctx: Context, config: Config): Promise<void> {
// schemastery (Config) has already filled every defaulted field.
const resolved = config as ResolvedConfig
assertPositiveInteger('globMaxResults', resolved.globMaxResults)
@@ -96,6 +132,10 @@ export function apply(ctx: Context, config: Config): void {
assertPositiveInteger('grepMaxLineBytes', resolved.grepMaxLineBytes)
assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes)
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
if (!await ripgrepAvailable(ctx)) {
ctx.logger.warn('tool-fs-search: ripgrep (rg) not found on the bash executor PATH; glob/grep tools not registered')
return
}
applyGlobTool(ctx, {
maxResults: resolved.globMaxResults,
rawOutputMaxBytes: resolved.rawOutputMaxBytes,

View File

@@ -18,9 +18,48 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1'
/**
* Deterministic bash service for this Loader guard: the test wants to exercise
* the real unwrap/inject path, not depend on whether the host image has rg.
*/
class ProbeSuccessBashExecutor extends BashExecutor {
override resolve(request: BashExecRequest): BashExecSpec {
return {
command: request.command,
workdir: request.workdir ?? '/work',
timeoutMs: request.timeoutMs ?? 60_000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
signal: request.signal,
sandboxMode: request.sandboxMode,
}
}
override run(spec: BashExecSpec): Promise<BashRunResult> {
if (spec.command !== RG_PROBE_COMMAND) {
throw new Error(`unexpected command in load-path guard: ${spec.command}`)
}
return Promise.resolve({
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
timeoutMs: spec.timeoutMs,
stdout: { text: '', truncated: false },
stderr: { text: '', truncated: false },
})
}
override start(): BashProcess {
throw new Error('load-path guard must not start background processes')
}
}
describe('dsh-tool-fs-search real-load-path guard', () => {
it('has no default export and keeps name/inject/Config through unwrapExports', () => {
expect('default' in toolFsSearch).toBe(false)
@@ -38,7 +77,7 @@ describe('dsh-tool-fs-search real-load-path guard', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalBashExecutor, {})
await ctx.plugin(ProbeSuccessBashExecutor)
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(toolFsSearch) as Parameters<Context['plugin']>[0]

View File

@@ -2,12 +2,12 @@
* Consumer-surface tests for the search tools over a FAKE bash executor and a
* FAKE spill backend, exercised through `ctx.tools.execute()` so nothing
* bypasses the tool registry. The fake executor makes every seam outcome
* scriptable — truncated stdout with/without a raw spill path, abort/timeout,
* signal kills, ripgrep exit codes — so these tests verify schemas, argument
* validation, shell-safe command construction, workdir derivation, signal
* forwarding, `SEARCH_*` error classification, retention, formatted-result
* spill handoff, and the no-background-task invariant. Real-`rg` behavior is
* pinned separately in integration.spec.ts.
* scriptable — registration-time `rg` probing, truncated stdout with/without a
* raw spill path, abort/timeout, signal kills, ripgrep exit codes — so these
* tests verify schemas, argument validation, shell-safe command construction,
* workdir derivation, signal forwarding, `SEARCH_*` error classification,
* retention, formatted-result spill handoff, and the no-background-task
* invariant. Real-`rg` behavior is pinned separately in integration.spec.ts.
*/
import { describe, expect, it } from 'vitest'
@@ -31,6 +31,8 @@ import {
toWorkdirRelative,
} from '@deepseek-ai/dsh-tool-fs-search'
const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1'
/** A successful run result over the given stdout; overrides script the failure shapes. */
function runResult(stdout: string, overrides?: Partial<BashRunResult>): BashRunResult {
return {
@@ -52,13 +54,18 @@ function runResult(stdout: string, overrides?: Partial<BashRunResult>): BashRunR
* create a background task.
*/
class FakeBash extends BashExecutor {
probeRequests: BashExecRequest[] = []
probeSpecs: BashExecSpec[] = []
requests: BashExecRequest[] = []
specs: BashExecSpec[] = []
startCalls = 0
probeResult: BashRunResult = runResult('')
probeError?: Error
handler: (spec: BashExecSpec) => BashRunResult = () => runResult('')
override resolve(request: BashExecRequest): BashExecSpec {
this.requests.push(request)
if (request.command === RG_PROBE_COMMAND) this.probeRequests.push(request)
else this.requests.push(request)
return {
command: request.command,
workdir: request.workdir ?? '/work',
@@ -68,9 +75,14 @@ class FakeBash extends BashExecutor {
sandboxMode: request.sandboxMode,
}
}
override run(spec: BashExecSpec): Promise<BashRunResult> {
override async run(spec: BashExecSpec): Promise<BashRunResult> {
if (spec.command === RG_PROBE_COMMAND) {
this.probeSpecs.push(spec)
if (this.probeError) throw this.probeError
return this.probeResult
}
this.specs.push(spec)
return Promise.resolve(this.handler(spec))
return this.handler(spec)
}
override start(): BashProcess {
this.startCalls++
@@ -97,18 +109,36 @@ class FakeSpill extends SpillStore {
interface SetupOptions {
config?: ToolFsSearch.Config
spill?: boolean
probeError?: Error
probeResult?: BashRunResult
}
async function setup(options: SetupOptions = {}) {
const ctx = new Context()
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FakeBash)
const bash = ctx.bash as FakeBash
if (options.probeResult) bash.probeResult = options.probeResult
if (options.probeError) bash.probeError = options.probeError
if (options.spill === true) await ctx.plugin(FakeSpill)
const fiber = await ctx.plugin(ToolFsSearch, options.config)
const bash = ctx.bash as FakeBash
const spill = options.spill === true ? ctx.get('spillStore') as FakeSpill : undefined
return { ctx, bash, spill, fiber }
return { ctx, bash, spill, fiber, warnings }
}
/** Assert plugin setup rejects without letting Vitest pretty-print a live Context on failure. */
async function expectSetupRejects(options: SetupOptions, message: RegExp): Promise<void> {
let thrown: string | undefined
try {
const loaded = await setup(options)
await loaded.fiber.dispose()
} catch (error: unknown) {
thrown = error instanceof Error ? error.message : String(error)
}
expect(thrown).toMatch(message)
}
/** A stand-in agent whose session header carries the given cwd (and a stable id). */
@@ -136,13 +166,37 @@ function matchLine(path: string, lineNumber: number, lineText: string): string {
describe('registration', () => {
it('registers glob and grep with their prompt sections', async () => {
const { ctx } = await setup()
const { ctx, bash } = await setup()
expect(bash.probeRequests).toHaveLength(1)
expect(bash.probeRequests[0]?.command).toBe(RG_PROBE_COMMAND)
expect(bash.probeRequests[0]).not.toHaveProperty('workdir')
expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['glob', 'grep'])
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
expect(prompt).toContain('Use the glob tool')
expect(prompt).toContain('Use the grep tool')
})
it('does not register glob or grep when the bash executor cannot find rg', async () => {
const { ctx, warnings } = await setup({ probeResult: runResult('', { exitCode: 1 }) })
expect(ctx.tools.schemas()).toHaveLength(0)
const sections = (await ctx.systemPrompt.assemble()).sections.map(s => s.name)
expect(sections).not.toContain('tool:glob')
expect(sections).not.toContain('tool:grep')
expect(warnings).toEqual([
'tool-fs-search: ripgrep (rg) not found on the bash executor PATH; glob/grep tools not registered',
])
})
it('rejects plugin load when the rg availability probe cannot run', async () => {
await expectSetupRejects({ probeError: new Error('spawn bash ENOENT') }, /spawn bash ENOENT/)
})
it('rejects plugin load when the rg availability probe is aborted or killed', async () => {
await expectSetupRejects({
probeResult: runResult('', { aborted: true, exitCode: null, signal: 'SIGTERM' }),
}, /tool-fs-search: ripgrep availability probe did not complete/)
})
it('stays pending until ctx.bash exists (inject)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)

View File

@@ -22,7 +22,7 @@ All keys are optional; the defaults are the shipped read caps.
| `readMaxBytes` | `51200` | Byte cap on one `read` call's selected lines; overflow ends the window with a "capped" footer. |
| `readStreamMinSize` | `10485760` | Files at or above this size (or with unknown size) stream instead of loading whole into memory. |
## Tools (schemas per [the filesystem tool schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md))
## Tools (schemas per [the filesystem tool schemas Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md))
| Tool | Arguments | Behavior |
|---|---|---|
@@ -34,7 +34,7 @@ Field names are snake_case to match Claude Code and existing harness tool schema
## The tool is the executor; policy is an event gate
The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd, signal })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash`, and forwarding tool cancellation through resolution (see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then:
The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd, signal })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash`, and forwarding tool cancellation through resolution (see [the per-session cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then:
- **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits `fs/observed` with a plain `ctx.emit`. (1 stat.)
- **write** — `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, intent)`, then `fs/observed`. (0 stat.)
@@ -46,7 +46,7 @@ The tool passes `exec` (the tool-execution context) as the opaque `actor` on eve
`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-fs-policy`'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.
`read` opts into concurrent scheduling because its only mutation is the synchronous version recorder. Recorder races fail closed when a later `write` or `edit` re-checks the version under its target lock; both mutation tools remain exclusive. See the [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md).
`read` opts into concurrent scheduling because its only mutation is the synchronous version recorder. Recorder races fail closed when a later `write` or `edit` re-checks the version under its target lock; both mutation tools remain exclusive. See the [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md).
The package root exports only the Cordis plugin contract (`name`, `inject`, `Config`, and `apply`). 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.
@@ -54,51 +54,91 @@ The package root exports only the Cordis plugin contract (`name`, `inject`, `Con
### System prompt
**What the model sees**: Every request in this plugin's registration scope receives the independently registered read, write, and edit guidance below. Scoped tool restrictions can hide schemas without removing these sections.
#### What the model sees
**Token effect**: Fixed guidance cost per request while the plugin is active, even when a restriction hides one or more tools.
Every request in this plugin's registration scope receives the independently registered read, write, and edit guidance below. Scoped tool restrictions can hide schemas without removing these sections.
#### Read guidance
##### Read guidance
```markdown
Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
```
#### Write guidance
##### Write guidance
```markdown
Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.
```
#### Edit guidance
##### Edit guidance
```markdown
Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.
```
#### Token effect
Fixed guidance cost per request while the plugin is active, even when a restriction hides one or more tools.
#### KV Cache effect
Prefix-stable while the plugin scope and guidance text are unchanged. Tool restrictions do not remove this section, but plugin activation or disposal may invalidate reuse from it.
### Tool schemas
**What the model sees**: The model sees the generated [`read`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. Scoped tool restrictions can remove any definition for one agent.
#### What the model sees
**Token effect**: Fixed schema cost on every request in that tool view.
The model sees the generated [`read`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. Scoped tool restrictions can remove any definition for one agent.
#### Token effect
Fixed schema cost on every request in that tool view.
#### KV Cache effect
Prefix-stable while the visible tool definitions and order are unchanged. Registration lifecycle or scoped restrictions may invalidate reuse from the first changed schema token.
### Read result
**What the model sees**: A successful read is exactly `<path><displayPath></path>`, newline, `<type>file</type>`, newline, `<content>`, numbered lines as `<lineNumber>: <text>`, a blank line, one footer, and `</content>`. The footer is exactly `(Output capped. Showing lines <start>-<end>. Use offset=<next> to continue.)`, `(Showing lines <start>-<end> of <total>. Use offset=<next> to continue.)`, or `(End of file - total <total> lines)`. A long line ends exactly `... (line truncated to <max> chars)`.
#### What the model sees
**Token effect**: Read output is capped by `readLimit`, `readMaxLineLength`, and `readMaxBytes`; the retained call and result are resent until compaction.
A successful read is exactly `<path><displayPath></path>`, newline, `<type>file</type>`, newline, `<content>`, numbered lines as `<lineNumber>: <text>`, a blank line, one footer, and `</content>`. The footer is exactly `(Output capped. Showing lines <start>-<end>. Use offset=<next> to continue.)`, `(Showing lines <start>-<end> of <total>. Use offset=<next> to continue.)`, or `(End of file - total <total> lines)`. A long line ends exactly `... (line truncated to <max> chars)`.
#### Token effect
Read output is capped by `readLimit`, `readMaxLineLength`, and `readMaxBytes`; the retained call and result are resent until compaction.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Write and edit results
**What the model sees**: Write returns the exact five-line envelope `<path><displayPath></path>`, `<type>file</type>`, `<content>`, `Created file` or `Updated file`, then `</content>`. Edit returns exactly `The file <displayPath> has been updated successfully.` or, for `replace_all`, `The file <displayPath> has been updated. All occurrences were successfully replaced.` The full write or replacement text remains in the assistant tool-call arguments.
#### What the model sees
**Token effect**: Success text is small, but large mutation arguments and any result are resent until compaction.
Write returns the exact five-line envelope `<path><displayPath></path>`, `<type>file</type>`, `<content>`, `Created file` or `Updated file`, then `</content>`. Edit returns exactly `The file <displayPath> has been updated successfully.` or, for `replace_all`, `The file <displayPath> has been updated. All occurrences were successfully replaced.` The full write or replacement text remains in the assistant tool-call arguments.
#### Token effect
Success text is small, but large mutation arguments and any result are resent until compaction.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Tool errors
**What the model sees**: Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs.
#### What the model sees
**Token effect**: Only a failing call adds these retained tokens.
Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs.
#### Token effect
Only a failing call adds these retained tokens.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work

View File

@@ -28,9 +28,12 @@
"peerDependencies": {
"@deepseek-ai/dsh-fs": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
@@ -42,9 +45,12 @@
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -13,6 +13,7 @@ import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
import { sessionResolveOptions } from './session-cwd.ts'
import type { FsSandboxSurface } from './sandbox.ts'
/** Validated `edit` arguments after defaulting. */
interface EditInput {
@@ -22,6 +23,20 @@ interface EditInput {
replaceAll: boolean
}
/**
* The `edit` tool's validated argument shape: the base parameters plus the two
* escalation fields, advertised only under a confining `ctx.fs` (absent from
* the schema otherwise, so the validator rejects them before `execute`).
*/
interface EditToolArgs {
file_path: string
old_string: string
new_string: string
replace_all?: boolean
sandbox_permissions?: string
justification?: string
}
/**
* Validate value constraints the schema DSL can't express: a non-blank
* `file_path`, a non-empty `old_string`, and `old_string !== new_string`
@@ -56,8 +71,9 @@ export function formatEditOutput(displayPath: string, replaceAll: boolean): stri
/**
* Register the `edit` tool and its system-prompt guidance.
* @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service.
* @param sandbox - the shared sandbox-escalation surface (advertisement, mode stamping, denial mapping).
*/
export function applyEditTool(ctx: Context): void {
export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
ctx.systemPrompt.section({
name: 'tool:edit',
order: 102,
@@ -72,20 +88,31 @@ export function applyEditTool(ctx: Context): void {
old_string: { type: 'string', required: true, description: 'Literal text to replace. Must match exactly.' },
new_string: { type: 'string', required: true, description: 'Literal replacement text. Use an empty string to delete the match.' },
replace_all: { type: 'boolean', description: 'Replace all matches. Defaults to false; when false, old_string must appear exactly once.' },
...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {},
},
async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
async execute(args: EditToolArgs, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
const input = parseEditArgs(args)
// Resolve the per-call sandbox mode (escalation grant > session override
// > backend default) BEFORE anything executes.
const sandboxMode = await sandbox.stampMode('edit', args, exec)
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec))
// 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 intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
const outcome = await ctx.fs.editText(
target,
{ oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll },
intent,
exec.signal,
)
let outcome
try {
outcome = await ctx.fs.editText(
target,
{ oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll },
intent,
exec.signal,
sandboxMode,
)
} catch (error: unknown) {
// A sandbox denial becomes the shared [sandbox: …] marker; any other error passes through.
throw sandbox.mapError(error, sandboxMode)
}
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)
// An edit necessarily changes content, so result metadata carries at least one applied hunk.

View File

@@ -7,10 +7,12 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-user-approval'
import { applyReadTool, READ_LIMIT, STREAM_MIN_SIZE } from './read.ts'
import { applyWriteTool } from './write.ts'
import { applyEditTool } from './edit.ts'
import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts'
import { FsSandboxSurface } from './sandbox.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'tool-fs'
@@ -61,6 +63,10 @@ export function apply(ctx: Context, config: Config): void {
maxBytes: resolved.readMaxBytes,
streamMinSize: resolved.readStreamMinSize,
})
applyWriteTool(ctx)
applyEditTool(ctx)
// One escalation surface shared by both mutating tools: advertisement gating,
// per-call mode stamping, and denial-marker mapping, all keyed off whether
// the mounted ctx.fs confines (ctx.fs.sandboxMode).
const sandbox = new FsSandboxSurface(ctx)
applyWriteTool(ctx, sandbox)
applyEditTool(ctx, sandbox)
}

View File

@@ -0,0 +1,135 @@
/**
* The sandbox-escalation surface shared by the `write` and `edit` tools: the
* per-call mode stamp, the advertised escalation fields, and the denial-marker
* mapping — all delegating the vocabulary and the fail-closed approval
* sequence to `@deepseek-ai/dsh-sandbox` (the same pieces `@deepseek-ai/dsh-tool-bash`
* uses), so bash and fs escalate identically. Built ONCE per plugin from
* `ctx.fs.sandboxMode` (the capability fact — is a confining backend mounted?)
* and shared by both mutating tools.
*
* @module @deepseek-ai/dsh-tool-fs/sandbox
*/
import type { Context } from 'cordis'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { ESCALATION_TARGETS, approveEscalation, escalationHintMarker, sandboxDenialMarker, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import { FsError } from '@deepseek-ai/dsh-fs'
/** The two escalation arguments a mutating tool may carry (advertised only under a confining backend). */
export interface FsEscalationArgs {
sandbox_permissions?: string
justification?: string
}
/** The schema fields for the escalation arguments, spread into a tool's `parameters` when a confining backend is mounted. */
export interface EscalationSchemaFields {
sandbox_permissions: { type: 'string'; enum: string[]; description: string }
justification: { type: 'string'; description: string }
}
/**
* The filesystem escalation surface: advertisement gating, per-call mode
* stamping (folding the session's `sandbox/mode` override), the one-approved
* wider retry, and denial-marker mapping. A pure product of `ctx` at plugin
* apply time.
*/
export class FsSandboxSurface {
/** The escalation targets this composition advertises (`[]` when no confining backend is mounted). */
readonly escalationModes: readonly SandboxMode[]
/** The backend's default mode, or `undefined` when `ctx.fs` does not confine. */
private readonly defaultMode: SandboxMode | undefined
constructor(private readonly ctx: Context) {
this.defaultMode = ctx.fs.sandboxMode
this.escalationModes = this.defaultMode === undefined ? [] : ESCALATION_TARGETS
}
/**
* The escalation schema fields for a mutating tool's `parameters`. Call it
* only under a confining backend (guard on {@link escalationModes}); the
* enum pins the closed target vocabulary, the strict-wider check happens per
* call at execution.
* @returns the two escalation parameter specs.
*/
schemaFields(): EscalationSchemaFields {
return {
sandbox_permissions: {
type: 'string',
enum: [...this.escalationModes],
description: 'The wider sandbox mode this file operation needs. Only valid as a one-shot retry '
+ 'of an operation the sandbox just denied; requires justification and user approval.',
},
justification: {
type: 'string',
description: 'Required with sandbox_permissions: one sentence for the user explaining '
+ 'why this exact file operation needs the wider access.',
},
}
}
/**
* The session's standing mode override for an ordinary (non-escalating)
* call — the `sandbox/mode` fold of the calling agent's log. Undefined for a
* non-confining backend and for agent-less callers.
*/
private sessionOverride(exec: ToolExecution): SandboxMode | undefined {
if (this.defaultMode === undefined || exec.agent === undefined) return undefined
return effectiveSandboxMode(exec.agent.session.events)
}
/**
* The mode to STAMP onto this mutation: an approved escalation grant (a
* strictly wider retry resolved through `ctx.approval` before anything
* executes), else the session's standing override, else `undefined` (the
* backend applies its own default). Validates the escalation argument
* pairing first.
* @param toolName - the mutating tool's name, for the approval audit trail.
* @param args - the call's escalation arguments.
* @param exec - the tool-execution context (agent, callId, signal).
* @returns the mode to pass to the mutation, or undefined for the backend default.
*/
async stampMode(toolName: string, args: FsEscalationArgs, exec: ToolExecution): Promise<SandboxMode | undefined> {
validateEscalationArgs(args.sandbox_permissions, args.justification)
if (args.sandbox_permissions === undefined || args.justification === undefined) {
return this.sessionOverride(exec)
}
if (this.escalationModes.length === 0) {
throw new Error('sandbox_permissions is not available in this composition (no sandboxing filesystem to escalate)')
}
const effectiveMode = (this.sessionOverride(exec) ?? this.defaultMode) as SandboxMode
return approveEscalation(
{ requestedMode: args.sandbox_permissions, justification: args.justification, effectiveMode, subject: 'operation' },
{
approver: this.ctx.get('approval'),
agent: exec.agent,
callId: exec.callId,
toolName,
...exec.signal ? { signal: exec.signal } : {},
},
)
}
/**
* Map a thrown provider error for the model: a `FS_SANDBOX_DENIED` becomes a
* `FsError` whose text is the shared `[sandbox: …]` denial marker plus the
* same-turn escalation hint, so a policy denial reads identically to bash's
* WHILE keeping the structured `FS_SANDBOX_DENIED` code — `ToolRegistry`
* populates `result.error` only for `HarnessError` instances, so a plain
* `Error` would strip the code retry/observers key off. Any other error
* passes through unchanged. A `FS_SANDBOX_DENIED` only arises under a
* confining backend, which always advertises the escalation fields, so the
* hint always applies here.
* @param error - the error thrown by the mutation.
* @param stampedMode - the mode stamped onto the call (names the mode in the marker).
* @returns the error to throw — the marker `FsError` for a sandbox denial, else the original.
*/
mapError(error: unknown, stampedMode: SandboxMode | undefined): unknown {
if (!(error instanceof FsError) || error.code !== 'FS_SANDBOX_DENIED') return error
// A FS_SANDBOX_DENIED only arises under a confining backend, so defaultMode
// (hence the resolved mode) is defined here.
const mode = (stampedMode ?? this.defaultMode) as SandboxMode
return new FsError(`${sandboxDenialMarker(mode)}\n${escalationHintMarker('operation')}`, 'FS_SANDBOX_DENIED', { cause: error })
}
}

View File

@@ -14,6 +14,7 @@ import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
import { sessionResolveOptions } from './session-cwd.ts'
import type { FsSandboxSurface } from './sandbox.ts'
/**
* Validate value constraints the schema DSL can't express: only a non-blank
@@ -41,11 +42,24 @@ ${verb} file
</content>`
}
/**
* The `write` tool's validated argument shape: the base parameters plus the
* two escalation fields, advertised only under a confining `ctx.fs` (absent
* from the schema otherwise, so the validator rejects them before `execute`).
*/
interface WriteToolArgs {
file_path: string
content: string
sandbox_permissions?: string
justification?: string
}
/**
* Register the `write` tool and its system-prompt guidance.
* @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service.
* @param sandbox - the shared sandbox-escalation surface (advertisement, mode stamping, denial mapping).
*/
export function applyWriteTool(ctx: Context): void {
export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
ctx.systemPrompt.section({
name: 'tool:write',
order: 101,
@@ -58,14 +72,26 @@ export function applyWriteTool(ctx: Context): void {
parameters: {
file_path: { type: 'string', required: true, description: 'Path to write, resolved by the filesystem backend.' },
content: { type: 'string', required: true, description: 'Full UTF-8 text content to write.' },
...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {},
},
async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
async execute(args: WriteToolArgs, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
const input = parseWriteArgs(args)
// Resolve the per-call sandbox mode (escalation grant > session override
// > backend default) BEFORE anything executes; an escalating call
// resolves approval here and throws its distinct text on any non-grant.
const sandboxMode = await sandbox.stampMode('write', args, exec)
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec))
// Single-slot decision: the policy plugin produces createIfAbsent/
// replaceIfVersion; the bare default is undefined (unconditional). No stat.
const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)
const outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal)
let outcome: FsWriteOutcome
try {
outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxMode)
} catch (error: unknown) {
// A sandbox denial becomes the shared [sandbox: …] marker (the model
// recognizes it from bash); any other error passes through.
throw sandbox.mapError(error, sandboxMode)
}
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)
// Overwrites carry applied hunks. Creates have no prior text, so result presentation uses

View File

@@ -24,6 +24,8 @@ import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import { STREAM_MIN_SIZE } from '../src/read.ts'
import { formatReadOutput } from '../src/read-render.ts'
import type { FileReadOutcome } from '../src/read-render.ts'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
/** An in-memory fake provider; a test can arm a rejection on any primitive. */
class FakeFs extends FileSystem {
@@ -580,3 +582,163 @@ describe('read caps are plugin config', () => {
expect('default' in ToolFs).toBe(false)
})
})
describe('sandbox escalation surface (write/edit)', () => {
/** A confining fake `ctx.fs`: reports a default mode, records the per-call mode stamped, and can arm a sandbox denial. */
class SandboxingFakeFs extends FakeFs {
stamped: (SandboxMode | undefined)[] = []
override get sandboxMode(): SandboxMode {
return 'workspace-write'
}
override async writeText(
target: FsTarget,
content: string,
expected?: FsWriteIntent,
_signal?: AbortSignal,
sandboxMode?: SandboxMode,
): Promise<FsWriteOutcome> {
this.stamped.push(sandboxMode)
return super.writeText(target, content, expected)
}
override async editText(
target: FsTarget,
edit: FsEditRequest,
expected?: { version: FsVersion },
_signal?: AbortSignal,
sandboxMode?: SandboxMode,
): Promise<FsEditOutcome> {
this.stamped.push(sandboxMode)
return super.editText(target, edit, expected)
}
}
async function setupConfining(opts: { approval?: boolean } = {}) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SandboxingFakeFs)
await ctx.plugin(FsPolicy)
if (opts.approval === true) await ctx.plugin(ApprovalService)
await ctx.plugin(ToolFs)
return { ctx, fs: ctx.fs as SandboxingFakeFs }
}
/** A fake agent whose session records appends (the approval audit surface), mid-turn, carrying the given events for the fold. */
function escalationAgent(events: Array<{ type: string; data?: Record<string, unknown> }> = []): object {
return {
id: 'agent-fs-esc',
session: {
header: { version: 0, id: 'sess-fs-esc', createdAt: 0 },
events: [{ type: 'turn/start' }, ...events],
append: (type: string, data: Record<string, unknown>) => { events.push({ type, data }) },
},
}
}
function fsSchema(ctx: Context, name: 'write' | 'edit') {
const schema = ctx.tools.schemas().find(s => s.name === name)
if (!schema) throw new Error(`${name} tool not registered`)
return schema as unknown as { parameters: { properties: Record<string, { enum?: string[] }> } }
}
it('advertises no escalation fields under a non-confining backend', async () => {
const { ctx } = await setup()
expect(ctx.fs.sandboxMode).toBeUndefined()
for (const name of ['write', 'edit'] as const) {
const props = fsSchema(ctx, name).parameters.properties
expect(props['sandbox_permissions']).toBeUndefined()
expect(props['justification']).toBeUndefined()
}
})
it('advertises the closed target vocabulary on write and edit under a confining backend', async () => {
const { ctx } = await setupConfining()
for (const name of ['write', 'edit'] as const) {
const props = fsSchema(ctx, name).parameters.properties
expect(props['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access'])
expect(props['justification']).toBeDefined()
}
})
it('a plain write stamps nothing (backend default) and no session override folds without one', async () => {
const { ctx, fs } = await setupConfining()
await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
expect(fs.stamped).toEqual([undefined])
})
it('a standing session override folds onto the stamp', async () => {
const { ctx, fs } = await setupConfining()
await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent([{ type: 'sandbox/mode', data: { mode: 'read-only' } }]))
expect(fs.stamped).toEqual(['read-only'])
})
it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => {
const { ctx, fs } = await setupConfining()
fs.rejectWith = new FsError('denied', 'FS_SANDBOX_DENIED')
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
expect(result.isError).toBe(true)
expect(text(result)).toContain('[sandbox: file access denied under workspace-write mode]')
expect(text(result)).toContain('retry this exact operation once with sandbox_permissions')
})
it('a non-FS_SANDBOX_DENIED provider error passes through unchanged', async () => {
const { ctx, fs } = await setupConfining()
fs.rejectWith = new FsError('boom', 'FS_IO_ERROR')
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
expect(result.isError).toBe(true)
expect(text(result)).toContain('boom')
expect(text(result)).not.toContain('[sandbox:')
})
it('an approved escalation stamps the granted mode onto that write', async () => {
const { ctx, fs } = await setupConfining({ approval: true })
ctx.on('approval/request', () => Promise.resolve('allowed-once' as const))
// Pass a signal so the escalation ask forwards it to the approval request
// (the request rides the tool-execution abort signal).
await ctx.tools.execute({
callId: CallId('call-fs-esc-grant'),
name: 'write',
arguments: { file_path: 'a.txt', content: 'x', sandbox_permissions: 'danger-full-access', justification: 'the test needs it' },
agent: escalationAgent() as never,
signal: new AbortController().signal,
})
expect(fs.stamped).toEqual(['danger-full-access'])
})
it('a rejected escalation fails closed with its own text and never mutates', async () => {
const { ctx, fs } = await setupConfining({ approval: true })
ctx.on('approval/request', () => Promise.resolve('rejected' as const))
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'x', new_string: 'y', sandbox_permissions: 'danger-full-access', justification: 'the test needs it' }, escalationAgent())
expect(result.isError).toBe(true)
expect(text(result)).toContain('the user rejected escalating this operation to "danger-full-access"')
expect(fs.stamped).toEqual([])
})
it('escalation without an approval service fails closed', async () => {
const { ctx } = await setupConfining()
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'danger-full-access', justification: 'why' }, escalationAgent())
expect(result.isError).toBe(true)
expect(text(result)).toContain('no approval service is composed')
})
it('escalation with an approval service but no agent fails closed', async () => {
const { ctx } = await setupConfining({ approval: true })
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'danger-full-access', justification: 'why' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('no agent to route it through')
})
it('rejects the escalation argument pairing (one field without the other)', async () => {
const { ctx } = await setupConfining()
const missing = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'workspace-write' }, escalationAgent())
expect(missing.isError).toBe(true)
expect(text(missing)).toContain('sandbox_permissions requires a justification')
})
it('sandbox_permissions under a non-confining backend fails closed (unadvertised field still reaches execute)', async () => {
const { ctx } = await setup()
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'workspace-write', justification: 'why' }, escalationAgent())
expect(result.isError).toBe(true)
expect(text(result)).toContain('not available in this composition')
})
})

Some files were not shown because too many files have changed in this diff Show More