Merge remote-tracking branch 'origin/master' into fix/subagent-depth-budget
This commit is contained in:
@@ -6,13 +6,21 @@ 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](../.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.
|
||||
- **Enforce at the operation boundary that owns the decision.** Schema omission, prompt filtering, facades, wrappers, and listener order are not enforcement when direct or alternate callers can bypass them; test denial through the executor.
|
||||
- **Publish state only at its commit point.** Emit each notification and update derived state only after the success boundary that makes it true; derive caches, prompts, UI echoes, replay, and query views from one authoritative source.
|
||||
- **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits.
|
||||
- **Registry contributions prove disposal.** Add the HMR-safety test required by the [testing policy](../docs/testing.md): dispose the contributing fiber and observe removal.
|
||||
|
||||
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)).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
- **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 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/).
|
||||
|
||||
@@ -38,21 +38,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
|
||||
|
||||
|
||||
@@ -31,13 +31,17 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp
|
||||
|
||||
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).
|
||||
|
||||
`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).
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/],
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# compact/ — compaction capability family
|
||||
|
||||
A three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract compaction interface, a backend that summarizes, and the model-facing tool that consumes it. The interface and a first backend (`compact-basic/`) exist; the consumer tool is deferred. All **product** packages.
|
||||
A three-package capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract compaction interface, a backend that summarizes, and the model-facing tool that consumes it. The interface and a first backend (`compact-basic/`) exist; the consumer tool is deferred. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
@@ -8,4 +8,4 @@ A three-package capability seam (see [capability seams](../../docs/rfc/implement
|
||||
| `compact-basic/` | A backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
|
||||
| `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/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. 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). Token measurement is a reusable LLM-family service rather than a `CompactService` method; a template- or model-backed compactor can replace `compact-basic` without changing the meter or callers.
|
||||
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. 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). Token measurement is a reusable LLM-family service rather than a `CompactService` method; a template- or model-backed compactor can replace `compact-basic` without changing the meter or callers.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -56,29 +56,45 @@ Loading the plugin registers `ctx.compact`. With `auto: true` (the default) it c
|
||||
|
||||
### Conversation history
|
||||
|
||||
**What the model sees**: After a successful step crosses the threshold, 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 that replacement. This one checkpoint replaces the selected older range and is followed by the retained recent units.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: The replacement reduces future input history rather than appending a second copy. The summary remains until a later compaction replaces it; one oversized indivisible unit can still exceed the budget.
|
||||
After a successful step crosses the threshold, 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 that replacement. This one 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
|
||||
|
||||
The replacement reduces future input history rather than appending a second copy. The summary remains until a later compaction replaces it; one oversized indivisible 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.
|
||||
@@ -116,6 +132,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.
|
||||
@@ -123,4 +147,4 @@ Rules:
|
||||
- **Single-unit and envelope-only overflow remain outside surface compaction** — recovery cannot split one indivisible message/tool unit or shrink system/tools/prefix.
|
||||
- **`compactRegion` requires an open turn** — a manual call on a fully-closed session throws ("no open turn") rather than compacting.
|
||||
- **Summarization failure fails closed with full, over-budget history** — including truncation at the summarization `maxTokens`, which hidden reasoning tokens can consume; the auto path logs a warning and proceeds.
|
||||
- **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)).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -750,7 +750,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).',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -500,7 +500,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 */
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 } : {},
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -8,9 +8,9 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona
|
||||
| `fs-local/` | Local-filesystem `FileSystem` implementation | (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-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. 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 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-repeat-tool-guard
|
||||
|
||||
An advisory loop-breaker, not a model-facing tool: it never appears in the tool list, never vetoes or rewrites a call, and adds exactly one behavior — it watches each agent's stream of tool calls, counts runs of consecutive calls to the same tool with identical canonicalized arguments, and at configured run lengths injects an escalating advisory reminder telling the model to stop repeating itself, re-read the last result, and either change approach or conclude. The decision (retry differently, gather more evidence, or finish) stays entirely with the model: a legitimately repeated call is delayed by nothing and blocked by nothing. Decision record: [the repeat-tool-guard RFC](../../../docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md).
|
||||
An advisory loop-breaker, not a model-facing tool: it never appears in the tool list, never vetoes or rewrites a call, and adds exactly one behavior — it watches each agent's stream of tool calls, counts runs of consecutive calls to the same tool with identical canonicalized arguments, and at configured run lengths injects an escalating advisory reminder telling the model to stop repeating itself, re-read the last result, and either change approach or conclude. The decision (retry differently, gather more evidence, or finish) stays entirely with the model: a legitimately repeated call is delayed by nothing and blocked by nothing. Decision record: [the repeat-tool-guard Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.md).
|
||||
|
||||
## Config
|
||||
|
||||
@@ -40,23 +40,31 @@ Unit suites drive a real agent loop against a mock adapter (no network) and cove
|
||||
|
||||
### First-threshold context message
|
||||
|
||||
**What the model sees**: At the first configured consecutive-repeat threshold, that agent receives the reminder below. No tool schema or normal-call text is added.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Zero tokens before the threshold. The reminder is retained history for that agent.
|
||||
At the first configured consecutive-repeat threshold, that agent receives the reminder below. No tool schema or normal-call text is added.
|
||||
|
||||
#### First-threshold reminder
|
||||
##### First-threshold reminder
|
||||
|
||||
```markdown
|
||||
You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero tokens before the threshold. The reminder is retained history for that agent.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Later-threshold context message
|
||||
|
||||
**What the model sees**: A later threshold receives the detailed reminder template below. A capped argument preview ends exactly `… (+<omitted> more chars)`.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Each reminder is retained history; `argumentsPreviewChars` bounds its data-dependent argument text, while agents keep independent counters.
|
||||
A later threshold receives the detailed reminder template below. A capped argument preview ends exactly `… (+<omitted> more chars)`.
|
||||
|
||||
#### Later-threshold reminder
|
||||
##### Later-threshold reminder
|
||||
|
||||
```markdown
|
||||
Repeated tool call detected:
|
||||
@@ -66,6 +74,14 @@ Repeated tool call detected:
|
||||
The repeated calls are not making progress. Do not call this tool with these exact arguments again. Inspect the latest result and choose a different action, different arguments, or finish the task if enough evidence has been gathered.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Each reminder is retained history; `argumentsPreviewChars` bounds its data-dependent argument text, while agents keep independent counters.
|
||||
|
||||
#### 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
|
||||
|
||||
- **Exact-match detection only** — canonicalization is a deep key-sort, so near-identical variants (a tweaked path, extra whitespace inside a value) evade the chain; fuzzy matching is rejected pending evidence of need.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Advisory per-agent repeat-call detector. It enriches post-execute decisions
|
||||
* with logged model context without vetoing or rewriting calls. Configuration
|
||||
* and chain semantics live in the package README; rationale lives in the
|
||||
* repeat-tool-guard RFC.
|
||||
* repeat-tool-guard Agent Note.
|
||||
* @module @deepseek-ai/dsh-repeat-tool-guard
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# hooks/ — hook bridges + shared protocol
|
||||
|
||||
The hooks subsystem lets users extend the agent at lifecycle points the way Claude Code and Codex do — by pointing a bridge plugin at an existing `hooks.json` (or settings) so those external shell hooks run faithfully. The canonical extension surface itself is the harness's typed interception seams ([the interception-seams RFC](../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)); a "native hook" is just an ordinary cordis plugin on those seams. These packages are the **bridges** that translate the external shell-hook protocol onto that same surface, plus the shared wire-protocol library they build on.
|
||||
The hooks subsystem lets users extend the agent at lifecycle points the way Claude Code and Codex do — by pointing a bridge plugin at an existing `hooks.json` (or settings) so those external shell hooks run faithfully. The canonical extension surface itself is the harness's typed interception seams ([the interception-seams Agent Note](../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md)); a "native hook" is just an ordinary cordis plugin on those seams. These packages are the **bridges** that translate the external shell-hook protocol onto that same surface, plus the shared wire-protocol library they build on.
|
||||
|
||||
| Package | Role | Shape |
|
||||
|---|---|---|
|
||||
|
||||
@@ -27,13 +27,17 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
|
||||
|
||||
Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`, with `appendHookResult` owning the decision rule). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog.md); `stderrSummary` is truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty).
|
||||
|
||||
Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `context/message` is the durable evidence) — see the hooks RFC.
|
||||
Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `context/message` is the durable evidence) — see the hooks Agent Note.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-hooks-claude` and `dsh-hooks-codex`, which can turn parsed hook output into prompt context, blocked outcomes, or continuation feedback.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`HookOutput.updatedInput` is parsed but not honored** — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)); a bridge logs + warns when a hook sets it. See `src/types.ts` for the full contracts.
|
||||
- **`HookOutput.updatedInput` is parsed but not honored** — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)); a bridge logs + warns when a hook sets it. See `src/types.ts` for the full contracts.
|
||||
- **An invalid matcher regex matches nothing, silently** — `matchesMatcher` never throws; surfacing the error needs a diagnostic-returning variant or parse-time validation (`TODO(matcher-diagnostics)`).
|
||||
|
||||
@@ -43,7 +43,7 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
/**
|
||||
* The bridge that ran a hook — the CC bridge stamps `'claude'`, the Codex
|
||||
* bridge `'codex'`. A native plugin on the interception seams is not a bridge
|
||||
* and writes no `hook/*` provenance (see the interception-seams RFC).
|
||||
* and writes no `hook/*` provenance (see the interception-seams Agent Note).
|
||||
*/
|
||||
export type HookDialect = 'claude' | 'codex'
|
||||
|
||||
@@ -130,7 +130,7 @@ export interface HookOutput {
|
||||
systemMessage?: string
|
||||
/**
|
||||
* A tool-input rewrite a hook requested (CC `updatedInput`). PARSED but NOT
|
||||
* honored — input rewrite is deferred (see the interception-seams RFC); a
|
||||
* honored — input rewrite is deferred (see the interception-seams Agent Note); a
|
||||
* bridge logs + warns when this is present.
|
||||
*/
|
||||
updatedInput?: Record<string, unknown>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
A cordis plugin that runs the supported command-hook subset of a user's existing **Claude Code** hook config (a `hooks.json`, or a settings file's `hooks` key) on the harness's canonical interception seams. It is the **CC dialect** half of the hooks subsystem: it owns the bridge's CC-shaped per-event stdin payloads, CC's env + `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the mapping from a hook's neutral outcome onto the harness's typed Decisions. The dialect-agnostic primitives (matcher, exit-code/stdout codec, `ctx.bash` execution, most-restrictive merge, the `hook/*` events) come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md).
|
||||
|
||||
A native cordis plugin could do everything this bridge does — more powerfully, with typed returns and no serialization boundary. **The bridge exists only as a compatibility path for the mapped CC command-hook subset**; anything bespoke should be a native plugin on the same seams (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)).
|
||||
A native cordis plugin could do everything this bridge does — more powerfully, with typed returns and no serialization boundary. **The bridge exists only as a compatibility path for the mapped CC command-hook subset**; anything bespoke should be a native plugin on the same seams (see [the interception-seams Agent Note](../../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md)).
|
||||
|
||||
## Config
|
||||
|
||||
@@ -44,7 +44,7 @@ The hooks **themselves** run in the agent's session workspace: for the agent-sco
|
||||
|
||||
The three emit points run detached — no seam awaits a `SessionStart`/`SubagentStart`/`SubagentStop` hook. Each run chain is tracked, and disposing the bridge aborts still-running hook processes, then drains the continuations before the dispose resolves (`createDetachedRuns` in `dsh-hook-protocol`).
|
||||
|
||||
The matcher subject is the tool name (`PreToolUse`/`PostToolUse`), the session source (`SessionStart`), or a constant `agent_type` of `general-purpose` (`SubagentStart`/`SubagentStop` — the harness subagent seam carries no per-kind label, so the bridge reports Claude Code's own Task-tool default; a default/`*`/empty `agent_type` matcher fires, a specific-kind matcher does not); `UserPromptSubmit`/`Stop` ignore matchers. Multiple file-configured hooks on one point run **serially, in config order**, and fold most-restrictively (`deny > ask > allow`, see `dsh-hook-protocol`); serial keeps each hook's `hook/invoked`/`hook/result` pair adjacent in the log, and the fold is order-independent for the decision (see the RFC's "run serially, not concurrently" note).
|
||||
The matcher subject is the tool name (`PreToolUse`/`PostToolUse`), the session source (`SessionStart`), or a constant `agent_type` of `general-purpose` (`SubagentStart`/`SubagentStop` — the harness subagent seam carries no per-kind label, so the bridge reports Claude Code's own Task-tool default; a default/`*`/empty `agent_type` matcher fires, a specific-kind matcher does not); `UserPromptSubmit`/`Stop` ignore matchers. Multiple file-configured hooks on one point run **serially, in config order**, and fold most-restrictively (`deny > ask > allow`, see `dsh-hook-protocol`); serial keeps each hook's `hook/invoked`/`hook/result` pair adjacent in the log, and the fold is order-independent for the decision (see the Agent Note's "run serially, not concurrently" note).
|
||||
|
||||
Every agent-scoped stdin payload carries `session_id` and string-shaped `transcript_path`. The bridge resolves the latter through `ctx.sessionPersistence.locate(session.header)` when available and otherwise sends `''`. Lookup does not create or flush the artifact, so a path can be absent before the first turn-end checkpoint or omit the current open turn.
|
||||
|
||||
@@ -56,22 +56,38 @@ Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude' }
|
||||
|
||||
### Hook-provided context
|
||||
|
||||
**What the model sees**: `SessionStart`, accepted prompt, post-tool, and live in-process subagent-start hooks can add source-attributed context messages; a blocking `Stop` hook adds its reason as next-step steering. Remote-child injection has no local target.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: No cost when hooks return no context. Hook text is data-dependent, logged, and resent in later conversation requests until compaction.
|
||||
`SessionStart`, accepted prompt, post-tool, and live in-process subagent-start hooks can add source-attributed context messages; a blocking `Stop` hook adds its reason as next-step steering. Remote-child injection has no local target.
|
||||
|
||||
#### Token effect
|
||||
|
||||
No cost when hooks return no context. Hook text is data-dependent, logged, and resent in later conversation requests until compaction.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Blocked prompt or tool outcome
|
||||
|
||||
**What the model sees**: Provider-supplied reasons pass through verbatim. When absent, a blocked prompt uses exactly `blocked by UserPromptSubmit hook`, a denied tool becomes `Error: blocked by PreToolUse hook`, blocked post-tool feedback is exactly `blocked by PostToolUse hook`, and a blocking stop adds steering exactly `continue: blocked by Stop hook`. `systemMessage` and `updatedInput` are logged or warned but are not model-visible in this implementation.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Blocking a prompt removes that prompt's request tokens; denial or feedback adds the retained fallback or provider text; forced continuation pays another full request.
|
||||
Provider-supplied reasons pass through verbatim. When absent, a blocked prompt uses exactly `blocked by UserPromptSubmit hook`, a denied tool becomes `Error: blocked by PreToolUse hook`, blocked post-tool feedback is exactly `blocked by PostToolUse hook`, and a blocking stop adds steering exactly `continue: blocked by Stop hook`. `systemMessage` and `updatedInput` are logged or warned but are not model-visible in this implementation.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Blocking a prompt removes that prompt's request tokens; denial or feedback adds the retained fallback or provider text; forced continuation pays another full request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
A blocked prompt sends no request and invalidates nothing. Denial, feedback, and forced-continuation context append after the reusable prefix without rewriting it.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Unsupported hook events (23 of Claude Code's current 30):** `Setup`, `InstructionsLoaded`, `UserPromptExpansion`, `MessageDisplay`, `PermissionRequest`, `PostToolUseFailure`, `PostToolBatch`, `PermissionDenied`, `Notification`, `TaskCreated`, `TaskCompleted`, `StopFailure`, `TeammateIdle`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `SessionEnd`, `Elicitation`, and `ElicitationResult`. Config for these events is parsed but never dispatched. The comparison baseline is Claude Code's [official hook-event reference](https://code.claude.com/docs/en/hooks#hook-events).
|
||||
- **`SessionStart` is partial:** JSON `additionalContext` is consumed, but plain stdout context, `initialUserMessage`, `sessionTitle`, `watchPaths`, `reloadSkills`, and `CLAUDE_ENV_FILE` are unsupported. The hook runs detached, so context can miss the first request (`TODO(session-start-gating)`), and the payload omits current optional fields such as `model`, `agent_type`, and `session_title`.
|
||||
- **`UserPromptSubmit` is partial:** blocking and JSON `additionalContext` work, but plain stdout context, `sessionTitle`, and `suppressOriginalPrompt` are unsupported. Unless overridden, the bridge also uses its 600-second default instead of Claude Code's event-specific 30-second command timeout.
|
||||
- **`PreToolUse` is partial:** `deny` and `ask` decisions work; `allow` does not pre-approve, `defer` is unsupported, `additionalContext` is ignored, and `updatedInput` is logged + warned but not honored ([the pre-tool-input-rewrite RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)).
|
||||
- **`PreToolUse` is partial:** `deny` and `ask` decisions work; `allow` does not pre-approve, `defer` is unsupported, `additionalContext` is ignored, and `updatedInput` is logged + warned but not honored ([the pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)).
|
||||
- **`PostToolUse` is partial:** blocking feedback and JSON `additionalContext` work, but `updatedToolOutput` and `updatedMCPToolOutput` are unsupported and `tool_response` is flattened to text.
|
||||
- **`SubagentStart` and `SubagentStop` are partial:** both report a constant `agent_type` of `general-purpose` and use the child session id where Claude Code reports the parent session. Start context is best-effort and can only reach a live in-process child, while stop is observe-only and cannot block the subagent or feed it context. Start omits `transcript_path`; stop also omits `agent_transcript_path`, `last_assistant_message`, `background_tasks`, and `session_crons` and always reports `stop_hook_active: false`.
|
||||
- **`Stop` is partial:** blocking forces another model turn, but `stop_hook_active` is always `false`, `last_assistant_message`, `background_tasks`, and `session_crons` are omitted, and the consecutive-block cap is not implemented (`TODO(stop-loop-guard)`). An unconditionally blocking hook therefore force-continues every step unless it self-limits.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* mapping; shared execution and parsing live in `dsh-hook-protocol`.
|
||||
* `updatedInput` is logged and warned but not honored. Bespoke behavior should
|
||||
* use typed native plugins on the same seams; see the
|
||||
* [hook-bridges RFC](../../../../docs/rfc/implemented/feature/2026-06-30-hook-bridges.md).
|
||||
* [hook-bridges Agent Note](../../../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md).
|
||||
* @module @deepseek-ai/dsh-hooks-claude
|
||||
*/
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ This bridge implements a deliberate subset of Codex's current hook protocol:
|
||||
- **No Codex plugin env injection and no config-time placeholder substitution** (the command still receives the executor's environment and runs through its shell).
|
||||
- **No pre-tool approval or rewrite path** — a hook can block, but the bridge does not pre-approve or replace tool input.
|
||||
|
||||
A native cordis plugin could do everything this bridge does, more powerfully; the bridge exists only as a compatibility path for the mapped Codex subset (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)).
|
||||
A native cordis plugin could do everything this bridge does, more powerfully; the bridge exists only as a compatibility path for the mapped Codex subset (see [the interception-seams Agent Note](../../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md)).
|
||||
|
||||
## Config
|
||||
|
||||
@@ -60,15 +60,31 @@ Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }`
|
||||
|
||||
### Hook-provided context
|
||||
|
||||
**What the model sees**: `SessionStart`, accepted prompt, and post-tool hooks can add source-attributed context messages; a blocking `Stop` hook adds its reason as next-step steering.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: No cost when hooks return no context. Hook text is data-dependent, logged, and resent until compaction.
|
||||
`SessionStart`, accepted prompt, and post-tool hooks can add source-attributed context messages; a blocking `Stop` hook adds its reason as next-step steering.
|
||||
|
||||
#### Token effect
|
||||
|
||||
No cost when hooks return no context. Hook text is data-dependent, logged, and resent until compaction.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Blocked prompt or tool outcome
|
||||
|
||||
**What the model sees**: Provider-supplied reasons pass through verbatim. When absent, a blocked prompt uses exactly `blocked by UserPromptSubmit hook`, a denied tool becomes `Error: blocked by PreToolUse hook`, blocked post-tool feedback is exactly `blocked by PostToolUse hook`, and a blocking stop adds steering exactly `continue: blocked by Stop hook`. Codex `systemMessage` is not surfaced.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Blocking a prompt removes its request tokens; denial or feedback adds the retained fallback or provider text; forced continuation pays another full request.
|
||||
Provider-supplied reasons pass through verbatim. When absent, a blocked prompt uses exactly `blocked by UserPromptSubmit hook`, a denied tool becomes `Error: blocked by PreToolUse hook`, blocked post-tool feedback is exactly `blocked by PostToolUse hook`, and a blocking stop adds steering exactly `continue: blocked by Stop hook`. Codex `systemMessage` is not surfaced.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Blocking a prompt removes its request tokens; denial or feedback adds the retained fallback or provider text; forced continuation pays another full request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
A blocked prompt sends no request and invalidates nothing. Denial, feedback, and forced-continuation context append after the reusable prefix without rewriting it.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* or command substitution, and no pre-tool approval or rewrite path; only
|
||||
* blocking decisions are honored. Shared execution and parsing live in
|
||||
* `dsh-hook-protocol`; see the
|
||||
* [hook-bridges RFC](../../../../docs/rfc/implemented/feature/2026-06-30-hook-bridges.md).
|
||||
* [hook-bridges Agent Note](../../../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md).
|
||||
* @module @deepseek-ai/dsh-hooks-codex
|
||||
*/
|
||||
|
||||
|
||||
@@ -9,4 +9,4 @@ The LLM seam and its provider adapters. The interface package (`llm`) owns the a
|
||||
| `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) |
|
||||
| `llm-pi-ai/` | Multi-provider adapter via `@earendil-works/pi-ai` | (registers on `ctx.llm`) |
|
||||
|
||||
The interface lives at `llm/llm/`; adapters and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. A new provider adapter joins here and registers one or more provider routes on `ctx.llm` without touching the interface. See [twin LLM adapters](../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the contract-validation origin of the two shipping implementations and the [replay token meter RFC](../../docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership.
|
||||
The interface lives at `llm/llm/`; adapters and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. A new provider adapter joins here and registers one or more provider routes on `ctx.llm` without touching the interface. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the contract-validation origin of the two shipping implementations and the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership.
|
||||
|
||||
@@ -52,15 +52,31 @@ Unit suites run against a local `node:http` mock SSE server (no network). Real-A
|
||||
|
||||
### DeepSeek request
|
||||
|
||||
**What the model sees**: The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. On a prior assistant turn with tool calls, its reasoning content is passed back as required; reasoning from tool-call-free turns is omitted.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Provider tokenization governs exact input. Conditional reasoning passback increases tool-round-trip context, while dropping other reasoning avoids paying those tokens again; cache-read usage is reported when available.
|
||||
The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. On a prior assistant turn with tool calls, its reasoning content is passed back as required; reasoning from tool-call-free turns is omitted.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Provider tokenization governs exact input. Conditional reasoning passback increases tool-round-trip context, while dropping other reasoning avoids paying those tokens again; cache-read usage is reported when available.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
An unchanged assembled prefix is eligible for DeepSeek cache reuse, which this adapter reports in usage. A model-route change or any upstream prompt, schema, prefix, or history change may prevent reuse from the first changed token; reasoning passback appends during tool round trips.
|
||||
|
||||
### DeepSeek response
|
||||
|
||||
**What the model sees**: Reasoning, text, and raw-string tool arguments are translated into harness chunks for the loop to log and assemble.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Generated tokens follow provider thinking and effort settings plus the request's `maxTokens`; only loop-retained blocks affect later input.
|
||||
Reasoning, text, and raw-string tool arguments are translated into harness chunks for the loop to log and assemble.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Generated tokens follow provider thinking and effort settings plus the request's `maxTokens`; only loop-retained blocks affect later input.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Loop-retained response blocks append to the next request and preserve its earlier reusable prefix; dropped blocks have no later cache effect. Changing the provider or model selects a different cache domain.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
@@ -91,6 +91,9 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
'content-type': 'application/json',
|
||||
'accept': 'text/event-stream',
|
||||
...attributionHeaders(),
|
||||
...options.sessionId !== undefined
|
||||
? { 'x-deepseek-harness-session-id': String(options.sessionId) }
|
||||
: {},
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
...options.signal ? { signal: options.signal } : {},
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { httpErrorCode } from '../src/adapter.ts'
|
||||
@@ -133,6 +134,19 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
expect(kinds).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish'])
|
||||
})
|
||||
|
||||
it('forwards the harness session id for host-side trajectory routing', async () => {
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const ctx = await harness(server.url)
|
||||
|
||||
await assemble(ctx, {
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
||||
sessionId: SessionId('child-session'),
|
||||
})
|
||||
|
||||
expect(server.headers[0]?.['x-deepseek-harness-session-id']).toBe('child-session')
|
||||
})
|
||||
|
||||
it('forwards thinking config onto the wire', async () => {
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const ctx = await harness(server.url, { thinking: 'disabled', reasoningEffort: 'high' })
|
||||
|
||||
@@ -63,15 +63,31 @@ Unit tests use pi-ai catalog models redirected to local mock servers and cover p
|
||||
|
||||
### Provider request through pi-ai
|
||||
|
||||
**What the model sees**: The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. This package adds no prompt prose. Provider-native replay metadata is restored only when the adapter validates it for the historical content.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Provider tokenization governs exact input. Conversion adds no model-visible text; replay metadata may let a native API reuse provider-side state.
|
||||
The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. This package adds no prompt prose. Provider-native replay metadata is restored only when the adapter validates it for the historical content.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Provider tokenization governs exact input. Conversion adds no model-visible text; replay metadata may let a native API reuse provider-side state.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Conversion preserves logical request order without adding text, while the selected provider's serialization and replay state determine reuse. Changing adapter instance, provider, model, or any upstream request token may prevent reuse from the first difference.
|
||||
|
||||
### Provider response
|
||||
|
||||
**What the model sees**: pi-ai events become harness reasoning, text, tool-call, usage, and finish chunks. Parsed tool arguments cross the harness boundary as raw JSON strings.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Generated content affects later inputs only after the loop records it. pi-ai folds reasoning tokens into output usage when the provider does not report them separately.
|
||||
pi-ai events become harness reasoning, text, tool-call, usage, and finish chunks. Parsed tool arguments cross the harness boundary as raw JSON strings.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Generated content affects later inputs only after the loop records it. pi-ai folds reasoning tokens into output usage when the provider does not report them separately.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Recorded response content appends to the next request and does not invalidate its earlier reusable prefix. Unrecorded transport metadata and usage accounting do not affect cache identity.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
@@ -168,6 +168,26 @@ describe('PiAiAdapter provider routing', () => {
|
||||
expect(server.paths).toEqual(['/v1/responses'])
|
||||
})
|
||||
|
||||
it('uses OpenAI Responses against an Azure project v1 path with its API key header', async () => {
|
||||
const server = await mockServer([{ status: 401, body: JSON.stringify({ error: { message: 'expected mock failure' } }) }])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: [{
|
||||
provider: 'openai',
|
||||
apiKey: 'test-key',
|
||||
baseURL: `${server.url}/api/projects/openai/openai/v1`,
|
||||
headers: { 'api-key': 'test-key', Authorization: '' },
|
||||
maxRetries: 0,
|
||||
}],
|
||||
})
|
||||
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-5.5', messages: [] })
|
||||
expect(result.finish.kind).toBe('error')
|
||||
expect(server.paths).toEqual(['/api/projects/openai/openai/v1/responses'])
|
||||
expect(server.headers[0]?.['api-key']).toBe('test-key')
|
||||
expect(server.headers[0]?.authorization).toBe('')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[401, 'AUTH'],
|
||||
[400, 'INVALID_REQUEST'],
|
||||
|
||||
163
packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts
Normal file
163
packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import type { PiAiReplayState } from '../src/replay.ts'
|
||||
import { assemble, type AssembledResult } from './assemble.ts'
|
||||
|
||||
interface ProviderCase {
|
||||
provider: 'openai' | 'anthropic'
|
||||
api: 'openai-responses' | 'anthropic-messages'
|
||||
model: string
|
||||
apiKey?: string
|
||||
baseURL?: string
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
const openAIBaseURL = process.env.DSH_PI_AI_OPENAI_BASE_URL
|
||||
const azureOpenAIKey = process.env.AZURE_OPENAI_API_KEY
|
||||
|
||||
const providerCases: ProviderCase[] = [
|
||||
{
|
||||
provider: 'openai',
|
||||
api: 'openai-responses',
|
||||
model: process.env.DSH_PI_AI_OPENAI_MODEL ?? 'gpt-5.5',
|
||||
...azureOpenAIKey
|
||||
? { apiKey: azureOpenAIKey, headers: { 'api-key': azureOpenAIKey, Authorization: '' } }
|
||||
: {},
|
||||
...openAIBaseURL ? { baseURL: openAIBaseURL } : {},
|
||||
},
|
||||
{
|
||||
provider: 'anthropic',
|
||||
api: 'anthropic-messages',
|
||||
model: process.env.DSH_PI_AI_ANTHROPIC_MODEL ?? 'claude-opus-4-8',
|
||||
...process.env.ANTHROPIC_API_KEY ? { apiKey: process.env.ANTHROPIC_API_KEY } : {},
|
||||
},
|
||||
]
|
||||
|
||||
const contexts: Context[] = []
|
||||
|
||||
async function harness(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: providerCases.map(profile => ({
|
||||
provider: profile.provider,
|
||||
...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey },
|
||||
...profile.baseURL === undefined ? {} : { baseURL: profile.baseURL },
|
||||
...profile.headers === undefined ? {} : { headers: profile.headers },
|
||||
})),
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
})
|
||||
|
||||
function ask(text: string): Message[] {
|
||||
return [{ role: 'user', content: [{ type: 'text', text }] }]
|
||||
}
|
||||
|
||||
function textOf(result: AssembledResult): string {
|
||||
return result.message.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function expectFinish(result: AssembledResult, expected: 'stop' | 'tool-calls'): void {
|
||||
if (result.finish.kind === 'error') {
|
||||
throw new Error(`provider request failed (${result.finish.code ?? 'unknown'}): ${result.finish.message}`)
|
||||
}
|
||||
expect(result.finish.kind).toBe(expected)
|
||||
}
|
||||
|
||||
function expectNativeReplay(result: AssembledResult, profile: ProviderCase): PiAiReplayState {
|
||||
const replayState = result.message.provenance?.replayState
|
||||
expect(replayState).toMatchObject({
|
||||
kind: 'pi-ai',
|
||||
version: 1,
|
||||
api: profile.api,
|
||||
provider: profile.provider,
|
||||
model: profile.model,
|
||||
})
|
||||
return replayState as PiAiReplayState
|
||||
}
|
||||
|
||||
const lookupTool: ToolSchema = {
|
||||
name: 'lookup_code',
|
||||
description: 'Look up the word represented by a short code.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { code: { type: 'string', description: 'The code to look up.' } },
|
||||
required: ['code'],
|
||||
},
|
||||
}
|
||||
|
||||
for (const profile of providerCases) {
|
||||
describe.skipIf(profile.apiKey === undefined)(
|
||||
`llm-pi-ai ${profile.provider} e2e (${profile.api})`,
|
||||
() => {
|
||||
it('streams text with usage and native replay metadata', async () => {
|
||||
const ctx = await harness()
|
||||
const result = await assemble(ctx, {
|
||||
provider: profile.provider,
|
||||
model: profile.model,
|
||||
messages: ask('Reply with exactly the word: pong'),
|
||||
maxTokens: 1024,
|
||||
})
|
||||
|
||||
expectFinish(result, 'stop')
|
||||
expect(textOf(result).toLowerCase()).toContain('pong')
|
||||
expect(result.usage?.inputTokens).toBeGreaterThan(0)
|
||||
expect(result.usage?.outputTokens).toBeGreaterThan(0)
|
||||
expect(expectNativeReplay(result, profile).stopReason).toBe('stop')
|
||||
})
|
||||
|
||||
it('round-trips a tool call with provider-native replay metadata', async () => {
|
||||
const ctx = await harness()
|
||||
const prompt = ask('Use lookup_code with code "blue". Do not answer without calling the tool.')
|
||||
const first = await assemble(ctx, {
|
||||
provider: profile.provider,
|
||||
model: profile.model,
|
||||
messages: prompt,
|
||||
tools: [lookupTool],
|
||||
maxTokens: 2048,
|
||||
})
|
||||
|
||||
expectFinish(first, 'tool-calls')
|
||||
const call = first.message.content.find(block => block.type === 'tool-call')
|
||||
expect(call).toBeDefined()
|
||||
expect(call!.name).toBe('lookup_code')
|
||||
expect(JSON.parse(call!.arguments)).toMatchObject({ code: 'blue' })
|
||||
expect(expectNativeReplay(first, profile).stopReason).toBe('toolUse')
|
||||
|
||||
const second = await assemble(ctx, {
|
||||
provider: profile.provider,
|
||||
model: profile.model,
|
||||
messages: [
|
||||
...prompt,
|
||||
first.message,
|
||||
{
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: CallId(call!.id),
|
||||
content: [{ type: 'text', text: 'The code blue means ocean.' }],
|
||||
}],
|
||||
},
|
||||
],
|
||||
tools: [lookupTool],
|
||||
maxTokens: 2048,
|
||||
})
|
||||
|
||||
expectFinish(second, 'stop')
|
||||
expect(textOf(second).toLowerCase()).toContain('ocean')
|
||||
expect(expectNativeReplay(second, profile).stopReason).toBe('stop')
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -40,7 +40,7 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta
|
||||
|
||||
### App attribution (`attribution.ts`)
|
||||
|
||||
Every product adapter sends application identity on provider HTTP requests. `attributionHeaders(identity?)` builds the standard `User-Agent`, defaulting to public `APP_IDENTITY`; white-label deployments may replace but not suppress it. Adapters verify the wire header directly or through their library hook. See [the attribution RFC](../../../docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md).
|
||||
Every product adapter sends application identity on provider HTTP requests. `attributionHeaders(identity?)` builds the standard `User-Agent`, defaulting to public `APP_IDENTITY`; white-label deployments may replace but not suppress it. Adapters verify the wire header directly or through their library hook. See [the attribution Agent Note](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md).
|
||||
|
||||
### Classes
|
||||
|
||||
@@ -52,17 +52,21 @@ Every product adapter sends application identity on provider HTTP requests. `att
|
||||
|
||||
### Real adapters
|
||||
|
||||
Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses hand-rolled fetch/SSE for the `deepseek` route, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves configured provider/model pairs through `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish, tool arguments remain raw strings, and errors take one of two sanctioned paths. See [the twin LLM adapters](../../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the design rationale.
|
||||
Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses hand-rolled fetch/SSE for the `deepseek` route, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves configured provider/model pairs through `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish, tool arguments remain raw strings, and errors take one of two sanctioned paths. See [the twin LLM adapters](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the design rationale.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this adapter registry forwards an already assembled request without adding or changing any model-bound text, schema, or message.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Pass-through; the registry preserves the assembled request prefix, while the selected adapter and provider own cache reuse and routing boundaries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No default retry/caching/rate-limit policy ships in this service** — `llm/stream` remains the call-wrapper seam; the agent loop separately offers proven model-request failures to `agent/request-error`, whose default preserves the original failure.
|
||||
- **`GenerateOptions` sampling is `temperature`/`maxTokens`/`stop` only** — no `tool_choice`, `top_p`, or penalty fields; the vocabulary grows when a producer lands ([dropped inert knobs](../../../docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md)).
|
||||
- **Producer-gated variants stay out until produced** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant were pruned as producerless ([RFC](../../../docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)).
|
||||
- **`GenerateOptions` sampling is `temperature`/`maxTokens`/`stop` only** — no `tool_choice`, `top_p`, or penalty fields; the vocabulary grows when a producer lands ([dropped inert knobs](../../../.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.md)).
|
||||
- **Producer-gated variants stay out until produced** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant were pruned as producerless ([Agent Note](../../../.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)).
|
||||
- **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw.
|
||||
- **`APP_IDENTITY.url` names a repository that does not exist yet** — `FIXME`: creating the public `deepseek-ai/deepseek-harness-sdk` repo gates the first release.
|
||||
- **`GenerateOptions.sessionId` is a locally-declared brand** — importing dsh-session's `SessionId` would cycle; a future ids-owning package would dissolve the workaround.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Centralize the non-secret product identity every provider request sends as `User-Agent`, keeping
|
||||
* adapters from drifting. See
|
||||
* `docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md`.
|
||||
* `.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md`.
|
||||
*
|
||||
* App-attribution vocabulary for provider requests.
|
||||
* @module @deepseek-ai/dsh-llm/attribution
|
||||
|
||||
@@ -35,7 +35,7 @@ declare module 'cordis' {
|
||||
* adapter's stream, or yield your own chunks to short-circuit.
|
||||
* @param options - the full request. A LOOP-built request arrives
|
||||
* deep-frozen (mutation throws): its content is a pure function of the
|
||||
* session log (the reconstructability RFC), so listeners read it, never
|
||||
* session log (the reconstructability Agent Note), so listeners read it, never
|
||||
* rewrite it. A hand-built one-shot (compaction summarize) is the
|
||||
* caller's own object and stays mutable here.
|
||||
* @mode waterfall
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Property-based tests for the BlockAssembler (the property-testing RFC).
|
||||
* Property-based tests for the BlockAssembler (the property-testing Agent Note).
|
||||
*
|
||||
* The assembler is protocol-shaped: arbitrary interleavings of block-start,
|
||||
* deltas, block-end, usage, and finish — valid and malformed (duplicate
|
||||
|
||||
@@ -42,6 +42,10 @@ Both plugins have usable defaults. A deployment with a different capacity config
|
||||
|
||||
Indirectly, through consumers such as `dsh-compact-basic`; the service itself adds no prompt, message, schema, tool, or model call.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The fixed heuristic is approximate** — content without reusable provider usage is priced by character count plus structural overhead, not an exact provider tokenizer or request serializer.
|
||||
|
||||
@@ -70,15 +70,31 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call`
|
||||
|
||||
### Discovered MCP tools
|
||||
|
||||
**What the model sees**: After initial discovery succeeds, each advertised MCP tool appears as a native tool named `mcp__<serverName>__<rawName>` (or its deterministic normalized form), with the server-provided description and input schema. A successful re-sync replaces the generation; plugin disposal removes it.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Data-dependent schema cost is paid on every request while the tools are registered. Re-sync replaces rather than accumulates schemas, and the server-qualified name adds tokens to every tool definition and call.
|
||||
After initial discovery succeeds, each advertised MCP tool appears as a native tool named `mcp__<serverName>__<rawName>` (or its deterministic normalized form), with the server-provided description and input schema. A successful re-sync replaces the generation; plugin disposal removes it.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Data-dependent schema cost is paid on every request while the tools are registered. Re-sync replaces rather than accumulates schemas, and the server-qualified name adds tokens to every tool definition and call.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while the discovered tool set and schemas are unchanged. A re-sync that adds, removes, renames, or changes a tool replaces definitions and may invalidate reuse from the first changed schema token.
|
||||
|
||||
### Tool-call history and results
|
||||
|
||||
**What the model sees**: The public tool name and JSON arguments remain in assistant history. Text result blocks are joined with newlines into one retained text result; image, audio, resource, and unsupported blocks become short placeholders, and MCP `isError` results follow the registry's model-visible error path.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Arguments and mapped text are retained until compaction. Binary and resource payloads are discarded rather than added to context.
|
||||
The public tool name and JSON arguments remain in assistant history. Text result blocks are joined with newlines into one retained text result; image, audio, resource, and unsupported blocks become short placeholders, and MCP `isError` results follow the registry's model-visible error path.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Arguments and mapped text are retained until compaction. Binary and resource payloads are discarded rather than added to context.
|
||||
|
||||
#### 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
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* under deterministic server-qualified public names, and handles re-sync when
|
||||
* the server's tool list changes.
|
||||
*
|
||||
* Naming contract (see the mcp-client RFC "Naming invariants"): every MCP tool
|
||||
* Naming contract (see the mcp-client Agent Note "Naming invariants"): every MCP tool
|
||||
* has the stable identity `(serverName, rawName)`; the model-facing public name
|
||||
* is `mcp__<serverName>__<rawName>`, normalized to the DeepSeek function-name
|
||||
* constraints. The raw name is only ever sent on the wire (`tools/call`); the
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# sandbox/ — process-sandbox capability family
|
||||
|
||||
The confinement half of the [capability-seam split](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md): an abstract provider interface and platform backends. Consumers hand `ctx.sandbox` the exact argv they are about to spawn and spawn the returned (wrapped) argv instead; policy (`SandboxPolicy`: mode + workspace root) rides each call, so different consumers confine under different policies at the same instant. All **product** packages.
|
||||
The confinement half of the [capability-seam split](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): an abstract provider interface and platform backends. Consumers hand `ctx.sandbox` the exact argv they are about to spawn and spawn the returned (wrapped) argv instead; policy (`SandboxPolicy`: mode + workspace root) rides each call, so different consumers confine under different policies at the same instant. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `sandbox/` | Abstract process-sandbox seam (the `SandboxProvider` contract + the mode/enforcement/policy vocabulary) | `ctx.sandbox` |
|
||||
| `sandbox-local/` | Local backends by platform chain: Linux `bwrap` else the `landlock-run` launcher (the npm-distributed [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) family, built and released from its own repository), darwin `sandbox-exec`/Seatbelt — multi-candidate chains functionally probed, sole candidates selected directly, verdict cached, fail-closed | (registers `ctx.sandbox`) |
|
||||
|
||||
The seam confines SAME-WORLD subprocesses only (shared filesystem and kernel). Containers, microVMs, and remote executors are NOT backends here — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups; the boundary is recorded in [the sandbox RFC](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).
|
||||
The seam confines SAME-WORLD subprocesses only (shared filesystem and kernel). Containers, microVMs, and remote executors are NOT backends here — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups; the boundary is recorded in [the sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
|
||||
|
||||
Consumers today: [`bash/bash-sandbox`](../bash/bash-sandbox/) (wraps `['bash', '-c', command]`; see [the acp-agent example's default composition](../../examples/acp-agent/) for the composed leaf). In-process tools (fs/web) cannot be confined by an OS wrapper — their sandbox semantics are policy at their own seams (the sandbox RFC's cross-family phase).
|
||||
Consumers today: [`bash/bash-sandbox`](../bash/bash-sandbox/) (wraps `['bash', '-c', command]`; see [the acp-agent example's default composition](../../examples/acp-agent/) for the composed leaf). In-process tools (fs/web) cannot be confined by an OS wrapper — their sandbox semantics are policy at their own seams (the sandbox Agent Note's cross-family phase).
|
||||
|
||||
@@ -4,9 +4,9 @@ Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and ca
|
||||
|
||||
The package root exports the default and named `LocalSandboxProvider` plugin, `Config`, and its public test-injection seam; platform profile builders stay internal.
|
||||
|
||||
Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries runner-failure signatures so consumers can distinguish a broken sandbox from a command failure. The [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences.
|
||||
Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries runner-failure signatures so consumers can distinguish a broken sandbox from a command failure. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences.
|
||||
|
||||
Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial and runner-failure signatures. `runnerCommand` is an operator assertion of a bwrap-shaped runner and skips probes, but missing or unexecutable commands still fail closed at execution. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics.
|
||||
Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial and runner-failure signatures. `runnerCommand` is an operator assertion of a bwrap-shaped runner and skips probes, but missing or unexecutable commands still fail closed at execution. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics.
|
||||
|
||||
The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes.
|
||||
|
||||
@@ -25,6 +25,10 @@ Consumers: [`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/); see [the
|
||||
|
||||
Indirectly, through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md) and [`dsh-tool-bash`](../../bash/tool-bash/README.md), which render this provider's enforcement and denial facts while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and runner selection and profiles stay outside context.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Windows has no runner** — `win32` fails closed with `SANDBOX_UNAVAILABLE`; an AppContainer-family backend is deferred.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# @deepseek-ai/dsh-sandbox
|
||||
|
||||
Abstract process-sandbox seam. Owns the `ctx.sandbox` service contract ([`SandboxProvider`](src/index.ts)) and the confinement vocabulary the harness shares: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, file effects only), `SandboxEnforcement` (`full` / `partial`, per kernel ABI), `SandboxPolicy` (per-CALL policy — mode + workspace root), and the fail-closed `SANDBOX_UNAVAILABLE` error. Interface package of the [capability-seam split](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md): depends only on cordis (+ the harness error base), never on a backend.
|
||||
Abstract process-sandbox seam. Owns the `ctx.sandbox` service contract ([`SandboxProvider`](src/index.ts)) and the confinement vocabulary the harness shares: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, file effects only), `SandboxEnforcement` (`full` / `partial`, per kernel ABI), `SandboxPolicy` (per-CALL policy — mode + workspace root), and the fail-closed `SANDBOX_UNAVAILABLE` error. Interface package of the [capability-seam split](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): depends only on cordis (+ the harness error base), never on a backend.
|
||||
|
||||
The contract in one line: `ctx.sandbox.confine(argv, policy)` returns the argv to spawn INSTEAD of your own — wrapped so the process (and everything it spawns) runs confined — plus two facts about the selected backend: the enforcement completeness it achieves and its denial dialect (`denialSignatures`, the stderr substrings its kernel prints on a denied file effect — what stderr-inferring consumers match instead of a cross-backend union); when no backend is usable it throws rather than passing the argv through unconfined.
|
||||
|
||||
Policy rides the call, not the provider: two consumers may confine under different policies at the same instant (bash under `read-only` while a confined child agent keeps its state directory writable), and an approved escalated retry is just a new call with a wider policy.
|
||||
|
||||
**Same-world confinement only.** A backend shares the host's filesystem and kernel (`bwrap`, Landlock, Seatbelt); `workspaceRoot` names a real host path. Containers, microVMs, and remote executors are NOT backends of this seam — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups. The boundary and its rationale: [the sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).
|
||||
**Same-world confinement only.** A backend shares the host's filesystem and kernel (`bwrap`, Landlock, Seatbelt); `workspaceRoot` names a real host path. Containers, microVMs, and remote executors are NOT backends of this seam — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups. The boundary and its rationale: [the sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
|
||||
|
||||
Implementations: [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) (Linux: `bwrap`, else the per-platform Landlock launcher; macOS: `sandbox-exec`/Seatbelt). Consumers: [`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/) (wraps `['bash', '-c', command]`).
|
||||
|
||||
@@ -14,16 +14,24 @@ Implementations: [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) (Linux: `
|
||||
|
||||
### Confinement error, indirectly
|
||||
|
||||
**What the model sees**: Through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md) and [`dsh-tool-bash`](../../bash/tool-bash/README.md), failure to enforce a requested mode produces code `SANDBOX_UNAVAILABLE` and the exact error below. An execution-time runner failure adds ` Runner failure: <detail>`.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Conditional error text is visible for that call and retained in history until compaction.
|
||||
Through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md) and [`dsh-tool-bash`](../../bash/tool-bash/README.md), failure to enforce a requested mode produces code `SANDBOX_UNAVAILABLE` and the exact error below. An execution-time runner failure adds ` Runner failure: <detail>`.
|
||||
|
||||
#### Exact error
|
||||
##### Exact error
|
||||
|
||||
```markdown
|
||||
sandbox mode "<mode>" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access.
|
||||
```
|
||||
|
||||
#### 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
|
||||
|
||||
- **File effects are the whole policy vocabulary** — the seam expresses no network, process, syscall, device, or credential restrictions.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Developer tooling for creating, editing, building, and running DeepSeek Harness projects.
|
||||
|
||||
The [feature RFC](../../docs/rfc/proposed/feature/2026-07-14-sdk-developer-projects.md) owns the developer workflow; the [architecture RFC](../../docs/rfc/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) owns the package and project-editing boundaries.
|
||||
The [feature Agent Note](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md) owns the developer workflow; the [architecture Agent Note](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) owns the package and project-editing boundaries.
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
|
||||
@@ -6,14 +6,18 @@ The supported package surface is the `create-sdk` bin. The package root exports
|
||||
|
||||
The initializer rejects every existing target path, creates one `SdkProject` edit session, validates and commits it, then asks whether to install NPM dependencies and build. Install or build failures keep the generated project and print a retry command.
|
||||
|
||||
Public flags are `[directory]`, `--description`, `--provider`, `--base-url`, `--api-key`, `--model`, `--interface`, `--pm`, and `--install`/`--no-install`. Flags prefill matching questions, but creation always requires a TTY.
|
||||
Public flags are `[directory]`, `--description`, `--provider`, `--base-url`, `--api-key`, `--model`, `--interface`, `--pm`, `--install`/`--no-install`, plus the headless flags `--config <path>` / `--config-json <json>` and `--json`. Interactive flags prefill matching questions; a headless spec (`--config`/`--config-json`) supplies every answer and its feature plan up front, so creation runs without a TTY and drives through a `HeadlessPromptPort` that fails loud on any missing required answer. `--json` emits NDJSON lifecycle events (`done` / `action-required` / `error`) so an agent can fill the named missing input and re-run.
|
||||
|
||||
The provider choice is DeepSeek or a custom endpoint backed by `llm-pi-ai`. DeepSeek asks only for an API key and uses the public endpoint plus `deepseek-v4-flash`; custom also asks for a base URL. An empty key requires confirmation and creates a commented empty `.env` variable so provider startup fails clearly until it is filled. Existing plugin defaults are omitted; required SDK presets remain typed against the owning package's Config.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the generated project composition and its selected runtime plugins.
|
||||
Indirectly, through the generated project composition and its selected runtime plugins; the headless `--config-json` + `--json` surface additionally lets an agent create a project end to end and react to `action-required` events.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **TTY-only creation** — flags prefill questions, but the wizard still requires an interactive terminal before it writes a project.
|
||||
- **Headless local plugins** — the headless spec supplies project answers and the feature plan; scaffolding a local plugin (the interactive none/plugin/tool choice) is not yet expressible in the spec and defaults to none.
|
||||
|
||||
@@ -19,6 +19,9 @@ export interface CreateArgs {
|
||||
packageManager?: PackageManagerName
|
||||
install?: boolean
|
||||
linkWorkspace?: boolean
|
||||
config?: string
|
||||
configJson?: string
|
||||
json?: boolean
|
||||
help: boolean
|
||||
}
|
||||
|
||||
@@ -32,6 +35,9 @@ interface CommanderCreateOptions {
|
||||
pm?: PackageManagerName
|
||||
install?: boolean
|
||||
linkWorkspace?: boolean
|
||||
config?: string
|
||||
configJson?: string
|
||||
json?: boolean
|
||||
help?: boolean
|
||||
}
|
||||
|
||||
@@ -60,6 +66,9 @@ function createProgram(): Command {
|
||||
.addOption(new Option('--install').default(undefined))
|
||||
.addOption(new Option('--no-install').default(undefined))
|
||||
.option('--link-workspace')
|
||||
.option('--config <path>')
|
||||
.option('--config-json <json>')
|
||||
.addOption(new Option('--json').default(undefined))
|
||||
}
|
||||
|
||||
/** Parse create-sdk positionals/options through Commander into a domain-neutral value. */
|
||||
@@ -79,6 +88,9 @@ export function parseCreateArgs(argv: readonly string[]): CreateArgs {
|
||||
...options.pm === undefined ? {} : { packageManager: options.pm },
|
||||
...options.install === undefined ? {} : { install: options.install },
|
||||
...options.linkWorkspace ? { linkWorkspace: true } : {},
|
||||
...options.config === undefined ? {} : { config: options.config },
|
||||
...options.configJson === undefined ? {} : { configJson: options.configJson },
|
||||
...options.json === undefined ? {} : { json: options.json },
|
||||
help: options.help ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,16 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import {
|
||||
ClackPromptPort,
|
||||
HeadlessPromptError,
|
||||
HeadlessPromptPort,
|
||||
NodeCommandRunner,
|
||||
PromptCancelledError,
|
||||
type PackageManagerVersionProbe,
|
||||
type PromptPort,
|
||||
} from '@deepseek-ai/dsh-helper'
|
||||
import { parseCreateArgs } from './args.ts'
|
||||
import { parseCreateArgs, type CreateArgs } from './args.ts'
|
||||
import { CreateWizard, type ResolvedCreateRequest } from './create-wizard.ts'
|
||||
import { resolveHeadless } from './headless.ts'
|
||||
import { scaffoldProject, type ScaffoldResult } from './project-scaffolder.ts'
|
||||
import { CREATE_TEMPLATES, packageManagerTemplateModel } from './templates/create-templates.ts'
|
||||
|
||||
@@ -42,24 +46,29 @@ export async function createProject(
|
||||
context: CreateCommandContext,
|
||||
): Promise<ScaffoldResult | undefined> {
|
||||
const args = parseCreateArgs(argv)
|
||||
// Under --json, stdout carries only NDJSON events: human-readable progress
|
||||
// and package-manager child output move to stderr.
|
||||
const progress = args.json === true ? context.stderr : context.stdout
|
||||
if (args.help) {
|
||||
context.stdout.write(CREATE_TEMPLATES.usage.render({}))
|
||||
return undefined
|
||||
}
|
||||
if (!context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) {
|
||||
throw new Error('create-sdk requires an interactive TTY')
|
||||
const headless = await resolveHeadless(args)
|
||||
if (!headless && !context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) {
|
||||
throw new Error('create-sdk requires an interactive TTY, --config <file>, or --config-json <json>')
|
||||
}
|
||||
const wizard = new CreateWizard({
|
||||
args,
|
||||
args: headless ? headless.args : args,
|
||||
/* v8 ignore next -- production TTY wiring is exercised by the built-bin smoke */
|
||||
port: context.port ?? new ClackPromptPort(context.stdin, context.stdout),
|
||||
port: context.port ?? (headless ? new HeadlessPromptPort() : new ClackPromptPort(context.stdin, context.stdout)),
|
||||
cwd: context.cwd,
|
||||
releaseVersion: context.releaseVersion ?? await readCreateSdkVersion(),
|
||||
...context.versionProbe ? { versionProbe: context.versionProbe } : {},
|
||||
...headless?.features ? { features: headless.features } : {},
|
||||
})
|
||||
const resolved = await wizard.run()
|
||||
const result = await scaffoldProject(resolved.directory, resolved.request)
|
||||
context.stdout.write(CREATE_TEMPLATES.created.render({
|
||||
progress.write(CREATE_TEMPLATES.created.render({
|
||||
name: resolved.request.name,
|
||||
directory: resolved.directory,
|
||||
}))
|
||||
@@ -67,8 +76,9 @@ export async function createProject(
|
||||
try {
|
||||
if (context.setup) await context.setup(resolved)
|
||||
else {
|
||||
await resolved.request.packageManager.install(resolved.directory)
|
||||
await resolved.request.packageManager.build(resolved.directory)
|
||||
const runner = args.json === true ? new NodeCommandRunner(context.stderr) : new NodeCommandRunner()
|
||||
await resolved.request.packageManager.install(resolved.directory, runner)
|
||||
await resolved.request.packageManager.build(resolved.directory, runner)
|
||||
}
|
||||
} catch (error) {
|
||||
context.stderr.write(CREATE_TEMPLATES.setupFailure.render({
|
||||
@@ -79,7 +89,7 @@ export async function createProject(
|
||||
throw error
|
||||
}
|
||||
}
|
||||
context.stdout.write(CREATE_TEMPLATES.nextSteps.render({
|
||||
progress.write(CREATE_TEMPLATES.nextSteps.render({
|
||||
directory: resolved.directory,
|
||||
setupRequired: !resolved.install,
|
||||
...packageManagerTemplateModel(resolved.request.packageManager),
|
||||
@@ -87,6 +97,17 @@ export async function createProject(
|
||||
return result
|
||||
}
|
||||
|
||||
/** Whether NDJSON lifecycle events were requested, tolerating unparseable argv. */
|
||||
function wantsJsonEvents(argv: readonly string[]): boolean {
|
||||
let parsed: CreateArgs
|
||||
try {
|
||||
parsed = parseCreateArgs(argv)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return parsed.json === true
|
||||
}
|
||||
|
||||
/** Run the create command with process defaults and convert cancellation to a clean exit. */
|
||||
export async function runCreateCommand(
|
||||
argv: readonly string[] = process.argv.slice(2),
|
||||
@@ -97,15 +118,27 @@ export async function runCreateCommand(
|
||||
stderr: process.stderr,
|
||||
},
|
||||
): Promise<number> {
|
||||
const json = wantsJsonEvents(argv)
|
||||
const emit = (event: Record<string, unknown>): void => {
|
||||
context.stdout.write(`${JSON.stringify(event)}\n`)
|
||||
}
|
||||
try {
|
||||
await createProject(argv, context)
|
||||
if (json) emit({ type: 'done' })
|
||||
return 0
|
||||
} catch (error) {
|
||||
if (error instanceof PromptCancelledError) {
|
||||
context.stderr.write('create-sdk: cancelled\n')
|
||||
if (json) emit({ type: 'error', reason: 'cancelled' })
|
||||
else context.stderr.write('create-sdk: cancelled\n')
|
||||
return 1
|
||||
}
|
||||
context.stderr.write(`create-sdk: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
if (json && error instanceof HeadlessPromptError) {
|
||||
emit({ type: 'action-required', prompt: error.prompt })
|
||||
return 1
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (json) emit({ type: 'error', message })
|
||||
else context.stderr.write(`create-sdk: ${message}\n`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export class CreateWizard {
|
||||
private readonly versionProbe: PackageManagerVersionProbe
|
||||
private readonly userAgent: string
|
||||
private readonly linkWorkspaceRoot: string | undefined
|
||||
private readonly featurePlan: readonly FeatureSelection[] | undefined
|
||||
|
||||
/** Bind parsed args and infrastructure to one wizard run. */
|
||||
constructor(options: {
|
||||
@@ -57,6 +58,7 @@ export class CreateWizard {
|
||||
releaseVersion: string
|
||||
versionProbe?: PackageManagerVersionProbe
|
||||
userAgent?: string
|
||||
features?: readonly FeatureSelection[]
|
||||
}) {
|
||||
this.args = options.args
|
||||
this.port = options.port
|
||||
@@ -68,6 +70,7 @@ export class CreateWizard {
|
||||
this.linkWorkspaceRoot = options.args.linkWorkspace
|
||||
? fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
: undefined
|
||||
this.featurePlan = options.features
|
||||
}
|
||||
|
||||
/** Collect all answers before constructing any project files. */
|
||||
@@ -129,39 +132,43 @@ export class CreateWizard {
|
||||
const configurable = registry.all().filter(feature => feature.id === 'bash'
|
||||
|| feature.id === 'persistence'
|
||||
|| (!feature.required && feature.isApplicable(profile)))
|
||||
const selected = [...requireAnswer(await this.port.nestedMultiselect({
|
||||
message: 'Select features',
|
||||
options: configurable.map((feature) => {
|
||||
const nested = feature.mode !== 'single'
|
||||
const defaults = new Set(feature.defaultOptions(profile))
|
||||
return {
|
||||
value: feature.id,
|
||||
label: feature.summary,
|
||||
required: feature.required,
|
||||
default: feature.required || feature.id === 'hmr' || feature.id === 'fs' || feature.id === 'todo'
|
||||
|| feature.id === 'skill',
|
||||
...nested ? {
|
||||
choiceMode: feature.mode === 'multiple' ? 'multiple' as const : 'exclusive' as const,
|
||||
choices: feature.options.map(option => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
default: defaults.has(option.id),
|
||||
})),
|
||||
} : {},
|
||||
const selected = this.featurePlan
|
||||
? this.featurePlan.map(feature => ({ value: feature.id, choices: feature.options }))
|
||||
: [...requireAnswer(await this.port.nestedMultiselect({
|
||||
message: 'Select features',
|
||||
options: configurable.map((feature) => {
|
||||
const nested = feature.mode !== 'single'
|
||||
const defaults = new Set(feature.defaultOptions(profile))
|
||||
return {
|
||||
value: feature.id,
|
||||
label: feature.summary,
|
||||
required: feature.required,
|
||||
default: feature.required || feature.id === 'hmr' || feature.id === 'fs' || feature.id === 'todo'
|
||||
|| feature.id === 'skill',
|
||||
...nested ? {
|
||||
choiceMode: feature.mode === 'multiple' ? 'multiple' as const : 'exclusive' as const,
|
||||
choices: feature.options.map(option => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
default: defaults.has(option.id),
|
||||
})),
|
||||
} : {},
|
||||
}
|
||||
}),
|
||||
}))]
|
||||
if (!this.featurePlan) {
|
||||
for (const { value: id } of [...selected]) {
|
||||
const feature = registry.get(id)
|
||||
for (const suggestedId of feature.suggests) {
|
||||
if (selected.some(item => item.value === suggestedId)) continue
|
||||
const suggested = registry.get(suggestedId)
|
||||
const add = requireAnswer(await new ConfirmQuestion({
|
||||
id: `${feature.id}.${suggested.id}`,
|
||||
message: `Add the recommended ${suggested.summary.toLowerCase()} for ${feature.summary.toLowerCase()}?`,
|
||||
initialValue: true,
|
||||
}).resolve(this.port))
|
||||
if (add) selected.push({ value: suggested.id, choices: suggested.defaultOptions(profile) })
|
||||
}
|
||||
}),
|
||||
}))]
|
||||
for (const { value: id } of [...selected]) {
|
||||
const feature = registry.get(id)
|
||||
for (const suggestedId of feature.suggests) {
|
||||
if (selected.some(item => item.value === suggestedId)) continue
|
||||
const suggested = registry.get(suggestedId)
|
||||
const add = requireAnswer(await new ConfirmQuestion({
|
||||
id: `${feature.id}.${suggested.id}`,
|
||||
message: `Add the recommended ${suggested.summary.toLowerCase()} for ${feature.summary.toLowerCase()}?`,
|
||||
initialValue: true,
|
||||
}).resolve(this.port))
|
||||
if (add) selected.push({ value: suggested.id, choices: suggested.defaultOptions(profile) })
|
||||
}
|
||||
}
|
||||
const fixed = new Set(selections.map(selection => selection.id))
|
||||
@@ -174,12 +181,16 @@ export class CreateWizard {
|
||||
for (const choice of selected) {
|
||||
choices.set(choice.value, choice.choices.length > 0 ? choice.choices : undefined)
|
||||
}
|
||||
const plannedById = new Map((this.featurePlan ?? []).map(feature => [feature.id, feature]))
|
||||
for (const [id, options] of choices) {
|
||||
const planned = plannedById.get(id)
|
||||
selections.push(await configurator.configure(
|
||||
registry.get(id),
|
||||
profile,
|
||||
undefined,
|
||||
options,
|
||||
planned?.secrets ?? {},
|
||||
planned?.values ?? {},
|
||||
))
|
||||
}
|
||||
return selections
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user