docs: trim generated prose

This commit is contained in:
Tianyi Cui
2026-07-12 03:36:43 +08:00
parent 3dca90261c
commit 75838e10b5
323 changed files with 2857 additions and 11833 deletions

View File

@@ -2,15 +2,15 @@
This directory contains all `@deepseek-ai/dsh-*` harness packages. Repo-wide conventions (effects, declaration merging, waterfall semantics, ESM, testing policy) are in the root [AGENTS.md](../AGENTS.md) § Conventions; the points below are packages-specific.
- **Plugin export shape namespace OR default, never both.** A *service* package exports the service class as `export default` (the Loader instantiates it). A *function/namespace* plugin exports `name` / `inject` / `Config` / `apply` as separate named exports and **must NOT add `export default`** — the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default export collapses the module to the bare `apply` function and silently discards the `inject`/`name`/`Config` namespace, leaving the plugin with no injected services (it then throws `cannot get property … without inject` at load). See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md).
- **Read an optional (non-injected) service via `ctx.get(name)`, not `ctx.<name>`.** For a service a plugin reads opportunistically but deliberately leaves out of `static inject` (e.g. `AgentLoop` reading `sessionPersistence`), the `ctx.<name>` property proxy resolves by an ancestor-only fiber walk that throws when the call arrives through a foreign traceable shadow (the service lives on a sibling fiber). `ctx.get(name)` is the topology-independent global-store lookup, strict by default (an inactive/absent backend reads as `undefined` — prefer it over the `ctx.get(name, false)` overload, which also skips the active-state check). Services that ARE in `static inject` resolve fine via `ctx.<name>`. See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md).
- **A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader/export path** hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape. Full testing policy (tiers, with-key generosity, real-entry-path guards): [docs/testing.md](../docs/testing.md).
- **Plugin export shape: namespace or default, never both.** Service packages default-export their service class; function plugins named-export `name` / `inject` / `Config` / `apply` and have no default export. The Loader otherwise discards the namespace ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)).
- **Read an optional, non-injected service with `ctx.get(name)`.** Use `ctx.<name>` only for injected services; its fiber-relative lookup is not safe for opportunistic sibling services ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)).
- **A plugin shipped through `cordis.yml` needs a real Loader-path test.** A hand-mounted plugin does not exercise `unwrapExports`; see [testing.md](../docs/testing.md).
Naming notes:
- A *service* `src/index.ts` exports the service class as `export default` + all public types; a *function/namespace plugin* `src/index.ts` exports `name`/`inject`/`Config`/`apply` as named exports and NO default (the export-shape rule above).
- A service `src/index.ts` default-exports the service class and named-exports public types; a function plugin named-exports its plugin namespace.
- `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; prose accuracy stays on the author ([the documentation standard](../docs/AGENTS.md)).
- Altered behavior updates the package README and JSDoc in the same commit; keep both concise under [the documentation standard](../docs/AGENTS.md).
Read the per-package README.md for package-specific details: service API, events, extension points, TODOs.

View File

@@ -1,16 +1,6 @@
/**
* `LocalBashExecutor`: the local-subprocess implementation of the
* `@deepseek-ai/dsh-bash` executor seam. Spawns `bash -c` per call in its
* own process group (see `./run.ts` for the plumbing and the agent-tool
* survey notes), tracks background tasks, and kills everything on dispose.
*
* TODO(permissions/sandbox): execution policy does NOT belong here — use
* the `tools/pre-execute` deny/ask gate (see docs/architecture.md
* § Extending The Harness) or implement a sandboxing `BashExecutor`.
* Reference points:
* Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies
* seatbelt/landlock plus an execpolicy prefix-rule engine.
*
* `LocalBashExecutor`: the local-subprocess implementation of the `@deepseek-ai/dsh-bash`
* executor seam.
* @module @deepseek-ai/dsh-bash-local
*/
@@ -90,10 +80,9 @@ export class LocalBashExecutor extends BashExecutor {
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
assertPositiveFinite('graceMs', this.config.graceMs)
ctx.effect(() => async () => {
// Kill every live process group and WAIT for the processes to close so
// nothing outlives the fiber (HMR safety) — a TERM-trapping child is
// held until the SIGKILL escalation lands. The base class already
// silenced listeners, so these kills complete without notices.
// Kill every live process group and WAIT for the processes to close so nothing outlives
// the fiber (HMR safety) — a TERM-trapping child is held until the SIGKILL escalation
// lands.
const pending: Promise<void>[] = []
for (const task of this.tasks.values()) {
if (task.status === 'running') {
@@ -154,23 +143,18 @@ export class LocalBashExecutor extends BashExecutor {
stdin: spec.stdin,
env: spec.env,
}, this.internals).done
// Classify the FIRST abort reason: a BASH_TIMEOUT TimeoutReason means our
// timeout cut the command short; any other abort — an upstream cancel, or a
// foreign (outer) deadline's timeout under nesting — is aborted. Scoping to
// our own code keeps a nested outer deadline from reading as our timeout.
// Mutually exclusive by construction — the fused signal reports one cause.
// Classify the FIRST abort reason: a BASH_TIMEOUT TimeoutReason means our timeout cut the
// command short; any other abort — an upstream cancel, or a foreign (outer) deadline's
// timeout under nesting — is aborted.
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
const aborted = d.signal.aborted && !timedOut
return { ...outcome, timedOut, aborted, timeoutMs: spec.timeoutMs }
}
start(spec: BashExecSpec): BashTask {
// No timeout for background tasks (matches Claude Code, which detaches
// the timeout when backgrounding); callers stop tasks via kill() — or
// via spec.signal, which the seam contract honors for background runs
// too (runBash wires it to the group kill). No deadline is created here,
// so spec.timeoutMs is ignored by design — background tasks stay
// timeout-free (see the timeout-library RFC).
// No timeout for background tasks (matches Claude Code, which detaches the timeout when
// backgrounding); callers stop tasks via kill() — or via spec.signal, which the seam
// contract honors for background runs too (runBash wires it to the group kill).
const running = runBash({
command: spec.command,
cwd: spec.workdir,

View File

@@ -1,23 +1,6 @@
/**
* Process plumbing for the local bash executor: spawn, output collection
* with tail-keep + spill-to-disk truncation, and process-group kill with
* SIGTERM→SIGKILL escalation.
*
* Everything here is deliberately free of Cordis concepts so it can be unit
* tested in isolation; `LocalBashExecutor` owns lifecycle and configuration.
*
* runBash owns NO timing: it kills the process group when its `spec.signal`
* fires and does not distinguish a timeout from a cancel. The executor fuses
* timeout + upstream cancellation into that one signal via
* `@deepseek-ai/dsh-timeout`'s `deadline`, and classifies the outcome from the
* signal afterward — the timing/classification half is shared, the kill is not.
*
* Design notes (surveyed against Claude Code, OpenCode, Codex, and pi — see
* the package README): spawn-per-call with `detached: true` so the child
* leads its own process group; kills target the group (`kill(-pid)`) so
* pipelines and subshells die with the parent. SIGTERM first, SIGKILL after a
* grace period (OpenCode's escalation; Codex/pi jump straight to SIGKILL).
*
* Process plumbing for the local bash executor: spawn, output collection with tail-keep +
* spill-to-disk truncation, and process-group kill with SIGTERM→SIGKILL escalation.
* @module dsh-bash-local/run
*/
@@ -50,18 +33,9 @@ export const ENV_OVERRIDES = {
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
/**
* `process.env` minus credential-shaped vars, plus the model-friendly
* overrides, plus any caller-supplied `extra` entries.
* `process.env` minus credential-shaped vars, plus the model-friendly overrides, plus any
* caller-supplied `extra` entries.
*
* Layering matters: the scrub drops `process.env` credentials, then
* `ENV_OVERRIDES` forces the model-friendly terminal vars, then `extra` is
* merged LAST so an explicit caller entry wins even when its name matches the
* scrub pattern (the scrub is the control that stops the HARNESS's ambient
* credentials leaking into a spawned command; a caller that explicitly sets a
* var named a value it already holds, not that ambient secret). `extra` is set
* by in-process plugins (the hooks bridges), not the model — `dsh-tool-bash`
* builds its request from named fields only and does not forward model input
* here (see its README, § "The tool builds its request from named args only").
* @param extra - caller-supplied entries merged last; an explicit entry wins even against the scrub and the overrides.
* @returns the environment to hand to `spawn` for the child process.
*/
@@ -262,10 +236,8 @@ export class OutputCollector {
try {
closeSync(this.spillFd)
} catch {
// close can surface delayed writeback failures (for example EIO/ENOSPC)
// after writeSync appeared to succeed. Keep finalize total so runBash's
// close handler still resolves, but stop advertising a spill file that
// may be missing its tail.
// close can surface delayed writeback failures (for example EIO/ENOSPC) after writeSync
// appeared to succeed.
this.spillFile = undefined
}
this.spillFd = undefined
@@ -275,13 +247,9 @@ export class OutputCollector {
}
/**
* Send `sig` to the process GROUP led by `pid` (requires the child to have
* been spawned with `detached: true`). NEVER throws: kills race process exit
* by design (ESRCH), and the other failure modes (EPERM from setuid
* children, …) fire inside timer callbacks where a throw would crash the
* host process — a kill that cannot be delivered is reported by the process
* NOT dying, which callers already handle via escalation/timeouts. No-op for
* non-positive pids (spawn never started a process).
* Send `sig` to the process GROUP led by `pid` (requires the child to have been spawned with
* `detached: true`).
*
* @param pid - the group leader's pid; non-positive means the spawn failed and the call is a no-op.
* @param sig - the signal to deliver to the whole group.
*/
@@ -311,24 +279,13 @@ export interface RunningBash {
}
/**
* Spawn `bash -c <command>` in its own process group and collect output.
*
* Outcome semantics: the returned promise REJECTS only for spawn-level
* failures (bad cwd → ENOENT, missing binary, pre-aborted signal); every
* runtime outcome — nonzero exit, timeout kill, abort kill, signal death —
* RESOLVES with a {@link SpawnOutcome} describing what happened, so callers
* shape one consistent report for the model.
*
* XXX(stateful-shell): per the agent-tool survey there are two proven
* stateful designs worth revisiting — Claude Code persists ONLY cwd between
* calls (captures `pwd -P` after each command), and Codex keeps whole PTY
* exec sessions addressable via session ids + stdin writes. We deliberately
* spawn a fresh non-login `bash -c` per call for determinism (no rc files,
* no inherited shell state); revisit when real workflows demand it.
* @param spec - the fully-resolved run (command, cwd, limits); no defaulting happens here.
* @param internals - test-only knobs; omitted fields fall back to the private per-process spill dir.
* @returns the live handle: pid, the two live collectors, the outcome promise, and `kill()`.
* Spawn one isolated `bash -c` process group and collect its output.
* Runtime exits resolve as {@link SpawnOutcome}; only spawn failures reject.
* @param spec - fully resolved command, cwd, limits, and cancellation.
* @param internals - test-only process and spill-directory overrides.
* @returns live process handle and outcome promise.
*/
// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state.
export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash {
const spillDir = internals.spillDir ?? privateSpillDir()
@@ -336,16 +293,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
}
// stdin is a pipe ONLY when the caller supplied bytes; with none it is `ignore`
// (fd 0 → /dev/null) — the exact pre-seam default. This matters: a spawn pipe
// and /dev/null are NOT observationally identical (node's pipe is an AF_UNIX
// socket, so a command that probes stdin's type — `test -c /dev/stdin`, `stat
// /proc/self/fd/0` — sees a char device vs a socket), so the no-stdin path
// (every model-driven call) must keep /dev/null rather than regress to a socket.
// Two LITERAL `stdio` tuples (not one variable tuple): only a literal lets the
// typed `spawn` overload infer non-null stdout/stderr, which the
// `ChildProcessByStdio` annotation captures (stdin `Writable | null`; stdout/
// stderr the non-null `Readable` the collectors attach to without a cast).
// Keep absent stdin as /dev/null; literal tuples preserve non-null output types.
const env = childEnv(spec.env)
const child: ChildProcessByStdio<Writable | null, Readable, Readable> = spec.stdin !== undefined
? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
@@ -358,8 +306,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
let graceTimer: NodeJS.Timeout | undefined
// pid is undefined when the spawn itself fails (bad cwd, missing binary);
// the 'error' handler rejects `done` and kills become no-ops via pid -1.
// Failed spawns use pid -1 so kill remains a no-op.
const pid = child.pid ?? -1
const kill = (): void => {
@@ -368,27 +315,11 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs)
}
// runBash owns no timer: the executor's `run()` fuses timeout+cancel into one
// deadline signal (`@deepseek-ai/dsh-timeout`) and passes it here; we only
// listen and run the SIGTERM→grace→SIGKILL kill. Whether the abort was a
// timeout or an upstream cancel is classified by the executor from that
// signal, not tracked here.
// The executor owns timeout classification; this layer only reacts to abort.
const onAbort = (): void => { kill() }
spec.signal?.addEventListener('abort', onAbort, { once: true })
// Write stdin and close it, but ONLY when the caller supplied bytes — with no
// stdin, fd 0 is `ignore` (/dev/null) and `child.stdin` is null. The error
// handler must exist whenever we write: an unhandled 'error' on the stream
// would throw and crash the host. We swallow the error rather than reject
// `done`, and that is correct for ANY stdin-write error, not just the common
// one — the stdin write is BEST-EFFORT, while the command's authoritative
// outcome is its exit code + captured output, which the `close` handler reports
// regardless of whether the write landed. The expected case is EPIPE (the child
// exited without reading, so closing our end of a still-full pipe fails); a rare
// non-EPIPE pipe fault means the command ran with incomplete stdin, and it
// surfaces that itself through its own exit/output (e.g. a hook that gets
// truncated JSON errors out) — rejecting here would instead discard that real
// output and turn it into an opaque infrastructure error, which is worse.
// Stdin writes are best-effort; process exit and captured output remain authoritative.
if (child.stdin !== null) {
child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ })
child.stdin.end(spec.stdin)
@@ -396,8 +327,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
const done = new Promise<SpawnOutcome>((resolve, reject) => {
child.on('error', (error) => {
// Spawn-level failure (ENOENT cwd, EACCES, …): no close event with
// meaningful output follows; clean up and reject.
// No meaningful close outcome follows a spawn failure.
cleanup()
reject(error)
})

View File

@@ -189,12 +189,8 @@ describe('stdin and extra env (set by in-process plugins)', () => {
})
it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => {
// The no-stdin path must stay observationally identical to the pre-seam
// `ignore` default: a command that probes stdin's file type sees a char
// device (/dev/null). Regressing to an always-open pipe would make fd 0 a
// socket (node's spawn pipe is an AF_UNIX socket, not a FIFO), flipping
// `test -c /dev/stdin` for every model-driven call. When bytes ARE supplied,
// fd 0 is that pipe (a socket), as it must be to carry them.
// The no-stdin path must stay observationally identical to the pre-seam `ignore` default: a
// command that probes stdin's file type sees a char device (/dev/null).
const none = await runBash(spec('test -c /dev/stdin && echo char || echo other')).done
expect(none.stdout.text).toBe('char\n')
const piped = await runBash(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done
@@ -219,9 +215,8 @@ describe('stdin and extra env (set by in-process plugins)', () => {
})
it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => {
// The child exits immediately without reading; closing our end of a stdin
// pipe still holding ~1MiB triggers EPIPE on the write. The handler must
// swallow it: `done` resolves normally with the child's real exit.
// The child exits immediately without reading; closing our end of a stdin pipe still
// holding ~1MiB triggers EPIPE on the write.
const big = 'x'.repeat(1024 * 1024)
const result = await runBash(spec('exit 7', { stdin: big })).done
expect(result.exitCode).toBe(7)

View File

@@ -1,43 +1,6 @@
/**
* `SandboxBashExecutor`: the sandbox-consuming implementation of the
* `@deepseek-ai/dsh-bash` executor seam. Every spawned command is wrapped by
* the `ctx.sandbox` provider (`@deepseek-ai/dsh-sandbox`) according to the
* configured {@link SandboxMode}: the executor hands the provider the exact
* `['bash', '-c', command]` argv it is about to spawn and spawns the wrapped
* argv instead. WHICH platform runner confines it — and whether one is
* usable at all (the provider fails CLOSED with a structured
* `SANDBOX_UNAVAILABLE` error rather than passing the argv through) — is the
* provider's concern (`@deepseek-ai/dsh-sandbox-local` first).
*
* Extends `LocalBashExecutor` so all process mechanics — spawn, process-group
* kills, timeout escalation, output collection and spill files, background
* tasks, the credential scrub — are the local implementation's, verbatim.
* This package adds only the seam consumption and the result facts, which is
* exactly the split the capability seam was designed for (a sandboxing
* executor replaces `dsh-bash-local` without touching `dsh-tool-bash`, and
* swapping the confinement backend never touches this package).
*
* A failed run whose stderr carries the selected backend's own denial
* dialect (the signatures the provider stamps on every wrap) is classified
* as a sandbox denial on `BashRunResult.sandbox`, and every confined result
* also carries how completely the selected runner enforces the mode
* (`sandbox.enforcement`, from the provider's wrap). A failure carrying the
* backend's RUNNER-FAILURE signature instead means the sandbox itself broke
* and the command never ran: the foreground path re-throws it as the
* structured fail-closed `SANDBOX_UNAVAILABLE` error (late twin of the
* provider's confine-time throw), a settled background task stamps
* `sandbox.runnerFailed` — either way a broken sandbox can never read as a
* failing command, and the command never slips through unconfined.
*
* Deny-only at the seam, escalation at the tool: a denial is a reported FACT
* here, and the one-shot user-approved escalated retry of a denied action
* (docs/rfc/implemented/feature/2026-07-06-sandbox.md) is driven by
* `dsh-tool-bash` through `ctx.approval` — this executor's contribution is the
* per-call `sandboxMode` override it honors in {@link resolve}: an escalated
* call runs (and classifies, and reports) under ITS granted mode while every
* neighboring call keeps its session's standing mode (or the configured
* default when that session has no override).
*
* `SandboxBashExecutor`: the sandbox-consuming implementation of the `@deepseek-ai/dsh-bash`
* executor seam.
* @module @deepseek-ai/dsh-bash-sandbox
*/
@@ -79,24 +42,9 @@ export function shellQuote(text: string): string {
}
/**
* Conservative sandbox-denial classifier: a run counts as denied only when it
* FAILED (nonzero exit — a signal kill is not a denial) and its stderr
* carries one of the SELECTED BACKEND's own denial signatures — the dialect
* the provider stamps on every wrap (`ConfinedArgv.denialSignatures`:
* `Read-only file system` under bwrap's EROFS mounts, `Permission denied`
* under Landlock's EACCES, `Operation not permitted` under Seatbelt's
* EPERM). Matching the backend's dialect rather than a cross-backend union
* keeps the classifier from claiming denials the active backend never
* produces (bare EPERM text under a Linux runner names non-file boundaries —
* mount, kill, ptrace — that fail the same way unsandboxed). Text inference
* is the fallback signal until a runner provides a structured one (which
* wins once it exists); it errs toward NOT claiming a denial, and its known
* residual imprecision is non-sandbox text in the active dialect (an ssh
* auth failure reads as a denial under Landlock, a refused `kill` under
* Seatbelt).
* Classify a nonzero run using the selected backend's denial signatures.
* @param result - the settled foreground run to classify.
* @param signatures - the active wrap's denial dialect, case-insensitive
* stderr substrings.
* @param signatures - the active wrap's denial dialect, case-insensitive stderr substrings.
* @returns whether the run's failure reads as a sandbox denial.
*/
export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean {
@@ -104,17 +52,7 @@ export function classifyDenial(result: BashRunResult, signatures: readonly strin
}
/**
* Runner-failure classifier: a failed run whose stderr carries the SELECTED
* BACKEND's own runner-failure signature (`ConfinedArgv.
* runnerFailureSignatures`: the runner's error prefix, which also matches
* the shell's runner-not-found message) means the SANDBOX itself failed and
* the command never ran. Checked BEFORE {@link classifyDenial} — a runner's
* error text can contain denial words (an unopenable grant root reports
* `Permission denied`) — and surfaced as the fail-closed
* `SANDBOX_UNAVAILABLE` error on the foreground path, `sandbox.runnerFailed`
* on a settled background task. Same conservative-text-inference stance and
* residual imprecision as the denial classifier (a failing task that itself
* prints the runner's prefix reads as a runner failure).
* Classify a nonzero run using the selected backend's runner-failure signatures.
* @param result - the settled foreground run to classify.
* @param signatures - the active wrap's runner-failure signatures,
* case-insensitive stderr substrings.
@@ -137,15 +75,7 @@ function matchesSignature(exitCode: number | null, stderr: string, signatures: r
}
/**
* Sandbox-consuming bash executor. Registers as `ctx.bash` (loading it
* INSTEAD OF `dsh-bash-local`, together with a `ctx.sandbox` provider, is
* the whole swap — the tool layer is untouched). Its configured mode is the
* fallback exposed by {@link sandboxMode}; `dsh-tool-bash` folds a session's
* durable `bash/sandbox-mode` override and stamps the effective mode onto each
* request, while an approved escalation may stamp a strictly wider mode for
* one call. The tool's per-agent prompt section states that same effective
* mode, and each run's `result.sandbox` reports what actually executed plus
* enforcement completeness.
* Sandbox-consuming bash executor.
*/
export class SandboxBashExecutor extends LocalBashExecutor {
static inject = ['sandbox']
@@ -163,15 +93,9 @@ export class SandboxBashExecutor extends LocalBashExecutor {
private readonly mode: SandboxMode
private readonly workspaceRoot: string
/**
* Per-task facts, keyed by task id from `start()` until the settle stamp
* consumes them: the mode the task runs under (per-call — an escalated task
* differs from its neighbors) plus its wrap facts. The seam returns facts
* PER WRAP — a provider may legally vary enforcement or dialect between
* calls — so overlapping background tasks must each classify against their
* OWN wrap; a single latest-wrap field would let a later `start()` clobber
* an earlier task's facts before it settles. A `danger-full-access` task
* has NO entry (nothing confined it), which is what the settle stamp keys
* off.
* Per-task facts, keyed by task id from `start()` until the settle stamp consumes them: the
* mode the task runs under (per-call — an escalated task differs from its neighbors) plus
* its wrap facts.
*/
private readonly taskFacts = new Map<BashTaskId, {
mode: ConfinedSandboxMode
@@ -215,11 +139,9 @@ export class SandboxBashExecutor extends LocalBashExecutor {
}
const confined = this.confine(spec.command, mode)
const result = await super.run({ ...spec, command: confined.command })
// Runner failure outranks denial: the sandbox itself failed and the
// command NEVER RAN — surface the same structured fail-closed error a
// confine-time discovery throws (late detection, same outcome), with
// the runner's own first stderr line as the cause. Returning it as a
// task result would let a broken sandbox read as a failing command.
// Runner failure outranks denial: the sandbox itself failed and the command never RAN —
// surface the same structured fail-closed error a confine-time discovery throws (late
// detection, same outcome), with the runner's own first stderr line as the cause.
if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) {
throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0])
}
@@ -230,11 +152,8 @@ export class SandboxBashExecutor extends LocalBashExecutor {
// Same stamped-by-resolve invariant as run().
const mode = spec.sandboxMode as SandboxMode
if (mode === 'danger-full-access') return super.start(spec)
// Sandbox facts are stamped at settle time by {@link notifyTaskDone}
// (denial classification runs against the settled task's collected
// stderr). The map entry lands synchronously after spawn, strictly
// before the earliest possible settle (a process exit reaches us no
// sooner than the next tick).
// Sandbox facts are stamped at settle time by {@link notifyTaskDone} (denial classification
// runs against the settled task's collected stderr).
const confined = this.confine(spec.command, mode)
const task = super.start({ ...spec, command: confined.command })
const { enforcement, denialSignatures, runnerFailureSignatures } = confined
@@ -243,27 +162,17 @@ export class SandboxBashExecutor extends LocalBashExecutor {
}
/**
* Stamp the sandbox facts BEFORE completion listeners run: the base
* executor notifies from inside the task's settle path, so overriding the
* notification point is what makes `task.sandbox` visible to `onTaskDone`
* consumers and `done` awaiters alike. Each task classifies against the
* facts of ITS OWN wrap and reports ITS OWN mode (consumed from the
* per-task map here — settle is the entry's end of life): with per-call
* escalation, tasks under different modes settle side by side, so keying
* anything off the configured default would misreport them. A
* `danger-full-access` task has no map entry and carries no facts (nothing
* confined it); a signal-killed task (null exit code) is never a denial,
* mirroring the foreground classifier.
* Stamp the sandbox facts before completion listeners run: the base executor notifies from
* inside the task's settle path, so overriding the notification point is what makes
* `task.sandbox` visible to `onTaskDone` consumers and `done` awaiters alike.
*/
protected override notifyTaskDone(task: BashTask): void {
const facts = this.taskFacts.get(task.id)
if (facts !== undefined) {
this.taskFacts.delete(task.id)
const stderr = this.collectedStderr(task.id)
// Runner failure outranks denial (the command never ran; the runner's
// own error text can contain denial words). A settled task has no
// error channel left, so the fact IS the surface here — the foreground
// path throws instead.
// Runner failure outranks denial (the command never ran; the runner's own error text can
// contain denial words).
const runnerFailed = matchesSignature(task.exitCode, stderr, facts.runnerFailureSignatures)
task.sandbox = {
mode: facts.mode,

View File

@@ -9,20 +9,9 @@ import { bwrapProfileArgs, LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
/**
* KEYLESS consumer-integration proof under bwrap: the REAL
* `LocalSandboxProvider` (nothing forced — bwrap is the ladder's first rung,
* so a passing probe selects it) underneath the REAL `SandboxBashExecutor`,
* driven through the executor's public run/start paths. Verifies the WORLD
* (files exist or don't) plus the stamped result facts — in particular that
* bwrap's EROFS denial text classifies as `denied: true` through the
* wrap-carried dialect; the backend-only confinement proofs live with
* `@deepseek-ai/dsh-sandbox-local`.
*
* Self-skips wherever the functional probe fails — no `bwrap` on PATH, or a
* host that denies unprivileged user namespaces.
*
* HOME-based dirs on purpose: bwrap's `/tmp` is an ephemeral mount, so only
* paths outside it prove the workspace-root boundary.
* Keyless consumer-integration proof under bwrap: the real `LocalSandboxProvider` (nothing
* forced — bwrap is the ladder's first rung, so a passing probe selects it) underneath the
* real `SandboxBashExecutor`, driven through the executor's public run/start paths.
*/
const probe = spawnSync('bwrap', [...bwrapProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' })

View File

@@ -1,11 +1,5 @@
/**
* SandboxBashExecutor tests: the CONSUMER side of the sandbox seam. A fake
* `ctx.sandbox` provider (injected as a real cordis service) makes wrapping,
* policy hand-off, fail-closed propagation, classification, and fact
* stamping all deterministic without any real runner; the real-provider
* integration proof lives in `tests/landlock.e2e.ts`. Denials are produced
* with plain unix permissions (a 0555 directory), which exercises the same
* stderr signature the classifier keys on.
* SandboxBashExecutor tests: the CONSUMER side of the sandbox seam.
*/
import { chmodSync, mkdirSync, mkdtempSync } from 'node:fs'
@@ -286,10 +280,7 @@ describe('background sandbox facts', () => {
})
it('overlapping background tasks keep their OWN wrap facts (per-task, not latest-wrap)', async () => {
// The seam returns facts PER WRAP — a legal provider may vary them
// between calls. The slow task settles AFTER the quick one started, so a
// latest-wrap field would classify its denial against the quick task's
// dialect (missing it) and stamp the wrong enforcement.
// The seam returns facts per WRAP — a legal provider may vary them between calls.
const wraps: Array<Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>> = [
{ enforcement: 'partial', denialSignatures: ['permission denied'] },
{ enforcement: 'full', denialSignatures: ['read-only file system'] },

View File

@@ -32,6 +32,6 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, owner, sandboxMode) before execution; `owner` and `sandboxMode` are optional on the request and **required-but-nullable** on the resolved spec, so a forgotten one is a visible `undefined` rather than a silently-absent property. `sandboxMode` is the explicit per-call sandbox-policy input: an escalation grant a human just issued ([the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md), which outranks) or the session's standing override ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)); a sandboxing executor's `resolve()` stamps its configured default when the request carries none, and a non-sandboxing executor carries the field verbatim and confines nothing.
The seam also owns the per-session mode override vocabulary (the sandbox RFC § Per-session mode switching): the log-only `'bash/sandbox-mode'` session event, the pure fold `effectiveSandboxMode(events)` (last event wins; `undefined` means "apply the executor default"), and THE write path `setSandboxMode(session, mode)` — the session log is the store, so an override survives restart by replay and two sessions can never see each other's mode. Writers must respect turn-enclosure: the ACP bridge anchors an idle switch at the next turn rather than appending between turns. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. A sandboxing executor additionally stamps `sandbox` result facts on results and settled tasks (`BashSandboxInfo`: the mode it executed under, the conservative `denied` classification, and — for confined modes — the backend's `enforcement` completeness); the mode/enforcement vocabulary is owned by the [`dsh-sandbox`](../../sandbox/sandbox/) seam, and the facts are documented in [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). See `src/types.ts` for the full contracts.
The seam owns per-session sandbox overrides through the log-only `bash/sandbox-mode` event, `effectiveSandboxMode`, and `setSandboxMode`; writers preserve turn enclosure, and replay restores the last override. `BashTaskId` and `OwnerToken` are distinct brands. Foreground `run` returns exit, timeout, cancellation, output, and optional sandbox facts; background `start` and `readOutput` use task records. A sandboxing executor reports the executed mode, conservative denial classification, and enforcement completeness. See [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md) for full shapes.
`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so they would be redundant tool params. This is not a security boundary: the implementation's credential scrub (not these fields) is what keeps the harness's ambient secrets out of a spawned command. They are plain optionals on the resolved spec (unlike `owner`'s required-but-nullable): a missing one means "none", the safe default. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).

View File

@@ -1,16 +1,6 @@
/**
* The bash executor seam (`ctx.bash`): an abstract service defining WHAT a
* bash backend does — run commands, manage background tasks — without saying
* HOW. Implementations subclass {@link BashExecutor} and register themselves
* as the `bash` service; `@deepseek-ai/dsh-bash-local` (local subprocesses)
* is the first. Future implementations swap in sandboxes, containers, or
* remote exec servers without touching the tool schemas that consume them
* (`@deepseek-ai/dsh-tool-bash`).
*
* The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the
* surveyed agents: pi hides execution behind a `BashOperations` interface
* (local shell / SSH / VM backends), Codex behind an exec-server protocol.
*
* The bash executor seam (`ctx.bash`): an abstract service defining what a bash backend does —
* run commands, manage background tasks — without saying how.
* @module @deepseek-ai/dsh-bash
*/
@@ -39,25 +29,9 @@ declare module 'cordis' {
}
/**
* Abstract bash execution service. Subclass, implement the abstract methods,
* and load the subclass as a plugin — it registers as `ctx.bash` (one
* implementation per context; loading a second throws, which is cordis'
* standard duplicate-service behavior).
*
* Semantics every implementation must honor:
* - {@link run} REJECTS only for infrastructure failures (unusable workdir,
* missing shell, pre-aborted signal). Nonzero exits, timeout kills, and
* abort kills RESOLVE with a descriptive {@link BashRunResult} — reporting
* a failed command is the tool layer's job, not an exception.
* - {@link start} returns immediately; no timeout applies to background
* tasks (callers stop them via {@link kill} or the spec's AbortSignal).
* Completion must fire the {@link onTaskDone} listeners exactly once per
* task, and must NOT fire after the service is disposed.
* - {@link readOutput} is incremental: consecutive reads never re-deliver
* output. Implementations bound their buffers; reads that lost data flag
* `lossy` and point at full-stream spill files when available.
* - Disposal kills every running task and awaits their exit (no orphan
* processes survive `fiber.dispose()`).
* Abstract bash execution service. Subclass, implement the abstract methods, and load the
* subclass as a plugin — it registers as `ctx.bash` (one implementation per context; loading a
* second throws, which is cordis' standard duplicate-service behavior).
*/
export abstract class BashExecutor extends Service {
private listeners = new Set<BashTaskListener>()
@@ -74,14 +48,10 @@ export abstract class BashExecutor extends Service {
}
/**
* The sandbox mode this executor confines commands under BY DEFAULT, or
* `undefined` when it does not sandbox at all — the capability fact the
* tool and ACP layers read to advertise sandbox controls honestly. The
* getter proves a sandboxing executor is mounted and supplies its fallback
* mode; a session override may make the effective mode narrower or wider,
* so strict escalation widening is checked per call rather than encoded in
* this default-relative capability fact. The base class reports
* `undefined`; a sandboxing implementation overrides the getter.
* The sandbox mode this executor confines commands under BY DEFAULT, or `undefined` when it
* does not sandbox at all — the capability fact the tool and ACP layers read to advertise
* sandbox controls honestly.
*
* @returns the configured default mode of a sandboxing executor;
* `undefined` for an executor that never confines.
*/
@@ -125,17 +95,9 @@ export abstract class BashExecutor extends Service {
abstract get(id: BashTaskId): BashTask | undefined
/**
* The opaque OWNER token recorded for a background task at {@link start}
* (from the {@link BashExecSpec}'s `owner`), or `undefined` for an unknown id
* OR a known-but-ownerless task. The executor stores and returns the token
* verbatim — it never interprets it; the access POLICY (who may read/kill a
* task) lives in the consumer (`@deepseek-ai/dsh-tool-bash`), which compares
* `ownerOf(id)` to the caller's token. Collapsing unknown-id and
* known-but-unowned into the same `undefined` is fine: the consumer's access
* gate treats `undefined` as "open", and a genuinely unknown id then fails
* loudly at the subsequent {@link readOutput}/{@link kill} ("unknown task").
* Storing ownership in the executor (disposed with ITS fiber) — not in the
* tool plugin — is what makes ownership survive a `tool-bash` HMR reload.
* The opaque OWNER token recorded for a background task at {@link start} (from the {@link
* BashExecSpec}'s `owner`), or `undefined` for an unknown id OR a known-but-ownerless task.
*
* @param id - the background task id to look up ownership for.
* @returns the token recorded at start, verbatim; undefined for an unknown
* id or a known-but-ownerless task.

View File

@@ -1,17 +1,5 @@
/**
* Per-session sandbox-mode override: the session log as the store. A runtime
* switch (an ACP `session/set_config_option`, a test scenario) is recorded as
* one `bash/sandbox-mode` event on the session it applies to;
* `effective = fold(events) ?? the executor's configured default`, so an
* override survives restart by replay, two sessions can never see each
* other's state, and there is no external config store. The event is
* log-only (the `approval/*` precedent): the model learns the mode from the
* prompt section and the boundary notices in `@deepseek-ai/dsh-tool-bash`,
* never from the event itself. EXECUTION honors the fold in the tool layer —
* it stamps the effective mode onto each call's `BashExecRequest.sandboxMode`
* (weakest-precedence: an escalation grant for the call outranks it) — the
* executor itself stays a config-fixed default plus per-call overrides.
*
* Per-session sandbox-mode override: the session log as the store.
* @module dsh-bash/session-mode
*/

View File

@@ -125,17 +125,8 @@ export interface BashExecRequest {
*/
owner?: OwnerToken | undefined
/**
* Explicit per-call sandbox-policy input, overriding the executor's
* configured default mode for THIS call. Never a silent default: a
* consumer sets it only from an explicit policy source — an
* `'allowed-once'` grant a human just issued through `ctx.approval` (the
* escalation flow in the sandbox RFC § Escalation, which outranks), or the
* session's standing override folded from its own `bash/sandbox-mode`
* events (the sandbox RFC § Per-session mode switching — the user's recorded per-session
* choice). A sandboxing executor confines THIS call under the given mode;
* a non-sandboxing executor carries the field and confines nothing (the
* tool layer stamps neither escalation nor overrides without a sandboxing
* executor — see {@link BashExecutor.sandboxMode}).
* Explicit per-call sandbox-policy input, overriding the executor's configured default mode
* for this call.
*/
sandboxMode?: SandboxMode | undefined
}

View File

@@ -38,7 +38,7 @@ The owning agent's session token (`session.header.id`) is stamped onto the task
## UI presentation
These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam, each returning a `card`-tagged render intent — a UI never special-cases tool names. A FOREGROUND `bash` run declares a **terminal card**: `presentCall` returns `{ card: 'terminal', title, description?, cwd? }` — the **title** is the exact `command` ("ls -la src"), the model-written `description` rides along (rendered ABOVE the card), and `cwd` comes from the model `workdir` when given (absolute as-is, relative for the UI bridge to resolve against the session cwd; else left for the bridge to fill from the session cwd) — and `presentResult` returns `{ card: 'terminal', title?, output?, exitCode?, signal? }` carrying the raw output plus the parsed `exitCode`/`signal`, so a capable client (Zed) renders a terminal card with an exit-status pill. The result carries the raw `output`; the bridge DERIVES the ` ```console ` fenced fallback for a no-terminal-capability UI (the tool no longer encodes the fences itself), so the model-facing result text stays unfenced. A `run_in_background` call is NOT a terminal (it returns a task id immediately and never streams a terminal — poll with `bash_output`) and instead returns a **generic card** (`{ card: 'generic', title, kind: 'execute', rawInput: command, content: [description] }`); an `isError` result (spawn failure / abort) likewise returns a `generic` result view with no exit pill (there is no real process exit). `bash_output`/`bash_kill` return a `generic` card with a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") and the task id as rawInput. These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/core/tools` ("Tool-owned UI presentation") and `packages/ui/acp` ("Terminal card" / "Tool-call presentation").
UI presentation is tool-owned through `presentCall` and `presentResult`. Foreground `bash` uses a terminal card whose title is the command, optional description is separate, and cwd follows `workdir` or the session; its result carries raw output and exit or signal data. Background runs, spawn failures, `bash_output`, and `bash_kill` use generic cards. Presenters are pure and replay-safe, and malformed older arguments fall back to generic rendering. See [`dsh-tools`](../../core/tools/) and [`dsh-acp`](../../ui/acp/) for card semantics.
## Background completion notices
@@ -52,7 +52,7 @@ The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks
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).
On top of a denial sits the escalation gate ([the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): an escalating call (`sandbox_permissions` + `justification`) resolves [`ctx.approval`](../../ui/user-approval/README.md) BEFORE anything executes — `allowed-once` stamps the granted mode onto the bash request as the seam-level `sandboxMode` override (that one call runs, classifies, and reports under the wider mode; its neighbors keep the session's effective mode), while `rejected`/`cancelled`/`unavailable` and the no-service / no-agent paths each fail closed with their own error text and execute nothing. The seam is consumed opportunistically (`ctx.get('approval')`, the dsh-tools ask-routing pattern); the grant is consumed by the very call that asked, and nothing is stored. The static description teaches — and a denied result itself prompts, via the escalation-available marker appended exactly when the fields are advertised — the SAME-TURN flow: on a denial a wider mode would cure, retry the exact command once with `sandbox_permissions` (the narrowest mode that suffices) + `justification` immediately, without detouring through chat (the approval prompt IS the user's consent); never speculatively — an escalation is grounded in a real denial (up-front only when the session already denied the same access), a prompt-stated approvals-disabled policy turns the exception off entirely, and a rejected escalation is final for that command.
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.
## Per-session mode switching

View File

@@ -1,57 +1,8 @@
/**
* The model-facing bash tools: `bash`, `bash_output`, `bash_kill`. Pure
* schema + text shaping — every process concern lives behind the `ctx.bash`
* executor seam (`@deepseek-ai/dsh-bash`), so sandbox/permission/remote
* executor implementations swap in without touching what the model sees.
*
* Background notifications: when a background task completes, a short notice
* is injected into the owning agent's session (`agent.inject()` — the
* documented context seam). Injection is durable context for the NEXT model
* request, not a wake-up: an idle agent stays idle until something sends a
* message, which is why the tool descriptions tell the model to poll with
* `bash_output`.
*
* Task ownership: a background task's OWNER is an opaque token — the owning
* agent's `session.header.id` — passed to the executor at spawn
* (`resolve({ …, owner })`) and stored ON THE TASK inside the executor
* (`@deepseek-ai/dsh-bash`'s `ownerOf(id)` seam), NOT in a plugin-local map.
* `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token
* and reject a task owned by a DIFFERENT session (`owner !== undefined && owner
* !== caller`); an unowned task (no token — started by a non-agent caller) is
* open to anyone. Task ids are global and predictable (`bash-1`, …); under
* multi-session ACP (RFC 011) this token check is the fence that stops one
* session's agent from reading or killing another session's background task.
*
* Storing the token on the task in the EXECUTOR (disposed with the `dsh-bash`
* fiber), rather than in this plugin, is what makes ownership survive a
* `tool-bash` HMR reload — a reload that reset a plugin-local map would orphan
* a task spawned before it. (The `onTaskDone` listener is still effect-scoped
* to this plugin's `apply`, so a
* completion landing during the reload gap still drops its one notice — the
* pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
*
* Commands run with the executor's full authority unless a sandboxing
* executor (`@deepseek-ai/dsh-bash-sandbox`) confines them; per-call
* allow/deny/ask policy is the `tools/pre-execute` waterfall — see
* docs/architecture.md § Extension And Composition. Under a sandboxing
* executor this plugin also advertises the ESCALATION surface
* (`sandbox_permissions`/`justification` — the sandbox RFC § Escalation,
* docs/rfc/implemented/feature/2026-07-06-sandbox.md): a command the
* sandbox denied may be retried once under a strictly wider mode, resolved
* through `ctx.approval` BEFORE anything executes and failing closed on every
* unanswerable path. The fields exist only when the mounted executor reports
* a confining default (`ctx.bash.sandboxMode`) — a lever is never advertised
* that the composition cannot honor.
*
* Per-session mode switching (the sandbox RFC § Per-session mode switching): a session may carry a
* standing sandbox-mode override — the `bash/sandbox-mode` event fold from
* `@deepseek-ai/dsh-bash` — which this plugin makes real at EXECUTION: each
* call is stamped `escalation grant > session override > executor default`.
* The prompt deliberately does NOT state the mode and no switch is narrated:
* the model learns the boundary from the denial marker (which names the mode
* it ran under) exactly when it matters, instead of preemptively refusing
* work a standing declaration would discourage.
*
* The model-facing bash tools: `bash`, `bash_output`, `bash_kill`. Pure schema + text shaping
* — every process concern lives behind the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`),
* so sandbox/permission/remote executor implementations swap in without touching what the
* model sees.
* @module @deepseek-ai/dsh-tool-bash
*/
@@ -154,14 +105,7 @@ const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access']
/**
* The bash tool's static description. The base text is byte-stable regardless
* of composition (it is part of the pinned snapshot header); the escalation
* teaching rides only when the mounted executor actually honors the fields —
* it names the ONE sanctioned exception to the base text's "do not retry
* another way" rule. Its deference clause ("If the session states approval
* prompts are disabled…") points at the approval plugin's never-policy prompt
* sentence by meaning, not by parsed wording — a rendezvous kept working by
* that sentence continuing to open with the approvals-disabled claim.
* The bash tool's static description.
*/
function bashDescription(escalationModes: readonly SandboxMode[]): string {
const base = 'Execute a bash command (`bash -c`) and return its stdout/stderr. '
@@ -192,15 +136,14 @@ function streamText(output: CollectedOutput): string {
}
/**
* Shape one finished run into the text the model sees: stdout, then a marked
* stderr section, then exit-status markers. Non-zero exits are REPORTED, not
* errored — the model decides how to react; only infrastructure failures
* (spawn errors, aborts) surface as isError results.
* Shape one finished run into the text the model sees: stdout, then a marked stderr
* section, then exit-status markers.
*
* @param result - the completed foreground run from the executor.
* @param escalationModes - the escalation targets this composition advertises;
* non-empty adds the same-turn escalation hint after a denial marker
* (default `[]`: no hint).
* @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
* @param escalationModes - the escalation targets this composition advertises; non-empty
* adds the same-turn escalation hint after a denial marker (default `[]`: no hint).
* @returns the model-facing text: output body (or `(no output)`), then any
* timeout/signal/exit markers, each on its own line.
*/
export function renderResult(
result: BashRunResult,
@@ -247,33 +190,10 @@ export function renderResult(
return body + markers.join('\n')
}
// ---------------------------------------------------------------------------
// UI presentation (tool-owned). These shape how a UI (e.g. the ACP bridge)
// renders a bash call's pending and completed states. They are display-only and
// pure — a UI may call them during live streaming AND a session-log replay.
// ---------------------------------------------------------------------------
// UI presentation (tool-owned).
/**
* Pending-state presentation for a `bash` call. The TITLE is the exact `command`
* — a `kind: 'execute'` card is rendered as a terminal whose header label IS the
* title, and an execute-kind card HIDES `rawInput` (Zed: `should_show_raw_input
* = !is_terminal_tool`), so the command must BE the title to be seen. This
* mirrors the reference ACP adapters (claude-agent-acp, codex-acp), which both
* use the bare command as an execute tool's title. The model-written
* `description` (a readable summary) rides as a `content` text block shown ABOVE
* the card. (Note: claude-agent-acp DROPS the description in terminal mode and
* shows only the card; surfacing it as a content block is a deliberate
* divergence here — we keep the human summary visible alongside the card.)
* `rawInput` still carries the bare command for non-execute UIs that DO render it.
*
* `terminal` marks the call so a capable UI renders a TERMINAL card — but ONLY a
* FOREGROUND run is a terminal: a `run_in_background` call returns a task id
* immediately (it never streams a terminal; its output is polled via
* `bash_output`), so it is NOT marked terminal and renders as an ordinary
* execute card. For a foreground run the `terminal.cwd` (header) is the model
* `workdir` when given — ABSOLUTE as-is, RELATIVE for the UI bridge to resolve
* against the session cwd; when omitted the bridge fills the session workspace
* cwd (this PURE presenter, args only, can't see it).
* Pending-state presentation for a `bash` call.
*/
type BashCallArgs = { command: string; description: string; workdir?: string; run_in_background?: boolean }
@@ -300,26 +220,7 @@ function presentBashCall(args: BashCallArgs): GenericCallView | TerminalCallView
}
/**
* Completed-state presentation for a `bash` call. Two parallel renderings of the
* same output: `terminal.output` for a UI that shows a terminal card (the run's
* stdout/stderr + status markers, exactly as the model sees them — the RAW text,
* newlines preserved, since a terminal renderer relies on exact bytes), and a
* fenced ```console `content` block as the fallback for a UI without terminal
* support (the fences are a UI-only affordance, so they live here, not in the
* model-facing result; the fenced body is trimmed of trailing blank lines for a
* tidy block). A capable UI also gets an exit-status pill from `terminal.exitCode`
* / `terminal.signal`, parsed from the status markers `renderResult` appended.
*
* Terminal output/exit is suppressed for results that are NOT a finished
* foreground run: a `run_in_background` start (`isBackground` — the text is a
* task-id ack, not a streamed run) and an `isError` result (a spawn failure or
* abort — there is no real process exit to pill, and the body is an error
* message, not `renderResult` output, so parsing it would be meaningless). Those
* return a `generic` result whose content is the fenced ```console block. A
* finished foreground run returns a `terminal` result carrying the RAW output
* and the parsed exit status; the BRIDGE derives the fenced fallback from
* `output` for a UI without terminal support, so the tool does not double-encode
* it. A non-text result (unexpected for bash) falls through to `undefined`.
* Completed-state presentation for a `bash` call.
*/
function presentBashResult(args: unknown, result: ToolResult): ToolResultView | undefined {
const block = result.content.length === 1 ? result.content[0] : undefined
@@ -337,29 +238,8 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView |
}
/**
* Recover the structured exit status from a rendered `renderResult` string — the
* inverse of the status markers it appends. A `[killed by signal: SIG]` marker
* yields `{signal}`; otherwise an `[exit code: N]` marker yields `{exitCode:N}`;
* absent both we report `{exitCode:0}` (a clean run appends no marker — and a
* trapped-timeout run that exits 0 also has none and is accurately exit 0).
*
* Why parse rendered text at all: `presentResult` is replay-safe and on a
* `session/load` the ONLY thing persisted is this content text — the structured
* `BashRunResult` is long gone — so unless the exit were added to the persisted
* event schema (deliberately NOT done; see the terminal-rendering RFC), parsing
* is the only channel. The match is anchored to a LEADING newline + end-of-string
* because `renderResult` always inserts a `\n` before the marker (line ~124) onto
* a non-empty body: a real marker is therefore always its own final line. That
* defeats the common spoof (program output that simply ENDS in `[exit code: 5]`
* with no trailing newline — a clean exit 0 — no longer reads as a failure).
*
* KNOWN RESIDUAL (inherent to the replay-only-sees-text design): a clean exit 0
* whose body's FINAL line is itself exactly the marker text — `[exit code: N]`
* or `[killed by signal: SIG]`, printed by the program with nothing after — is
* still indistinguishable from a real marker and would show a wrong pill. This is
* display-only (execution and the model-facing text are unaffected) and narrow;
* the complete fix is to persist a structured exit on the result event, which the
* RFC names as the escape hatch.
* Recover the structured exit status from a rendered `renderResult` string — the inverse of
* the status markers it appends.
*/
function parseExitStatus(text: string): { exitCode: number } | { signal: string } {
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
@@ -375,15 +255,7 @@ function presentTaskCall(verb: string, args: { task_id: string }): GenericCallVi
}
/**
* Resolve the working directory for a bash call. Precedence: an explicit model
* `workdir` wins; otherwise default to the calling agent's session cwd
* (`session.header.cwd`) so each ACP session's commands run in ITS workspace,
* not the server's launch dir. A RELATIVE model `workdir` is resolved against
* the session cwd (the tool tells the model to pass `workdir` instead of `cd`,
* so a relative one should be relative to the session's root, not `process.cwd()`).
* Returns `undefined` when neither is available (no agent / headerless session /
* no session cwd) — the executor then applies its own config/`process.cwd()`
* default, preserving today's non-ACP behavior.
* Resolve the working directory for a bash call.
*/
function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent }): string | undefined {
const sessionCwd = exec.agent?.session.header.cwd
@@ -443,14 +315,6 @@ export function apply(ctx: Context): void {
}
// Background completion → inject a notice into the owning agent's session.
// Find the live agent by its session id token via the agent registry, read
// opportunistically with `ctx.get('agents')` (NOT `ctx.agents`/static inject):
// this listener runs from `task.done.then` on the bash fiber — a foreign
// fiber — where the `ctx.agents` property proxy would throw through the
// traceable shadow; `ctx.get(name)` is the topology-independent lookup. No
// registry mounted (`undefined`) → drop the notice. Match on
// `agent.session.header.id`, NOT the registry key: a config agent's id differs
// from its session id, and the owner token IS the session id.
ctx.bash.onTaskDone((task) => {
const ownerToken = ctx.bash.ownerOf(task.id)
if (ownerToken === undefined) return
@@ -462,21 +326,14 @@ export function apply(ctx: Context): void {
{ source: { kind: 'plugin', plugin: 'tool-bash' } },
)
} catch (error: unknown) {
// The ONE expected failure: the agent was disposed between task
// completion and this injection (ReactLoopAgent.inject throws
// `agent "<id>" is disposed`). That race is benign — drop the notice.
// Anything else is a real bug and must surface, not be swallowed.
// The one expected failure: the agent was disposed between task completion and this
// injection (ReactLoopAgent.inject throws `agent "<id>" is disposed`).
if (error instanceof Error && error.message.includes('is disposed')) return
throw error
}
})
// The escalation surface exists whenever the mounted executor confines.
// Its enum is the closed target vocabulary, deliberately NOT cut down by
// the configured default: a session may switch to a narrower effective mode
// while sharing this globally registered schema. Strict widening therefore
// belongs to the per-call check below. An executor swap restarts this fiber
// (static inject) and re-registers the schema.
const defaultMode = ctx.bash.sandboxMode
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
@@ -504,20 +361,13 @@ export function apply(ctx: Context): void {
* deployment without it degrades per call, never at registration.
*/
const approveEscalation = async (mode: string, justification: string, exec: ToolExecution): Promise<SandboxMode> => {
// Schema validation only checks ADVERTISED keys, so an unadvertised
// `sandbox_permissions` (no sandboxing executor) still reaches execute — reject it here so a
// human is never prompted to "escalate" a sandbox that is not there. When
// the fields ARE advertised, the registry's SchemaSpec enum has already
// pinned `mode` to this ladder for every caller.
// Schema validation only checks ADVERTISED keys, so an unadvertised `sandbox_permissions`
// (no sandboxing executor) still reaches execute — reject it here so a human is never
// prompted to "escalate" a sandbox that is not there.
if (escalationModes.length === 0) {
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
}
// Strict widening is an EXECUTION check against the call's effective
// mode — session override ?? executor default, the same fold ordinary
// calls are stamped with — deliberately not a schema constraint (the
// enum is the closed target vocabulary; the effective mode is per-call
// truth). A non-widening request fails closed here and never prompts a
// human.
// Reject sandbox widening against the call's effective mode before requesting approval.
const effectiveMode = (sessionOverride(exec) ?? defaultMode) as SandboxMode
if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) {
throw new Error(`sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`)
@@ -580,14 +430,9 @@ export function apply(ctx: Context): void {
},
async execute(args: BashToolArgs, exec) {
validateBashArgs(args)
// `description` is display/logging metadata only (surfaced to UIs via
// the tool/call session event); it is intentionally NOT forwarded to
// ctx.bash and has no effect on execution.
// An escalating call resolves approval BEFORE anything executes; every
// non-grant outcome throws its distinct error text and runs nothing.
// (validateBashArgs pinned the pairing, so the double narrow is exact.)
// An ordinary call carries the session's standing override instead —
// grant > session override > executor default (see sessionOverride).
// `description` is display/logging metadata only (surfaced to UIs via the tool/call
// session event); it is intentionally not forwarded to ctx.bash and has no effect on
// execution.
const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined
? await approveEscalation(args.sandbox_permissions, args.justification, exec)
: sessionOverride(exec)
@@ -603,10 +448,8 @@ export function apply(ctx: Context): void {
...sandboxMode !== undefined ? { sandboxMode } : {},
}
if (args.run_in_background === true) {
// Stamp the owner token (the agent's session id) onto the spec so the
// executor stores it on the task — the isolation fence for bash_output/
// bash_kill. Foreground runs pass no owner (they finish inline; nothing
// to fence).
// Stamp the owner token (the agent's session id) onto the spec so the executor stores
// it on the task — the isolation fence for bash_output/ bash_kill.
const task = ctx.bash.start(ctx.bash.resolve({ ...request, owner: callerToken(exec) }))
return [{ type: 'text', text: `started background task ${task.id}` }]
}
@@ -645,10 +488,7 @@ export function apply(ctx: Context): void {
// error; a settled task's read carries the marker instead.
text += `\n[sandbox: the sandbox runner itself failed under ${read.task.sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`
} else if (read.task.sandbox?.denied) {
// Mirrors the foreground result marker (and its same-turn escalation
// hint). Background denials are only classifiable once the task
// settles (the classifier needs the whole stderr), so the marker
// rides every read that sees the settled task.
// Mirrors the foreground result marker (and its same-turn escalation hint).
text += `\n[sandbox: file access denied under ${read.task.sandbox.mode} mode]`
if (escalationModes.length > 0) {
text += '\n[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]'

View File

@@ -56,12 +56,7 @@ async function setup() {
*/
const fakeAgentDisposers = new Map<Context, (() => Promise<void> | void)[]>()
function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void): Agent {
// The registry KEY (agent.id) is deliberately DIFFERENT from the session
// token (session.header.id) — a config agent has `agentId !== sessionId`. The
// owner token IS the session id, so the notice path must find the agent by
// `session.header.id`, NOT the registry key. Using distinct values here makes
// the test fail if a regression matched on the wrong field (a same-value fake
// would pass either way — the "hits the line but not the scenario" trap).
// Distinct ids ensure notices match the session owner token, not the registry key.
const agent = { id: `agent-${sessionId}`, inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent
const dispose = ctx.agents.register(agent)
const list = fakeAgentDisposers.get(ctx) ?? []
@@ -416,9 +411,8 @@ describe('background tools', () => {
it('injects a completion notice into the owning agent (found via the registry by session token)', async () => {
const ctx = await setup()
const inject = vi.fn()
// The notice path looks the agent up in ctx.agents by its session token, so
// the agent must be REGISTERED (not merely passed to execute). Mount a
// registry and register a fake whose session.header.id IS the owner token.
// The notice path looks the agent up in ctx.agents by its session token, so the agent must
// be REGISTERED (not merely passed to execute).
const agent = registerFakeAgent(ctx, 'bg', inject)
const started = await ctx.tools.execute({
@@ -481,11 +475,9 @@ describe('background tools', () => {
})
it('drops the notice cleanly when the owning agent is gone from the registry by completion', async () => {
// A bash task (owned by the host-scoped bash-local fiber) can OUTLIVE its
// per-session agent — e.g. the ACP session disconnects and its AgentHandle
// disposes while the background task is still running. The owner token is
// still on the task, but no live agent carries it anymore, so the registry
// lookup finds nothing and the notice is dropped (no throw).
// A bash task (owned by the host-scoped bash-local fiber) can OUTLIVE its per-session agent
// — e.g. the ACP session disconnects and its AgentHandle disposes while the background task
// is still running.
const ctx = await setup()
const inject = vi.fn()
const agent = registerFakeAgent(ctx, 'bg', inject)
@@ -515,11 +507,9 @@ describe('background task ownership (cross-session isolation)', () => {
function callAs(ctx: Context, agent: import('@deepseek-ai/dsh-agent').Agent | undefined, name: string, args: unknown) {
return ctx.tools.execute({ callId: CallId(`own-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
}
// Ownership is by TOKEN (session.header.id), NOT agent object identity — so
// each agent needs a DISTINCT session id, else every fake yields the same
// token and the isolation tests pass for the wrong reason (all tasks owned by
// the same token). The impl reads `session.header.id`, so the fakes MUST carry
// it.
// Ownership is by TOKEN (session.header.id), not agent object identity — so each agent needs
// a DISTINCT session id, else every fake yields the same token and the isolation tests pass
// for the wrong reason (all tasks owned by the same token).
const fakeAgent = (sessionId: string) =>
({ inject: () => undefined, session: { header: { version: 0, id: sessionId, createdAt: 0 } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent
@@ -599,11 +589,8 @@ describe('background task ownership (cross-session isolation)', () => {
})
it('ownership SURVIVES an independent tool-bash HMR reload (token lives on the executor)', async () => {
// The owner token lives on the TASK inside the executor (dsh-bash fiber), NOT
// in a tool-bash plugin-local map. So reloading ONLY tool-bash (executor +
// task survive) preserves ownership. This is the regression guard: a
// plugin-local map would make B accessible after reload, and this test would
// catch it.
// The owner token lives on the TASK inside the executor (dsh-bash fiber), not in a
// tool-bash plugin-local map.
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
@@ -822,11 +809,9 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
it('bash presentResult: a clean exit-0 whose output ENDS in marker-like text is NOT read as a failure', async () => {
const ctx = await setup()
const args = { command: 'printf "[exit code: 5]"', description: 'print' }
// A successful command can print text that looks like a marker. renderResult
// for a clean exit 0 appends NOTHING (and no trailing newline), so the body's
// own tail is `[exit code: 5]`. The parse requires a LEADING newline before
// the marker (renderResult always inserts one before a REAL marker), so this
// no-trailing-newline body is NOT mistaken for a failure → exitCode 0.
// A successful command can print text that looks like a marker. renderResult for a clean
// exit 0 appends NOTHING (and no trailing newline), so the body's own tail is `[exit code:
// 5]`.
const out = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false })
expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 })
// Same for a fake signal marker with no leading newline.
@@ -889,26 +874,17 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
it('presentCall validates softly: malformed args (missing required description) return undefined, never throw', async () => {
const ctx = await setup()
// defineTool wraps presentCall to soft-validate against the schema and fall
// back to undefined (a generic UI presentation) rather than throwing on the
// display path — it may run on replay of arbitrary logged args. The
// ToolDefinition.presentCall takes `unknown`, so a malformed shape needs no cast.
// defineTool wraps presentCall to soft-validate against the schema and fall back to
// undefined (a generic UI presentation) rather than throwing on the display path — it may
// run on replay of arbitrary logged args.
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls' })).toBeUndefined()
})
})
describe('the model-facing bash tool builds its request from named args only (no {...args} forward)', () => {
/**
* Records every {@link BashExecRequest} the consumer hands to `resolve()`, so a
* test can assert what the model-facing tool DID and DID NOT forward. The `bash`
* tool does not expose `stdin`/`env` as parameters (bash syntax already gives a
* model that power), so it must build its request from named args only and
* never spread unknown tool-call keys into it. This guard's job is to catch a
* future refactor that blindly forwards `...args` — which would silently thread
* model input into the post-scrub `env` merge — 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()` is
* unused here.
* Records every {@link BashExecRequest} the consumer hands to `resolve()`, so a test can
* assert what the model-facing tool DID and DID NOT forward.
*/
class RecordingBashExecutor extends BashExecutor {
readonly requests: BashExecRequest[] = []
@@ -951,12 +927,7 @@ describe('the model-facing bash tool builds its request from named args only (no
it('does not forward env/stdin even when the model includes them as extra arguments', async () => {
const { ctx, bash } = await setupRecording()
// Extra args: the model includes `env` and `stdin` keys hoping they reach the
// executor. The bash tool's schema ignores unknown keys, and execute() builds
// the request from only command/workdir/timeoutMs/signal — so the recorded
// request carries NEITHER. (Not a security wall — the model could set an env
// var or feed stdin via shell syntax anyway; this just keeps the request
// shape honest so a future `...args` spread can't silently forward input.)
// Extra args: the model includes `env` and `stdin` keys hoping they reach the executor.
await ctx.tools.execute({
callId: CallId('no-forward-1'),
name: 'bash',
@@ -1447,11 +1418,8 @@ describe('per-session sandbox mode (the bash/sandbox-mode fold)', () => {
})
it('escalates relative to the session effective mode, not the executor default (narrower override)', async () => {
// The blocker scenario: a workspace-write default with a read-only
// override — the sensible escalation is workspace-write, which a
// default-relative ladder could not even express. The static target
// vocabulary advertises it and the execution check accepts it as
// strictly wider than the CALL's effective (overridden) mode.
// The blocker scenario: a workspace-write default with a read-only override — the sensible
// escalation is workspace-write, which a default-relative ladder could not even express.
const ctx = await setupModal('workspace-write', { approval: true })
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
const seen: (string | undefined)[] = []

View File

@@ -1,12 +1,7 @@
/**
* Worker-side execution logic, written as plain functions over an injected
* port so the unit suite can run every line IN-PROCESS against a fake port
* (a real worker thread is a separate V8 isolate the coverage provider
* cannot observe). The real worker entry (`worker.ts`) is a thin
* self-executing glue file over {@link runWorkerMain}, excluded from
* coverage the same way `bin.ts` entrypoints are, and exercised end-to-end
* by the integration tests that spawn real workers.
*
* Worker-side execution logic, written as plain functions over an injected port so the unit
* suite can run every line IN-PROCESS against a fake port (a real worker thread is a separate
* V8 isolate the coverage provider cannot observe).
* @module @deepseek-ai/dsh-code-runtime-worker/src/bootstrap
*/
@@ -96,12 +91,9 @@ export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS)
/**
* Redirect a stream's `write` into the log buffer (the program-visible
* `process.stdout`/`process.stderr` in the real worker), so raw writes land
* in emission order alongside console output instead of racing down a pipe.
* The shim keeps Node's `write(chunk[, encoding][, callback])` contract: the
* callback fires asynchronously once the chunk is admitted (a program
* awaiting flush completion must complete, not sit until the wall timeout),
* even for writes the exhausted budget drops.
* `process.stdout`/`process.stderr` in the real worker), so raw writes land in emission order
* alongside console output instead of racing down a pipe.
*
* @param logs - the buffer captured writes are pushed into.
* @param stream - the stream whose `write` slot is patched.
* @param source - the log source the captured writes are attributed to.
@@ -152,16 +144,11 @@ export function truncateUtf8Bytes(text: string, maxBytes: number): string {
}
/**
* Prepare the program's completion value for the done message: a value whose
* MEASURED cross-boundary size fits `maxValueBytes` crosses raw — exact
* bytes for a string, the structured-clone wire size (`v8.serialize`) for
* everything else, so a huge container whose BOUNDED inspect rendering
* happens to be small cannot smuggle itself past the cap. Anything else
* (non-cloneable, or oversized) is REPLACED by its bounded `util.inspect`
* rendering, byte-truncated ({@link truncateUtf8Bytes}) with an in-band
* marker — the seam contract's "a non-transferable value is replaced by a
* string rendering", extended to oversized ones so a huge return cannot
* flood the host.
* Prepare the program's completion value for the done message: a value whose MEASURED
* cross-boundary size fits `maxValueBytes` crosses raw — exact bytes for a string, the
* structured-clone wire size (`v8.serialize`) for everything else, so a huge container whose
* BOUNDED inspect rendering happens to be small cannot smuggle itself past the cap.
*
* @param value - the program's completion value.
* @param maxValueBytes - the byte cap for the value.
* @returns the done-message fragment: `{}` for `undefined`, else `{ value }`.
@@ -215,13 +202,10 @@ export function wireReplies(port: BootstrapPort, pending: Map<number, PendingCal
}
/**
* Build the binding namespace objects the program sees: one null-prototype
* global per namespace, each declared name an own enumerable async function
* that bridges over the port (`__proto__`/`constructor`/`toString` are
* ordinary keys, never prototype collisions). A non-cloneable argument
* rejects that one call with a descriptive error; the host's reply (`ok`
* false) rejects it likewise, so a failed tool call surfaces in the program
* as an ordinary promise rejection.
* Build the binding namespace objects the program sees: one null-prototype global per
* namespace, each declared name an own enumerable async function that bridges over the port
* (`__proto__`/`constructor`/`toString` are ordinary keys, never prototype collisions).
*
* @param data - the boot payload's namespace declarations (globals + names).
* @param port - the port binding calls are posted to.
* @param pending - the id-keyed map each posted call parks its handles in.
@@ -256,17 +240,11 @@ export function makeNamespaces(
}
/**
* Run one program to settlement and post the {@link DoneMessage}: wires the
* reply handler, materializes the namespaces and console shim, compiles the
* type-stripped body as an async function (top-level `await`/`return`
* work), and reports a thrown program error as the done message's `error`
* field. Exactly one done message is ever posted.
* @param port - the message port to the host (the real `parentPort`, or the tests' fake).
* Run one program and post its terminal {@link DoneMessage}.
* @param port - host message port or test double.
* @param data - the boot payload the host sent.
* @param streams - the stream objects whose `write` is captured (the real
* `process.stdout`/`process.stderr` in the worker; fakes in tests).
* @returns resolves after the done message is posted (the tests await it;
* the real entry lets the worker exit naturally).
* @param streams - stdout/stderr objects captured as program logs.
* @returns after posting the done message.
*/
export async function runWorkerMain(
port: BootstrapPort,

View File

@@ -1,14 +1,7 @@
/**
* Worker-thread implementation of the code-execution seam: one fresh Node
* worker per run, executing the model's TypeScript after a host-side
* type-strip, with bindings bridged over the message port. Containment, not
* a security boundary (bash-equivalent trust — see the Code Mode RFC's
* trust-posture section): the worker gets an EMPTY environment, a heap cap,
* and two independent budgets — `computeMs` metered on the worker's
* measured event-loop busy time (a hot loop cannot hide behind a pending
* binding call) and a never-pausing `maxWallMs` ceiling — all funneling
* into `worker.terminate()`, which ends hot synchronous loops too.
*
* Worker-thread implementation of the code-execution seam: one fresh Node worker per run,
* executing the model's TypeScript after a host-side type-strip, with bindings bridged over
* the message port.
* @module @deepseek-ai/dsh-code-runtime-worker
*/
@@ -278,11 +271,9 @@ export class WorkerCodeRuntime extends CodeRuntime {
// Model code gets NO ambient environment — stronger than the scrubbed
// env the defensive-patterns rule requires for spawned commands.
env: {},
// Hermetic flags too: without this the worker inherits the host
// process's execArgv (a test runner's or tsx's loader hooks), which a
// bare isolate with an empty environment cannot satisfy. The entry
// needs nothing beyond native type stripping, on this repo's whole
// Node range.
// Hermetic flags too: without this the worker inherits the host process's execArgv (a
// test runner's or tsx's loader hooks), which a bare isolate with an empty environment
// cannot satisfy.
execArgv: [],
resourceLimits: { maxOldGenerationSizeMb: this.config.maxOldGenerationSizeMb },
// Backstop capture: the bootstrap patches JS-level writes into its own
@@ -298,12 +289,9 @@ export class WorkerCodeRuntime extends CodeRuntime {
const logs: CodeLogEntry[] = []
const strayLogs: CodeLogEntry[] = []
// ONE host-side ledger for everything that lands in `logs`/`strayLogs`,
// whatever the path: honest port entries, FORGED port entries (model
// code posting `log` messages directly, bypassing the worker-side
// LogBuffer), and stray pipe bytes. On the first overflow it emits the
// same in-band marker the worker's LogBuffer would and drops the rest,
// so the documented cap is one shared `maxLogBytes` however it is hit.
// one host-side ledger for everything that lands in `logs`/`strayLogs`, whatever the
// path: honest port entries, FORGED port entries (model code posting `log` messages
// directly, bypassing the worker-side LogBuffer), and stray pipe bytes.
let logBudget = this.config.maxLogBytes
let logsTruncated = false
const admit = (entry: CodeLogEntry, sink: CodeLogEntry[]): void => {
@@ -327,11 +315,9 @@ export class WorkerCodeRuntime extends CodeRuntime {
worker.stdout.on('data', captureStray('stdout'))
worker.stderr.on('data', captureStray('stderr'))
// Settlement: exactly one outcome wins; every path funnels through
// here, cleans up the timers/listeners, terminates the worker, and
// resolves only after the worker actually exited (quiescence). Logs
// streamed eagerly before the settlement are kept — a timed-out or
// killed program still shows the model what it printed.
// Settlement: exactly one outcome wins; every path funnels through here, cleans up the
// timers/listeners, terminates the worker, and resolves only after the worker actually
// exited (quiescence).
let finishResolve!: () => void
const finished = new Promise<void>((done) => { finishResolve = done })
const finish = (result: Omit<CodeRunResult, 'logs'>): void => {
@@ -349,11 +335,9 @@ export class WorkerCodeRuntime extends CodeRuntime {
const onDone = (message: WorkerToHost): void => {
if (message.type !== 'done') return
// Re-cap the completion value HOST-side: the honest path already
// capped it in the worker (prepareValue there), but a forged done
// message bypasses the bootstrap entirely — without this, model code
// could flood the host past maxValueBytes. Honest values pass
// unchanged (see VALUE_RENDER_SLACK); the error text is bounded too.
// Re-cap the completion value HOST-side: the honest path already capped it in the
// worker (prepareValue there), but a forged done message bypasses the bootstrap
// entirely — without this, model code could flood the host past maxValueBytes.
finish({
...prepareValue(message.value, this.config.maxValueBytes + VALUE_RENDER_SLACK),
...message.error ? { error: { kind: 'exception' as const, message: truncateUtf8Bytes(message.error.message, this.config.maxValueBytes) } } : {},

View File

@@ -1,11 +1,5 @@
/**
* Wire protocol between the host runtime and the worker bootstrap. Everything
* crossing the message port is structured-clone-plain and versionless — both
* ends ship in this package, always at the same version. The host treats
* inbound traffic as HOSTILE (the worker runs model code, which can reach
* `parentPort` via `import('node:worker_threads')` and forge any of these
* shapes); the worker treats inbound traffic as trusted.
*
* Wire protocol between the host runtime and the worker bootstrap.
* @module @deepseek-ai/dsh-code-runtime-worker/src/protocol
*/

View File

@@ -1,12 +1,6 @@
/**
* The worker-thread entrypoint: self-executing glue over
* `bootstrap.ts`'s {@link runWorkerMain}, kept to the spawn wiring alone.
* Like `bin.ts` CLI entrypoints, this file executes only inside a spawned
* worker isolate — a place the coverage provider cannot observe — so it is
* excluded from the coverage gate while every line of actual logic lives in
* `bootstrap.ts`, unit-tested in-process; the real spawn path is pinned by
* the integration tests that run genuine workers.
*
* The worker-thread entrypoint: self-executing glue over `bootstrap.ts`'s {@link
* runWorkerMain}, kept to the spawn wiring alone.
* @module @deepseek-ai/dsh-code-runtime-worker/src/worker
*/

View File

@@ -5,19 +5,10 @@ import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
/**
* BUILT-ARTIFACT smoke for the published package (the real-load-path guard
* from docs/testing.md): the unit suite runs `src/` under vitest, where the
* worker entry resolves to `src/worker.ts` — a consumer runs `lib/index.js`
* under plain `node`, where it must resolve the sibling `lib/worker.js`
* bundle instead. This spawns plain `node` (NOT tsx) from inside the package
* directory and imports the package BY NAME, so resolution flows through the
* real `exports` map exactly as it would from a downstream install; the
* program exercises the type-strip, the worker spawn, the binding bridge,
* and log capture end-to-end through the built bundles.
*
* It build-gates: SKIPS when the built artifacts are absent (suite run
* without `pnpm run build`); CI runs it after the build step. KEYLESS — no
* model is involved.
* Built-ARTIFACT smoke for the published package (the real-load-path guard from
* docs/testing.md): the unit suite runs `src/` under vitest, where the worker entry resolves
* to `src/worker.ts` — a consumer runs `lib/index.js` under plain `node`, where it must
* resolve the sibling `lib/worker.js` bundle instead.
*/
const pkgDir = fileURLToPath(new URL('..', import.meta.url))

View File

@@ -255,10 +255,8 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
it('captures pipe writes that bypass the patched write slot as stray logs, capped by the same budget', async () => {
const { runtime } = await setup({ maxLogBytes: 4 })
const result = await runtime.run({
// The bootstrap patches the stream instance's own `write`; going
// through the prototype's slot reaches the real pipe underneath, so
// the bytes arrive host-side as stray data. The pauses keep the two
// writes in separate pipe chunks and let them land before settlement.
// The bootstrap patches the stream instance's own `write`; going through the prototype's
// slot reaches the real pipe underneath, so the bytes arrive host-side as stray data.
program: `
const write = (text) => Object.getPrototypeOf(process.stdout).write.call(process.stdout, text);
write('abcd');

View File

@@ -1,15 +1,10 @@
import { defineConfig } from 'tsdown'
/**
* Package-shape override (see the root tsdown.config.ts): besides the
* default lib/index.js bundle, the worker BOOTSTRAP ships as its own
* sibling entry — `new Worker(new URL('./worker.js', import.meta.url))`
* loads it as a file, so it cannot be part of the index bundle. TWO
* single-entry builds, not one two-entry build: a multi-entry build emits
* the shared bootstrap module as a `lib/bootstrap-*.js` chunk both bundles
* import, which the package.json `files` whitelist (deliberately exact)
* would omit from the packed artifact — each single-entry build inlines its
* own bootstrap copy instead, keeping every shipped file self-contained.
* Package-shape override (see the root tsdown.config.ts): besides the default lib/index.js
* bundle, the worker BOOTSTRAP ships as its own sibling entry — `new Worker(new
* URL('./worker.js', import.meta.url))` loads it as a file, so it cannot be part of the index
* bundle.
*/
export default defineConfig([
{

View File

@@ -1,18 +1,5 @@
/**
* The code-execution seam (`ctx.codeRuntime`): an abstract service 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. Implementations subclass {@link CodeRuntime} and register
* themselves as the `codeRuntime` service; backends may differ by execution
* substrate (worker thread, separate process, container) and by source
* language, both declared as readonly descriptors. The design and its
* consumer (the tool registry's Code Mode) are specified in the Code Mode RFC
* (docs/rfc/implemented/feature/2026-06-15-code-mode.md).
*
* The split mirrors the bash seam (`BashExecutor`): the runtime knows nothing
* about tools or sessions — it is handed named async functions and a program,
* and everything tool-shaped stays with the consumer.
*
* Code-execution seam for running one model-written program against host bindings.
* @module @deepseek-ai/dsh-code-runtime
*/
@@ -35,26 +22,9 @@ declare module 'cordis' {
}
/**
* Abstract code-execution service. Subclass, implement {@link run} and the
* two descriptors, and load the subclass as a plugin — it registers as
* `ctx.codeRuntime` (one implementation per context; loading a second throws,
* cordis' standard duplicate-service behavior).
*
* Semantics every implementation must honor:
* - {@link run} resolves with an error FIELD for every program outcome —
* parse/transform failures, thrown exceptions, budget expiry, abort,
* substrate death ({@link CodeRunFailure}'s taxonomy). It REJECTS only for
* caller misuse of the seam itself (e.g. a run submitted after disposal).
* - Binding calls bridge to the caller's {@link CodeBindingFunction}s
* verbatim; arguments and resolutions must be structured-cloneable, and the
* runtime treats the program as a hostile peer (arbitrary binding names are
* own properties, malformed traffic is rejected or ignored, never crashes
* the host).
* - Runs are isolated from each other: no state survives from one run to the
* next through the runtime.
* - Disposal reaches quiescence: in-flight runs are terminated AND awaited
* before the service's own teardown completes (no orphan substrate survives
* `fiber.dispose()`).
* Abstract code-execution service. Subclass, implement {@link run} and the two descriptors,
* and load the subclass as a plugin — it registers as `ctx.codeRuntime` (one implementation
* per context; loading a second throws, cordis' standard duplicate-service behavior).
*/
export abstract class CodeRuntime extends Service {
/**

View File

@@ -1,31 +1,6 @@
/**
* `BasicCompactService`: the first implementation of the
* `@deepseek-ai/dsh-compact` seam. It owns the entire compaction strategy:
*
* - **Token estimation** — chars/`charsPerToken` heuristic (config, default 4)
* with per-block structural overhead.
* - **Retention policy** — walk surface nodes tail→head, keep recent nodes up
* to a token budget, compact everything older. The cutoff is snapped forward
* to the next balanced tool-pairing boundary so a compacted region never
* splits a step's tool-call/result pair (an open tail step is never crossed —
* compaction declines and retries once it closes).
* - **Summarization** — a direct one-shot `ctx.llm.stream()` call assembled
* via `BlockAssembler` with a fixed condense-the-history system prompt;
* NOT a loop step, so `agent/request` never fires — interception happens
* at `llm/stream` like any other direct call.
* - **Surface mutation** — a single `user/message` replace node carries the
* summary; `compact/*` events are log-only lock + provenance records.
* - **Auto-compaction** — an `agent/pre-step` listener delegates to
* {@link BasicCompactService.compactIfNeeded} before EVERY step (so a
* tool-heavy turn that grows the surface mid-turn still compacts); it owns the
* sole token-pressure check.
*
* A different backend (real tokenizer, template summarizer, turn-count
* retention) either subclasses this and overrides the {@link
* BasicCompactService.estimateContentTokens} / {@link
* BasicCompactService.summarize} hooks, or implements the abstract
* {@link CompactService} from scratch.
*
* `BasicCompactService`: the first implementation of the `@deepseek-ai/dsh-compact` seam. It
* owns the entire compaction strategy.
* @module @deepseek-ai/dsh-compact-basic
*/
@@ -54,15 +29,8 @@ const SUMMARY_OPEN_TAG = '<compacted-summary>'
const SUMMARY_CLOSE_TAG = '</compacted-summary>'
/**
* The summarization system prompt: instructs the model to condense the
* conversation into a fixed, fully-populated structure rather than freeform
* bullets. The fixed structure guarantees coverage of the things a resuming
* model needs (original intent, pending work, the next step, critical context)
* and is stable across compaction cycles, so a prior checkpoint can be merged
* in place. The final rule keys off {@link SUMMARY_OPEN_TAG}: when the
* transcript already contains a prior checkpoint, the model consolidates rather
* than re-summarizing it verbatim (a cheap incremental-merge that needs no
* extra log/event machinery — the tag travels on the summary surface node).
* The summarization system prompt: instructs the model to condense the conversation into a
* fixed, fully-populated structure rather than freeform bullets.
*/
const SUMMARIZE_SYSTEM_PROMPT = [
'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.',
@@ -100,29 +68,13 @@ const SUMMARIZE_SYSTEM_PROMPT = [
`- If the transcript already contains a ${SUMMARY_OPEN_TAG} 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.`,
].join('\n')
/**
* Framing prepended to the landed summary so a resuming model reads it as a
* checkpoint rather than a fresh user request, and continues the task from it.
* It summarizes an earlier span of the conversation; the messages that follow
* are the continuation. Because region compaction can be invoked manually, a
* surface may hold several checkpoints, so the framing does NOT claim that
* everything after it is recent or verbatim — only that the captured context
* should be built on, not restated.
*/
/** Framing that makes a landed summary established context rather than a new request. */
const CHECKPOINT_PREAMBLE =
'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.'
/**
* Map a terminal `FinishReason` to the error a SUMMARIZATION must throw, or
* `undefined` for an acceptable finish. `FinishReason` is merge-extensible.
*
* Compaction fails CLOSED on a truncated summary: `error`, `aborted`, AND
* `max-tokens` all raise. Unlike an ordinary agent turn — where `max-tokens` is
* a normal "the model hit its budget" outcome the loop keeps — a summary cut off
* at the token cap is an INCOMPLETE checkpoint, and committing it would shadow
* (discard) the real history it summarizes. Raising here keeps the original
* surface intact (the caller appends `compact/end` with the error and the auto
* path proceeds with full history). `stop`/future kinds are accepted.
* Map a terminal `FinishReason` to the error a SUMMARIZATION must throw, or `undefined` for an
* acceptable finish. `FinishReason` is merge-extensible.
*/
function finishError(finish: FinishReason): Error | undefined {
switch (finish.kind) {
@@ -164,25 +116,7 @@ export class BasicCompactService extends CompactService {
this.config = resolveConfig(config)
if (this.config.auto) {
// Auto-compaction: delegate to compactIfNeeded before EVERY step. This is
// LOAD-BEARING for runaway-turn survival: a tool-heavy ReAct turn appends
// an assistant/message and a tool/result per step, so the surface (and the
// derived token count) grows WITHIN a turn. The only moment to rescue a
// turn that alone approaches the window is the next step's pre-step
// checkpoint; gating to a turn's first step would let a runaway turn
// overflow before the next turn's check. The listener owns NO threshold
// logic — compactIfNeeded is the single place that decides whether to
// compact, and its in-progress lock serializes concurrent attempts.
//
// It runs on `agent/pre-step` (a serial surface-mutation checkpoint fired
// AFTER turn/start but BEFORE step/start), NOT `agent/request`: compaction
// mutates the session surface, and the loop derives the request `messages`
// AFTER this fires — so a single derive already reflects the compaction,
// with no double-derive and no need to rewrite an already-assembled
// `messages` array. Firing pre-step (outside any open step) keeps the
// log-only `compact/*` records and the replacement node cleanly outside a
// step, so a crash mid-compaction leaves an inert orphan the turn-repair
// closes — never a half-open step.
// Auto-compaction: delegate to compactIfNeeded before every step.
ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal) => {
try {
const result = await this.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)
@@ -289,27 +223,8 @@ export class BasicCompactService extends CompactService {
}
/**
* Summarize conversation text into content blocks via `ctx.llm.stream()`
* assembled through a `BlockAssembler`. A direct one-shot model call, NOT a
* loop step: it does not run the `agent/request` waterfall (that seam shapes
* the loop's conversation requests); per-call
* interception happens at `llm/stream` like any other direct call. The model
* comes from `BasicCompactConfig.summarizationModel`, falling back to the
* agent's own model.
* Override in a subclass for a template or remote summarizer.
*
* Honors the adapter failure contract: an adapter may report a model failure
* by throwing from `stream()` (propagated here) OR by ending the stream with
* a `finish {kind:'error'|'aborted'}` chunk — the latter is re-thrown so a
* provider error never yields an empty summary.
*
* Forwards `signal` into `GenerateOptions.signal` so an abort/dispose tears
* down the in-flight summarization rather than orphaning the model call.
*
* Returns the summary blocks TOGETHER with the call envelope it actually
* used (`model`, `maxTokens`) — the caller logs the envelope on the
* `compact/summary` provenance event, so an overriding subclass (template
* or remote summarizer) reports its own envelope honestly.
* Summarize conversation text into content blocks via `ctx.llm.stream()` assembled through a
* `BlockAssembler`.
*
* @param text - plain-text rendering of the conversation region to condense.
* @param agent - supplies the fallback model and the session id stamped on
@@ -359,42 +274,10 @@ export class BasicCompactService extends CompactService {
// ---- Core API (implements the abstract contract) ----
/**
* The sole token-pressure gate: estimate the NEXT request's pressure — the
* session prefix + the surface-derived history + the system prompt
* ({@link estimatePressure}) — and if it exceeds the threshold
* (`contextWindow * thresholdRatio`), compact
* the oldest surface nodes outside the `retainTokens` budget. The auto-
* compaction listener delegates here rather than pre-checking, so this is the
* only place the decision lives. The prefix counts because every request
* carries it in front of the history (`EpochHeader.messagePrefix`) even
* though it is not derived history — omitting it would under-estimate by
* exactly the prefix and let a deployment at the window edge skip
* compaction, then ship an over-window request. The loop composes the
* prefix BEFORE the pre-step seam and hands it through, so the gate sees
* this instance's actual prefix (never a previous instance's logged one —
* a resumed/forked instance whose contributor grew is gated on the grown
* value from its very first step). Compaction itself can only
* shrink HISTORY: a prefix that alone approaches the window is a
* configuration error no compactor fixes.
*
* Retention is a UNIFORM tail→head walk over the whole surface — turn
* boundaries play NO role. Walking node-by-node from the tail and summing
* token estimates, once the retained total reaches `retainTokens` the cutoff
* is rounded to a balanced tool-pairing boundary: if the cut before the
* retained node is unbalanced (an unanswered tool-call sits before it — i.e.
* it is mid-step), the walk continues head-ward until the cut is balanced so
* the whole step is retained (never splitting a step's tool-calls from their
* results); if it stopped on a free node (a node belonging to no step), that
* cut is already balanced. This always rounds toward retaining MORE (retained
* ≥ `retainTokens`) and is boundary-safe by construction — no separate snap
* pass.
*
* The compacted range is always anchored at the surface HEAD (`nodes[0]`):
* auto-compaction re-consolidates any prior head checkpoint into one fresh
* checkpoint. Declines (`null`) when nothing is over threshold, when the whole
* surface fits the retain budget, or when no balanced cutoff exists in the
* compactable range (its only content is an open tail step — retry once it
* closes).
* The sole token-pressure gate: estimate the NEXT request's pressure — the session prefix +
* the surface-derived history + the system prompt ({@link estimatePressure}) — and if it
* exceeds the threshold (`contextWindow * thresholdRatio`), compact the oldest surface nodes
* outside the `retainTokens` budget.
*/
override async compactIfNeeded(
agent: Agent,
@@ -450,13 +333,7 @@ export class BasicCompactService extends CompactService {
agent: Agent,
signal?: AbortSignal,
): Promise<CompactionResult> {
// Resolve the range by surface POSITION, not numeric seq interval. A prior
// replace lands a fresh high-seq summary node AT the shadowed range's
// position, so the surface order (head→tail) no longer tracks seq order —
// `[newSummarySeq, olderRetainedSeq, …]` is normal. Indexing into the
// ordered node list and slicing it is the only correct way to read a range;
// a `node.seq >= start && node.seq <= end` interval test would mis-collect
// nodes (and `start > end` would falsely reject) once that happens.
// Resolve the range by surface POSITION, not numeric seq interval.
const nodes = session.surface.nodes
const startIdx = nodes.findIndex(n => n.seq === start)
const endIdx = nodes.findIndex(n => n.seq === end)
@@ -466,14 +343,8 @@ export class BasicCompactService extends CompactService {
throw new Error(`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`)
}
// The region must never split a step's assistant-message tool-calls from
// their tool/results (which would orphan one side and produce a transcript
// every provider rejects). A region is safe iff BOTH its edges are balanced
// cuts: the cut before `start`, and the cut after `end`. A node that belongs
// to no step (pre-step user message, inter-step steering, injection context)
// is a balanced (free) boundary; an `end` inside an open (unclosed) tail step
// leaves the cut after it unbalanced (the open tool-call has no result yet),
// so it is rejected. See dsh-session's tool-pairing balance check.
// The region must never split a step's assistant-message tool-calls from their tool/results
// (which would orphan one side and produce a transcript every provider rejects).
const events = session.events
if (!isToolPairingBalanced(nodes, events, start)) {
throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
@@ -490,13 +361,8 @@ export class BasicCompactService extends CompactService {
throw new Error('compaction already in progress')
}
// Compaction's events (compact/* and the replacement user/message) must be
// turn-enclosed: the session-log contract rejects any plugin event appended
// outside an open turn. Auto-compaction satisfies this — it runs on the
// `agent/pre-step` seam, after `turn/start` and before `step/start`, so
// strictly inside the open turn (but outside any step). A manual call on a
// fully-closed session has no turn to enclose the events, so reject rather
// than emit an un-enclosed run.
// Compaction's events (compact/* and the replacement user/message) must be turn-enclosed:
// the session-log contract rejects any plugin event appended outside an open turn.
const openTurn = this._openTurn(session)
if (openTurn === null) {
throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn')
@@ -537,13 +403,8 @@ export class BasicCompactService extends CompactService {
...maxTokens !== undefined ? { maxTokens } : {},
})
// --- Surface replacement ---
// The user/message directly shadows all compacted surface nodes with a
// single replace op. It is the ONLY surface event in the compaction
// sequence — compact/start, compact/summary, and compact/end are log-only
// (surfaceOp is rejected by the compiler for non-SurfaceEventType).
// The landed content is FRAMED (checkpoint preamble + tag-wrapped summary);
// the compact/summary provenance event above holds the raw model output.
// --- Surface replacement --- The user/message directly shadows all compacted surface
// nodes with a single replace op.
session.append('user/message', {
content: framedSummary,
source: { kind: 'plugin', plugin: 'compact' },
@@ -597,17 +458,8 @@ export class BasicCompactService extends CompactService {
}
/**
* Whether a compaction is currently in progress for `session` — an unmatched
* `compact/start` (no later `compact/end`) WITHIN the current turn.
*
* The scan is scoped to the current turn: walking back from the tail it stops
* at the first `turn/end` (the boundary closing the prior turn). A
* `compact/start` left orphaned by a crash mid-compaction lives in a turn that
* persistence repair then closes with a synthetic `turn/end`; scoping here so
* that a stale orphan from a PAST turn cannot wedge compaction forever (it sits
* before the nearest `turn/end`, so the scan never reaches it). An in-progress
* compaction's `compact/start` is always in the still-open current turn,
* before any `turn/end`, so it is still detected.
* Whether a compaction is currently in progress for `session` — an unmatched `compact/start`
* (no later `compact/end`) WITHIN the current turn.
*/
private _isCompactionInProgress(session: Session): boolean {
const events = session.events
@@ -650,14 +502,10 @@ export class BasicCompactService extends CompactService {
// The whole surface fits the retain budget — nothing to compact.
if (keepFromIdx === 0) return null
// Round the cutoff to a tool-pairing boundary: if the cut before
// `nodes[keepFromIdx]` is unbalanced (an unanswered tool-call sits before
// it — i.e. it is mid-step), extend the retained side head-ward until the
// cut is balanced, so the compacted range ends without splitting an
// assistant↔result pair. A node that belongs to no step is already a
// balanced (free) boundary. Decline if no balanced cut exists at or below
// `keepFromIdx` (the compactable range is only an un-splittable open tail
// step — retry once it closes).
// Round the cutoff to a tool-pairing boundary: if the cut before `nodes[keepFromIdx]` is
// unbalanced (an unanswered tool-call sits before it — i.e. it is mid-step), extend the
// retained side head-ward until the cut is balanced, so the compacted range ends without
// splitting an assistant↔result pair.
while (keepFromIdx > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!.seq)) break
@@ -673,17 +521,7 @@ export class BasicCompactService extends CompactService {
return { start: firstSeq, end: cutoffSeq }
}
/**
* Keep ONLY text blocks from the model-produced summary before storing it.
*
* The summary lands on the surface as a synthesized `user/message` (see
* {@link _frameSummary}), so the only block type that is both useful and safe
* there is `text`. A model assistant message can otherwise carry `reasoning`
* (private chain-of-thought, must not leak into the durable checkpoint) and
* `tool-call` blocks — and a surviving `tool-call` in a user message would be
* an orphaned call with no matching `tool-result`, exactly the tool-pairing
* breakage compaction works to avoid. Filtering to text drops both.
*/
/** Keep only text; checkpoints cannot contain reasoning or orphan tool calls. */
private _textOnly(blocks: readonly ContentBlock[]): ContentBlock[] {
return blocks.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
}

View File

@@ -48,13 +48,6 @@ export type ResolvedConfig = Required<BasicCompactConfig>
/**
* Default `auto`/`charsPerToken` when unset and reject nonsensical numeric knobs.
*
* Convergence is not a static config invariant: provider generation caps can be
* spent on hidden or surfaced reasoning tokens, and the model may emit a summary
* of unpredictable size. The backend instead enforces convergence dynamically:
* each committed summary must be smaller than the content it shadows, and
* `compactIfNeeded` may re-compact up to `compactionRetries` extra times before
* throwing if the surface still exceeds the threshold.
*
* @param config - the raw, unresolved backend config.
* @returns the validated config with `auto` and `charsPerToken` defaulted.
*/

View File

@@ -211,12 +211,7 @@ function expectNoOrphanToolResults(messages: Message[]): void {
describe('BasicCompactService step-alignment (never split a tool-call/result pair)', () => {
it('compactIfNeeded rounds the retained boundary head-ward to keep a whole step (no orphaned tool-result)', async () => {
// 3 turns, each one step = { assistant(tool-call), tool/result }. Surface
// (9 nodes): user1, asst1, res1, user2, asst2, res2, user3, asst3, res3 —
// 10/20/10 tokens. The tail→head walk retains by whole units; the compacted
// region always ends on a step boundary, so no step's tool-call is split
// from its result. retainTokens=55 keeps the recent tail; the older steps
// compact intact.
// 3 turns, each one step = { assistant(tool-call), tool/result }.
const svc = createTestService({ contextWindow: 280, thresholdRatio: 0.5, retainTokens: 55 })
const session = toolTurnSession(3)
@@ -231,12 +226,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai
})
it('compactIfNeeded returns null when the only compactable region is an un-splittable single step', async () => {
// The surface is exactly ONE step: [assistant(tool-call), tool/result]. Over
// threshold (by the derived role overhead), the tail→head walk stops with the
// retained boundary at the tool/result — which is NOT a step-aligned start (its
// issuing assistant precedes it in the same step). Rounding head-ward to find a
// clean boundary reaches index 0, so there is no step-aligned cutoff in the
// compactable range: compactIfNeeded declines rather than splitting the step.
// The surface is exactly one step: [assistant(tool-call), tool/result].
const s = new Session(SessionId('one-step'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
@@ -605,27 +595,14 @@ describe('BasicCompactService.compactIfNeeded', () => {
})
it('returns null when the whole surface fits the retain budget (over threshold by role/system overhead)', async () => {
// threshold = floor(480*0.1) = 48. The 4 surface nodes weigh 10 each (raw 40
// for the retention walk), but the derived estimate adds 4 role tokens per
// message → 56 ≥ 48, so the threshold check passes and the walk runs. The
// walk accumulates all 40 < retainTokens (45) without crossing the budget,
// so keepFromIdx reaches 0 and compaction declines.
// threshold = floor(480*0.1) = 48.
const svc = createTestService({ contextWindow: 480, thresholdRatio: 0.1, retainTokens: 45 })
const session = multiTurnSession(2, 1)
expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull()
})
it('compacts a runaway turn: its early CLOSED steps summarize while recent steps stay verbatim', async () => {
// The REGRESSION that motivated dropping turn-protection. A single in-flight
// (open) turn has grown past the threshold on its own: several CLOSED steps,
// each [assistant(tool-call), tool/result]. Retention is turn-agnostic, so
// the turn's OWN early closed steps are eligible — they compact while the
// recent tail stays verbatim, and the harness survives.
//
// On the OLD layer-2 code this test FAILS: the entire open turn was retained
// verbatim (protectedIdx = first open-turn node = 0), so compactIfNeeded
// returned null and shadowedSeqs would be empty — the runaway turn could
// never compact and the next model call would overflow the window.
// The Regression that motivated dropping turn-protection.
const svc = createTestService({ contextWindow: 800, thresholdRatio: 0.1, retainTokens: 25 })
const s = new Session(SessionId('runaway'))
// ONE open turn with 5 closed steps; each step is [asst(tool-call), result].
@@ -665,12 +642,7 @@ describe('BasicCompactService.compactIfNeeded', () => {
})
it('compacts again after a prior summary node heads the surface (the summary stays eligible)', async () => {
// After the first compaction lands a replacement summary node at the head,
// a second compaction (still over threshold) re-consolidates it with newer
// context — head-anchoring means the prior checkpoint is always re-included,
// never stranded. retainTokens=25 leaves a couple of retained nodes after
// the first compaction (so the surface is [summary, …retained], not just
// [summary]).
// Head-anchored recompaction must include the previous summary and retained context.
const svc = createTestService({ contextWindow: 800, thresholdRatio: 0.1, retainTokens: 25 })
const s = multiTurnSession(4, 1) // turns 1-4 closed, turn 5 open (no surface yet)
@@ -776,10 +748,8 @@ describe('BasicCompactService blocking (compaction in progress)', () => {
})
it('is not wedged by an orphaned compact/start from a prior (now-closed) turn', async () => {
// A crash mid-compaction left a compact/start with no compact/end; the turn
// it lived in was later closed (persistence repair appends turn/end). A
// whole-log scan would treat that stale start as an active lock forever. The
// scan is scoped to the current turn, so a NEW turn compacts normally.
// A crash mid-compaction left a compact/start with no compact/end; the turn it lived in was
// later closed (persistence repair appends turn/end).
const svc = createTestService()
const s = new Session(SessionId('stale-lock'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -860,11 +830,8 @@ describe('BasicCompactService HMR safety', () => {
})
it('disposing the plugin fiber unregisters ctx.compact', async () => {
// Mount through the real plugin fiber (the Loader path), then dispose it and
// confirm the service registration is torn down. LlmService is mounted first
// so the service's `inject: ['llm']` resolves and the fiber activates. (The
// sibling-fiber ctx.llm resolution this same setup also exercises is covered
// under the "llm inject (real plugin-load path)" suite.)
// Mount through the real plugin fiber (the Loader path), then dispose it and confirm the
// service registration is torn down.
const ctx = new Context()
await ctx.plugin(LlmService)
const fiber = await ctx.plugin(BasicCompactService, cfg({ auto: false }))
@@ -1259,11 +1226,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
it('summarization is interceptable at llm/stream (model routing for direct calls)', async () => {
const { ctx, adapter } = await ctxWithModel('ROUTED SUMMARY', 'routed-model')
// The summarize call is a direct one-shot model call, not a loop step: it
// does not run agent/request (that seam shapes the loop's conversation
// requests). llm/stream is its interception surface, and a hand-built
// request is not frozen, so mutate-then-next model routing works — the
// adapter resolves AFTER the waterfall, so the rewrite picks the adapter.
// One-shot summaries use llm/stream, not the loop's agent/request seam.
ctx.on('llm/stream', (options, next) => {
options.model = 'routed-model'
return next()
@@ -1526,10 +1489,7 @@ describe('BasicCompactService edge cases', () => {
s.append('context/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('step/end', { turn: 1, step: 1 })
// Step 2: a tool exchange whose tool/result has empty content → empty
// extraction → skipped. The assistant carries the matching tool-call so the
// surface stays tool-pairing balanced; its text extracts to the tool-call
// placeholder (the one surviving line).
// Step 2: a tool exchange whose tool/result has empty content → empty extraction → skipped.
s.append('step/start', { turn: 1, step: 2 })
s.append('assistant/message', {
turn: 1, step: 2,
@@ -1594,11 +1554,8 @@ describe('BasicCompactService edge cases', () => {
describe('BasicCompactService positional range (surface seqs are not monotonic after a replace)', () => {
it('compacts a second region after the first replace lands a high-seq summary at the head position', async () => {
// A replace inserts the new summary node (a high seq) AT the shadowed
// range's surface position, so the surface becomes
// [highSeqSummary, …olderRetainedLowerSeqs]. A second compaction over a
// range whose start node has a HIGHER seq than its end node must still
// succeed — the range is positional, not a numeric seq interval.
// A replace inserts the new summary node (a high seq) AT the shadowed range's surface
// position, so the surface becomes [highSeqSummary, …olderRetainedLowerSeqs].
const svc = createTestService({ auto: false })
const session = multiTurnSession(4, 1)
@@ -1606,19 +1563,13 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a
const nodes0 = session.surface.nodes
const first = await compactRegion(svc, session, nodes0[0]!.seq, nodes0[1]!.seq, 'm')
// The summary node now sits at the head with a seq HIGHER than the
// retained older nodes that follow it — the non-monotonic surface. (The
// head is the user/message replace node, appended after the compact/summary
// provenance event, so its seq is at least first.summarySeq.)
// The summary node now sits at the head with a seq HIGHER than the retained older nodes
// that follow it — the non-monotonic surface.
const nodes1 = session.surface.nodes
expect(nodes1[0]!.seq).toBeGreaterThanOrEqual(first.summarySeq)
expect(nodes1[0]!.seq).toBeGreaterThan(nodes1[1]!.seq)
// Second compaction: shadow [summary(head) … turn-2's step end]. The start
// seq (the head summary node) is GREATER than the end seq (an older retained
// node), so the range is a SURFACE-POSITION span, not a numeric seq interval.
// The end must land on a step boundary (turn-2's assistant message closes
// its step).
// Second compaction: shadow [summary(head) … turn-2's step end].
const startSeq = nodes1[0]!.seq
const endSeq = nodes1[2]!.seq
expect(startSeq).toBeGreaterThan(endSeq)
@@ -1661,10 +1612,8 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a
describe('BasicCompactService llm inject (real plugin-load path)', () => {
it('declares llm in static inject so a sibling fiber can resolve ctx.llm', () => {
// summarize() reads ctx.llm; the inject lets the cordis ctx proxy resolve a
// sibling LlmService when this service is mounted as its own plugin fiber.
// Asserting the declaration (and exercising the real mount below) guards the
// resolution that root-ctx unit tests cannot, since they share one fiber.
// summarize() reads ctx.llm; the inject lets the cordis ctx proxy resolve a sibling
// LlmService when this service is mounted as its own plugin fiber.
expect(BasicCompactService.inject).toContain('llm')
})

View File

@@ -14,24 +14,9 @@ import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
/**
* CBR-001 regression: a compaction checkpoint that the REAL loop lands is a
* free surface boundary (it carries no tool-call/result pair), so it must be a
* valid region edge on BOTH sides. A surface-anchored balance check sees that;
* the abandoned log-position scan did not.
*
* The loop fires the compaction seam mid-flight, so the landed checkpoint
* `user/message{replace}` sits at a HIGH log seq positioned beside the current
* step even though its SURFACE position is the head. A log-position forward scan
* from the checkpoint reaches the step's own later `assistant/message` and
* wrongly reports the checkpoint as mid-step — refusing it as a region end. A
* SECOND compaction that re-summarizes just that head checkpoint (region end ==
* checkpoint) therefore throws and is swallowed, so the surface never
* re-consolidates.
*
* This drives a real auto-compaction through the agent-loop and asserts the
* landed checkpoint balances on both sides AND that re-compacting it (end ==
* checkpoint) succeeds. RED on the log-position predicates; GREEN once alignment
* is decided from surface tool-pairing balance.
* CBR-001 regression: a compaction checkpoint that the real loop lands is a free surface
* boundary (it carries no tool-call/result pair), so it must be a valid region edge on BOTH
* sides.
*/
const TOKENS_PER_BLOCK = 10
@@ -132,14 +117,9 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
)
expect(checkpoints.length).toBeGreaterThan(0)
// The loop fired compaction mid-flight, so each landed checkpoint sits at a
// high log seq beside the step it landed in, even though its SURFACE
// position is the head of the range it shadowed. A checkpoint carries no
// tool-call/result pair (only summarized prose), so every checkpoint still
// on the surface must be a balanced cut on BOTH sides — the cut before it
// (region START) and the cut after it (region END). The abandoned
// log-position scan reported the END as mis-aligned because the forward log
// scan reached the neighbouring step's assistant/message.
// The loop fired compaction mid-flight, so each landed checkpoint sits at a high log seq
// beside the step it landed in, even though its surface position is the head of the range
// it shadowed.
const nodes = agent.session.surface.nodes
for (const cp of checkpoints) {
const node = nodes.find(n => n.seq === cp.seq)

View File

@@ -1,23 +1,7 @@
/**
* The compaction service seam (`ctx.compact`): an abstract service defining
* WHAT compaction does — decide when to compact, summarize a range of
* conversation history into a single surface node — without saying HOW.
*
* Implementations subclass {@link CompactService}, implement
* {@link CompactService.compactIfNeeded} and {@link CompactService.compactRegion},
* and load as a plugin — registering as `ctx.compact` (one implementation per
* context). A tokenizer-, template-, or model-backed implementation can live
* as a sibling package; callers stay on the same `ctx.compact` seam without
* touching consumers.
*
* The split follows the capability-seams RFC — interface (this) /
* implementation (deferred) / consumer (a `/compact` tool, deferred) — modeled
* on the bash trio. Unlike `dsh-bash`, this interface necessarily
* depends on `dsh-session` and `dsh-llm`: the contract's verbs are defined over
* a `Session` and its output is the `ContentBlock` vocabulary. 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).
*
* The compaction service seam (`ctx.compact`): an abstract service defining what compaction
* does — decide when to compact, summarize a range of conversation history into a single
* surface node — without saying how.
* @module @deepseek-ai/dsh-compact
*/
@@ -42,25 +26,9 @@ declare module 'cordis' {
}
/**
* Abstract compaction service. Subclass implement the two abstract methods,
* and load the subclass as a plugin — it registers as `ctx.compact` (one
* implementation per context; loading a second throws, which is cordis'
* standard duplicate-service behavior).
*
* Both core methods are abstract: the contract states WHAT compaction does,
* while the entire strategy — token estimation, retention policy, event
* sequencing, summarization — is a HOW decision owned by the implementation.
*
* Implementations MUST honor:
* - **Surface contract**: a successful compaction shadows the compacted surface
* nodes with a SINGLE replacement node carrying the summary. Because
* `SurfaceEventType` is a closed union, that node is a `user/message` with
* `surfaceOp: { op:'replace', start, end }`; the `compact/*` events are
* log-only (lock + provenance).
* - **Blocking**: no compaction begins while another is in progress for the
* same session. The recommended mechanism is the log-recorded lock — append
* `compact/start` before the slow work and `compact/end` after (even on
* failure) — so the lock is visible to replay and crash recovery.
* Abstract compaction service. Subclass implement the two abstract methods, and load the
* subclass as a plugin — it registers as `ctx.compact` (one implementation per context;
* loading a second throws, which is cordis' standard duplicate-service behavior).
*/
export abstract class CompactService extends Service {
constructor(ctx: Context) {
@@ -70,42 +38,11 @@ export abstract class CompactService extends Service {
/**
* Check token pressure and compact if the conversation is too large.
*
* Estimates the NEXT request's size — the session prefix, the
* surface-derived history, and the system prompt — and if it exceeds the
* backend's threshold, compacts an older range
* via {@link compactRegion}, keeping recent context intact. Returns `null`
* when no compaction is needed.
*
* Scope and guarantees a backend MUST honor:
* - **Compaction acts on surface-derived history only**, but the ESTIMATE
* counts everything the request carries: the loop composes the session
* prefix before the pre-step seam fires and hands it here, so the gate
* sees the prefix this instance will actually send (`EpochHeader.messagePrefix`
* — request-only, never derived history). Non-surface context injected
* downstream (into the request `messages` by a later listener) is out of
* this accounting by construction.
* - **Head-anchored, best-effort.** Auto-compaction consolidates from the
* surface HEAD up to a balanced tool-pairing cutoff, so a prior head
* checkpoint is
* re-summarized into one fresh checkpoint (the surface holds at most one
* auto-generated checkpoint, always at the head). It is best-effort over
* CLOSED steps: when the only compactable content left is an un-splittable
* open tail step, it declines (`null`) and retries once that step closes.
* - **Single-unit overflow is out of scope.** If a single retained unit (one
* closed step, or a large free node such as a pasted `user/message`) ALONE
* exceeds the budget, compaction cannot help and the call may go out
* over-budget. Bounding an individual unit's size is a separate concern —
* as is a session prefix that alone approaches the window (a
* configuration error no compactor fixes: compaction cannot shrink the
* prefix).
*
* @param agent - agent context owning the session surface and model options.
* @param fullSystemPrompt - assembled system prompt, counted toward the estimate.
* @param sessionPrefix - the instance's composed session prefix, counted toward the estimate.
* @param signal - cancellation signal. A backend summarizing via
* `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal`
* so an abort/dispose tears down the in-flight summarization rather than
* leaving an orphaned model call running past the cancellation.
* @param sessionPrefix - the instance's composed session prefix, counted toward the
* estimate.
* @param signal - cancellation signal.
* @returns the compaction result, or `null` if no compaction was needed.
*/
abstract compactIfNeeded(
@@ -118,35 +55,13 @@ export abstract class CompactService extends Service {
/**
* Forcibly compact a range of surface nodes into a single summary node.
*
* `start` and `end` are inclusive seqs of surface nodes to shadow; the backend
* summarizes their content and appends a replacement surface node. Used by the
* (future) `/compact` tool and internally by {@link compactIfNeeded}.
*
* The region MUST NOT split a step's `assistant/message` tool-calls from their
* `tool/result`s, leaving the rehydrated transcript with a dangling tool-call
* or an orphaned tool-result that every provider rejects. A region is safe iff
* both its edges are balanced cuts on the surface: the cut before `start` and
* the cut after `end` each have no unanswered tool-call before them. A node
* that belongs to no step (a pre-step user message, inter-step steering, or an
* injection context message) is a balanced (free) boundary; an `end` inside an
* open (unclosed) tail step is invalid — its tool-calls have no results yet.
* `dsh-session` exports `isToolPairingBalanced` for this check.
*
* @param session - the session whose surface is mutated.
* @param start - inclusive seq of the first surface node to compact.
* @param end - inclusive seq of the last surface node to compact.
* @param agent - agent context used by router-aware summarizers.
* @param signal - optional cancellation signal. A backend that summarizes via
* `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal`
* so an abort/dispose tears down the in-flight summarization rather than
* leaving an orphaned model call running past the cancellation.
* @throws if compaction is already in progress, if `start`/`end` are not
* valid surface nodes, if `start` is positioned after `end` on the surface
* (the range is a surface-POSITION span, not a numeric seq interval — a
* prior replace can leave the surface non-monotonic in seq order), or if
* either boundary is not a balanced tool-pairing cut (would split a step's
* tool-call/result pair).
* @returns what the compaction did (the replaced range and its summary node).
* @param session - session to mutate.
* @param start - first surface seq, inclusive.
* @param end - last surface seq, inclusive.
* @param agent - summarizer context.
* @param signal - optional cancellation.
* @throws when compaction is active or the range is invalid or unbalanced.
* @returns the replaced range and summary.
*/
abstract compactRegion(
session: Session,

View File

@@ -1,16 +1,7 @@
/**
* Plain-text transcript rendering over session events: the shared projection
* used wherever a compaction-class consumer needs "what a model once saw" as
* readable text — a summarizer's input, or a recall tool's output.
*
* Extracted from the basic backend's private helpers so the summarize path and
* the recall read path render one span identically (two renderers would drift,
* and a recall reader would then see a different transcript than the one the
* summary was written from). Both functions are pure over their arguments: no
* session access beyond the provided events, no clock, no randomness — a
* rendered span is a pure function of the log, so replay reproduces it
* byte-identically.
*
* Plain-text transcript rendering over session events: the shared projection used wherever a
* compaction-class consumer needs "what a model once saw" as readable text — a summarizer's
* input, or a recall tool's output.
* @module @deepseek-ai/dsh-compact/render
*/
@@ -18,14 +9,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
/**
* Render content blocks to a single plain-text string. Text and reasoning
* contribute their text (reasoning wrapped as `[reasoning: …]`); every other
* block type contributes a type-tagged placeholder (`[tool-call: name(args)]`,
* `[tool-result: …]`, …) so the reader is told what non-text content existed
* rather than silently losing it. A `tool-result` block recurses into its
* nested content (`[tool-result: <inner rendering>]`), falling back to a bare
* `[tool-result]` when the nested content renders to nothing. Blocks join
* with newlines; empty-text blocks contribute nothing.
* Render content blocks to a single plain-text string.
*
* @param blocks - the content blocks to render.
* @returns the newline-joined plain-text rendering; empty string when nothing renders.
@@ -59,17 +43,7 @@ export function renderContentBlocks(blocks: readonly ContentBlock[]): string {
}
/**
* Render a set of surface-node seqs as a `User:`/`Assistant:`/`Tool result:`
* transcript. Walks `seqs` in the order given — callers pass surface order
* (e.g. a `compactRegion` slice of the surface-node list), which after a
* `replace` is NOT ascending log-seq order (a high-seq summary node can sit at
* the head of the surface before older retained lower-seq nodes); a log-order
* scan would render the transcript out of order.
*
* Only the five surface (message-producing) event types render; a seq naming
* any other event type contributes nothing. `SessionEventMap` is
* merge-extensible, so unknown types are simply non-message events with no
* renderable text.
* Render a set of surface-node seqs as a `User:`/`Assistant:`/`Tool result:` transcript.
*
* @param events - the session log the seqs index into (`session.events`).
* @param seqs - the surface-node seqs to render, in surface order.

View File

@@ -1,17 +1,5 @@
/**
* Compaction vocabulary: the result type and the `compact/*` session events.
*
* Extends {@link SessionEventMap} with `compact/*` event types via declaration
* merging. {@link SurfaceEventType} is deliberately NOT extended — `compact/*`
* events are log-only markers (lock + provenance); only the five
* surface-eligible types can carry `surfaceOp`. The actual surface mutation is
* performed by a separate `user/message` event carrying the summary (see the
* [compaction capability-seam RFC](../../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)).
*
* Configuration lives in the backend, not here: the contract states WHAT
* compaction produces, while every tunable (context window, thresholds,
* retention budget) is a HOW decision owned by the implementation.
*
* @module @deepseek-ai/dsh-compact/types
*/

View File

@@ -261,13 +261,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'agent/pre-step',
mode: 'serial',
signature: '\'agent/pre-step\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise<void> | void',
summary: 'Awaited pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step\'s `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`.',
summary: 'Awaited checkpoint for surface mutation before `step/start` snapshots request history.',
},
{
name: 'agent/prompt-submit',
mode: 'waterfall',
signature: '\'agent/prompt-submit\'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>',
summary: 'Waterfall: decide what happens to ONE drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it.',
summary: 'Waterfall: decide what happens to one drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it.',
},
{
name: 'agent/queued',
@@ -285,7 +285,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'agent/session-prefix',
mode: 'waterfall',
signature: '\'agent/session-prefix\'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>',
summary: 'Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the ENTIRE derived history (directly after the provider\'s system slot) on every request this loop instance sends.',
summary: 'Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the entire derived history (directly after the provider\'s system slot) on every request this loop instance sends.',
},
{
name: 'agent/session-start',
@@ -429,19 +429,19 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'tools/post-execute',
mode: 'waterfall',
signature: '\'tools/post-execute\'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>',
summary: 'Waterfall AFTER a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code\'s `PostToolUse`).',
summary: 'Waterfall after a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code\'s `PostToolUse`).',
},
{
name: 'tools/pre-execute',
mode: 'waterfall',
signature: '\'tools/pre-execute\'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>',
summary: 'Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code\'s `PreToolUse`).',
summary: 'Waterfall before a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code\'s `PreToolUse`).',
},
{
name: 'tools/result',
mode: 'parallel',
signature: '\'tools/result\'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): Promise<void> | void',
summary: 'Awaited notification of the authoritative FINAL tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization.',
summary: 'Awaited notification of the authoritative final tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization.',
},
{
name: 'workflow/agent-end',

View File

@@ -1,15 +1,7 @@
/**
* Runtime mirror of the cordis `FiberState` const enum plus human-readable
* labels, shared by the mount lifecycle (state reporting) and the inspect
* renderers (plugin-list and mount-table labels).
*
* Cordis exposes `FiberState` as a `const enum`: there is no runtime object for
* Node's type-stripping runner to import, so the members are mirrored here as
* values — each typed (via the type-only import) as the cordis enum member it
* mirrors, so enum-typed reads like `fiber.state` compare against them under a
* shared enum type. Source of truth: vendor/cordis/src/fiber.ts (pinned; drift
* only happens through a deliberate vendor sync).
*
* Runtime mirror of the cordis `FiberState` const enum plus human-readable labels, shared by
* the mount lifecycle (state reporting) and the inspect renderers (plugin-list and mount-table
* labels).
* @module @deepseek-ai/dsh-tool-cordis/fiber-state
*/

View File

@@ -1,50 +1,9 @@
/**
* The registration boundary between sandboxed mount code and the real runtime:
* SchemaSpec normalization + validation with teaching errors, the
* marker-guarded `harness.defineTool` / `harness.registerTool` pair, the
* SANDBOX CONTEXT FAÇADE a mounted plugin's `apply` receives in place of the
* real `ctx`, and the plugin-shape helpers the mount lifecycle narrows sandbox
* The registration boundary between sandboxed mount code and the real runtime: SchemaSpec
* normalization + validation with teaching errors, the marker-guarded `harness.defineTool` /
* `harness.registerTool` pair, the SANDBOX CONTEXT FAÇADE a mounted plugin's `apply` receives
* in place of the real `ctx`, and the plugin-shape helpers the mount lifecycle narrows sandbox
* return values with.
*
* The façade is a WHITELIST, not a pass-through proxy. Mount code needs to do
* exactly four things — register a tool, listen to an event, provide a service,
* call an injected service (timers included) — so the façade exposes only those
* verbs and the injected services, each object-valued service individually
* wrapped (a primitive provided value passes through as-is — see
* {@link sandboxContext}). Every framework plumbing member (`root`, `parent`, `scope`, `fiber`, `reflect`, `registry`,
* `events`, `extend`, `isolate`, `intercept`, `plugin`, `set`, `mixin`, …) is
* DENIED with a teaching error rather than passed through. This closes an
* entire escape class at once: a pass-through proxy that only special-cased
* `ctx.tools` still handed back the raw context through `ctx.root`,
* `ctx.extend()`, or a service instance's `.ctx`, and mount code could then
* `ctx.root.tools.register({…})` to bypass the marker check and host-realm
* normalization — a raw vm-realm result then errors a real agent turn at the
* session-log plainness check. The whitelist has no such hole: there is no
* context-valued member to reach, and any injected-service method that returns
* a `Context` is rejected (harness services never do — see {@link denyContext}).
*
* Two realm facts drive the tool path. Objects built inside the vm carry the vm
* realm's `Object.prototype`, and the session log's append-time plainness check
* (`dsh-session`'s `isJsonValue`, a prototype-identity comparison) rejects
* foreign-realm data — so every dynamic tool's `execute` return is JSON
* round-tripped into the host realm and shape-checked against the two
* `ToolExecuteReturn` forms before it reaches the registry (the registry
* trusts the shape blindly — it spreads `result.content`, so an unvalidated
* `{ content: 'ok' }` would enter the session log as `['o','k']` and silently
* corrupt the next model request), and the schema itself is rebuilt as fresh
* host-realm objects. And a malformed tool
* schema must fail at REGISTRATION, not when a later request assembles it — so
* dynamic tool registration accepts only definitions produced by the sandbox's
* `harness.defineTool`, which normalizes `parameters` up front.
*
* Normalize, don't lecture, where the input has exactly one meaning: models
* write the JSON-Schema dialect by strong prior (the `{ type: 'object',
* properties, required: […] }` wrapper, `type: 'integer'`, `required: false`),
* and each rejection costs a model turn — so those convert to the SchemaSpec
* DSL silently, and only genuinely meaningless input (an unknown type, a
* non-boolean `required`) is rejected, with the error enumerating the valid
* vocabulary.
*
* @module @deepseek-ai/dsh-tool-cordis/guard
*/
@@ -195,14 +154,11 @@ function assertExecuteReturn(value: unknown): ToolExecuteReturn {
}
/**
* The `harness.defineTool` handed into the sandbox: the real DSL, with
* `parameters` normalized into a fresh host-realm SchemaSpec (JSON-Schema
* wrapper unwrapped, `integer` mapped, `required: false` dropped) and the
* tool's `execute` return normalized into the host realm via a JSON round-trip
* (see the module doc). The round-trip projects the return onto exactly what
* the log would durably store, and {@link assertExecuteReturn} then vets that
* projection — so a non-JSON-serializable OR wrong-shape return surfaces as
* that one call's teaching error instead of poisoning the turn.
* The `harness.defineTool` handed into the sandbox: the real DSL, with `parameters` normalized
* into a fresh host-realm SchemaSpec (JSON-Schema wrapper unwrapped, `integer` mapped,
* `required: false` dropped) and the tool's `execute` return normalized into the host realm
* via a JSON round-trip (see the module doc).
*
* @param options - the standard `defineTool` options; `parameters` may be the SchemaSpec DSL or a JSON-Schema-style wrapper.
* @returns the marker-tagged definition `harness.registerTool` (and the guarded `ctx.tools.register`) accepts.
*/
@@ -236,15 +192,9 @@ export function sandboxRegisterTool(ctx: Context, tool: unknown): () => Promise<
}
/**
* The verbs a mounted plugin may reach through the sandbox `ctx` façade,
* beyond its injected services. `on`/`once` observe events, `provide` exposes
* a service to other mounts, and the timer helpers schedule work — each a
* fiber effect that unwinds on unmount. Everything else on a real cordis `ctx`
* is framework plumbing and is denied. Forwarded LAZILY: the timer helpers are
* mixin accessors that throw `without inject` when read on a plugin that did
* not inject `timer`, so the façade reads `ctx[verb]` only at call time — the
* plugin that never touches a timer never trips that, and one that does gets
* cordis's own inject error at the call site.
* The verbs a mounted plugin may reach through the sandbox `ctx` façade, beyond its injected
* services. `on`/`once` observe events, `provide` exposes a service to other mounts, and the
* timer helpers schedule work — each a fiber effect that unwinds on unmount.
*/
const CTX_VERBS = new Set(['on', 'once', 'provide', 'timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce'])
@@ -258,11 +208,7 @@ const CTX_VERBS = new Set(['on', 'once', 'provide', 'timeout', 'interval', 'setT
* name/description/parameters view as `schemas()`, and nothing invocable.
*/
function sandboxTools(ctx: Context): Record<string, unknown> {
// Reads resolve through the MOUNT's own scope (`scopeOf(ctx)`), mirroring
// where the façade's `register` lands its writes (the calling context's
// layer): mount code always sees the tools its own world sees — the global
// view for today's global mounts, its agent's view if a mount ever runs
// under an agent scope.
// Resolve reads and writes through the mount's own scope.
return {
register: (tool: unknown): (() => Promise<void> | void) => sandboxRegisterTool(ctx, tool),
schemas: () => ctx.tools.schemas(scopeOf(ctx)),
@@ -320,15 +266,7 @@ function declaredInjects(ctx: Context): Set<string> {
}
/**
* The sandbox context façade handed to a mounted plugin's `apply` in place of
* the real `ctx`. A whitelist (see the module doc): the registration/eventing
* verbs, the timer helpers, a guarded `tools`, and injected services resolved
* through a guarded `get` / property access. A service is reachable only if the
* plugin DECLARED it in `inject` — an undeclared service is denied even when a
* global provider exists, so cordis's activation/unload semantics (park the
* mount when a declared provider goes away) actually bind. Every
* framework-plumbing member is denied with a teaching error; there is no
* context-valued member to reach.
* The sandbox context façade handed to a mounted plugin's `apply` in place of the real `ctx`.
*/
function sandboxContext(ctx: Context): Context {
const tools = sandboxTools(ctx)
@@ -348,16 +286,8 @@ function sandboxContext(ctx: Context): Context {
+ 'Framework internals (root, fiber, registry, extend, plugin, …) are withheld by design.',
)
}
// Read a service for either access path (property or `get`). `tools` is the
// façade's own surface. An UNDECLARED name is denied with the teaching
// error; a DECLARED one resolves to the guarded service. A declared inject
// is required in cordis (the fiber only activates once every declared
// service is live), so at `apply`/`execute` time `ctx.get(name)` is present
// for a declared name — no undefined case to handle here. `provide()`
// accepts ANY value though (cross-mount composition advertises
// `ctx.provide('name', value)`), so a primitive or null value passes
// through unwrapped: Proxy throws on a non-object target, and only an
// object can carry a method that hands back a Context.
// Read a service for either access path (property or `get`). `tools` is the façade's own
// surface.
const readService = (name: string): unknown => {
if (name === 'tools') return tools
if (!declared.has(name)) return denyRead(name)
@@ -408,20 +338,11 @@ export function isPlugin(value: unknown): value is Plugin {
}
/**
* Wrap a plugin so its `apply` receives the sandbox context façade instead of
* the real `ctx` (see {@link sandboxContext} and the module doc). Both
* function-form and object-form plugins go through the same wrap; the plugin's
* own `inject` declaration is preserved (cordis reads it from the plugin
* object, and pending/active gating happens on the real fiber before `apply`
* runs), so cross-mount provide/inject works unmodified.
*
* `ctx.effect(customCleanup)` is deliberately absent from the façade for now —
* `on` / `provide` / `tools.register` cover every mount seen so far, and each
* is already a fiber effect. FIXME(sandbox-effect): expose a guarded `effect`
* once a real mount needs a bespoke disposer.
* Wrap a plugin so `apply` receives the sandbox context while preserving injection metadata.
* @param plugin - the plugin the mount code returned.
* @returns an equivalent plugin whose `apply` sees the sandbox context façade.
*/
// FIXME(sandbox-effect): expose guarded custom effects when a mount needs bespoke cleanup.
export function guardedPlugin(plugin: Plugin): Plugin {
if (typeof plugin === 'function') {
const functionPlugin = plugin as (ctx: Context, config?: unknown) => unknown

View File

@@ -1,37 +1,6 @@
/**
* The self-referential cordis toolset: three model-facing tools that let the
* agent inspect and MODIFY the live cordis runtime it is running inside.
*
* - `cordis_inspect` — read-only: provided services, the flat plugin list
* with lifecycle states, registered tools, the dynamic mounts, and the
* catalog-backed `api` / `events` references.
* - `cordis_mount` — evaluate model-written code in a `node:vm` sandbox; the
* code returns a cordis plugin, which is mounted as a child of a dedicated
* `cordis-dynamic` group fiber and tracked under an id (`dyn-1`, `dyn-2`, …).
* - `cordis_unmount` — dispose one dynamic mount by id, awaiting quiescence.
*
* Everything the model's plugin registers (listeners via `ctx.on`, tools via
* `harness.registerTool`, services via `ctx.provide`) is an effect on the
* dynamic fiber, so unmounting — or disposing this plugin itself (HMR) — cleans
* it all up through the ordinary cordis lifecycle. The group fiber exists
* exactly so the dynamic mounts form ONE subtree, disposed as a unit with
* this plugin. Design home:
* docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md.
*
* The vm sandbox guards against ACCIDENTAL global pollution only, and the `ctx`
* a mounted plugin's `apply` receives is a WHITELIST façade (register a tool,
* observe events, provide/consume services, use timers — framework internals
* withheld; see the guard module). Neither is a security boundary: the verbs
* the façade DOES expose reach the real runtime unsandboxed (a mounted tool can
* shell out through `ctx.bash`), so a deployment loads this plugin as
* deliberately as it grants a bash tool. Design home:
* docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md.
*
* Plugin export shape: named exports, NO default. The cordis Loader's
* `unwrapExports` does `exports.default ?? exports`, so a stray default would
* collapse the module to the bare `apply` and drop `inject`, crashing at load
* (see docs/postmortem/0001).
*
* The self-referential cordis toolset: three model-facing tools that let the agent inspect and
* MODIFY the live cordis runtime it is running inside.
* @module @deepseek-ai/dsh-tool-cordis
*/
@@ -75,9 +44,7 @@ type ResolvedConfig = Required<Config>
*/
export function apply(ctx: Context, config: Config): void {
const { vmTimeoutMs } = config as ResolvedConfig
// The one group fiber every dynamic mount hangs under. Mounted here (a child
// of this plugin's fiber) so disposing tool-cordis cascades over the whole
// dynamic subtree — the ordinary parent→child fiber lifecycle, nothing extra.
// The one group fiber every dynamic mount hangs under.
const group = ctx.plugin({ name: 'cordis-dynamic', apply: () => {} })
const mounts = new Map<string, DynamicMount>()

View File

@@ -1,11 +1,7 @@
/**
* Read-only renderers over the live runtime for `cordis_inspect`: the service
* list, the flat plugin list, the registered tools, the dynamic-mount
* table (with per-mount provides/waits), and the catalog-backed `api` /
* `events` sections. Every renderer is a pure function of the runtime handles
* it receives — no session state, no clock — so inspect output is exactly the
* runtime it describes.
*
* Read-only renderers over the live runtime for `cordis_inspect`: the service list, the flat
* plugin list, the registered tools, the dynamic-mount table (with per-mount provides/waits),
* and the catalog-backed `api` / `events` sections.
* @module @deepseek-ai/dsh-tool-cordis/inspect
*/
@@ -131,16 +127,11 @@ function typeClosure(seeds: string[], types: readonly TypeApiEntry[]): TypeApiEn
}
/**
* The `api` section: the generated service catalog intersected with the LIVE
* runtime — catalogued live services render summary + method signatures, live
* services without a catalog entry (e.g. ones another mount provides) render
* name + owning fiber, catalog services that are not running are listed
* tersely, the type shapes the live signatures reference follow, and the
* inherited `ctx` surface closes the section.
* Render the generated service catalog against the live runtime.
* @param ctx - the runtime to intersect the catalog with.
* @param api - the service catalog (the generated one by default; injectable for tests).
* @param inherited - the inherited `ctx` surface lines (generated by default; injectable for tests).
* @param types - the type-shape catalog (generated by default; injectable for tests).
* @param api - generated service entries, replaceable in tests.
* @param inherited - inherited `ctx` entries, replaceable in tests.
* @param types - public type shapes, replaceable in tests.
* @returns the section lines.
*/
export function describeApi(

View File

@@ -21,11 +21,8 @@ export interface DynamicMount {
}
/**
* Mount a plugin under the group fiber and settle it. The group fiber loads
* asynchronously right after the owning plugin's `apply`, so it is awaited
* before hanging a child off its context. The child fiber's `await()` settles
* its lifecycle work and rethrows a startup error (e.g. a throwing `apply`);
* on error the fiber is disposed first — a failed mount never lingers.
* Mount a plugin under the group fiber and settle it.
*
* @param group - the `cordis-dynamic` group fiber every mount hangs under.
* @param plugin - the plugin the sandbox returned; wrapped with the registration guard before mounting.
* @returns the settled child fiber (possibly pending on unsatisfied `inject`).

View File

@@ -1,20 +1,8 @@
/**
* The `node:vm` sandbox `cordis_mount` code evaluates in: a fresh realm whose
* globals are a tagged write-through console, the `harness` registration
* helpers, the encoding primitives a bare vm context lacks, and callable traps
* over the Node APIs the sandbox deliberately withholds. Capability access is
* routed through cordis services, never Node built-ins: filesystem work goes
* through `ctx.fs`, network through `ctx.web`, processes through `ctx.bash`,
* timers through the `ctx.timer` helpers (fiber effects, unwound on unmount)
* — so a well-behaved mount stays inspectable and disposable. That routing is
* STEERING toward the cordis services, not containment: the sandbox guards
* against ACCIDENTAL global pollution, and it is not a security boundary. The
* host-realm helpers on the sandbox global (`harness`, `console`, `btoa`) are
* reachable functions, so a mount that goes looking — e.g. through such a
* helper's `.constructor` — can still reach the host realm; that is accepted,
* because the `ctx` a mounted plugin's `apply` later receives is the real,
* fully privileged runtime handle, and that is the point of the toolset.
*
* The `node:vm` sandbox `cordis_mount` code evaluates in: a fresh realm whose globals are a
* tagged write-through console, the `harness` registration helpers, the encoding primitives a
* bare vm context lacks, and callable traps over the Node APIs the sandbox deliberately
* withholds.
* @module @deepseek-ai/dsh-tool-cordis/sandbox
*/
@@ -35,17 +23,8 @@ function taggedConsole(id: string): Record<'log' | 'info' | 'warn' | 'error' | '
}
/**
* Per-sandbox prelude: give the vm realm's own constructors a
* `Symbol.hasInstance` that checks BOTH realms. Model code runs against a
* fresh vm realm, but most objects it touches are HOST-realm (the `args` a
* tool's `execute` receives, event payloads a listener observes, service
* return values), so a plain `x instanceof Array` / `instanceof Object` in
* sandbox code would silently be false. The patch replaces each vm
* constructor's own `[Symbol.hasInstance]` with "ordinary check against the
* vm constructor OR the host counterpart" — the ordinary algorithm is a pure
* prototype-chain walk, so calling it with the host constructor as receiver
* needs no host-side change. ONLY vm-realm globals are modified; host
* intrinsics are passed in as values and never touched.
* Per-sandbox prelude: give the vm realm's own constructors a `Symbol.hasInstance` that checks
* BOTH realms.
*/
const DUAL_REALM_INSTANCEOF_PRELUDE = `
(hostIntrinsics) => {
@@ -156,13 +135,10 @@ export function syntaxErrorContext(error: Error): string {
}
/**
* Evaluate mount code as the body of an async function inside the sandbox.
* `vmTimeoutMs` only bounds the SYNCHRONOUS portion; an async body escapes it
* — acceptable under the module's trust stance. A parse failure is answered
* with the offending line + caret and a teaching hint: TypeScript syntax on
* the failing line gets the remove-annotations fix, anything else gets the
* function-body/bracket-balance reminder (models habitually close the returned
* plugin object with `});` as if it were a callback argument).
* Evaluate mount code as the body of an async function inside the sandbox. `vmTimeoutMs` only
* bounds the SYNCHRONOUS portion; an async body escapes it — acceptable under the module's
* trust stance.
*
* @param sandbox - the contextified object from {@link createSandbox}.
* @param code - the model-written function body; must `return` a plugin.
* @param id - the mount id, used as the vm filename (`cordis-mount-<id>.js`).

View File

@@ -48,12 +48,7 @@ describe('cordis_mount', () => {
})
it('normalizes a self-made tool\'s result into the host realm, so the session log accepts it', async () => {
// The model's execute builds its content blocks INSIDE the vm, where
// Object.prototype is a different object — dsh-session's isJsonValue (the
// gate every `tool/result` append runs through) compares prototype
// IDENTITY, so a raw foreign-realm result would error the whole turn the
// first time the self-made tool runs. harness.defineTool round-trips the
// return into host-realm JSON before it reaches the registry.
// Normalize vm-realm results into host JSON before session validation.
const ctx = await setup()
await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
const reversed = await call(ctx, 'reverse_text', { text: 'harness' })
@@ -94,11 +89,9 @@ describe('cordis_mount', () => {
['object-form blocks missing the type tag', 'return { content: [{ text: \'hi\' }] }', '{"content":[{"text":"hi"}]}'],
['undefined — a forgotten return', 'return undefined', 'undefined'],
])('rejects an execute return of %s as that one call\'s teaching error', async (_label, returnStatement, preview) => {
// The failure this prevents: the registry trusts the return shape
// (postExecute spreads result.content), so an unvalidated { content: 'ok' }
// would enter the session log as ['o','k'] and silently corrupt the next
// model request. The shape check turns it into THIS call's error instead —
// one well-formed text block the log and the model can digest.
// The failure this prevents: the registry trusts the return shape (postExecute spreads
// result.content), so an unvalidated { content: 'ok' } would enter the session log as
// ['o','k'] and silently corrupt the next model request.
const ctx = await setup()
await call(ctx, 'cordis_mount', {
code: `
@@ -150,10 +143,8 @@ describe('cordis_mount', () => {
})
it('accepts a JSON-Schema-style parameters wrapper and normalizes it to the DSL', async () => {
// The dialect models write by strong prior: the { type:'object',
// properties, required: […] } wrapper, `type: 'integer'`, and
// `required: false`. All of it has exactly one meaning — normalize instead
// of burning a model turn on a lecture.
// The dialect models write by strong prior: the { type:'object', properties, required: […]
// } wrapper, `type: 'integer'`, and `required: false`.
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
@@ -535,10 +526,9 @@ describe('cordis_mount', () => {
})
it('makes instanceof inside the sandbox see BOTH realms (patched vm constructors, host untouched)', async () => {
// The args a tool's execute receives are HOST-realm objects; without the
// dual-realm Symbol.hasInstance prelude, `args.items instanceof Array` in
// sandbox code is silently false. The patch lives on the vm realm's own
// constructors only — the host realm's must stay pristine.
// The args a tool's execute receives are HOST-realm objects; without the dual-realm
// Symbol.hasInstance prelude, `args.items instanceof Array` in sandbox code is silently
// false.
const ctx = await setup()
await call(ctx, 'cordis_mount', {
code: `

View File

@@ -77,10 +77,7 @@ describe('sandbox context façade — escape surface is closed', () => {
it('denies a service whose method returns a Context (the .ctx escape), registering nothing', async () => {
// A cordis Service instance carries `.ctx` (a real Context), so
// `ctx.systemPrompt.ctx.root.tools.register(…)` would be a fresh unguarded
// handle. The service wrapper's return-value guard rejects any Context on
// the way back to sandbox code, so the escape never lands. (`systemPrompt`
// is in the setup harness, so the plugin activates and its apply runs.)
// `ctx.systemPrompt.ctx.root.tools.register(…)` would be a fresh unguarded handle.
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
@@ -104,10 +101,8 @@ describe('sandbox context façade — escape surface is closed', () => {
})
it('guards an async injected-service method: a host-realm Promise resolves through the guard', async () => {
// The return guard's Promise arm only fires for a HOST-realm Promise
// (a vm-realm one is not `instanceof` the host `Promise`). Provide a
// host-realm service from the test, then inject + await it from a mount:
// the resolved value is non-Context data and passes through.
// The return guard's Promise arm only fires for a HOST-realm Promise (a vm-realm one is not
// `instanceof` the host `Promise`).
const ctx = await setup()
ctx.plugin({
name: 'host-async-svc',
@@ -194,11 +189,9 @@ describe('sandbox context façade — inject gate on services', () => {
})
it('a cross-mount consumer must declare the provider — the undeclared path is refused, not left as a zombie tool', async () => {
// The finding's scenario: a consumer registers a tool built on a provider's
// service WITHOUT declaring inject. cordis would then never park the
// consumer when the provider unmounts, leaving a tool that fails only at
// execution. The gate refuses the undeclared access up front, so the
// dependency is always visible to cordis.
// The finding's scenario: a consumer registers a tool built on a provider's service WITHOUT
// declaring inject. cordis would then never park the consumer when the provider unmounts,
// leaving a tool that fails only at execution.
const ctx = await setup()
await call(ctx, 'cordis_mount', {
code: 'return { name: \'greeter-provider\', apply(ctx) { ctx.provide(\'greeter\', { greet: (n) => \'hi \' + n }) } }',
@@ -231,11 +224,9 @@ describe('sandbox context façade — inject gate on services', () => {
describe('sandbox tools façade — get is a read-only schema view', () => {
it('ctx.tools.get returns a schema, not the live ToolDefinition with execute', async () => {
// The finding: returning the raw ToolDefinition hands mount code the
// tool's execute function, letting it bypass ToolRegistry.execute (and its
// pre/post hooks). get now returns the same name/description/parameters
// view as schemas(), with no execute. Asserted via a self-made tool that
// reports the shape it saw — world-checked, not self-reported.
// The finding: returning the raw ToolDefinition hands mount code the tool's execute
// function, letting it bypass ToolRegistry.execute (and its pre/post hooks). get now
// returns the same name/description/parameters view as schemas(), with no execute.
const ctx = await setup()
await call(ctx, 'cordis_mount', {
code: `

View File

@@ -1,47 +1,5 @@
/**
* The default executor-less, UI-less agent spine as ONE bundle plugin.
*
* Loads the fixed set of services every harness agent needs — `timer`, the LLM
* service, the session store, system-prompt assembly, the tool registry, the
* skill registry plus local skill provider, the agent registry, the dev-mode
* invariants, the model-facing `bash` and `skill` tool schemas, and the concrete `agent-loop` — and forwards the loop's `agents`
* list as its OWN config (default `[]`), so each app supplies its own
* pre-created agents.
*
* It is deliberately NOT the whole app: the swappable choices stay OUTSIDE the
* bundle, picked by whatever loads it.
* - the LLM ADAPTER (`llm-deepseek`/`llm-pi-ai`/`llm-replay`) — the bundle
* ships the abstract `llm` service + `tool-bash` consumer schema; the leaf
* registers a concrete adapter on `ctx.llm`.
* - the bash EXECUTOR (`bash-local` or a sandboxed impl) — the bundle ships
* the `bash` tool consumer; the leaf provides `ctx.bash`.
* - the PRESENTATION (stdio UI / ACP bridge / a logger) and the per-app infra
* (a console logger, `hmr`) — these are the coupled "front-door cluster" the
* app packages ({@link @deepseek-ai/dsh-stdio-agent},
* {@link @deepseek-ai/dsh-acp-agent}) bake in, NOT the shared spine.
* - additional SKILL PROVIDERS; the bundle ships the local filesystem provider
* because local skills are default agent behavior, while embedded or remote
* providers remain deployment choices.
*
* This is the interface/implementation/consumer seam at the composition level:
* the bundle owns the shared spine, the leaf owns the backends, the app package
* owns the front door. `timer` is in the spine (common to every front door — it
* writes nothing to stdout); the console logger is NOT (it writes to stdout,
* which the ACP bridge reserves for its JSON-RPC channel).
*
* Services register in the root store keyed by their isolate symbol, so a child
* loaded here via `ctx.plugin(...)` is visible to the bundle's SIBLINGS (the
* leaf's adapter and executor) exactly as a nested `plugin-include` subtree's
* services were before this bundle existed — cordis gates every read on
* `inject`, never on load order, so the fixed child set resolves regardless of
* which entry loads first.
*
* Plugin export shape: named `name`/`Config`/`apply`, NO default export — the
* cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray
* default would collapse the module to the bare `apply` function and drop the
* `Config` schema (see docs/postmortem/0001). The keyless Loader-path smokes in
* the app packages guard this end-to-end.
*
* The default executor-less, UI-less agent spine as one bundle plugin.
* @module @deepseek-ai/dsh-agent-core
*/
@@ -73,16 +31,11 @@ export interface SkillConfig {
}
/**
* Bundle config: each field forwarded verbatim to the child that owns it —
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
* plugin (the deployment's persona section and the explicit model-facing tool
* order), the `tools` object to the tool registry (its presentation `mode`),
* and `skills` to the skill registry/local provider/tool consumer. Every field
* is optional INPUT here because each owner's schema supplies the default;
* the schema is the INTERSECTION of the owners' own schemas (with registry
* schemas nested under their bundle keys), so validation and defaulting can
* never drift from them.
* Bundle config: each field forwarded verbatim to the child that owns it — `agents` to the
* agent loop (an app that pre-creates no agents, like the ACP bridge, simply omits it),
* `persona` and `toolOrder` to the system-prompt plugin (the deployment's persona section and
* the explicit model-facing tool order), the `tools` object to the tool registry (its
* presentation `mode`), and `skills` to the skill registry/local provider/tool consumer.
*/
export interface Config {
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
@@ -124,12 +77,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(Timer)
ctx.plugin(LlmService)
ctx.plugin(SessionStore)
// The forwarded fields are validated + defaulted by this bundle's intersected
// schema before apply runs, so the ?? fallbacks only narrow the
// optional-input TYPES — they mirror the owners' schema defaults, never
// introduce different ones. toolOrder has no owner-supplied default value —
// ABSENT means "lexicographic order" — so it is forwarded conditionally
// rather than via ??.
// Owner schemas resolve defaults; forward toolOrder only when explicitly set.
ctx.plugin(SystemPrompt, {
persona: config.persona ?? '',
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},

View File

@@ -187,15 +187,7 @@ describe('dsh-agent-core bundle', () => {
})
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => {
// Postmortem 0001 guard: a stray `export default apply` makes the Loader's
// `unwrapExports` (`exports.default ?? exports`) collapse the module to the
// bare `apply` function, DROPPING the named `name`/`Config`. This package has
// no `inject` export (it mounts children that carry their own), so that
// collapse would NOT crash at load — the plugin would boot but silently lose
// its config schema. This bundle is also never Loader-unwrapped by any smoke
// (the apps import it directly; the mount test namespace-mounts it), so this
// is its ONLY export-shape guard. Assert directly AND through the real
// `unwrapExports` so adding `export default` to src/index.ts fails here.
// A default export would make Loader discard this namespace's plugin metadata.
expect('default' in agentCore).toBe(false)
expect(typeof agentCore.apply).toBe('function')

View File

@@ -1,18 +1,5 @@
/**
* Negative-path tests for the config catalog generator (`scripts/gen-config-catalog.ts`).
*
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
* the freshness half is exercised by `pnpm run verify-config-catalog` in CI.
* What a freshness diff CANNOT prove is that the generator REJECTS malformed
* source the way it promises to — an unclassifiable package, an undocumented
* config field, a schema key the config type does not declare, or a referenced
* type name that resolves nowhere. These tests drive `collectConfigCatalog()`
* against synthetic fixture packages to prove each guard fires (and that
* well-formed packages classify and extract correctly), mirroring the
* negative tests for gen-cordis-catalog. The spec lives in this package
* because agent-core is the config-composition plugin (its schema is the
* intersection of its children's), the shape the generator's cross-package
* folding exists for.
*/
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'

View File

@@ -155,11 +155,8 @@ export class ReactLoopAgent implements Agent {
private setStatus(status: AgentStatus): void {
if (this._status === status || this._status === 'disposed') return
this._status = status
// Release quiescence waiters on a transition OUT of running BEFORE emitting
// (the disposer handles the disposed transition separately). Settling first
// means a throwing `agent/status` subscriber cannot starve a `whenIdle()`
// waiter (docs/defensive-patterns.md "contain callback exceptions" — a lifecycle await must
// not hang on one bad listener).
// Release quiescence waiters on a transition OUT of running before emitting (the disposer
// handles the disposed transition separately).
if (status !== 'running') this.settleIdleWaiters()
try {
this.loopCtx.emit(this.carrier, 'agent/status', this, status)
@@ -220,25 +217,15 @@ export class ReactLoopAgent implements Agent {
// No turn open: wrap the injection in a one-shot turn so every event stays
// turn-enclosed (the durability/replay boundary is the turn).
const turn = lastTurnNumber(this.session) + 1
// Once turn/start enters the log, a turn/end is OWED no matter what — even
// if a throwing `session/event` listener escapes from the turn/start append
// (Session.append pushes the event BEFORE notifying listeners) or the
// context/message append throws (non-serializable content, throwing
// listener). The finally re-checks the log via isTurnOpen() and closes the
// turn if one was actually opened, so the log never carries a permanently
// open injection turn that would corrupt later turns/replay. (If the
// turn/start append throws BEFORE pushing — non-serializable trigger, which
// can't happen for our fixed trigger — no turn was opened and none is owed.)
// Once turn/start enters the log, a turn/end is OWED no matter what — even if a throwing
// `session/event` listener escapes from the turn/start append (Session.append pushes the
// event before notifying listeners) or the context/message append throws (non-serializable
// content, throwing listener).
try {
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
this.session.append('context/message', { content, source }, { surfaceOp: 'append' })
} finally {
// Close the turn if turn/start made it into the log. Contain a throwing
// turn/end listener: Session.append pushes before notifying, so a throw
// here still leaves turn/end in the log (the turn is balanced) — swallow
// it so it neither replaces the original exception nor skips the flush
// decision below. (It surfaces through the flush path is not needed; the
// turn-balance contract is what matters and it holds.)
// Close the turn if turn/start made it into the log.
if (isTurnOpen(this.session)) {
try {
this.session.append('turn/end', { turn, reason: { kind: 'completed' } })
@@ -247,25 +234,12 @@ export class ReactLoopAgent implements Agent {
// so the turn is balanced; the throw is the listener's bug.
}
}
// Decide the durability checkpoint from the LOG, not a flag: a turn was
// recorded iff this turn's turn/start is logged (it may have been closed
// by a throwing-listener turn/end above, which still counts). A
// `turnRecorded` boolean set after append('turn/end') would be skipped by
// a throwing turn/end listener, losing the flush for a balanced in-memory
// turn (crash before the next turn/dispose would drop the idle injection).
// Decide the durability checkpoint from the LOG, not a flag: a turn was recorded iff this
// turn's turn/start is logged (it may have been closed by a throwing-listener turn/end
// above, which still counts).
const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
// Checkpoint the one-shot turn for durability, exactly as the loop does at
// every turn/end. The loop is NOT running (we are idle), so nothing else
// will flush this turn. Fire-and-forget with error containment: inject()
// is synchronous, and a persistence backend failing must not throw into
// the caller (e.g. a tool-bash task-done callback). Disposal still drains
// independently, so a slow flush is safe. The task is tracked until it
// settles: driver disposal awaits every pending idle-injection checkpoint
// before unregistering the agent or detaching the session. A flush failure
// is reported via agent/error (step 0 — the idle-injection convention,
// there is no real step) AND the logger, mirroring the loop's post-turn/end
// flush path so plugins monitoring agent/error see idle-injection
// persistence failures too. A throwing agent/error listener is contained.
// Checkpoint the one-shot turn for durability, exactly as the loop does at every
// turn/end.
if (turnRecorded) {
// Through the store's flush (the carrier owner), never a raw parallel.
const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => {
@@ -279,10 +253,8 @@ export class ReactLoopAgent implements Agent {
}
})
this.pendingIdleFlushes.add(flush)
// Attach the same retirement callback to both settlement arms so even a
// logger failure in the catch above cannot become an unhandled rejection.
// Teardown uses allSettled for the same reason: a reporting failure must
// not strand ownership.
// Attach the same retirement callback to both settlement arms so even a logger failure
// in the catch above cannot become an unhandled rejection.
const retire = (): void => { this.pendingIdleFlushes.delete(flush) }
void flush.then(retire, retire)
}
@@ -291,15 +263,8 @@ export class ReactLoopAgent implements Agent {
cancel(reason?: string): void {
this.assertDriveEnabled('cancel')
// Arm-gate: only mark a cancellation when there is actually work to cancel —
// a running turn, an in-flight step, or queued/steering work. An idle cancel
// with nothing pending is a true no-op; arming the marker then would wrongly
// drop the NEXT legitimate prompt (the marker is consumed only at the loop's
// turn-decision points, which an idle parked loop does not reach until woken
// by a real send()). Note the gate canNOT be `status === 'running'` alone:
// the pre-step window (a send() queued but the loop not yet flipped to
// running) has status `idle` with `hasQueued` true, and the marker exists
// precisely to cover it.
// Arm-gate: only mark a cancellation when there is actually work to cancel — a running
// turn, an in-flight step, or queued/steering work.
if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) {
this.cancelRequested = true
// Capture the resolved reason for the marker-only windows (pre-step /
@@ -307,10 +272,8 @@ export class ReactLoopAgent implements Agent {
// below; the marker path reads it via the LoopHandle's cancelReason().
this.cancelReason = reason ?? 'cancelled'
}
// Drop all pending queued + steering work (un-started prompts never run; the
// cancelled turn's steering is not re-enqueued). Cleared directly even when
// the loop is parked in waitForQueued — there is no turn to stop and nothing
// left for the parked loop to run, so no wake is needed.
// Drop all pending queued + steering work (un-started prompts never run; the cancelled
// turn's steering is not re-enqueued).
this.#inbox.clear()
// Interrupt an in-flight step immediately (the running turn observes the
// abort and ends `aborted`). The marker covers the windows where no step is
@@ -319,29 +282,15 @@ export class ReactLoopAgent implements Agent {
}
/**
* Resolve once the agent has reached quiescence after settling out of
* `running`. If it is already disposed, awaits {@link done} (the loop-exit
* promise) — `agent/status('disposed')` fires in the disposer BEFORE the
* driver loop has unwound, so it is NOT itself a quiescence signal. If it is
* idle AND has no queued work, resolves immediately. Otherwise queues an
* internal waiter (see {@link idleWaiters}) released on the next
* running→idle/disposed transition, resolving on `idle` directly (the turn
* fully ended) or chaining {@link done} on `disposed` (wait for the loop to
* actually exit). Implements the {@link Agent.whenIdle} contract: a non-owner
* quiescence-observation hook, distinct from teardown (a lifecycle owner stops
* and unregisters via `AgentHandle.dispose()`, whose driver boundary awaits
* both {@link done} and outstanding idle-injection flushes, not through this).
* Resolve once the agent has reached quiescence after settling out of `running`.
*/
whenIdle(): Promise<void> {
if (this._status === 'disposed') return this.done
if (this._status !== 'running' && !this.#inbox.hasQueued) return Promise.resolve()
// Register an internal waiter (resolved by settleIdleWaiters on the next
// running→idle/disposed transition), NOT an effect-scoped `ctx.on` listener:
// a concurrent fiber disposal runs this agent's listener disposers, which
// could remove a `ctx.on` waiter before the `disposed` transition fires and
// hang the promise. On disposal the disposer settles the waiter AND we chain
// `done` here for true loop-exit quiescence (status flips to disposed before
// the loop unwinds); a plain idle transition resolves directly.
// running→idle/disposed transition), not an effect-scoped `ctx.on` listener: a concurrent
// fiber disposal runs this agent's listener disposers, which could remove a `ctx.on` waiter
// before the `disposed` transition fires and hang the promise.
return new Promise<void>((resolve) => {
this.idleWaiters.push(() => {
resolve(this._status === 'disposed' ? this.done : undefined)
@@ -370,12 +319,10 @@ export class ReactLoopAgent implements Agent {
isCancelled: () => this.cancelRequested,
cancelReason: () => this.cancelReason,
clearCancel: () => { this.cancelRequested = false },
// Settle whenIdle() waiters WITHOUT a status transition — the pre-step
// cancel-skip path drops the about-to-run turn and re-parks without ever
// flipping running→idle, so a waiter registered in the pre-step window
// (status idle, hasQueued was true) would otherwise hang. This emits no
// agent/status, so an ACP agent/status listener never sees a spurious idle
// that would resolve a freshly-queued prompt as cancelled.
// Settle whenIdle() waiters WITHOUT a status transition — the pre-step cancel-skip path
// drops the about-to-run turn and re-parks without ever flipping running→idle, so a
// waiter registered in the pre-step window (status idle, hasQueued was true) would
// otherwise hang.
settleIdle: () => { this.settleIdleWaiters() },
})
// The disposer must be infallible: it runs inside the fiber's LIFO
@@ -404,10 +351,6 @@ export class ReactLoopAgent implements Agent {
// final lifecycle backstop for anything outside those boundaries.
await Promise.allSettled([this.done])
// No new inject() can start after the synchronous disposed transition.
// Loop because settled tasks retire themselves in promise reactions that
// may run beside this continuation; either the set is empty or this waits
// the exact remaining quiescence boundary. allSettled keeps a failure in
// error reporting from skipping the registry/session/scope disposers.
while (this.pendingIdleFlushes.size > 0) {
await Promise.allSettled([...this.pendingIdleFlushes])
}

View File

@@ -42,17 +42,8 @@ export interface Config {
/** Optional workspace cwd for the config-created fresh session. */
cwd?: string
/**
* If set, the config agent RESUMES this persisted session id instead of
* starting a fresh `${id}-session-<uuid>`. Sourced from an env var in
* cordis.yml (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`), so a
* demo can continue a prior conversation without code changes. Requires a
* `dsh-session-persistence` backend; the resume is deferred until that
* service is available (via `ctx.inject`) and the loaded session's events
* seed the live session so history continues.
*
* The schema accepts a plain string at runtime (cordis.yml values are
* untyped); the brand is compile-time only — the config format is the
* boundary where an id enters, so the TYPE declares the brand here.
* If set, the config agent RESUMES this persisted session id instead of starting a fresh
* `${id}-session-<uuid>`.
*/
resumeSessionId?: SessionId
})[]
@@ -75,11 +66,9 @@ export class AgentLoop extends Service implements AgentFactory {
private pendingAgentIds = new Set<AgentId>()
private pendingSessionIds = new Set<SessionId>()
// The schema validates plain strings (cordis.yml config values are untyped at
// runtime); the {@link Config} TYPE declares the branded `id`/`resumeSessionId`
// because the config format is the boundary where an id enters. The brand is a
// zero-cost compile-time cast, so the runtime schema stays string-based and we
// assert the branded view once here — the single schema boundary.
// The schema validates plain strings (cordis.yml config values are untyped at runtime); the
// {@link Config} TYPE declares the branded `id`/`resumeSessionId` because the config format
// is the boundary where an id enters.
static Config = z.object({
agents: z.array(z.object({
id: z.string().required(),
@@ -94,25 +83,12 @@ export class AgentLoop extends Service implements AgentFactory {
// Provide the agent-creation factory to the registry (effect-scoped: the
// slot is cleared on dispose).
ctx.effect(() => this.ctx.agents.setFactory(this), 'agentLoop.setFactory()')
// The prompt variables the shipped loop provides, registered once. The
// sections themselves (`harness:identity`, `deployment:persona`) belong to
// dsh-system-prompt — they must survive a swapped loop plugin — but
// `{{model}}`/`{{cwd}}` are runtime facts of the agents THIS loop drives:
// it assembles with `{ agent }` each step (loop.ts), and the variables
// project the agent's configured model and its session workspace from that
// context. A provider returns undefined when the fact is absent
// (renderPrompt then rejects a persona that claims it — fail loud).
// The prompt variables the shipped loop provides, registered once.
ctx.systemPrompt.variable('model', context => context.agent?.options.model)
ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
for (const { id, cwd, resumeSessionId, ...options } of config.agents) {
if (resumeSessionId !== undefined && resumeSessionId !== '') {
// Resume a prior session instead of starting fresh. resume() needs
// `ctx.sessionPersistence`, which may load AFTER this plugin (cordis.yml
// lists the backend later). `ctx.inject(['sessionPersistence'], cb)`
// runs `cb` with a child ctx once the service exists; the child reads
// the persistence and hands it to resumeWith (which uses this.ctx — the
// parent — for sessions/registry, all in AgentLoop's static inject). A
// failed resume is contained + logged: startup must not crash.
// Wait for a late persistence service before resuming the configured session.
ctx.effect(() => {
const fiber = this.ctx.inject(['sessionPersistence'], (childCtx: Context) => {
void this.resumeWith(childCtx.sessionPersistence, { agentId: id, resumeSessionId, agentOptions: options })
@@ -120,10 +96,7 @@ export class AgentLoop extends Service implements AgentFactory {
this.ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
})
})
// Return the EXACT child-fiber disposer. Cordis moves a returned
// effect into this labeled owner's teardown tree by function
// identity; a wrapper would leave the child as a concurrent sibling
// and could discard its async quiescence promise.
// Return the exact child-fiber disposer.
return fiber.dispose
}, `agentLoop.resume(${id})`)
} else {
@@ -133,54 +106,32 @@ export class AgentLoop extends Service implements AgentFactory {
}
/**
* Config-driven create: an agent on a FRESH, non-colliding session id per run
* (`${id}-session-<uuid>`). Used for `cordis.yml`-configured agents and as
* the shared core for the programmatic factory {@link createAgent}.
*
* Why a per-run id, not a fixed `${id}-session`: once a durable persistence
* backend is loaded, a fixed id collides on the second run — the backend
* refuses to re-create an id whose log already exists on disk (the SessionId
* is the identity). A fresh id means each run is a new session.
*
* TODO(demo): each run starting a brand-new session is fine for demos but is
* NOT real conversation continuity. A production config-driven agent needs a
* deliberate resume-or-create policy (resume the prior session if one exists,
* else start fresh) or an explicit caller-chosen session id — revisit when the
* UI/ACP path owns session selection.
* @param id - the agent id; also seeds the generated session id.
* @param options - loop options (model, limits, …); defaults applied per option.
* @param meta - optional session metadata for the fresh session.
* @returns the running agent, owned by the calling fiber (no handle).
* Create a config-driven agent with a unique session id for this run.
* @param id - agent id and generated-session prefix.
* @param options - loop options.
* @param meta - optional fresh-session metadata.
* @returns running agent owned by the calling fiber.
*/
// TODO(demo): define a production resume-or-create policy for config-driven agents.
create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
this.assertAgentIdFree(id)
// Config/programmatic path: prepare the session and let start() fold its
// lifecycle into the agent's composite effect (so a fiber unload tears the
// session + agent down as one ordered chain, capturing the loop's closing
// flush). The whole effect is owned by THIS fiber; no AgentHandle is needed.
// The calling fiber owns the prepared session and agent lifecycle.
const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta })
const { agent } = this.start(id, options, session, 'startup')
return agent
}
/**
* Programmatic factory create ({@link AgentFactory}): an agent on a
* caller-supplied `sessionId` (NOT `${id}-session`), with optional session
* metadata (validated `cwd`, lineage) and an optional `seed` event prefix. The
* ACP bridge uses this so the client-generated session id becomes the
* live/persisted session id; the in-process FORK subagent backend passes a
* `seed` (a balanced completed-turn prefix of the parent's log) so the child
* starts with the parent's context. Returns an {@link AgentHandle} the owner
* disposes to tear down exactly this agent.
* Programmatic factory create ({@link AgentFactory}): an agent on a caller-supplied
* `sessionId` (not `${id}-session`), with optional session metadata (validated `cwd`,
* lineage) and an optional `seed` event prefix.
*
* @param options - agent id, caller-supplied session id, optional seed/meta,
* and agent options.
* @returns the handle whose dispose tears down exactly this agent.
*/
async createAgent(options: CreateAgentOptions): Promise<AgentHandle> {
// Snapshot every caller-owned field before the first async setup boundary.
// The callback itself is an identity capability; all data fields are
// detached so caller mutation cannot drift a reserved/published identity or
// the options the accepted agent observes.
const agentId = options.agentId
const sessionId = options.sessionId
const setup = options.setup
@@ -201,35 +152,17 @@ export class AgentLoop extends Service implements AgentFactory {
}
/**
* Resume an agent on a persisted session ({@link AgentFactory}). Loads the
* session log + metadata via `ctx.sessionPersistence`, reconstructs the live
* session with the loaded events (so `lastTurnNumber`/`deriveMessages`
* continue), and starts a fresh agent on it. The live session id is the
* resumed id, NOT `${agentId}-session`.
* Resume an agent on a persisted session ({@link AgentFactory}). Loads the session log +
* metadata via `ctx.sessionPersistence`, reconstructs the live session with the loaded
* events (so `lastTurnNumber`/`deriveMessages` continue), and starts a fresh agent on it.
* The live session id is the resumed id, not `${agentId}-session`.
*
* Requires `ctx.sessionPersistence`; rejects with a clear error if it is not
* configured. NOT hard-injected (that would make non-persistent demos pend
* forever) — callers that need resume (ACP) inject `sessionPersistence`, so
* by the time this runs the service exists.
* @param options - the persisted session id to reload, plus agent id/options.
* @returns the handle for the agent resumed on the reconstructed session.
*/
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
// Read the service through `ctx.get('sessionPersistence')` — a direct
// global-store lookup keyed by the isolate symbol — NOT
// `this.ctx.sessionPersistence`. AgentLoop deliberately does NOT inject
// `sessionPersistence` (injecting it would pend non-persistent demos
// forever). The `ctx.<name>` property proxy resolves a service by an
// ancestor-only walk of the current fiber's parent chain; from AgentLoop's
// own fiber (which lacks the inject) that walk never reaches the sibling
// backend fiber and throws "cannot get property … without inject". Worse,
// when the call arrives via a traceable shadow (e.g. the ACP bridge child
// fiber → `ctx.agents.resume()` → `this.factory.resume()`), the walk starts
// at the shadow's origin fiber and fails the same way. `ctx.get(name)`
// sidesteps the fiber walk entirely (a store lookup by the global isolate
// key), so resume works from any caller fiber. It is strict by default: a
// backend that is not ACTIVE (absent, or mid-teardown) reads as undefined
// and we reject below, rather than handing back an unusable handle.
// Read the service through `ctx.get('sessionPersistence')` — a direct global-store lookup
// keyed by the isolate symbol — not `this.ctx.sessionPersistence`.
const persistence = this.ctx.get('sessionPersistence')
if (persistence === undefined) {
throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
@@ -257,13 +190,7 @@ export class AgentLoop extends Service implements AgentFactory {
const { promise: ownerDisposed, resolve: markOwnerDisposed } = Promise.withResolvers<void>()
const { promise: transactionSettled, resolve: markTransactionSettled } = Promise.withResolvers<void>()
let observingOwner = true
// Resume must observe its caller from BEFORE persistence I/O begins. The
// full agent lifecycle does not exist until load returns, so without this
// sentinel a never-settling backend outlives owner disposal and holds both
// public identities forever. `this.ctx.effect` retains the traceable caller
// ownership used by startOwned's lifecycle effect. Install it before even
// reserving the ids: an inactive owner cannot leak a reservation if effect
// registration fails.
// Resume must observe its caller from before persistence I/O begins.
const disposeLoadSentinel = this.ctx.effect(() => () => {
if (!observingOwner) return
markOwnerDisposed()
@@ -306,11 +233,8 @@ export class AgentLoop extends Service implements AgentFactory {
}
} finally {
try {
// Manual handoff/removal must not return transactionSettled: awaiting
// that promise from inside this transaction would deadlock it. If the
// owner already triggered cleanup, this idempotent second disposal is a
// no-op and the owner's first cleanup remains parked on the shared
// settlement promise.
// Manual handoff/removal must not return transactionSettled: awaiting that promise from
// inside this transaction would deadlock it.
observingOwner = false
await disposeLoadSentinel()
} finally {
@@ -360,11 +284,9 @@ export class AgentLoop extends Service implements AgentFactory {
publish: (source: SessionStartSource) => void
disposeAgent: () => Promise<void>
} {
// When creation is invoked through an agent scope (subagents), the owner
// agent's disposed status flips synchronously at handle teardown—earlier
// than Cordis reaches nested scope effects. Include that signal in the
// pre-publication liveness check so a same-turn parent dispose cannot race
// an already-fulfilled setup promise into briefly publishing a child.
// When creation is invoked through an agent scope (subagents), the owner agent's disposed
// status flips synchronously at handle teardown—earlier than Cordis reaches nested scope
// effects.
const ownerAgent = this.ctx.agent
const ownerFiber = this.ctx.fiber
const driver = prepareReactLoopAgent(this.ctx, id, options, session)
@@ -458,22 +380,7 @@ export class AgentLoop extends Service implements AgentFactory {
}
/**
* Build an {@link AgentHandle} for a PREPARED session + a fresh agent. The
* handle's `dispose()` runs the composite effect's disposer (see
* {@link start}) — which stops the loop, awaits its exit and outstanding
* idle-injection flushes, unregisters the agent, and detaches the session, in
* that order.
* The same composite effect is what a fiber unload disposes, so both teardown
* triggers honor the ordering identically.
*
* `dispose()` is MEMOIZED: the underlying cordis effect disposer is
* single-shot (a second call returns immediately because the effect's epoch is
* already cleared, NOT awaiting the in-flight teardown), so concurrent/repeated
* `dispose()` calls would otherwise resolve before the first call's
* loop + flush quiescence boundary completed. Memoizing the promise makes
* every caller observe that SAME boundary, honoring the
* `AgentHandle.dispose(): Promise<void>` contract (mirrors the ACP `quiesce()`
* helper).
* Build an {@link AgentHandle} for a PREPARED session + a fresh agent.
*/
private async startOwned(
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
@@ -491,11 +398,8 @@ export class AgentLoop extends Service implements AgentFactory {
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
}),
])
// Cordis begins a fiber unload synchronously but invokes nested effect
// disposers from its next microtask. Give that already-started unload one
// checkpoint to deactivate this lifecycle before publication; otherwise
// an immediately fulfilled setup continuation can outrun its owner's
// same-turn dispose and briefly publish an already-doomed child.
// Cordis begins a fiber unload synchronously but invokes nested effect disposers from its
// next microtask.
await Promise.resolve()
if (!lifecycle.active()) {
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)

View File

@@ -51,20 +51,8 @@ function assertContinuationStop(value: unknown): asserts value is ContinuationSt
}
/**
* Map a model-call {@link FinishReason} to the step error it should raise, or
* `undefined` when the step completed normally.
*
* Adapters report provider/transport failures one of two sanctioned ways (see
* the StreamChunk contract in dsh-llm): throw from `stream()` (handled by the
* caller's try/catch), OR end the stream with a finish-error/aborted chunk
* (the only option for adapters that can't throw mid-stream, e.g.
* library-backed ones). This translates the latter into a thrown step error
* so the turn ends error/aborted (the failure recorded on `turn/end.reason`),
* never as a normal `completed` assistant message.
*
* `FinishReason` is merge-extensible (plugins/adapters can add `kind`s), so
* the switch handles the known terminal-failure kinds and treats every other
* kind — `stop`, `tool-calls`, `max-tokens`, future additions — as success.
* Map a model-call {@link FinishReason} to the step error it should raise, or `undefined` when
* the step completed normally.
*/
function finishError(finish: FinishReason): CodedError | undefined {
switch (finish.kind) {
@@ -93,17 +81,8 @@ function errorData(err: CodedError): { message: string; code?: string } {
}
/**
* The turn-end contribution of a step's *successful* finish, or `undefined`
* when the step finished ordinarily (a plain `completed`).
*
* {@link finishError} has already converted `error`/`aborted` finishes into
* thrown step errors, so the finishes that reach here are `stop`,
* `tool-calls`, `max-tokens`, or a future merge-extensible kind. Only
* `max-tokens` carries forward as a distinct {@link TurnEndReason}: a step that
* hit the output-token ceiling ended the turn cut-short rather than by the
* model's choice. `stop`/`tool-calls`/unknown kinds contribute nothing beyond
* the default `completed`. {@link runTurn} applies this with the rule "any
* `max-tokens` step in the turn makes the turn end `max-tokens`".
* The turn-end contribution of a step's *successful* finish, or `undefined` when the step
* finished ordinarily (a plain `completed`).
*/
function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
switch (finish.kind) {
@@ -161,66 +140,15 @@ export interface LoopHandle {
}
/**
* The agent loop. One invocation drives one agent for its whole lifetime:
* The agent loop. One invocation drives one agent for its whole lifetime.
*
* ```
* create agent → emit agent/session-start(source) ⟵ once, before turn 1
* forever:
* wait for queued messages (idle)
* TURN (error-contained — a throwing plugin ends the turn, never the loop):
* 'turn/start'; each queued msg: waterfall agent/prompt-submit ⟵ durable turn boundary (no agent/* mirror)
* allow → session('user/message'…) (+ inject additionalContext) | block → drop
* every prompt blocked → 'turn/end'(rejected), 0 steps
* STEP loop:
* drain steering → session('steering/message') ⟵ catches late steering
* assembly = ctx.systemPrompt.assemble(assembleContextFor(agent)) ⟵ waterfall system-prompt/assemble
* (scope-filtered; scoped sections/tools join); renderPrompt
* (persona section + {{variables}}) IS the full prompt
* prefix ??= waterfall agent/session-prefix ⟵ once per loop instance (first step): frozen
* session prefix; logged on the header, never
* session history (scope-filtered, fused dispatch)
* await events.serial('agent/pre-step', …, prefix) ⟵ surface mutation (compaction) OUTSIDE the step;
* pressure gates see the prefix the request carries
* boundary = session.deriveMessages() ⟵ the reconstruction boundary: snapshot in the
* session('step/start') same sync frame, strictly before step/start
* config = waterfall agent/request(config) ⟵ frozen seed; a returned replacement switches
* session('request/header'|'request/header-delta') ⟵ the header event this request owes the
* log (initial/resume anchor, delta, fallback)
* req = freeze({header..., messages: prefix+boundary, sessionId, signal})
* stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks, frozen req)
* session('assistant/chunk')
* msg = waterfall agent/step-result ⟵ BEFORE the log append, so the
* session('assistant/message' {content, usage?}) session records what actually ran
* each tool-call in msg (sequential, abort-checked):
* session('tool/call'); ctx.tools.execute() ⟵ tools/pre-execute (allow/deny/ask)
* → dispatch → tools/post-execute
* session('tool/result')
* append buffered post-execute additionalContext → session('context/message')(s)
* drain steering → session('steering/message')
* session('step/end') ⟵ durable step boundary (no agent/* mirror)
* cont = waterfall agent/turn-continuation ⟵ ContinuationDecision; default
* {action: hadToolCalls||steered ? 'continue':'stop'}; a continue.reason is
* recorded as next-step steering
* if action==stop && steering arrived (step/end/continuation listeners): continue anyway
* terminal = serial agent/turn-stop ⟵ stop or abstain; after all ordinary
* continuation and steering folding
* if terminal: discard pending steering and break
* if action==stop: break
* session('turn/end') ⟵ durable turn boundary (no agent/* mirror)
* await ctx.sessions.flush(session) ⟵ durability checkpoint (store-owned carrier)
* re-enqueue leftover steering as queued ⟵ steering is never stranded
* idle (emit agent/status) unless more queued
* ```
* @param ctx - the plugin context the loop reaches events (agent/…, session/flush) and services (systemPrompt, llm, tools) through.
* @param agent - the agent this invocation drives for its whole lifetime (its inbox, session, and options).
* @param handle - the bridge to the agent's mutable state: status/abort setters plus the disposal and cancel-marker reads.
*/
export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise<void> {
// Per-instance transmission bookkeeping: whether THIS loop instance has
// anchored the log's header fold yet (its first request logs a
// 'initial'/'resume' request/header snapshot). Everything else the request
// needs is read from the session log itself — the loop holds no
// conversation state (the reconstructability RFC).
// Per-instance transmission bookkeeping: whether this loop instance has anchored the log's
// header fold yet (its first request logs a 'initial'/'resume' request/header snapshot).
const transmission = createTransmissionLog()
const { session } = agent
@@ -233,19 +161,8 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
await handle.inbox.waitForQueued(handle.disposed)
if (handle.isDisposed()) break
// Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the
// idle wait but before we flip to `running`. The cancelled queued/steering
// work is already cleared by `cancel()`. Clear the marker, then:
// - if NOTHING new is queued, drop the about-to-run turn and re-park,
// settling any `whenIdle()` waiter DIRECTLY (no running→idle transition
// fires here to settle it) and WITHOUT emitting `agent/status` (an ACP
// listener must not see a spurious idle that resolves a freshly-queued
// prompt as cancelled);
// - if a NEW prompt was queued AFTER the cancel (a send() that raced in
// before the loop resumed), the marker was for the cancelled work only —
// fall through and run the new prompt's turn. Do NOT settle waiters here:
// a whenIdle() waiter must wait for that new turn's running→idle, not
// resolve before it runs (the quiescence contract).
// Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the idle wait but
// before we flip to `running`.
if (handle.isCancelled()) {
handle.clearCancel()
if (!handle.inbox.hasQueued) {
@@ -256,18 +173,8 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
handle.setStatus('running')
// Pre-step cancel (window 2): `setStatus('running')` emits `agent/status`
// SYNCHRONOUSLY, so a `running` listener can `cancel()` in the gap between the
// check above and `runTurn`. Mirror window 1: clear the marker, then
// - if NOTHING new is queued, drop the about-to-run turn and transition
// back to `idle` (`running` was already emitted, so a real idle
// transition balances the status AND settles `whenIdle()` waiters);
// - if a NEW prompt was queued AFTER the cancel (a `running` listener that
// cancels then sends), the marker was for the cancelled work only — fall
// through and run the new prompt's turn (status is already `running`), so
// a `whenIdle()` waiter resolves on THAT turn's running→idle, not before
// it runs. Settling here would resolve quiescence while the replacement
// is still queued and unrun (the same early-resolve race window 1 fixes).
// Pre-step cancel (window 2): `setStatus('running')` emits `agent/status` SYNCHRONOUSLY, so
// a `running` listener can `cancel()` in the gap between the check above and `runTurn`.
if (handle.isCancelled()) {
handle.clearCancel()
if (!handle.inbox.hasQueued) {
@@ -276,21 +183,14 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
}
}
// Re-derive the turn number from the log each iteration (do NOT keep a local
// counter): an idle `agent.inject()` can append its own one-shot turn while
// the loop waits above, so the next real turn must continue from whatever
// turn number is actually last in the log — a stale counter would collide.
// Re-derive turn numbers because idle injection can advance the log.
const turn = lastTurnNumber(session) + 1
let terminalStopped = false
try {
terminalStopped = await runTurn(ctx, events, agent, handle, turn, transmission)
} catch (error: unknown) {
// Backstop: runTurn rethrows only a PRE-turn throw (the invariant guard
// before turn/start) — no turn/start was appended, so no turn is open and
// none is owed. A session `error` here would land outside any turn (after
// the previous turn/end), where the persistence backend drops it as a
// crash tail (the turn-enclosure RFC). Report via agent/error + the logger only; the
// driver survives and moves on.
// Backstop: runTurn rethrows only a PRE-turn throw (the invariant guard before
// turn/start) — no turn/start was appended, so no turn is open and none is owed.
const err = toError(error)
ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`)
try {
@@ -298,21 +198,12 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
} catch { /* contained: a throwing agent/error listener must not kill the driver */ }
}
// Reset the cancel marker UNCONDITIONALLY here, after the turn returns and
// before the next iteration's idle wait. NOT gated on the idle transition
// below: a `send()` that lands during the cancelled turn's flush window makes
// `hasQueued` true at the `setStatus('idle')` guard, so an idle-gated reset
// would never fire and the stale marker would wrongly drop that next prompt's
// turn. Resetting per iteration scopes the marker to exactly the turn that was
// cancelled.
// Reset the cancel marker UNCONDITIONALLY here, after the turn returns and before the next
// iteration's idle wait.
handle.clearCancel()
// Steering that arrived too late to join an ordinary turn (turn-end
// listeners, flush) becomes queued input so it is never stranded. A
// terminal-stop owner is the deliberate exception: discard the steering
// again after the close + flush window so terminal policy cannot be undone
// after its in-turn drain. Ordinary queued sends live in a separate FIFO and
// remain untouched.
// Steering that arrived too late to join an ordinary turn (turn-end listeners, flush)
// becomes queued input so it is never stranded.
for (const message of handle.inbox.drainSteering()) {
if (!terminalStopped) handle.inbox.enqueue(message)
}
@@ -326,10 +217,7 @@ async function runTurn(
): Promise<boolean> {
const { session } = agent
// --- Pre-turn. A throw here (the invariant guard) is owed NO turn/end —
// turn/start has not been appended — so it propagates to runLoop's backstop
// untouched. The queued messages are drained here but appended AFTER
// turn/start (below), so every event in the log lives inside a turn.
// --- Pre-turn.
const queued = handle.inbox.drainQueued()
const first = queued[0]
/* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
@@ -342,29 +230,19 @@ async function runTurn(
let errorReported = false
let terminalStopped = false
// Close the open step exactly once (idempotent via stepOpen). Step boundaries
// are durable session events only — there is no agent/* step emit to mirror
// them (see the agent event-domain rule). A throwing step/end session-event
// listener must not abort finalization and strand the turn open (turn/end
// balance > notifying one bad listener); it is contained and surfaced as a
// turn error below.
// Close the open step exactly once (idempotent via stepOpen).
const closeStep = (): boolean => {
if (!stepOpen) return false
stepOpen = false
// Session.append pushes step/end BEFORE notifying session/event listeners,
// so a throwing listener leaves step/end in the log (balance holds) but
// would otherwise abort finalization. Contain it and surface it as a turn
// error below.
// Preserve step balance even when an event listener throws after append.
let failure: unknown
try {
session.append('step/end', { turn, step })
} catch (error: unknown) {
failure = error
}
// A throwing step/end session-event listener surfaces as a turn error via
// failTurn (idempotent). This prevents a throwing listener from producing a
// silent "completed" turn when the step itself succeeded, AND keeps
// finalization going when closeStep runs from the outer catch.
// A throwing step/end session-event listener surfaces as a turn error via failTurn
// (idempotent).
if (failure !== undefined) {
failTurn(toError(failure))
return true
@@ -372,21 +250,16 @@ async function runTurn(
return false
}
// Record a step/turn failure exactly once: set the error reason (carrying the
// failing `step` — the durable failure lives entirely on turn/end.reason, there
// is no separate session error event) and emit agent/error (contained — trap: a
// throwing agent/error listener must not re-escape and strand the turn).
// Disposal and abort set `reason` directly without calling this (they are not
// failures).
// Record a step/turn failure exactly once: set the error reason (carrying the failing `step`
// — the durable failure lives entirely on turn/end.reason, there is no separate session error
// event) and emit agent/error (contained — trap: a throwing agent/error listener must not
// re-escape and strand the turn).
const failTurn = (err: CodedError): void => {
if (errorReported) return
errorReported = true
// The turn is always still open here: the only failure that can reach
// failTurn once turn/end is appended would be a throwing turn-boundary
// listener, and turn boundaries are durable session events with no agent/*
// mirror to throw. A throwing `turn/end` session-event listener is already
// contained inside closeTurn (append pushes before notifying, so the
// boundary is durable). So set the error reason for closeTurn to append.
// The turn is always still open here: the only failure that can reach failTurn once
// turn/end is appended would be a throwing turn-boundary listener, and turn boundaries are
// durable session events with no agent/* mirror to throw.
reason = { kind: 'error', step, ...errorData(err) }
try {
events.emit('agent/error', turn, step, err)
@@ -396,18 +269,11 @@ async function runTurn(
}
}
// Close the turn. Called exactly once per turn — the normal loop exit and the
// outer catch are mutually exclusive paths, and this never throws (the append
// is contained below), so there is no re-entry to guard against (unlike
// closeStep, which the cancel branches and the outer catch can both reach).
// Turn boundaries are durable session events only — there is no agent/* turn
// emit to mirror them (see the agent event-domain rule).
// Close the turn.
const closeTurn = (): void => {
// Session.append pushes turn/end BEFORE notifying session/event listeners,
// so a throwing listener leaves turn/end in the log (the turn is balanced)
// but would otherwise escape — from the outer catch it would propagate to
// the runLoop backstop. Contain it: the boundary is durable either way, and
// finalization must not abort on a bad listener.
// Session.append pushes turn/end before notifying session/event listeners, so a throwing
// listener leaves turn/end in the log (the turn is balanced) but would otherwise escape —
// from the outer catch it would propagate to the runLoop backstop.
try {
session.append('turn/end', { turn, reason })
} catch (error: unknown) {
@@ -416,16 +282,10 @@ async function runTurn(
}
try {
// --- Turn boundary. Once turn/start is appended, a turn/end is owed no
// matter what throws below; the catch + closeTurn guarantee it (the catch
// decides "owed" from the log via isTurnOpen, so even a throwing turn/start
// listener — append pushes before notifying — still gets its turn/end).
// --- Turn boundary.
session.append('turn/start', { turn, trigger })
// Each drained queued message runs the `agent/prompt-submit` waterfall before
// it becomes a `user/message` — a hook can rewrite the prompt or block it.
// Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed;
// turn/end is now owed, so a throwing prompt-submit listener (the waterfall
// throws) is caught below and the turn still closes.
// Each drained queued message runs the `agent/prompt-submit` waterfall before it becomes a
// `user/message` — a hook can rewrite the prompt or block it.
let anyAllowed = false
// Seeded with a floor (only observable if the batch were empty, which
// runTurn never allows — it is called with ≥1 queued message); each `block`
@@ -439,13 +299,7 @@ async function runTurn(
)
if (decision.kind === 'block') {
lastBlockReason = decision.reason
// Record the veto durably: `PromptDecision.reason` is the durable record
// of why a prompt was blocked, but a fully-blocked batch's `rejected`
// turn/end only preserves the LAST reason, and a MIXED batch (this prompt
// blocked, another allowed) does not end `rejected` at all — so without
// this append a blocked prompt would vanish from the log whenever any
// sibling prompt is allowed. `prompt/blocked` sits in the open turn in
// place of the `user/message` this prompt would have become.
// Log each veto because turn/end cannot represent every blocked prompt.
session.append('prompt/blocked', { content: message.content, source: message.source, reason: decision.reason })
continue
}
@@ -461,11 +315,7 @@ async function runTurn(
}
while (true) {
// A fully-blocked batch (every prompt vetoed by prompt-submit) opens a
// zero-step turn that ends `rejected`: break BEFORE the first step so the
// boundary stays balanced (turn/start → turn/end) and the block is a
// durable in-turn fact. `anyAllowed` never changes inside the loop, so this
// only ever fires on the first iteration.
// A fully blocked batch ends as a balanced zero-step rejected turn.
if (!anyAllowed) {
reason = { kind: 'rejected', reason: lastBlockReason }
break
@@ -476,56 +326,29 @@ async function runTurn(
// the request.
drainSteering(agent, handle.inbox, turn)
// The step's AbortController exists BEFORE any async pre-step work so a
// dispose() or cancel() — in a synchronous turn-start listener or an
// async listener whose effect fires before we block — always has an armed
// abort to cancel against. isDisposed below covers disposal, which does
// NOT set the cancel marker. Cleared on every exit path below.
// The step's AbortController exists before any async pre-step work so a dispose() or
// cancel() — in a synchronous turn-start listener or an async listener whose effect fires
// before we block — always has an armed abort to cancel against. isDisposed below covers
// disposal, which does not set the cancel marker.
const abort = new AbortController()
handle.setAbort(abort)
// Assemble the system prompt for this step. Done HERE (before step/start)
// because the pre-step seam needs it: compaction measures token pressure
// against the system prompt (it counts toward the budget). runStep reuses
// this same assembly for the request, so the prompt is assembled once per
// step. renderPrompt IS the full prompt — the persona is the order-0
// section (owned by dsh-system-prompt) and `{{variable}}`
// interpolation happens in the render, so there is no separate join.
// Assemble the system prompt for this step.
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
const fullSystemPrompt = renderPrompt(assembly)
// Interruption landing after assembly: dispose() or cancel() in a
// turn-start listener (or a listener whose promise resolved before the
// await above) arms either handle.isDisposed() or handle.isCancelled().
// The Abort was created first, so any concurrent abort also lands on it.
// Drop the about-to-start step WITHOUT running the seam — no step is open
// yet, so end the turn accordingly (disposed wins for an unambiguous
// reason).
// Interruption landing after assembly: dispose() or cancel() in a turn-start listener (or
// a listener whose promise resolved before the await above) arms either
// handle.isDisposed() or handle.isCancelled().
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
break
}
// Compose the session prefix ONCE per loop instance, lazily before the
// instance's first pre-step: request-only messages placed in front of
// the ENTIRE derived history on every request this instance sends. It
// MUST precede the pre-step seam so compaction gates on THIS instance's
// prefix — reading a previous instance's logged prefix would let a
// resumed/forked instance whose contributor grew skip compaction and
// ship an over-window first request. The result is deep-cloned
// (decoupled from listener-held references), deep-frozen, and cached on
// the transmission bookkeeping, so reuse is structural — the prefix
// cannot change mid-session and the provider prefix cache holds by
// construction (resume = a new instance = a recompose, anchored by its
// 'resume' snapshot). The prefix is not session history — the header
// event in runStep is its only durable record
// (EpochHeader.messagePrefix). The frozen empty seed serves both the
// listener chain and the no-listener fallback: a contribution is a
// RETURNED extension of `await next()`, never an in-place push. This
// runs OUTSIDE the step, before the boundary snapshot: a composing
// listener's session append lands before the boundary and joins the
// CURRENT request.
// Compose the session prefix ONCE per loop instance, lazily before the instance's first
// pre-step: request-only messages placed in front of the entire derived history on every
// request this instance sends.
if (transmission.sessionPrefix === undefined) {
const emptyPrefix: Message[] = deepFreeze([])
const composed = await events.waterfall(
@@ -533,16 +356,9 @@ async function runTurn(
() => Promise.resolve(emptyPrefix),
)
// Interruption landing during prefix composition: mirror the assembly
// window above — drop the about-to-start step without running the
// seam, and DISCARD the composition instead of caching it. An
// abort-aware listener may have returned a degraded fallback under
// the firing signal; committing it would ship a prefix no request
// ever used (and no header ever logged) on this instance's next real
// request. The next turn recomposes under a live signal — the cache
// only ever holds a fully composed prefix. The cache-hit path needs
// no such check: nothing awaits between the assembly check above and
// the pre-step seam.
// Interruption landing during prefix composition: mirror the assembly window above —
// drop the about-to-start step without running the seam, and DISCARD the composition
// instead of caching it.
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
@@ -551,19 +367,7 @@ async function runTurn(
transmission.sessionPrefix = deepFreeze(structuredClone(composed))
}
// Pre-step surface-mutation checkpoint (compaction), fired OUTSIDE the
// step: after `turn/start` (and the prior step's close) but before
// `step/start`, so a compaction's log-only `compact/*` records and its
// replacement node land cleanly outside any step (honest structure that
// crash-safety relies on — a dangling `compact/start` sits before the
// synthetic `turn/end` repair appends). Serial (awaited, in order, no
// veto): each listener completes its surface mutation before the next, so
// concurrent listeners cannot interleave their `session.append`s. A
// throwing listener escapes to the outer catch, which closes the (not-yet-
// open) step as a no-op and ends the turn via failTurn — a broken
// pre-step plugin ends the turn, not the loop. The composed session
// prefix rides along so token-pressure listeners count everything the
// request will actually carry.
// Run compaction between steps so its surface events remain outside step brackets.
await events.serial('agent/pre-step', turn, step, fullSystemPrompt, transmission.sessionPrefix, abort.signal)
// Interruption landing during the pre-step seam: do not open an empty step.
@@ -573,29 +377,20 @@ async function runTurn(
break
}
// The reconstruction boundary (the reconstructability RFC): the request's
// messages are snapshotted HERE, in the same synchronous frame as the
// step/start append directly below — so the snapshot is exactly the
// derivation over the log prefix strictly before step/start's seq.
// Anything appended later — by a step/start session/event listener, an
// agent/request-window inject(), any concurrent task — lands after the
// boundary and joins the NEXT request. An external reconstructor
// recovers these exact messages by folding the surface over
// events[0..stepStartSeq).
// The reconstruction boundary (the reconstructability RFC): the request's messages are
// snapshotted HERE, in the same synchronous frame as the step/start append directly below
// — so the snapshot is exactly the derivation over the log prefix strictly before
// step/start's seq.
const boundaryMessages = session.deriveMessages()
// Mark the step open BEFORE the append: Session.append pushes the event
// to the log before notifying session/event listeners, so a THROWING
// step/start listener leaves step/start in the log. Setting stepOpen first
// means the outer catch's closeStep() then appends the balancing step/end
// (turn stays enclosed) instead of stranding an open step under turn/end.
// Mark the step open before the append: Session.append pushes the event to the log before
// notifying session/event listeners, so a THROWING step/start listener leaves step/start
// in the log.
stepOpen = true
session.append('step/start', { turn, step })
// Cancel landing in the step-start window: a synchronous `session/event`
// step/start listener can cancel after the step is already open. Check
// AFTER the step/start append and before `runStep`: drop the step, end the
// turn accordingly. closeStep balances the already-appended step/start.
// Cancel landing in the step-start window: a synchronous `session/event` step/start
// listener can cancel after the step is already open.
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
@@ -630,13 +425,7 @@ async function runTurn(
break
}
// The successful step's finish reason carries forward: a `max-tokens`
// step makes the whole turn end `max-tokens` (the ACP RFC's rule "any
// max-tokens step surfaces as max-tokens"). `stepFinishReason` returns
// `max-tokens` or `undefined`, so a later ordinary step never resets a
// max-tokens turn back to completed, and a never-truncated turn keeps the
// default `completed`. The disposal/abort/error branches above and the
// continuation-window disposal check below override this — they win.
// Preserve max-tokens once any step reports it.
const stepReason = stepFinishReason(stepOutcome.finish)
if (stepReason) reason = stepReason
@@ -672,10 +461,8 @@ async function runTurn(
// the next iteration's drain records it.
if (!shouldContinue && handle.inbox.hasSteering) shouldContinue = true
// Terminal policy runs only AFTER the extensible continuation waterfall,
// its optional reason, and late steering have all been folded. Unlike the
// waterfall, this serial seam is monotonic: the first stop bail wins, and
// no later listener or steering override can resurrect the turn.
// Terminal policy runs only after the extensible continuation waterfall, its optional
// reason, and late steering have all been folded.
let terminalStop = false
try {
const stop = await events.strictSerial('agent/turn-stop', turn)
@@ -689,19 +476,13 @@ async function runTurn(
}
if (terminalStop) {
terminalStopped = true
// A continuation reason or listener may have queued steering before the
// terminal checkpoint. Discard only steering (never ordinary queued
// prompts) so it cannot become a next step or be re-enqueued as a fresh
// turn by runLoop's late-steering fallback.
// A continuation reason or listener may have queued steering before the terminal
// checkpoint.
handle.inbox.drainSteering()
shouldContinue = false
}
// A cancel that landed during the continuation window — after the step's
// AbortController was cleared (setAbort(undefined)) but before the next
// step starts — has no controller to observe it, so the turn-scoped marker
// ends the turn here. cancel() also cleared the steering FIFO, so the
// override above did not re-arm continuation.
// A turn-scoped marker catches cancellation between step controllers.
if (handle.isCancelled()) {
reason = { kind: 'aborted', reason: handle.cancelReason() }
break
@@ -718,29 +499,10 @@ async function runTurn(
closeTurn()
} catch (error: unknown) {
// Decide whether this turn was ever opened from the LOG, not a flag.
// Session.append pushes the event BEFORE notifying session/event listeners,
// so a throwing listener on the `turn/start` append leaves turn/start in the
// log even though execution never reached the lines after that append.
// Gating on a "turn started" boolean would skip turn/end and leave a
// permanently OPEN turn that poisons the next turn/replay (the turn-enclosure RFC). We
// check the log for THIS turn's turn/start: present means a turn/end is owed
// and the normal-exit `closeTurn()` did NOT run (we are here because a throw
// preceded it — the two `closeTurn()` sites are on mutually exclusive paths),
// so this catch appends turn/end with the disposed/error reason chosen below.
// `closeStep()` IS idempotent (guarded by `stepOpen`) — it may have run
// already in a step branch, so running it again is a safe no-op. Absent
// turn/start means the append threw BEFORE its push (a non-serializable
// trigger — impossible for our fixed trigger); nothing was opened, so rethrow
// to the runLoop backstop.
const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
if (!turnStartLogged) throw error
closeStep()
// Choose the close reason. Disposal wins only if no error was already
// reported: a turn disposed mid-step sets reason=disposed in the step-error
// branch (without reporting an error), so preserve disposed rather than
// overwrite it. Otherwise a mid-step throw on a live agent is a real
// failure → failTurn. (errorReported is mutated only inside the failTurn
// closure, which the analyzer can't follow, hence the inline lint-disable.)
// Choose the close reason.
if (handle.isDisposed() && !errorReported) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
reason = { kind: 'disposed' }
} else {
@@ -755,13 +517,8 @@ async function runTurn(
try {
await ctx.sessions.flush(session)
} catch (error: unknown) {
// The turn is already closed (turn/end appended above) and flush must run
// AFTER turn/end to be a checkpoint — so there is no in-turn position left
// for a session `error` event. Appending one here would land it after the
// last turn/end, where the persistence backend treats it as a crash tail
// and drops it on resume (the turn-enclosure RFC: every event is turn-enclosed). Report
// the failure via agent/error + the logger only; persistence keeps the
// buffered events for the next flush/dispose, so nothing is lost.
// The turn is already closed (turn/end appended above) and flush must run after turn/end to
// be a checkpoint — so there is no in-turn position left for a session `error` event.
const err = toError(error)
ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${err.message}`)
try {
@@ -803,27 +560,17 @@ async function runStep(
): Promise<{ hadToolCalls: boolean; finish: FinishReason }> {
const { session, options } = agent
// Seed the call config: the first request of THIS loop instance seeds from
// current AgentOptions — explicit options always win over the logged
// baseline, which is what keeps fork model-overrides and resume-time
// reconfiguration correct. Later steps seed from the log's folded header,
// which by then is exactly what this instance last logged.
// One deep-cloned, frozen seed serves BOTH the listener chain and the
// no-listener fallback: structuredClone decouples it from the session's
// cached header fold (a raw reference would let a delegating listener
// mutate the fold in place and silently skip the delta log), and the freeze
// makes in-place shaping unrepresentable — a switch is a RETURNED
// replacement, which the header event below records.
// Seed the call config: the first request of this loop instance seeds from current
// AgentOptions — explicit options always win over the logged baseline, which is what keeps
// fork model-overrides and resume-time reconfiguration correct.
const seedConfig: LlmCallConfig = deepFreeze(structuredClone(transmission.loggedHeader
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log
? session.requestHeader()!.config
: { model: options.model ?? '' }))
// Shape the call config: listeners return a replacement to switch model or
// sampling (the seed is frozen — content shaping is not expressible here;
// model-visible content flows through the log channels). The header event
// below records whatever the request ACTUALLY uses, so a listener's switch
// is a logged, reconstructable fact, never silent drift.
// Shape the call config: listeners return a replacement to switch model or sampling (the seed
// is frozen — content shaping is not expressible here; model-visible content flows through
// the log channels).
const config = await events.waterfall('agent/request', turn, step, seedConfig, () => Promise.resolve(seedConfig))
if (!config.model) {
throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
@@ -845,11 +592,9 @@ async function runStep(
})
recordRequestHeader(session, transmission, header)
// Build and freeze: the request is a pure function of (boundary snapshot,
// logged header) — llm/stream listeners and adapters read it, mutation
// throws. sessionId + frozen is the loop-built marker the dev invariant
// keys on. Message order: header.messagePrefix, then the boundary
// snapshot — the reconstruction equation the invariant recomputes.
// Build and freeze: the request is a pure function of (boundary snapshot, logged header) —
// llm/stream listeners and adapters read it, mutation throws. sessionId + frozen is the
// loop-built marker the dev invariant keys on.
const request: GenerateOptions = deepFreeze({
model: header.config.model,
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
@@ -873,23 +618,16 @@ async function runStep(
assembler.push(chunk)
}
// Adapters report provider/transport failures one of two sanctioned ways
// (see the StreamChunk contract in dsh-llm): throw from stream() — already
// handled by the caller's try/catch — OR end the stream with a
// finish-error/aborted chunk. finishError() maps the latter to the step
// error to raise (turn ends error/aborted, not a normal completed message).
// Normalize terminal error chunks into the same failure path as thrown adapter errors.
const stepError = finishError(assembler.finish)
if (stepError) throw stepError
if (assembler.finish.kind === 'max-tokens') {
let message: Message = withoutToolCalls(assembler.message())
message = withoutToolCalls(await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message)))
// Fire the assistant/message when there is content OR usage: a max-tokens
// step can be cut off with empty content but still carry token accounting,
// and assistant/message is the only host for usage (there is no standalone
// usage event). An empty-content assistant/message is skipped by
// deriveMessages(), so hosting usage on it never injects a spurious assistant
// turn into derived history.
// Fire the assistant/message when there is content OR usage: a max-tokens step can be cut
// off with empty content but still carry token accounting, and assistant/message is the
// only host for usage (there is no standalone usage event).
if (message.content.length > 0 || assembler.usage) {
// A max-tokens finish is itself a streamed `finish` chunk, so chunkSeqs is
// never empty here — pass the provenance unconditionally.
@@ -908,14 +646,7 @@ async function runStep(
let message: Message = assembler.message()
message = await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message))
// Same content-or-usage guard as the max-tokens branch: a step that finishes
// with neither assembled content nor usage (e.g. a bare `stop` finish that
// streamed nothing) records no assistant/message — an empty-content message
// exists only to host usage, and deriveMessages() skips it either way, so
// appending one with no usage would be a pure trace-only row.
//
// sourceEventSeqs records the assistant/chunk provenance, but is omitted when
// no chunks streamed (the surface invariant rejects an empty sourceEventSeqs).
// Do not append an assistant message without content or usage; omit empty provenance too.
if (message.content.length > 0 || assembler.usage) {
session.append(
'assistant/message',
@@ -928,11 +659,7 @@ async function runStep(
// ToolRegistry.execute converts tool failures (including aborts) into
// isError results, so abort is re-checked around every call here.
const toolCalls = message.content.filter(block => block.type === 'tool-call')
// Per-step buffer of `additionalContext` attached by tools/post-execute
// listeners. Appended as context/message(s) only AFTER every tool/result for
// the step, so a multi-call step keeps tool-call/result adjacency
// (interleaving context between a call's result and the next call's would
// break the pairing the next model request relies on).
// Per-step buffer of `additionalContext` attached by tools/post-execute listeners.
const pendingContext: HookContext[] = []
for (const call of toolCalls) {
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
@@ -944,12 +671,7 @@ async function runStep(
} catch {
parsedArguments = call.arguments
}
// TODO(pre-tool-input-rewrite): tools/pre-execute deliberately cannot rewrite
// `arguments` — tool/call (the audit record) and assistant/message (the
// model-history source) are logged BEFORE execute, and live consumers (ACP,
// tool-bash presentation) read the pre-execution args, so an execution-only
// rewrite would desync the UI from what ran. Designing that consistently is
// its own proposed RFC (docs/rfc/proposed/feature/…-pre-tool-input-rewrite.md).
// TODO(pre-tool-input-rewrite): arguments cannot change after their audit and history events are logged.
const result = await ctx.tools.execute({
callId: call.id,
name: call.name,
@@ -959,12 +681,10 @@ async function runStep(
})
session.append('tool/result', {
turn, step,
// The correlation id MUST be the loop's authoritative call.id (the
// model-transcript id that deriveMessages turns into toolCallId), NOT
// result.callId — a post-execute waterfall listener returning a
// mismatched id would otherwise orphan the call↔result pairing in the
// next model request. A listener-internal id, if ever needed, belongs in
// a separate diagnostic field, never overloaded onto callId.
// The correlation id must be the loop's authoritative call.id (the model-transcript id
// that deriveMessages turns into toolCallId), not result.callId — a post-execute
// waterfall listener returning a mismatched id would otherwise orphan the call↔result
// pairing in the next model request.
callId: call.id,
content: result.content,
isError: result.isError,
@@ -1008,13 +728,9 @@ export function lastTurnNumber(session: Session): number {
}
/**
* Whether a turn is currently open in the session log (a `turn/start` with no
* matching later `turn/end`). Decided from the LOG, not agent status: status
* can be `running` while no turn is open (an `agent/status` listener firing
* before `turn/start`, or the post-`turn/end` flush window before status
* returns to idle), so status is not a reliable open-turn signal. Used by
* `inject()` to choose between appending into an open turn vs. wrapping the
* injection in its own one-shot turn (the turn-enclosure RFC).
* Whether a turn is currently open in the session log (a `turn/start` with no matching later
* `turn/end`).
*
* @param session - the session whose log is inspected.
* @returns true when the log's last turn boundary is a `turn/start` with no matching `turn/end` yet.
*/

View File

@@ -1,12 +1,7 @@
/**
* Per-loop-instance transmission bookkeeping for the reconstructability
* contract: which header event to append before a request so the session log
* always explains the request (the reconstructability RFC). The loop is
* otherwise transmission-stateless — the comparison baseline is the log's own
* folded header (`Session.requestHeader()`), so resume and fork need no
* special path: a fresh loop instance simply logs a `'resume'` snapshot on
* its first request and deltas from there.
*
* Per-loop-instance transmission bookkeeping for the reconstructability contract: which header
* event to append before a request so the session log always explains the request (the
* reconstructability RFC).
* @module dsh-agent-loop/request-log
*/
@@ -37,22 +32,8 @@ export function createTransmissionLog(): TransmissionLog {
}
/**
* Append whatever header event this request owes the log, so folding the log
* reproduces the header the request was built under. Exactly one of four
* things happens:
*
* 1. This loop instance has not logged a header yet → a full `request/header`
* snapshot anchors the fold: reason `'initial'` when the log has no header
* events at all (a new conversation), `'resume'` when it does (process
* restart, fork seed — the boundary itself is a recorded fact, so the
* snapshot is appended even when nothing changed).
* 2. The header equals the folded baseline → nothing; the log already
* explains this request.
* 3. It differs and the delta round-trips (`applyHeaderDelta` on the baseline
* reproduces the header exactly) → a `request/header-delta`.
* 4. It differs and the delta encoding cannot express the change (a pure tool
* reordering) → a full snapshot with reason `'fallback'`; deltas are an
* encoding optimization, never a correctness dependency.
* Append whatever header event this request owes the log, so folding the log reproduces the
* header the request was built under. Exactly one of four things happens.
*
* @param session - the session whose log explains the request.
* @param state - this loop instance's bookkeeping (mutated on first log).

View File

@@ -140,10 +140,8 @@ describe('ReactLoopAgent', () => {
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
// Non-serializable injected content makes Session.append throw AFTER
// turn/start was recorded. The turn/end must still be appended (finally),
// AND the durability checkpoint must still fire — the balanced turn is in
// memory and a crash before the next turn/dispose would otherwise lose it.
// Non-serializable injected content makes Session.append throw after turn/start was
// recorded.
expect(() => {
agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } })
}).toThrow(/non-JSON-serializable/)
@@ -159,10 +157,7 @@ describe('ReactLoopAgent', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
// A session/event listener that throws on the synthetic turn/end. Append
// pushes before notifying, so turn/end is in the log (turn balanced) but the
// throw must NOT skip the durability checkpoint — the flush decision is made
// from the log, not a flag set after the (throwing) append.
// A session/event listener that throws on the synthetic turn/end.
let threw = false
ctx.on('session/event', (_s, event) => {
if (!threw && event.type === 'turn/end') { threw = true; throw new Error('boom turn/end') }
@@ -202,10 +197,8 @@ describe('ReactLoopAgent', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// A non-serializable source makes the turn/start append throw BEFORE the
// event is pushed (Session.append validates before push), so NO turn opens.
// The finally's isTurnOpen() guard sees no open turn and appends nothing —
// the log stays empty, not left with a dangling turn/start.
// A non-serializable source makes the turn/start append throw before the event is pushed
// (Session.append validates before push), so NO turn opens.
expect(() => {
agent.inject([{ type: 'text', text: 'x' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
}).toThrow(/non-JSON-serializable/)
@@ -326,10 +319,9 @@ describe('ReactLoopAgent', () => {
})
it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => {
// Covers the waiter's disposed arm: whenIdle() queues an internal waiter
// while running (not the fast path), then the disposer settles it and chains
// `done` (loop exit), not an eager resolve. A bare ReactLoopAgent + direct
// internal driver disposer keeps the emit synchronous.
// Covers the waiter's disposed arm: whenIdle() queues an internal waiter while running (not
// the fast path), then the disposer settles it and chains `done` (loop exit), not an eager
// resolve.
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
@@ -355,11 +347,9 @@ describe('ReactLoopAgent', () => {
})
it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => {
// The waiter is internal agent state, NOT an effect-scoped ctx.on listener:
// disposing the OWNING fiber runs the agent's listener disposers, which would
// have dropped a ctx.on-based waiter before the 'disposed' transition and
// hung the promise. With internal waiters, the fiber disposer still settles
// it. Regression for the round-3 whenIdle finding.
// The waiter is internal agent state, not an effect-scoped ctx.on listener: disposing the
// OWNING fiber runs the agent's listener disposers, which would have dropped a ctx.on-based
// waiter before the 'disposed' transition and hung the promise.
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
@@ -377,10 +367,8 @@ describe('ReactLoopAgent', () => {
})
it('whenIdle() on a disposed agent awaits the loop exit (done), not just the status flip', async () => {
// The disposer emits agent/status('disposed') BEFORE the driver loop
// unwinds, so whenIdle() must chain `done` (true quiescence) on the
// disposed path. Dispose a running agent, then assert whenIdle() resolves
// only after `done` — i.e. the loop has actually exited.
// The disposer emits agent/status('disposed') before the driver loop unwinds, so whenIdle()
// must chain `done` (true quiescence) on the disposed path.
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent

View File

@@ -1,12 +1,8 @@
/**
* Tests for the queue-aware `Agent.cancel()` primitive. `cancel()` is the
* broad verb — it clears queued + steering work, aborts an in-flight step, and
* drops a turn about to start — whereas a bare step abort (the loop's private
* `AbortController`) kills only the current step and leaves the queue intact.
* These tests exercise every window where a cancel can land (idle, pre-step,
* mid-step, continuation) and the marker's arm/reset rules that keep a cancel
* from leaking to a later prompt or hanging `whenIdle()`.
*
* Tests for the queue-aware `Agent.cancel()` primitive. `cancel()` is the broad verb — it
* clears queued + steering work, aborts an in-flight step, and drops a turn about to start —
* whereas a bare step abort (the loop's private `AbortController`) kills only the current step
* and leaves the queue intact.
* @module dsh-agent-loop/tests/cancel
*/
@@ -95,10 +91,8 @@ describe('Agent.cancel()', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// Queue work, then register a whenIdle() waiter while in the pre-step window
// (status idle, hasQueued true) — it does NOT take the fast path. Then cancel.
// The skip path must settle this waiter directly (no running→idle transition
// ever fires), or it would hang forever.
// Queue work, then register a whenIdle() waiter while in the pre-step window (status idle,
// hasQueued true) — it does not take the fast path.
send(agent, 'q')
const idle = agent.whenIdle()
agent.cancel('pre-step')
@@ -234,12 +228,8 @@ describe('Agent.cancel()', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// The first composition is interrupted mid-waterfall and — like an
// abort-aware listener bailing on a firing signal — contributes nothing.
// Caching that degraded result would silently strip the prefix from every
// later request of this instance; the loop must discard it and recompose
// on the next send, and the SECOND composition's value must be what the
// wire and the header log carry.
// The first composition is interrupted mid-waterfall and — like an abort-aware listener
// bailing on a firing signal — contributes nothing.
const opener: Message = { role: 'user', content: [{ type: 'text', text: 'fresh opener' }] }
let compositions = 0
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
@@ -268,10 +258,8 @@ describe('Agent.cancel()', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// A turn/start listener fires right after turn/start is appended, BEFORE any
// AbortController is installed for the step. Cancelling there must still drop
// the step (the turn-scoped marker, not the step AbortController, is what
// catches this) — no model step runs.
// A turn/start listener fires right after turn/start is appended, before any
// AbortController is installed for the step.
let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
const dispose = ctx.on('session/event', (session, event) => {
@@ -400,10 +388,8 @@ describe('Agent.cancel()', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// setStatus('running') emits agent/status SYNCHRONOUSLY, so a running
// listener can cancel in the gap between the loop's pre-step check and
// runTurn. The second check (after the running flip) must drop the turn —
// runTurn would otherwise throw on the now-empty queue.
// setStatus('running') emits agent/status SYNCHRONOUSLY, so a running listener can cancel
// in the gap between the loop's pre-step check and runTurn.
let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
const dispose = ctx.on('agent/status', (subject, status) => {
@@ -421,11 +407,7 @@ describe('Agent.cancel()', () => {
})
it('window 2: whenIdle() does NOT resolve early when a running listener cancels then queues replacement work', async () => {
// The window-1 early-resolve race has a window-2 twin: a synchronous
// agent/status('running') listener cancels the about-to-run turn AND queues a
// replacement. window 2 must NOT settle waiters (via setStatus('idle')) while
// the replacement is still queued-and-unrun — it must fall through and run it,
// so whenIdle() resolves on the replacement turn's running→idle, not before.
// Cancellation must not settle idle while replacement work remains queued.
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -451,11 +433,8 @@ describe('Agent.cancel()', () => {
})
it('whenIdle() does NOT resolve early when a new prompt is queued during a pre-step cancel', async () => {
// The subtle race: a whenIdle() waiter is registered for prompt A; cancel()
// clears A; prompt B is queued BEFORE the loop resumes from the idle wait.
// The window-1 cancel branch must NOT settle the waiter while B is still
// queued-and-unrun — whenIdle() must wait for B's turn to actually run and
// settle (the quiescence contract), not resolve before B's first event.
// The subtle race: a whenIdle() waiter is registered for prompt A; cancel() clears A;
// prompt B is queued before the loop resumes from the idle wait.
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -465,9 +444,8 @@ describe('Agent.cancel()', () => {
agent.cancel('drop A') // arms marker, clears A
send(agent, 'B') // B races in before the loop resumes
// whenIdle() must resolve only AFTER B's turn fully ran — by which point B's
// user message and a turn/end are in the log. (Before the fix it resolved
// immediately, with zero events, then B ran afterward.)
// whenIdle() must resolve only after B's turn fully ran — by which point B's user message
// and a turn/end are in the log.
await idle
expect(userTexts(agent)).toContain('B')
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)

View File

@@ -101,9 +101,7 @@ describe('config-driven session id', () => {
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
// Run 2: a CONFIG agent with resumeSessionId continues that session. The
// resume is deferred until sessionPersistence loads (ctx.inject), so wait
// for the agent to appear, then assert it is on the resumed id with history.
// Run 2: a CONFIG agent with resumeSessionId continues that session.
const ctx2 = new Context()
await ctx2.plugin(LlmService)
await ctx2.plugin(SessionStore)

View File

@@ -37,11 +37,7 @@ function send(agent: ReactLoopAgent, text: string) {
describe('turn boundary listener throws (handled in-turn, loop survives)', () => {
it('a pre-push turn/start failure (non-serializable source) is rethrown to the runLoop backstop', async () => {
// A non-serializable message source makes the turn/start append throw BEFORE
// the event is pushed (Session.append validates before push), so turn/start
// never enters the log. runTurn sees no logged turn/start and rethrows; the
// runLoop backstop reports via agent/error (step 0) + the logger and the
// driver survives. This is the ONLY path that reaches the backstop.
// Pre-append validation reports through agent/error without corrupting the log.
const adapter = new MockAdapter([textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })

View File

@@ -70,9 +70,8 @@ describe('Inbox', () => {
r1()
await p1
// Now enqueue: the first waiter's wakeup (which was overwritten) won't
// fire, and the second waiter's wakeup was cleared by cancel.
// The enqueue calls wakeup?.() but wakeup was cleared — no crash, no hang.
// Now enqueue: the first waiter's wakeup (which was overwritten) won't fire, and the second
// waiter's wakeup was cleared by cancel.
inbox.enqueue({ content: [{ type: 'text', text: 'hey' }], source: { kind: 'user' } })
// The overwrite path + finally cleanup are exercised
})

View File

@@ -117,14 +117,9 @@ describe('agent/prompt-submit', () => {
})
it('a prompt-submit rewrite + additionalContext is VISIBLE to the agent/pre-step seam (merged ordering)', async () => {
// The merge of the interception seams with master's compaction seam pins one
// ordering: `agent/prompt-submit` runs (rewriting the prompt and injecting
// context) BEFORE the step loop, and `agent/pre-step` fires INSIDE the step
// before the single deriveMessages(). So a compaction listener on
// `agent/pre-step` must observe the surface AFTER the prompt rewrite/inject —
// otherwise it would measure/compact stale history. This cross-test proves
// the two seams compose in the right order (each is covered in isolation
// elsewhere; this asserts they see each other's effects on the same turn).
// The merge of the interception seams with master's compaction seam pins one ordering:
// `agent/prompt-submit` runs (rewriting the prompt and injecting context) before the step
// loop, and `agent/pre-step` fires inside the step before the single deriveMessages().
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -189,9 +184,7 @@ describe('agent/prompt-submit', () => {
})
it('a mixed batch records a prompt/blocked for the vetoed prompt while the allowed one runs', async () => {
// Two prompts queued into ONE turn: block "secret", allow "safe". The turn is
// NOT rejected (a prompt was allowed), so without a durable prompt/blocked the
// vetoed prompt and its reason would vanish from the log entirely.
// Two prompts queued into one turn: block "secret", allow "safe".
const adapter = new MockAdapter([textResponse('ran once')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -618,11 +611,9 @@ describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end t
})
describe('worked example: a native hook plugin is just a cordis plugin on the seams', () => {
// The whole point of the interception taxonomy: a "native hook" needs no
// dsh-hook-protocol, no external command, no hook/* log — it is an ordinary
// cordis plugin subscribing to the canonical events and returning typed
// decisions. This proves all four seams compose end-to-end through the REAL
// loop, with NO hook/* SessionEvents involved (those belong to the bridge lib).
// The whole point of the interception taxonomy: a "native hook" needs no dsh-hook-protocol,
// no external command, no hook/* log — it is an ordinary cordis plugin subscribing to the
// canonical events and returning typed decisions.
const NativeGuard = {
name: 'native-guard',
apply(ctx: Context) {

View File

@@ -183,11 +183,7 @@ describe('agent loop', () => {
})
it('contains a strict-variable render failure: the turn errors, the loop keeps serving turns', async () => {
// A persona claiming {{cwd}} on a session with NO cwd is a deployment
// authoring error — renderPrompt throws, the turn ends with an error, and
// the same agent must then RUN a later turn to completion (not merely
// report idle status): a rescue listener supplies the variable and the
// follow-up prompt reaches the model.
// A missing cwd variable must fail one turn without preventing a later valid turn.
const adapter = new MockAdapter([textResponse('ok after rescue')])
const ctx = await harness(adapter, 'In {{cwd}}.')
const errors: Error[] = []
@@ -522,9 +518,8 @@ describe('agent loop', () => {
})
it('agent/pre-step fires BEFORE the step it precedes opens (events land outside the step)', async () => {
// A listener appending a surface node in pre-step lands it BEFORE step/start
// in the log — proving the seam fires outside the step. The node is still in
// the derived request for that step (derive happens after step/start).
// A listener appending a surface node in pre-step lands it before step/start in the log —
// proving the seam fires outside the step.
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -557,10 +552,9 @@ describe('agent loop', () => {
})
it('a throwing agent/pre-step listener ends the turn (error), not the loop', async () => {
// The seam fires before step/start, so a throw escapes to runTurn's outer
// catch: the not-yet-open step closes as a no-op, the failure surfaces via
// agent/error, and the turn ends `error` (recorded on the durable turn/end).
// The loop survives and a follow-up prompt still runs.
// The seam fires before step/start, so a throw escapes to runTurn's outer catch: the
// not-yet-open step closes as a no-op, the failure surfaces via agent/error, and the turn
// ends `error` (recorded on the durable turn/end).
const adapter = new MockAdapter([textResponse('second turn ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -633,10 +627,8 @@ describe('agent loop', () => {
})
it('a max-tokens step earlier in a turn still surfaces as max-tokens after a later completed step', async () => {
// Step 1 is cut off (max-tokens, no tool calls → would stop by default), so
// continuation must be FORCED to reach step 2 which finishes normally
// (stop). The rule "any max-tokens step surfaces as max-tokens" means the
// turn ends max-tokens even though the LAST step completed cleanly.
// Step 1 is cut off (max-tokens, no tool calls → would stop by default), so continuation
// must be FORCED to reach step 2 which finishes normally (stop).
const adapter = new MockAdapter([
maxTokensResponse('first half'),
textResponse('second half'),
@@ -718,11 +710,8 @@ describe('agent loop', () => {
expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false)
expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
expect(reasons).toEqual([{ kind: 'max-tokens' }])
// No-data-loss: a max-tokens step whose only content was a dropped tool call
// has EMPTY assistant content, but its usage must still be represented. It
// rides on an (empty-content) assistant/message — there is no standalone
// usage event — and that empty message is skipped by deriveMessages(), so
// the derived history above is NOT corrupted by a spurious assistant turn.
// No-data-loss: a max-tokens step whose only content was a dropped tool call has EMPTY
// assistant content, but its usage must still be represented.
const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message')
expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data).toEqual({
turn: 1, step: 1, content: [], usage: { inputTokens: 10, outputTokens: 5 },
@@ -730,10 +719,9 @@ describe('agent loop', () => {
})
it('appends no assistant/message for a max-tokens step with empty content and no usage', async () => {
// A max-tokens step truncated to a dropped tool call AND with no usage chunk
// has nothing to record: empty content and no accounting → no assistant/message
// (the empty-content host exists only to carry usage). The turn still ends
// max-tokens.
// A max-tokens step truncated to a dropped tool call AND with no usage chunk has nothing to
// record: empty content and no accounting → no assistant/message (the empty-content host
// exists only to carry usage).
const callId = CallId('c1')
const adapter = new MockAdapter([[
{ type: 'block-start', index: 0, blockType: 'tool-call' },

View File

@@ -1,12 +1,5 @@
/**
* Property-based tests for the agent loop's inbox/turn scheduling (the
* property-testing RFC). 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.
*
* Invariants: every sent message appears exactly once in the log (none lost);
* turn numbers strictly increase; status transitions follow the legal machine
* idle→running→idle (and →disposed at teardown).
* Property-based tests for the agent loop's inbox/turn scheduling (the property-testing RFC).
*/
import { describe, expect, it } from 'vitest'
@@ -146,10 +139,8 @@ describe('agent loop scheduling properties', () => {
const ctx = await harness()
try {
const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' })
// Capture an idle waiter before EACH send; the last one is guaranteed
// to resolve because the final send always triggers (or joins) a turn
// that ends idle. Awaiting an already-resolved waiter is a no-op, so a
// trailing settle step can't cause a hang.
// Capture an idle waiter before EACH send; the last one is guaranteed to resolve
// because the final send always triggers (or joins) a turn that ends idle.
let lastIdle: Promise<void> | undefined
for (const step of steps) {
const idle = nextIdle(ctx, agent)

View File

@@ -9,15 +9,12 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
/**
* With-key proof that log-derived requests translate into REAL provider cache
* hits: a 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
* layer: prefix stability is corollary #1). Mocks prove the requests are
* append-extensions; only the real API proves those bytes actually hit the
* provider cache. Key-gated — skips entirely without $DEEPSEEK_API_KEY.
* With-key proof that log-derived requests translate into real provider cache hits: a
* 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
* layer: prefix stability is corollary #1).
*/
// Long enough that the shared request prefix comfortably spans the provider's

View File

@@ -1,11 +1,8 @@
/**
* Loop-level reconstructability: every request the loop sends is a pure
* function of the session log — messages are the derivation at the step/start
* boundary, the header is the fold of request/header* events — and every
* request is an append-extension of its predecessor unless a logged event
* (compaction replace, header change) explains the difference. The requests
* recorded by the mock adapter are the observable; the offline-rebuild test
* at the bottom is the theorem stated end-to-end.
* Loop-level reconstructability: every request the loop sends is a pure function of the
* session log — messages are the derivation at the step/start boundary, the header is the fold
* of request/header* events — and every request is an append-extension of its predecessor
* unless a logged event (compaction replace, header change) explains the difference.
*/
import { describe, expect, it } from 'vitest'

View File

@@ -412,10 +412,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
})
it('an idle inject() is flushed durably on its own (survives without explicit flush/dispose)', async () => {
// Lifecycle 1: run a turn, then inject context while idle. The idle inject
// wraps its context/message in a one-shot turn AND checkpoints it (the turn-enclosure RFC)
// — without an explicit flush or clean dispose, the notice must still reach
// disk, since a crash before the next turn would otherwise lose it.
// Lifecycle 1: run a turn, then inject context while idle.
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
@@ -437,10 +434,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
})
it('an idle inject() survives persist + resume (turn-enclosed, not dropped as crash tail)', async () => {
// Lifecycle 1: run a turn, then inject context while idle. The idle inject
// wraps its context/message in a one-shot turn so it is turn-enclosed —
// otherwise scanLog would treat the trailing context as a crash tail and
// drop it on reload (the bug this guards).
// Lifecycle 1: run a turn, then inject context while idle.
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent

View File

@@ -114,10 +114,8 @@ describe('HIGH: abort during tool execution ends the turn', () => {
parameters: {},
async execute() {
executed.push('aborter')
// Fire the in-flight step's AbortController directly (the loop registers
// it on the agent). This is the bare step-abort path — distinct from
// cancel(), which would also clear the inbox; here the subject is the
// loop's response to its running step being aborted mid-tool.
// Fire the in-flight step's AbortController directly (the loop registers it on the
// agent).
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
return [{ type: 'text', text: 'done' }]
},
@@ -171,21 +169,8 @@ describe('HIGH: steering from late extension points is never stranded', () => {
})
it('steer() from a step/end session-event listener forces a SAME-TURN next step (/goal pattern)', async () => {
// The /goal pattern steers from a step boundary so the model addresses a
// standing goal before stopping. Step boundaries have no agent/* mirror, so
// the surviving hook point is the durable step/end session event. With a
// no-tools first step the default continuation is stop; the steering queued
// here must force the `!shouldContinue && hasSteering` override so the SAME
// turn runs another step.
//
// The override is what this test guards, so it asserts the same-turn shape —
// NOT merely that the content reaches requests[1]. Without the override the
// turn would stop, and leftover steering is re-enqueued as a next-turn queued
// message, which ALSO lands in requests[1] (just one turn later). So a
// content-only assertion passes with the override disabled and guards
// nothing. The discriminator is the turn/step shape: override ⇒ ONE turn with
// TWO steps and the steering recorded as a `steering/message` BEFORE step 2;
// re-enqueue fallback ⇒ TWO turns.
// The /goal pattern steers from a step boundary so the model addresses a standing goal
// before stopping.
const adapter = new MockAdapter([
textResponse('no tools, would stop'),
textResponse('after goal reminder'),
@@ -252,11 +237,9 @@ describe('HIGH: steering from late extension points is never stranded', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
agent.steer([{ type: 'text', text: 'redirect' }])
// Abort ONLY the in-flight step, via its AbortController directly — NOT
// cancel(), which clears the inbox and would drop the queued steering this
// test proves survives a step abort. There is no public step-only abort
// verb (cancel() is the only public stop primitive), so reach the private
// controller the loop registered.
// Abort only the in-flight step, via its AbortController directly — not cancel(), which
// clears the inbox and would drop the queued steering this test proves survives a step
// abort.
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
await waitForIdle(ctx, agent)
@@ -497,10 +480,9 @@ describe('LOW: discriminated SessionEvent narrows without casts', () => {
describe('HIGH: a finish-error stream chunk ends the turn as error, not completed', () => {
it('translates finish {kind:error} into a turn error with a logged error event', async () => {
// The second sanctioned adapter error path (besides throwing): an
// adapter that cannot throw mid-stream ends the stream with a
// finish-error chunk (e.g. the pi-ai adapter mapping a provider 401).
// The loop must NOT log a normal assistant/message + completed turn.
// The second sanctioned adapter error path (besides throwing): an adapter that cannot throw
// mid-stream ends the stream with a finish-error chunk (e.g. the pi-ai adapter mapping a
// provider 401).
const errorStream: StreamChunk[] = [
{ type: 'finish', reason: { kind: 'error', message: 'provider 401', code: 'AUTH' } },
]
@@ -567,10 +549,8 @@ describe('P1-6: a step/start session-event listener sees the event already in th
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-step-order'), { model: 'mock' })
// Session.append pushes the event BEFORE notifying session/event listeners,
// so a step/start listener always finds the matching event already in the
// log. (Step boundaries have no agent/* mirror — the session log is the live
// feed.)
// Session.append pushes the event before notifying session/event listeners, so a step/start
// listener always finds the matching event already in the log.
const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = []
ctx.on('session/event', (subject, event) => {
if (subject !== agent.session || event.type !== 'step/start') return
@@ -593,10 +573,7 @@ describe('P1-6: a step/start session-event listener sees the event already in th
})
describe('P1-5: a started turn (and any open step) is always closed on a boundary throw', () => {
// Harness with the invariants plugin loaded as an oracle: it throws on
// append if the log goes unbalanced (turn/end while a step is open,
// turn/start while a turn is open, etc.), so a regression surfaces as an
// InvariantError on the NEXT turn's append rather than a silent imbalance.
// Invariants turn latent log imbalance into an immediate test failure.
async function balancedHarness(adapter: MockAdapter) {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -628,14 +605,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { model: 'mock' })
// Step boundaries have no agent/* mirror; a throwing step/start session-event
// listener is the surviving step-boundary-listener failure. The loop marks
// the step open BEFORE appending step/start (Session.append pushes before
// notifying, so a post-push listener throw still leaves stepOpen=true), so
// the outer catch's closeStep() appends the balancing step/end — the turn
// stays enclosed. The invariants oracle (balancedHarness) rejects any
// imbalance, so a green run proves turn/start → step/start → step/end →
// turn/end nesting holds.
// Step boundaries have no agent/* mirror; a throwing step/start session-event listener is
// the surviving step-boundary-listener failure.
let threw = false
ctx.on('session/event', (_s, event) => {
if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') }
@@ -720,13 +691,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => {
// Reach the OUTER catch while disposed: an `agent/pre-step` listener requests
// disposal AND throws. The throw escapes the pre-step `await` (line ~419) to
// the loop's outer catch — BEFORE the post-pre-step disposal check at ~422
// gets to run — so the catch sees `isDisposed() && !errorReported` and must
// PRESERVE reason=disposed rather than overwrite it with the listener's throw
// (disposal is not a failure). This is the surviving path to that sub-branch
// now that there is no turn-boundary emit to throw from.
// Reach the OUTER catch while disposed: an `agent/pre-step` listener requests disposal AND
// throws.
const adapter = new MockAdapter([textResponse('never reached')])
const ctx = await balancedHarness(adapter)
let agent!: ReactLoopAgent
@@ -763,14 +729,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
it('a throwing session/event listener on the turn/start append still balances the turn', async () => {
// Session.append pushes the event BEFORE notifying session/event listeners,
// so a listener throwing on turn/start leaves turn/start IN THE LOG. The
// loop must therefore still owe (and append) a turn/end — deciding "owed"
// from the log via isTurnOpen, not a "turn started" flag that the throw
// skipped. Otherwise the turn stays permanently open and poisons the next
// turn/replay (the turn-enclosure RFC). (Uses the plain harness — NOT the invariants
// oracle — because the throwing listener is itself a session/event
// subscriber.)
// Session.append pushes the event before notifying session/event listeners, so a listener
// throwing on turn/start leaves turn/start IN THE LOG.
const adapter = new MockAdapter([textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-preturn'), { model: 'mock' })
@@ -787,10 +747,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
// The error was surfaced exactly once via agent/error.
expect(errors.map(e => e.message)).toEqual(['boom turn/start append'])
// The turn is BALANCED: turn/start is in the log (it was pushed before the
// listener threw), so a turn/end was owed and appended — no open turn. The
// last turn-boundary event being turn/end is exactly the loop's isTurnOpen
// check (no open turn remains).
// The turn is BALANCED: turn/start is in the log (it was pushed before the listener threw),
// so a turn/end was owed and appended — no open turn.
const types = [...agent.session.events].map(e => e.type)
expect(types.filter(t => t === 'turn/start')).toHaveLength(1)
expect(types.filter(t => t === 'turn/end')).toHaveLength(1)
@@ -805,11 +763,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
it('a throwing step/end session-event listener during a successful step ends the turn as error, not completed', async () => {
// closeStep() must surface a throwing step/end listener via failTurn so the
// turn ends with reason error, not a silent "completed" with the throw
// swallowed. Regression test for the closeStep() catch that previously
// swallowed the throw in the normal (no-tool, no-steering) path. (Step
// boundaries have no agent/* mirror; the session-event listener is the path.)
// closeStep() must surface a throwing step/end listener via failTurn so the turn ends with
// reason error, not a silent "completed" with the throw swallowed.
const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { model: 'mock' })
@@ -848,13 +803,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
it('a throwing session/event listener on step/end during finalization still appends turn/end', async () => {
// A finish-error stream opens a step then fails it, driving finalization
// through closeStep() with the step open. closeStep appends step/end; a
// session/event listener throwing on THAT must not abort the catch before
// closeTurn — step/end is already logged (balance holds) and the throw is
// contained + surfaced via failTurn, so turn/end is still appended. (The
// failed step itself also routes through failTurn; the step/end-listener
// throw is the second, contained, failure.)
// A step/end listener failure must not prevent turn/end finalization.
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
const ctx = await harness(adapter)
@@ -884,12 +833,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
it('a throwing session/event listener on turn/end is contained (turn still balanced, loop survives)', async () => {
// closeTurn appends turn/end; Session.append pushes it BEFORE notifying
// session/event listeners, so a throwing listener leaves turn/end in the log
// (the turn is balanced) but must not escape — from the normal-path closeTurn
// it would otherwise propagate; the append is contained so the loop continues.
// Turn boundaries are durable session events only (no agent/* mirror), so this
// session/event append-notify throw is the sole turn-end-listener failure path.
// closeTurn appends turn/end; Session.append pushes it before notifying session/event
// listeners, so a throwing listener leaves turn/end in the log (the turn is balanced) but
// must not escape — from the normal-path closeTurn it would otherwise propagate; the append
// is contained so the loop continues.
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { model: 'mock' })
@@ -931,9 +878,6 @@ describe('P1-7: tool/result is logged under the originating call.id, not result.
}))
// A post-execute listener transforms the result (accept-with-replacement).
// The loop must still record the tool/result under the model's authoritative
// call.id (the loop ignores result.callId — which the registry always sets to
// exec.callId anyway — and uses call.id, the model-transcript id).
ctx.on('tools/post-execute', (exec, _result) => {
expect(exec.callId).toBe(CallId('c1')) // the loop passed the real id in
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] })
@@ -966,11 +910,8 @@ describe('P1-7: tool/result is logged under the originating call.id, not result.
describe('surface: assistant/message omits sourceEventSeqs when no chunks streamed', () => {
it('a step-result listener injecting content over an empty stream appends with surfaceOp but no sourceEventSeqs', async () => {
// An empty stream yields zero assistant/chunk events (finish defaults to
// `stop`), so chunkSeqs is empty. A step-result listener injects content, so
// the content-or-usage guard fires and an assistant/message is appended. Its
// sourceEventSeqs MUST be omitted (not `[]`) — the surface invariant rejects
// an empty sourceEventSeqs, and the dev invariants plugin would throw on it.
// An empty stream yields zero assistant/chunk events (finish defaults to `stop`), so
// chunkSeqs is empty.
const adapter = new MockAdapter([[]])
const ctx = await harness(adapter)
await ctx.plugin(Invariants)
@@ -997,12 +938,7 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream
describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => {
// Block `system-prompt/assemble` on a promise. Start disposal (which
// calls stop() synchronously, setting status=disposed), then release the
// block. The loop must check isDisposed() after assembly and end the turn
// `disposed` — no LLM call. Don't await fiber.dispose() before releasing
// the blocker: the dispose chain awaits agent.done, which hangs until the
// loop unblocks.
// Block `system-prompt/assemble` on a promise.
const adapter = new MockAdapter(['hang'])
let releaseAssemble!: () => void
const blocked = new Promise<void>(r => void (releaseAssemble = r))
@@ -1113,9 +1049,8 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
})
it('disposal during agent/pre-step seam ends the turn disposed', { timeout: 15000 }, async () => {
// Block the `agent/pre-step` serial seam on a promise we control, then
// dispose the agent's fiber. When the block releases, the loop must see
// isDisposed() at the post-seam check and end the turn disposed.
// Block the `agent/pre-step` serial seam on a promise we control, then dispose the agent's
// fiber.
const adapter = new MockAdapter(['hang'])
let releasePreStep!: () => void
const blocker = new Promise<void>(r => void (releasePreStep = r))

View File

@@ -364,11 +364,9 @@ describe('agent scope lifecycle', () => {
})
it('a listener may drive the agent through its declared `this` (the carrier is method-transparent)', async () => {
// ds-review-bot regression: agent/* listeners are typed
// `this: Scoped<Agent>`, and ReactLoopAgent's send/steer/cancel read the
// native-private #carrier — a proxy-receiver carrier made
// `this.send(...)` throw TypeError. The carrier binds methods to the real
// agent, so driving through the event `this` is a working supported shape.
// ds-review-bot regression: agent/* listeners are typed `this: Scoped<Agent>`, and
// ReactLoopAgent's send/steer/cancel read the native-private #carrier — a proxy-receiver
// carrier made `this.send(...)` throw TypeError.
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -403,11 +401,9 @@ describe('agent scope lifecycle', () => {
order.push(`session-still-stored=${ctx.sessions.get(SessionId('o1-s')) !== undefined}`)
})
// Open a turn so the drain has real work: the loop must finish it BEFORE
// the registry entry goes away (the agent/disposed contract: "its fiber
// and any in-flight turn have been torn down"). Wait for the turn to be
// OPEN in the log — a dispose landing in the pre-step window would drop
// the queued prompt without ever opening a turn.
// Open a turn so the drain has real work: the loop must finish it before the registry entry
// goes away (the agent/disposed contract: "its fiber and any in-flight turn have been torn
// down").
const turnOpen = new Promise<void>((resolve) => {
const off = ctx.on('session/event', (_s, event) => {
if (event.type === 'turn/start') { off(); resolve() }

View File

@@ -1,10 +1,8 @@
/**
* Loop-level tool-order determinism: the request/header event — and therefore
* the frozen request the adapter receives — carries the assembly's canonical
* tool order (system-prompt's `toolOrder` config, or lexicographic name
* order), regardless of the order tool plugins happened to register in.
* Registration order is a plugin-load artifact (concurrent dynamic imports
* race), so nothing downstream of the registry may depend on it.
* Loop-level tool-order determinism: the request/header event — and therefore the frozen
* request the adapter receives — carries the assembly's canonical tool order (system-prompt's
* `toolOrder` config, or lexicographic name order), regardless of the order tool plugins
* happened to register in.
*/
import { describe, expect, it } from 'vitest'
@@ -93,11 +91,7 @@ describe('loop-level canonical tool order', () => {
})
it('fails the turn — no model request — when toolOrder names an unregistered tool', async () => {
// The assemble rejection escapes to runTurn's outer catch: the open turn
// closes with an `error` reason (agent/error mirrors it), no step opens,
// no request/header is logged, the adapter never sees a request, and the
// agent returns to idle — a misconfigured deployment fails every turn
// deterministically instead of silently reordering nothing.
// Unknown tool order fails before step or request creation and returns the agent to idle.
const adapter = new MockAdapter([textResponse('never sent')])
const ctx = await harness(adapter, ['ghost', TOOL_ORDER_REST])
registerNamed(ctx, 'alpha')

View File

@@ -1,13 +1,5 @@
/**
* Fused scope-carrier dispatch for agent-subject events, plus the assembly
* context builder. The ONE sanctioned spelling for dispatching `agent/*`
* events: `agentEvents(ctx, agent).waterfall('agent/request', …)` builds the
* scope carrier ({@link scopeTarget} keyed by the agent) AND injects the
* subject as the first event argument in one move, so the correct dispatch is
* also the shortest — a dispatch site cannot pass a carrier keyed to one
* agent while naming another as the subject, which is the invariant the
* dev-mode scoped-dispatch check asserts at runtime.
*
* Fused scope-carrier dispatch for agent-subject events, plus the assembly context builder.
* @module @deepseek-ai/dsh-agent/dispatch
*/
@@ -89,11 +81,7 @@ export interface AgentEventDispatch {
*/
export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
const carrier: Scoped<Agent> = scopeTarget(agent, agent)
// The ordinary dispatch methods forward through Cordis' variadic mixins. The
// fused (carrier, name, agent, ...rest) tuple is provably a valid argument
// list for the matching thisArg overload, but TypeScript cannot relate the
// generic Tail<K> spread back to that overload's conditional parameter
// tuple — hence one contained, shape-preserving cast per method.
// The ordinary dispatch methods forward through Cordis' variadic mixins.
return {
emit(name, ...rest) {
// eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function
@@ -108,10 +96,8 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
strictSerial(name, ...rest) {
return (async (): Promise<unknown> => {
// EventsService.dispatch applies the carrier filter and emits the same
// internal/dispatch instrumentation as ctx.serial, then mutates `args`
// down to the actual listener parameters. Invoke those callbacks in order
// ourselves so every non-undefined value reaches the caller's validator;
// Cordis serial would discard null/false before validation could see them.
// internal/dispatch instrumentation as ctx.serial, then mutates `args` down to the
// actual listener parameters.
const args: unknown[] = [carrier, name, agent, ...rest]
const callbacks = ctx.events.dispatch('serial', args)
for (const callback of callbacks) {

View File

@@ -65,18 +65,7 @@ export interface CreateAgentOptions {
/** Per-agent options (model, …). */
agentOptions?: AgentOptions
/**
* Creation-time composition of the agent's scoped world. The factory awaits
* setup after minting `agentCtx` but BEFORE inserting or announcing either
* the session or agent, so observers can never see a partially configured
* world. Everything registered through `agentCtx` (scoped tools, prompt
* sections/variables, `restrict()`, listeners, awaited child plugins) exists
* before `session/created`, `agent/created`, `agent/session-start`, and the
* first prompt assembly. A throw/rejection or owner disposal rolls the scope
* back without publishing either id.
*
* **Setup composes, it never drives**: calling `send`/`steer`/`inject` here
* would run an unpublished agent and violate the session-start boundary.
* Drive the agent only after the creation promise resolves.
* Creation-time composition of the agent's scoped world.
*/
setup?: (agentCtx: Context) => Promise<void> | void
}
@@ -105,18 +94,8 @@ export interface ResumeAgentOptions {
}
/**
* An owned agent plus its disposer, returned by {@link AgentRegistry.create} /
* {@link AgentRegistry.resume}. The disposer is a CAPABILITY: only the holder
* can tear this agent down. `dispose()` stops the loop, awaits its exit and
* every outstanding idle-injection flush (quiescence — 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 {@link Agent} — the handle is only
* for the OWNER that created it. Config-created agents (the loop's own startup)
* are owned by the loop fiber and never need a handle.
* An owned agent plus its disposer, returned by {@link AgentRegistry.create} / {@link
* AgentRegistry.resume}.
*/
export interface AgentHandle {
agent: Agent
@@ -131,15 +110,8 @@ export interface AgentHandle {
*/
export interface AgentFactory {
/**
* Create a new agent on a caller-supplied session id. Async because creation
* awaits unpublished setup, inserts both session and agent, emits their
* creation notifications in order, unlocks driving at
* `agent/session-start`, and only then starts the loop. The sequence is
* rollback-covered, but notifications delivered before a later listener
* failure remain observable; if agent announcement began, rollback emits
* `agent/disposed`, while the session entry is removed without a separate
* disposal event. The owner disposes the resolved handle to stop/drain,
* unregister, remove the session, and unwind the scope.
* Create a new agent on a caller-supplied session id.
*
* @param options - agent/session identity, configuration, and optional setup.
* @returns the owned handle after setup, both announcements, and loop start complete.
*/
@@ -174,12 +146,8 @@ export class AgentRegistry extends Service {
constructor(ctx: Context) {
super(ctx, 'agents')
// The `ctx.agent` DX accessor: default `undefined` on every context, so a
// plain plugin context reads cleanly instead of hitting the Cordis
// unknown-property throw. Each Agent.ctx shadows it with an own property
// (own properties resolve before the context proxy is consulted), so the
// accessor body never needs to resolve a scope itself. Effect-scoped:
// unwinds with this service's fiber.
// The `ctx.agent` DX accessor: default `undefined` on every context, so a plain plugin
// context reads cleanly instead of hitting the Cordis unknown-property throw.
ctx.accessor('agent', { get: () => undefined })
}
@@ -198,10 +166,7 @@ export class AgentRegistry extends Service {
this.factory = factory
return () => { this.factory = undefined }
}, 'agents.setFactory()')
// The exact cordis effect disposer (the agents.register() convention): a
// caller's composite effect can yield it for in-order teardown; the
// loop's constructor effect returns it directly, identity-nesting the
// registration under that effect.
// Return the exact Cordis disposer to preserve teardown nesting.
return dispose
}
@@ -232,22 +197,11 @@ export class AgentRegistry extends Service {
}
/**
* Register a live agent. Throws if an agent with the same id is already
* registered. Emits `agent/created` on registration and `agent/disposed`
* when the calling fiber is disposed — both with the agent's scope carrier
* (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the
* emits are scope-filtered regardless of which context invoked `register`
* (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always
* requires passing the carrier). Returns the disposer.
* Register a live agent.
*
* @param agent - the already-constructed agent to record in the store.
* @returns the EXACT Cordis effect disposer (single-shot; a repeat call
* returns undefined without awaiting an in-flight teardown). Exact
* identity is load-bearing: a composite (generator) effect that owns a
* teardown ORDER — the agent factory's lifecycle chain — must yield THIS
* function so Cordis nests the unregistration at that yield position;
* yielding a wrapper would leave it disposing as a concurrent sibling on
* owner unload, unregistering the agent (and emitting `agent/disposed`)
* while its final turn is still draining.
* @returns the EXACT Cordis effect disposer (single-shot; a repeat call returns undefined
* without awaiting an in-flight teardown).
*/
register(agent: Agent): () => Promise<void> | void {
const dispose = this.ctx.effect(function* (this: AgentRegistry) {
@@ -277,10 +231,8 @@ export class AgentRegistry extends Service {
if (!entered) return
entered = false
this.store.delete(agent.id)
// An insertion rolled back before announce was never externally created,
// so emitting disposed would invent an impossible lifecycle edge. Marking
// happens before the created emit: if a later created listener throws,
// earlier listeners may already have observed it and must see disposal.
// An insertion rolled back before announce was never externally created, so emitting
// disposed would invent an impossible lifecycle edge.
if (!this.announced.delete(agent)) return
try {
this.ctx.emit(scopeTarget(agent, agent), 'agent/disposed', agent)

View File

@@ -1,47 +1,7 @@
/**
* Agent interface and event taxonomy. Every plugin programs against the
* `Agent` handle defined here; the concrete implementation lives in
* `@deepseek-ai/dsh-agent-loop`.
*
* Merge-extensible: `AgentOptions` supports declaration merging for
* plugin-specific creation options.
*
* ## Event-domain semantics (the boundary rule)
*
* The harness has three event domains, each with one job:
*
* - **`session/*`** (`@deepseek-ai/dsh-session`) — the DURABLE, replayable FACT
* log. Owns `SessionEventMap`; every entry is JSON-only (no live objects).
* One `session/event` emit per append, plus the `session/flush` parallel
* durability checkpoint. Answers "what happened, durably/replayably." A
* consumer that wants the live transcript subscribes here.
* - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the
* live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/
* `agent/request`/`agent/session-prefix`/`agent/step-result`/
* `agent/turn-continuation` waterfalls and the serial `agent/pre-step` /
* `agent/turn-stop` checkpoints) that mutate/veto, and TRANSIENT emits
* (`agent/status`, `agent/error`, `agent/created`/
* `agent/disposed`, `agent/queued`, `agent/session-start`)
* that notify with the `Agent` in hand. Turn/step boundaries are NOT here —
* they are durable `session/event` records. Answers "right now, with the agent
* object — intercept or observe."
* - **`tools/*`** (`@deepseek-ai/dsh-tools`) — the tool registry + execution.
*
* **The rule:** a durable, replayable fact is a SessionEvent; a live
* interception or a transient/live-object signal is an `agent`/`tools` Cordis
* event. A turn/step boundary is a durable fact: it lives in the session log
* and is read off the `session/event` feed — it is NOT mirrored as an `agent/*`
* emit. A consumer that needs the `Agent` handle (or its short id) at a boundary
* keeps a session-id→agent map from `agent/created`/`agent/disposed`.
* See `docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md`
* and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`.
*
* The interception waterfalls here (`agent/prompt-submit`, `agent/request`,
* `agent/step-result`, `agent/turn-continuation`) each return a typed Decision;
* the terminal serial `agent/turn-stop` returns the stop-only subset. The
* convention is pinned by
* `docs/rfc/implemented/feature/2026-06-30-interception-seams.md`.
*
* Agent interface and event taxonomy. Every plugin programs against the `Agent` handle defined
* here; the concrete implementation lives in `@deepseek-ai/dsh-agent-loop`.
* Scope-filtered dispatch: keyed to `agent`.
* @module @deepseek-ai/dsh-agent/types
*/
@@ -110,54 +70,22 @@ export interface SendOptions {
*/
export type AgentStatus = 'idle' | 'running' | 'disposed'
/**
* Model-facing context an interception listener wants the agent to SEE on the
* next request — the canonical shape behind every "inject extra context"
* decision ({@link PromptDecision}, {@link PostToolDecision},
* {@link ContinuationDecision}). It is `agent.inject()`ed as a
* `context/message`, so it carries a REQUIRED {@link MessageSource}: `inject()`
* defaults a missing source to `{kind:'user'}`, which would MISLABEL plugin
* context as a user prompt and corrupt derived history. A bridge sets
* `{kind:'plugin', plugin:'…'}`; a native plugin names itself. Required, not
* optional — the label is load-bearing, never defaulted here.
*/
/** Model-facing injected context with an explicit, non-defaulted source. */
export interface HookContext {
content: ContentBlock[]
source: MessageSource
}
/**
* The decision an {@link Agent} `agent/prompt-submit` waterfall listener returns
* for ONE drained queued message, before it becomes a `user/message`. Maps onto
* the Claude Code `UserPromptSubmit` hook's allow/block + `additionalContext`.
*
* - `allow` proceeds with the prompt; optional `content` REPLACES the prompt
* bytes (a rewrite), and optional `additionalContext` is `inject()`ed as a
* separate `context/message` the next request also sees.
* - `block` drops the prompt (it never becomes a `user/message`); `reason` is
* the durable record of why. The loop appends a `prompt/blocked` session event
* (carrying the original content, source, and `reason`) in place of the
* dropped `user/message`, so the veto survives replay even in a MIXED batch
* where a sibling prompt is allowed. A batch whose EVERY prompt is blocked
* additionally opens a zero-step turn that ends with {@link TurnEndReason}
* `rejected` (so the boundary stays balanced and a UI can render "blocked by
* hook").
* The decision an {@link Agent} `agent/prompt-submit` waterfall listener returns for one
* drained queued message, before it becomes a `user/message`. Maps onto the Claude Code
* `UserPromptSubmit` hook's allow/block + `additionalContext`.
*/
export type PromptDecision =
| { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext }
| { kind: 'block'; reason: string }
/**
* The decision an {@link Agent} `agent/turn-continuation` waterfall listener
* returns. The loop computes the default (`continue` when the step had tool
* calls or steering was injected, else `stop`); listeners override it to
* force-continue (`/goal`, `/loop`) or force-stop (budget guards).
*
* A `continue` may carry a `reason`: model-facing context recorded as next-STEP
* steering within the SAME turn (the loop enqueues it through the steering
* channel, so the continued turn's next step sees it). This is the typed twin of
* the existing "steer from a step/end listener" `/goal` pattern.
*/
/** Continuation override; a continue reason is recorded as next-step steering. */
export type ContinuationDecision =
| { action: 'stop' }
| { action: 'continue'; reason?: HookContext }
@@ -212,304 +140,130 @@ export interface Agent {
steer(content: ContentBlock[], options?: SendOptions): void
/**
* Inject in-session context (file-change notices, skill content, cron
* notifications, …): appends a `context/message` session event the next model
* request sees at its chronological position, rendered as tagged synthetic
* context rather than a user prompt. Does not run the model.
*
* Turn-enclosure (the turn-enclosure RFC): an inject while a turn is open joins that turn;
* an inject while idle wraps its `context/message` in a one-shot `injection`
* turn (`turn/start` → `context/message` → `turn/end`) and checkpoints it for
* durability, so every event stays inside a turn and a persistence backend
* never loses a between-turn notice. The idle checkpoint is fire-and-forget
* from this synchronous method, but lifecycle disposal awaits it before
* unregistering the agent or detaching its session. A failing flush is
* reported via `agent/error` (step `0`) and the logger, never thrown into the
* caller.
*
* Live-adapter review has validated the tagged-envelope rendering against
* current DeepSeek behavior; provider-specific mismatches belong in that
* adapter, not in the canonical session vocabulary.
* Inject in-session context (file-change notices, skill content, cron notifications, …):
* appends a `context/message` session event the next model request sees at its chronological
* position, rendered as tagged synthetic context rather than a user prompt. Does not run the
* model.
*/
inject(content: ContentBlock[], options?: SendOptions): void
/**
* Cancel ALL pending work for the agent. `cancel()`:
*
* - clears the queued FIFO (un-started prompts never run) and the steering
* FIFO (steering for the cancelled turn is dropped, not re-enqueued);
* - aborts the in-flight step if one is running (the turn ends `aborted`);
* - drops a turn that is about to start (a `cancel()` landing in the
* pre-step window — after a `send()` queued but before the loop flips to
* `running`, or after `running` is emitted but before the first step) so
* that queued prompt does not run and cannot be batched into the cancelled
* turn.
*
* After `cancel()`, `whenIdle()` resolves on the post-cancel quiescent state.
* `cancel()` on an idle agent with nothing queued or running is a safe no-op
* — it does NOT arm anything that would drop a later legitimate prompt.
* Cancel ALL pending work for the agent. `cancel()`.
*/
cancel(reason?: string): void
/**
* Resolve once the agent has reached quiescence after settling out of
* `running`, or immediately if it is already idle with no queued work. A
* non-owner's quiescence-observation hook: a consumer that does NOT own the
* agent's lifecycle awaits this to proceed only after queued/running work has
* fully stopped, rather than returning while the driver is still streaming or
* about to start a queued turn — without itself tearing the agent down. (A
* lifecycle OWNER does not need it: `AgentHandle.dispose()` already awaits the
* loop-exit promise directly as part of stopping and unregistering. So this is
* for a non-owning observer — e.g. a test awaiting a turn to settle, or a
* monitor — that wants the settle signal but must not dispose the agent.)
*
* "Quiescence", not merely "status changed": a disposed agent emits
* `agent/status('disposed')` from inside its disposer, BEFORE the driver loop
* has unwound — so `whenIdle()` resolving on `disposed` must wait for the loop
* to actually exit (the implementation chains the loop-exit promise), not just
* observe the status flip. A mid-step disposal that never reaches `idle` still
* unblocks the await this way.
* Resolve once the agent has reached quiescence after settling out of `running`, or
* immediately if it is already idle with no queued work.
*/
whenIdle(): Promise<void>
// Subagent delegation is realized on top of this interface by the
// `@deepseek-ai/dsh-subagent` seam, not by a method here: a backend creates
// the child through `ctx.agents.create` (fork seeds the child Session with a
// balanced prefix of the parent's log via `CreateAgentOptions.seed`; spawn
// starts fresh) and drives it as an ordinary Agent handle, so steer() and
// event subscription work uniformly. See docs/core-data-structures/subagent.md.
// Subagent backends create ordinary child Agent handles through the subagent seam.
}
declare module 'cordis' {
interface Events {
// ---- lifecycle (emit) ----
/**
* An agent's fully composed scoped world was published in the
* {@link AgentRegistry}. Its session is already live in the session store,
* but concrete factories may keep driving verbs locked until the subsequent
* `agent/session-start` boundary; that event is the first supported place
* to inject or queue work during startup.
* An agent's fully composed scoped world was published in the {@link AgentRegistry}.
*
* @param agent - the newly registered agent with its live session and completed setup.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @mode emit
*/
'agent/created'(this: Scoped<Agent>, agent: Agent): void
/**
* An agent was removed from the registry after its driver and any in-flight
* turn reached quiescence. Ordered teardown may still be detaching the
* session and unwinding the agent's scoped registrations when this
* notification runs.
* An agent was removed from the registry after its driver and any in-flight turn
* reached quiescence.
*
* Scope-filtered dispatch: keyed to `agent`.
* @param agent - the deregistered agent; its driving handle is now inert.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @mode emit
*/
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
/**
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive
* lifecycle off this transition, never off a status you just requested —
* `send()` does not flip status to `running` before it returns.
* Agent status changed (`idle` ⇄ `running`, or → `disposed`).
*
* Scope-filtered dispatch: keyed to `agent`.
* @param agent - the agent whose status flipped.
* @param status - the status just entered (the transition's destination).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @mode emit
*/
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
/**
* A message entered the agent's inbox (queued or steering). `source` is
* the resolved source (defaults applied), not the caller's raw options.
* A message entered the agent's inbox (queued or steering). `source` is the resolved
* source (defaults applied), not the caller's raw options.
*
* Scope-filtered dispatch: keyed to `agent`.
* @param agent - the agent whose inbox received the message.
* @param content - the enqueued content blocks, verbatim.
* @param info - the resolved source plus whether it entered as steering.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @mode emit
*/
'agent/queued'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
// ---- session lifecycle (emit) ----
/**
* The agent's session lifecycle began, fired once before its first turn.
* `source` says why ({@link SessionStartSource}: fresh startup, a resumed
* persisted session, …). A pure NOTIFICATION (emit, not waterfall): it
* carries no veto — a session-start listener that wants to seed context does
* so via `agent.inject()` (a `context/message` the first request sees), not
* by returning a decision. Cannot block the session from starting; that gap
* is deliberate (a bridge logs/injects, it does not gate startup).
* The agent's session lifecycle began, fired once before its first turn. `source` says why
* ({@link SessionStartSource}: fresh startup, a resumed persisted session, …).
*
* Scope-filtered dispatch: keyed to `agent`.
* @param agent - the agent whose session lifecycle began.
* @param source - why the session started (fresh startup, resume, …).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* Dispatch is scoped to `agent`.
* @mode emit
*/
'agent/session-start'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void
// Turn and step boundaries are NOT mirrored as agent/* emits: a consumer
// that needs them reads the durable `turn/start`/`turn/end`/`step/start`/
// `step/end` session events off the `session/event` feed (the session log is
// the live transcript feed). See the module doc's three-domain rule and the
// "remove agent boundary mirror events" RFC.
// Turn and step boundaries are not mirrored as agent/* emits: a consumer that needs them
// reads the durable `turn/start`/`turn/end`/`step/start`/ `step/end` session events off the
// `session/event` feed (the session log is the live transcript feed).
// ---- step/request extension seams (serial + waterfall) ----
/**
* Awaited pre-step surface-mutation checkpoint, fired once per step AFTER
* `turn/start` (and after the prior step closed) but BEFORE this step's
* `step/start` — so anything a listener appends lands OUTSIDE the step,
* between `turn/start`/`step/end` and the upcoming `step/start`. `step` is
* the number of the step about to start. The loop awaits
* `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then
* opens the step and derives the request history ONCE from whatever the
* surface now holds. This is where compaction belongs: it mutates the session
* surface in place (shadowing an older range with a summary node) with its
* log-only `compact/*` records cleanly outside any step, and the single
* subsequent derive reflects the mutation — so there is no double-derive and
* no listener can see (or be expected to act on) an assembled `messages`
* array that does not exist yet.
*
* Serial (awaited in registration order), not a waterfall: a listener
* mutates the surface as a side effect; there is nothing to transform, but
* the loop must wait for the mutation to complete before opening the step
* and deriving. Cordis `serial` bails early if a listener returns a bail
* value; this event is typed and documented as `void`, so listeners must not
* return a semantic veto value. `fullSystemPrompt` is the assembled prompt a
* listener needs to measure pressure (the system prompt counts toward the
* budget), and `sessionPrefix` is the instance's composed
* {@link agent/session-prefix} product for the same reason — every request
* carries it in front of the derived history, and it is composed BEFORE
* this seam fires precisely so a pressure gate counts the prefix the
* request will actually send (never a stale logged one). `signal` cancels
* any in-flight work a listener starts (e.g. a
* summarization model call).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* Awaited checkpoint for surface mutation before `step/start` snapshots request history.
* Scope-filtered dispatch: keyed to `agent`.
* @param agent - the agent about to open the step.
* @param turn - the already-open turn this step belongs to.
* @param step - the number of the step about to start.
* @param turn - open turn number.
* @param step - upcoming step number.
* @param fullSystemPrompt - the assembled prompt, for measuring token pressure.
* @param sessionPrefix - the instance's frozen session prefix, for the same measurement.
* @param sessionPrefix - frozen prefix for the same measurement.
* @param signal - aborts in-flight listener work when the turn is torn down.
* @mode serial
*/
// TODO: `fullSystemPrompt`/`sessionPrefix` are a smell on a generic
// per-step seam — compaction
// is their only consumer, so a wide event carries payloads just one listener
// reads. Revisit if no second consumer appears: e.g. hand listeners a lazy
// prompt provider, or move token-pressure measurement behind a
// compaction-specific seam instead of the shared pre-step checkpoint.
// TODO: move prompt-pressure inputs behind compaction if no second consumer appears.
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise<void> | void
/**
* Waterfall: decide what happens to ONE drained queued message before it
* becomes a `user/message` — allow (optionally rewriting the prompt bytes or
* attaching `additionalContext`) or block it. Fires inside the already-open
* turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook.
* Call `next()` to delegate to the default (allow unchanged), or return a
* {@link PromptDecision} without calling `next()` to short-circuit.
* Waterfall: decide what happens to one drained queued message before it becomes a
* `user/message` — allow (optionally rewriting the prompt bytes or attaching
* `additionalContext`) or block it.
*
* Scope-filtered dispatch: keyed to `agent`.
* @param agent - the agent draining its inbox.
* @param content - the drained message's blocks, as queued.
* @param source - the message's resolved source.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @mode waterfall
*/
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
/**
* Waterfall: shape the step's call configuration — model switching,
* sampling overrides — by returning a replacement {@link LlmCallConfig}
* (the frozen seed is the config the loop would otherwise use). Config is
* ALL a listener shapes here: every request is a pure function of the
* session log (the reconstructability RFC), so model-visible content
* flows through the log channels — `inject()`, steering, prompt-submit
* `additionalContext`, prompt sections via `system-prompt/assemble`, or
* the header-logged session prefix via {@link agent/session-prefix}
* — never through request mutation, and the loop records whatever config
* the request actually uses as a `request/header*` event before dispatch.
* The step's messages are already snapshotted when this fires (the
* `step/start` boundary): an `inject()` from a listener here lands in the
* log but joins the NEXT request. For surface mutation that must precede
* the snapshot (compaction), use {@link agent/pre-step}. Call `next()` to
* delegate, or return an {@link LlmCallConfig} without it to
* short-circuit.
* Waterfall: shape the step's call configuration — model switching, sampling overrides
* — by returning a replacement {@link LlmCallConfig} (the frozen seed is the config the
* loop would otherwise use).
*
* Scope-filtered dispatch: keyed to `agent`.
* @param agent - the agent making the model call.
* @param turn - the open turn number.
* @param step - the step whose request this is.
* @param config - the config the loop would use (frozen); return a replacement to switch.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @param config - the config the loop would use (frozen); return a replacement to
* switch.
* @mode waterfall
*/
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
/**
* Waterfall: compose the SESSION PREFIX — request-only messages placed in
* front of the ENTIRE derived history (directly after the provider's
* system slot) on every request this loop instance sends. Fired ONCE per
* loop instance, lazily before its first step's {@link agent/pre-step}
* seam — BEFORE the pre-step so a token-pressure gate (compaction) counts
* the prefix this instance will actually send, never a previous
* instance's logged one. The composed
* result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the
* instance's anchoring `'initial'`/`'resume'` header snapshot, and reused
* verbatim for every subsequent request — never recomputed mid-session,
* so the provider prefix cache holds by construction (a process restart
* or `ctx.agents.resume()` is a new instance: it recomposes, and any
* drift lands attributably on the `'resume'` snapshot). Composition runs
* outside the step, before the boundary snapshot: a composing listener's
* session append joins the CURRENT request's derived history. A
* composition interrupted by a cancel/dispose landing inside the
* waterfall is discarded — never cached, logged, or sent — and the next
* turn recomposes under a live signal, so an abort-aware listener's
* degraded fallback cannot leak into later requests.
* Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the
* entire derived history (directly after the provider's system slot) on every request this
* loop instance sends.
*
* This is the home for session-stable openers the model must always see
* but that must NOT become durable history — a skills catalog, an
* AGENTS.md digest, a workspace baseline: `Session.deriveMessages()`
* never returns the prefix, and the header events are its only durable
* record, so the request stays reconstructable from the log. Content
* that CHANGES mid-session belongs in the append-only history channels
* instead — `agent.inject()`, a `tools/post-execute` decision's
* `additionalContext`, prompt-submit `additionalContext` — each a
* durable `context/message` paid once and prefix-cached thereafter.
*
* The seed is a frozen empty list; a contributing listener returns a NEW
* array — never an in-place push. The canonical contribution is a
* PREPEND, `[mine, ...await next()]`: the waterfall unwinds
* innermost-first (the LAST-registered listener's `next()` resolves
* first), so prepending yields registration order on the wire, and every
* plugin using it composes deterministically. The append form
* `[...await next(), mine]` is legal but places a contribution AFTER
* every later-registered plugin's — reverse registration order when all
* contributors append. Call `next()` to
* delegate, or return a list without it to short-circuit.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* Scope-filtered dispatch: keyed to `agent`.
* @param agent - the agent whose session prefix is being composed.
* @param prefix - the frozen empty seed; return an extended replacement to contribute.
* @param signal - aborts in-flight listener work (e.g. a discovery scan) when the step is torn down.
@@ -517,71 +271,49 @@ declare module 'cordis' {
*/
'agent/session-prefix'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
/**
* Waterfall: post-process the assembled assistant {@link Message} before
* tool dispatch (validation, content rewriting, …).
* Waterfall: post-process the assembled assistant {@link Message} before tool dispatch
* (validation, content rewriting, …).
*
* Scope-filtered dispatch: keyed to `agent`.
* @param agent - the agent that received the step's response.
* @param turn - the open turn number.
* @param step - the step that produced the message.
* @param message - the assistant message as assembled from the stream.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @mode waterfall
*/
'agent/step-result'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>
/**
* Waterfall: override the turn-continuation decision via a typed
* {@link ContinuationDecision}. The loop's `defaultDecision` is `continue`
* when the step had tool calls or steering was injected, else `stop`.
* Listeners force-continue (`/goal`, `/loop` — optionally attaching a
* `reason` recorded as next-step steering) or force-stop (budget guards).
* Call `next()` to delegate to the default, or return a decision to override.
* Waterfall: override the turn-continuation decision via a typed {@link
* ContinuationDecision}.
*
* Scope-filtered dispatch: keyed to `agent`.
* @param agent - the agent deciding whether to run another step.
* @param turn - the turn being continued or stopped.
* @param defaultDecision - what the loop would do absent an override.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @mode waterfall
*/
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
/**
* Serial terminal-stop checkpoint after the ordinary
* `agent/turn-continuation` waterfall, any `continue.reason`, and the
* pending-steering continuation override have been folded. A listener
* returns `{ action: 'stop' }` to make this turn terminal, or `undefined`
* to abstain. Terminal stop is monotonic: listener order and steering
* cannot resume the turn, and pending steering is discarded rather than
* becoming another step or turn. A malformed non-undefined result fails
* the turn closed.
* Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` waterfall,
* any `continue.reason`, and the pending-steering continuation override have been folded.
*
* Scope-filtered dispatch: keyed to `agent`.
* @param agent - the agent whose composed continuation outcome may be stopped.
* @param turn - the turn at its terminal-stop checkpoint.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* Dispatch is scoped to `agent`.
* @mode serial
*/
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): Promise<ContinuationStop | undefined> | ContinuationStop | undefined
// ---- error notifications (emit) ----
/**
* A step or turn errored. The loop reports a failure here (plus the logger)
* even when the error has no in-turn position for a session `error` event.
* A step or turn errored.
*
* Scope-filtered dispatch: keyed to `agent`.
* @param agent - the agent whose turn errored.
* @param turn - the turn in which the failure surfaced.
* @param step - the step at which the failure surfaced.
* @param error - the failure, verbatim.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @mode emit
*/
'agent/error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: Error): void

View File

@@ -1,16 +1,5 @@
/**
* Negative-path tests for the cordis catalog generator (`scripts/gen-cordis-catalog.ts`).
*
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
* the freshness half is exercised by `pnpm run verify-cordis-catalog` in CI.
* What a freshness diff CANNOT prove is that the generator REJECTS malformed
* source the way it promises to — a missing `@mode` tag, a tag that
* contradicts the signature shape, or a JSDoc-completeness violation (missing
* prose, an undocumented parameter, a stale `@param`, a missing `@returns`, an
* unannotated return type). These tests drive `collectEvents()` /
* `collectServices()` against synthetic fixture packages to prove each guard
* fires (and that well-formed declarations pass), mirroring the drift-guard
* negative tests for verify-type-equiv.
*/
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'

View File

@@ -1,15 +1,5 @@
/**
* Negative-path tests for the export-surface JSDoc gate
* (`scripts/verify-export-jsdoc.ts`).
*
* The gate's positive half runs against the real tree in CI (`pnpm run
* verify-export-jsdoc`, part of doc-sync). What that run cannot prove is that
* the walk REJECTS an undocumented surface the way it promises to — and that
* every deliberate exemption (heritage members, plugin-protocol slots,
* constructors, overload implementations, augmentation bodies, re-exports)
* actually holds. These tests drive `collectExportJsdocViolations()` against
* synthetic fixture packages, mirroring the gen-cordis-catalog negative
* tests.
* Negative-path tests for the export-surface JSDoc gate (`scripts/verify-export-jsdoc.ts`).
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'

View File

@@ -1,21 +1,18 @@
# dsh-scope
Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis context that TAGS everything registered through it with an opaque `ScopeKey` and OWNS those registrations' lifetime (one backing fiber drives both facts); `scopeOf(ctx)` reads the tag; `scopeTarget(base, key)` builds the dispatch carrier that makes an event scope-filtered — listeners registered through a scoped context fire only for their key's subject, while plain plugin listeners keep firing for every subject. The agent loop is the one scope minter today (one scope per live agent, key = the `Agent` object — the `Agent.ctx` contract in `dsh-agent`), but the mechanism is key-agnostic so packages below the agent layer (`dsh-session`, `dsh-system-prompt`) depend on it without a dependency cycle.
Scoped Cordis registrations. `createScope(ctx, key)` returns a context whose registrations are visible only to the matching dispatch subject and are owned by one backing fiber. The agent loop creates one scope per live agent; lower-level packages depend only on the generic `ScopeKey` mechanism.
## Public API
- `createScope(ctx: Context, key: ScopeKey): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). Throws on a primitive key, or when `ctx`'s fiber is disposing (`INACTIVE_EFFECT`).
- `Scope.ctx` The tagged context: registrations through it are scope-visible AND scope-lifetime. Derived contexts (an `extend`, a fiber mounted under it) inherit the tag; nested scopes shadow (nearest tag wins).
- `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling).
- `Scope.dispose(): Promise<void>` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first.
- `scopeOf(ctx: Context): ScopeKey | undefined` The tag a context (or any context derived from it) carries; `undefined` = context-global.
- `scopeTarget(base: T, key?: ScopeKey): Scoped<T>` Build the dispatch `thisArg` for a scope-filtered event: composes `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). Listener `this` stays `base`-shaped. `{ global: true }` listeners bypass filtering (Cordis semantics).
- `Scoped<T>` The compile-time carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error.
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
- `scopeHost(ctx, services)` Test/tooling host whose shared `dispose()` waits for both the host fiber and every minted scope, including a child already tearing down through `rawDispose`.
- `createScope(ctx, key): Scope` creates a tagged child context. Derived contexts inherit the tag; a nested scope replaces it. Primitive keys and creation during disposal throw.
- `Scope.ctx` is the registration context.
- `Scope.rawDispose` is the exact Cordis disposer used when nesting the scope in a composite effect.
- `Scope.dispose(): Promise<void>` is the idempotent quiescence boundary for ordinary callers, including races started through `rawDispose`.
- `scopeOf(ctx)` returns the nearest key or `undefined` for global registration.
- `scopeTarget(base, key): Scoped<T>` creates the event receiver that admits global listeners plus listeners for `key`. An undefined key admits only global listeners; Cordis `{ global: true }` remains an explicit bypass.
- `Scoped<T>` brands scope-filtered event receivers at compile time. `isScopeCarrier()` and `carrierKeyOf()` support runtime invariants.
- `scopeHost(ctx, services)` provides a test/tooling host whose disposer awaits its fiber and all scopes it minted.
## Design contract
Visibility and cleanup come from the same registration context, so a contribution cannot be visible to one scope but owned by another. A scoped context retains the minting plugin's injected service view; mint it from a context whose capabilities are appropriate for holders.
Ownership and visibility derive from ONE fact — which context a registration went through. An explicit `{ scope }` registration parameter could express "visible to X, disposed with Y", which is almost always a bug; the scoped context makes it unrepresentable. Rationale and alternatives: [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md).
Handing out a scoped context hands out the minting plugin's service-resolution capability (resolution walks the minting fiber's dependency chain, not the holder's) — mint scopes from a plugin whose `inject` surface is what scope holders should reach.
See [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md) for rationale and lifecycle integration.

View File

@@ -1,24 +1,7 @@
/**
* Scoped-context primitive: mint a Cordis context that TAGS everything
* registered through it with an opaque {@link ScopeKey}, and dispatch events so
* listeners registered through such a context fire only for their key's
* subject. Scope-aware registries (`ctx.tools`, `ctx.systemPrompt`) read the
* tag via {@link scopeOf} to file a registration in the right layer; the agent
* loop is the one scope MINTER today (one scope per live agent, key = the
* `Agent` object — see `Agent.ctx` in `@deepseek-ai/dsh-agent`), but the
* mechanism is key-agnostic by design so packages below the agent layer
* (`dsh-session`, `dsh-system-prompt`) can depend on it without a dependency
* cycle.
*
* Ownership and visibility derive from ONE fact — which context a registration
* went through: the scope's fiber owns the disposal (a `ctx.effect()`/
* `ctx.on()`/registry call through the scoped context unwinds on
* {@link Scope.dispose}, because Cordis routes a service method's `this.ctx`
* to the ACCESSING context), and the tag decides who sees it. Splitting those
* two — an explicit `{ scope }` registration parameter — would let a caller
* express "visible to X, disposed with Y", which is almost always a bug; the
* scoped context makes it unrepresentable.
*
* Scoped-context primitive: mint a Cordis context that TAGS everything registered through it
* with an opaque {@link ScopeKey}, and dispatch events so listeners registered through such a
* context fire only for their key's subject.
* @module @deepseek-ai/dsh-scope
*/
@@ -113,18 +96,6 @@ function scope(): void {}
/**
* Mint a registration scope for `key` under `ctx`.
*
* Mounts a runtime fiber (`ctx.plugin`) and tags a child of its context with
* `key`. The fiber is usable synchronously — Cordis activates it on a
* microtask, but effect collection is uid-gated (not state-gated) and service
* resolution falls through the pending fiber to the MINTING plugin's
* dependency surface, so a caller may register through {@link Scope.ctx} the
* moment this returns.
*
* Service resolution through the scoped context flows through the minting
* plugin's dependency chain (the fiber walk), regardless of what the eventual
* holder's own fiber injected — handing out the scoped context hands out that
* capability; see `Agent.ctx` in `@deepseek-ai/dsh-agent` for the harness's
* contract.
* @param ctx - the context to mount the scope under; its fiber must be active
* (a disposing owner throws Cordis's INACTIVE_EFFECT), and its plugin's
* `inject` surface is what the scoped context resolves services against.
@@ -172,38 +143,11 @@ export function scopeOf(ctx: Context): ScopeKey | undefined {
}
/**
* Build the dispatch carrier for a scope-filtered event: `base` overlaid with
* a `Context.filter` that admits a listener iff
*
* - its registering context is UNTAGGED (a context-global listener — the
* compatibility default: plain plugin listeners see every subject), or
* - its tag IS `key` (a scoped listener seeing exactly its own subject),
*
* AND `base`'s own filter (a Cordis `Service`'s isolation check) also admits
* it. Dispatching with `key === undefined` — a subject-less dispatch, e.g. a
* tool call with no calling agent or a bare (agent-less) session's events —
* admits only untagged listeners: a scoped listener never fires for someone
* else's (or nobody's) subject. Listeners registered `{ global: true }`
* bypass all filtering (Cordis semantics).
*
* Use it as the `thisArg` of the dispatch:
* `ctx.waterfall(scopeTarget(this, exec.agent), 'tools/pre-execute', …)`. The
* carrier is a TRANSPARENT proxy over `base`: reads delegate with `base` as
* the receiver and retrieved methods are bound to `base`, so a listener may
* call subject methods through its `this` (`this.send(…)` on a
* `Scoped<Agent>`) even when the subject uses native `#private` fields — a
* bare proxy receiver would throw on those. Identity is still not
* transparent: `this !== subject` and method identity varies per read; the
* subject always travels in the event's arguments. The returned carrier is
* branded {@link Scoped} and runtime-marked ({@link isScopeCarrier} /
* {@link carrierKeyOf}) so both the type system and the dev invariants can
* tell a carrier from a bare subject.
* @param base - the object the event is dispatched on behalf of (the owning
* service, or the subject agent itself); its own `Context.filter` is
* preserved and composed.
* @param key - the subject's scope key, or `undefined` for a subject-less
* dispatch.
* @returns the carrier to pass as the dispatch `thisArg`.
* Build an event receiver admitting global listeners plus listeners tagged with `key`.
* The proxy preserves `base` filtering and binds subject methods to `base`.
* @param base - dispatch subject whose filter is preserved.
* @param key - subject scope, or `undefined` for global-only delivery.
* @returns branded receiver for the dispatch `thisArg`.
*/
export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined): Scoped<T> {
const baseFilter = (base as { [CordisContext.filter]?: (ctx: Context) => boolean })[CordisContext.filter]
@@ -216,39 +160,20 @@ export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined
[CordisContext.filter]: filter,
[kCarrier]: { key },
}
// A hand-rolled proxy, NOT cordis withProps: withProps delegates gets with
// the PROXY as receiver, so a getter on `base` runs with proxy `this` and a
// method call through the carrier gets a proxy receiver — either one throws
// on a native `#private` field of the subject (TypeError: private member
// not declared). Cordis hands the carrier to listeners as `this`, and the
// event declarations type it `Scoped<Agent>` — so subject method calls
// through it are a SUPPORTED shape and must reach the real object: gets
// delegate with `base` as receiver, functions come back bound to `base`,
// and sets land on `base` directly.
// Bind through the real subject so native private fields remain accessible.
return new Proxy(base, {
get(target, prop) {
// Proxy get invariants pin what this trap may report for a
// non-configurable OWN property of the base: a non-writable data prop
// must be reported AS-IS (neither overlaid nor bound), a getterless
// accessor as undefined — checked FIRST so even an overlay key
// colliding with a frozen own prop of a (pathological) base yields the
// base's value instead of an engine TypeError. Such a base forgoes
// scope filtering; no production base freezes these keys.
// Non-configurable own properties must be reported unchanged.
const own = Reflect.getOwnPropertyDescriptor(target, prop)
const pinned = own !== undefined && own.configurable === false
&& own.get === undefined && own.writable !== true
// hasOwn, not `in`: the overlay literal inherits Object.prototype, so
// `in` would claim `toString`/`constructor` and shadow the subject's.
// `in` would let Object.prototype shadow subject properties.
if (!pinned && Object.hasOwn(overlay, prop)) return overlay[prop]
const value: unknown = Reflect.get(target, prop, target)
if (typeof value !== 'function' || pinned) return value
// `constructor` is looked up, never invoked as a subject method — keep
// the real one (withProps special-cases it the same way), so
// `carrier.constructor` still identifies the subject's class.
// Preserve class identity.
if (prop === 'constructor') return value
// `Function.prototype.bind` types as `any`; the value is structurally
// T[prop] and the trap's contract is untyped (`any`), so unknown is the
// honest safe return.
// `bind` is typed as `any`; keep the trap boundary `unknown`.
return value.bind(target) as unknown
},
set(target, prop, value) {
@@ -312,14 +237,9 @@ export interface ScopeHost {
}
/**
* Mount a scope-minting host plugin that injects `services`, THE sanctioned
* way to mint scopes in tests (production scopes are minted by the agent
* loop). Exists because the naive spelling fails confusingly twice over:
* a plugin with no `inject` mints scopes whose service reads throw Cordis's
* cryptic `cannot get property … without inject`, and a plugin whose inject
* can never be satisfied RESOLVES its fiber await without ever running the
* callback — a silent no-op host. This helper fails LOUD instead: when the
* callback did not run, it names the absent services and disposes the host.
* Mount a scope-minting host plugin that injects `services`, THE sanctioned way to mint scopes
* in tests (production scopes are minted by the agent loop).
*
* @param ctx - the context to mount the host under.
* @param services - the service names scopes minted through this host reach
* (the host plugin's `inject` list).
@@ -349,9 +269,7 @@ export async function scopeHost(ctx: Context, services: string[]): Promise<Scope
const scopes = new Set<Scope>()
let disposing: Promise<void> | undefined
const dispose = async (): Promise<void> => {
// Start every boundary before awaiting any one of them. A child whose raw
// disposer already ran is still followed through Scope.dispose(); a child
// the host unload claims first is followed through the same fiber inertia.
// Start every boundary before awaiting any one of them.
const tasks = [quiesceFiber(fiber), ...[...scopes].map(scope => scope.dispose())]
const results = await Promise.allSettled(tasks)
scopes.clear()

View File

@@ -209,12 +209,8 @@ describe('scopeTarget dispatch filtering', () => {
})
it('is transparent for subjects with native #private fields: methods and getters through the carrier reach the real object', () => {
// The ds-review-bot regression: cordis hands the carrier to listeners as
// `this` (typed Scoped<Agent>), so subject method calls through it are a
// supported shape. A proxy that delegates with the PROXY as receiver
// (cordis withProps) throws TypeError on any native #private the method
// or getter touches; the carrier must delegate with the BASE as receiver
// and bind retrieved methods to it.
// The ds-review-bot regression: cordis hands the carrier to listeners as `this` (typed
// Scoped<Agent>), so subject method calls through it are a supported shape.
class Subject {
#count = 0
bump(): number { return ++this.#count }
@@ -252,11 +248,9 @@ describe('scopeTarget dispatch filtering', () => {
})
it('honors the get invariant even when an overlay key collides with a frozen own prop of the base', () => {
// Pathological but engine-enforced: a base whose own [Context.filter] is
// a non-configurable, non-writable data prop pins what any proxy over it
// may report for that key. The carrier must yield the base's value (an
// overlay there would be a runtime TypeError from the engine, not a
// filtering choice). Such a base forgoes scope filtering by construction.
// Pathological but engine-enforced: a base whose own [Context.filter] is a
// non-configurable, non-writable data prop pins what any proxy over it may report for that
// key.
const pinnedFilter = (): boolean => true
const base = {}
Object.defineProperty(base, Context.filter, { value: pinnedFilter, writable: false, configurable: false })

View File

@@ -3,6 +3,7 @@
* the derived LLM message history. Persistence is a plugin concern (subscribe
* to `session/event`, drain on `session/flush`).
*
* Scope-filtered dispatch: keyed to the session's captured owner.
* @module @deepseek-ai/dsh-session
*/
@@ -35,44 +36,24 @@ declare module 'cordis' {
interface Events {
/**
* A session was created in the store.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
* session's owner scope, captured when the session was ENTERED (an agent's
* session is entered through `agent.ctx`, so its events dispatch in that
* agent's scope; a bare `sessions.create()` from a plain plugin dispatches
* subject-less). A listener registered through `agent.ctx` hears only that
* agent's sessions; a plain plugin listener hears every session.
* Dispatch uses the session's captured owner scope.
* @param session - the session just entered and announced.
* @mode emit
*/
'session/created'(this: Scoped<Session>, session: Session): void
/**
* An event was appended to a session log (sync, fire-and-forget). This is
* the per-append feed a UI or invariant plugin tails.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
* session's owner scope, captured when the session was ENTERED (an agent's
* session is entered through `agent.ctx`, so its events dispatch in that
* agent's scope; a bare `sessions.create()` from a plain plugin dispatches
* subject-less). A listener registered through `agent.ctx` hears only that
* agent's sessions; a plain plugin listener hears every session.
* An event was appended to a session log (sync, fire-and-forget).
*
* Scope-filtered dispatch: keyed to the session's captured owner.
* @param session - the session whose log grew.
* @param event - the appended event, exactly as recorded.
* @mode emit
*/
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
/**
* Awaited durability checkpoint. The agent loop awaits
* `ctx.sessions.flush(session)` at every turn end; persistence
* plugins (JSONL, SQLite) drain their write-behind buffers here and on
* fiber dispose. Awaited (parallel), not a waterfall: every listener runs
* and the caller waits for all of them, but none can veto. Dispatch it
* through {@link SessionStore.flush} — the store owns the carrier — never
* via a raw `ctx.parallel`.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
* session's owner scope, captured when the session was ENTERED (an agent's
* session is entered through `agent.ctx`, so its events dispatch in that
* agent's scope; a bare `sessions.create()` from a plain plugin dispatches
* subject-less). A listener registered through `agent.ctx` hears only that
* agent's sessions; a plain plugin listener hears every session.
* Awaited durability checkpoint.
*
* Scope-filtered dispatch: keyed to the session's captured owner.
* @param session - the session whose buffered events must reach durable storage.
* @mode parallel
*/
@@ -137,13 +118,7 @@ export class Session {
constructor(public readonly id: SessionId, seed?: SessionEvent[], header?: SessionHeader) {
if (seed) {
// Validate the seed to the SAME invariants `append` enforces, so a
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
// live log that no persistence backend could store: each event's `data`
// must be JSON-serializable, and `seq` must be contiguous from 0 (the
// `seq = log.length` contract the whole system relies on). Without this,
// a bad seed would surface only later as a backend rejection or a silent
// divergence between the live log and disk.
// Validate seed JSON and contiguous sequence numbers just as append would.
seed.forEach((event, index) => {
if (event.seq !== index) {
throw new Error(`seed event at index ${index} has seq ${event.seq} (expected ${index}); seed must be contiguous from 0`)
@@ -151,25 +126,13 @@ export class Session {
if (!isJsonValue(event.data)) {
throw new Error(`seed event "${event.type}" (seq ${event.seq}) carries non-JSON-serializable data`)
}
// Surface-eligible events MUST carry a surfaceOp marker — the surface is
// the sole source of derived history, so a marker-less message event
// would load fine yet vanish from deriveMessages(). `append` enforces
// this at compile time via its typed overload; a seed arrives as raw
// SessionEvent[] (replay/fork/load), bypassing that, so re-check at
// runtime here rather than silently resuming with empty history.
// Seed events bypass append's overloads, so enforce surface markers at runtime.
if (isSurfaceEligibleType(event.type)
&& (event as SessionEvent<SurfaceEventType>).surfaceOp === undefined) {
throw new Error(`seed event "${event.type}" (seq ${event.seq}) is surface-eligible but carries no surfaceOp marker`)
}
})
// Deep-clone each seed event, NOT just the array: the seed events and
// their `data` are still owned by the caller (or the source session of a
// fork), so keeping the references would let a post-create mutation of the
// original rewrite this session's durable log — or reintroduce a
// non-JSON-serializable value AFTER the validation above. Snapshotting at
// the boundary makes `session.events` independent and keeps it equal to
// what was validated. Serializability is guaranteed by the check above, so
// structuredClone can never hit a non-cloneable value here.
// Clone seed events so callers cannot mutate the durable log after validation.
this.log = seed.map(event => structuredClone(event))
}
this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
@@ -189,29 +152,14 @@ export class Session {
}
/**
* Append one typed event to the log and synchronously notify observers via
* `onAppend`. The hot path never blocks on I/O — persistence plugins buffer
* asynchronously.
* Append one typed event to the log and synchronously notify observers via `onAppend`. The
* hot path never blocks on I/O — persistence plugins buffer asynchronously.
*
* @param type - The event type (key of {@link SessionEventMap}).
* @param data - The event payload; must be JSON-serializable.
* @param opts - Surface metadata: `surfaceOp` controls how the event enters
* the surface linked list; `sourceEventSeqs` records provenance (the seq
* numbers of events this one derives from). REQUIRED for
* {@link SurfaceEventType} events (every message-producing event must
* declare how it joins the surface, the sole source of derived history) and
* rejected by the compiler for non-surface types like `turn/start` or
* `assistant/chunk`.
* @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
* `data` that entered the log, so reading `event.data` back sees the logged
* value, never the caller's still-mutable input.
* @throws if `data` is not losslessly JSON-serializable (BigInt, function,
* symbol, undefined, non-finite number, circular ref, or an exotic object
* like Map/Set/Date). The event log is the durable source of truth, so this
* invariant is enforced at the source — a bad event never enters the log,
* keeping `session.events` always equal to what a backend can persist. The
* throw surfaces at the buggy caller's append site, not asynchronously in a
* backend flush.
* @param opts - required surface placement and optional provenance for message-producing events.
* @returns the event with assigned sequence, time, and snapshotted data.
* @throws if data is not losslessly JSON-serializable or surface placement is missing.
*/
append<T extends SessionEventType>(
type: T,
@@ -222,36 +170,12 @@ export class Session {
throw new Error(`session event "${type}" carries non-JSON-serializable data`)
}
const surfaceOpts: SurfaceIntent | undefined = opts[0]
// Surface-eligible events MUST carry a surfaceOp marker — the surface is the
// sole source of derived history, so a marker-less message event would be
// logged yet vanish from deriveMessages(). The typed `opts` overload makes
// the marker mandatory only when `T` is a SPECIFIC SurfaceEventType literal;
// when `T` widens to the SessionEventType union (a caller iterating raw
// events: `for (const e of log) append(e.type, e.data)`), the conditional
// rest collapses to optional and the compiler stops enforcing it. Re-check
// at runtime so that loophole can't silently drop history.
// Recheck the conditional overload when `T` has widened to the full union.
if (isSurfaceEligibleType(type) && surfaceOpts?.surfaceOp === undefined) {
throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`)
}
// Snapshot `data` into the log, NOT the caller's reference: the validation
// above proves it is JSON-serializable AT THIS MOMENT, but the caller still
// owns the object and could mutate it afterwards (before a persistence
// flush, or permanently in the in-memory history) — making `session.events`
// diverge from the value that passed validation, or reintroducing a
// non-serializable value. Cloning here keeps the log equal to what was
// validated. structuredClone is safe because serializability was just
// checked. The returned event carries the SAME snapshot, so a caller reading
// back `event.data` sees the logged value, not its own mutable input.
//
// Surface metadata is snapshot separately: sourceEventSeqs (number[] —
// primitives, so array spread is a complete copy) and surfaceOp (a string
// primitive, or cloned if it's a replace object).
// Build the event shape with conditional surface fields via spreading.
// The result is cast through `unknown` because the conditional spreads
// produce an intersection type that the assignability checker can't
// narrow to a specific discriminated-union member when T is generic.
// This is a safe internal boundary: data was validated above, and
// surface metadata was snapshot from primitive/clone-safe values.
// Snapshot caller-owned data and metadata before they enter durable history.
// The generic conditional spreads require an internal union-boundary cast.
const event = {
type,
seq: this.log.length,
@@ -300,22 +224,9 @@ export class Session {
private derivedGeneration = 0
/**
* Derive the LLM message history by walking the session surface — the linked
* list of message-producing events maintained by `surfaceOp` markers. The
* surface is the single source of derived history: every message-producing
* append records its `surfaceOp`, so a raw event with no marker (a chunk, a
* turn boundary) is correctly absent, and a compaction `replace` deletes the
* shadowed nodes from the derivation. The projection rules are
* {@link deriveEventMessage}, folded per node.
* Derive the LLM message history by walking the session surface — the linked list of
* message-producing events maintained by `surfaceOp` markers.
*
* CACHED: each surface node is projected exactly once, when first seen — a
* call costs O(new nodes), and a surface rewrite (a `replace`;
* {@link SurfaceManager.replaceGeneration}) rebuilds. The returned array is
* a fresh snapshot per call (later appends never grow an array a caller
* already holds); the `Message` objects in it are SHARED and **deep-frozen**
* — cloned once off the log at projection time, so consumers can never
* mutate logged data, and mutation attempts throw instead of silently
* diverging replay from history.
* @returns a fresh array of the shared, frozen derived history.
*/
deriveMessages(): Message[] {
@@ -341,15 +252,10 @@ export class Session {
}
/**
* Project a single event into the LLM message it derives to, or null when
* it produces none — a non-surface event (chunk, boundary, log-only record)
* or an empty-content assistant/message (which exists only to host usage).
* 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 `content` is
* deep-cloned off the logged event: the log is append-only by contract, so
* no live reference to logged data leaves this boundary.
* Project a single event into the LLM message it derives to, or null when it produces none —
* a non-surface event (chunk, boundary, log-only record) or an empty-content
* assistant/message (which exists only to host usage).
*
* @param event - the event to project.
* @returns the derived message, or null when the event produces none.
*/
@@ -441,31 +347,15 @@ export class SessionStore extends Service {
}
/**
* Create a session owned by the calling fiber: disposing that fiber stops
* event notification and removes the session from the store. `options.seed`
* populates the session with a copy of those events (replay/fork);
* `options.meta` attaches creation metadata (validated absolute `cwd`,
* `parentSession` lineage) as the immutable {@link SessionHeader} (the store
* fills `version`/`id`/`createdAt`).
*
* For an agent whose session must be torn down IN ORDER with its loop (so the
* loop's final flush is captured before `onAppend` detaches), do NOT use this
* — fold the session lifecycle into the agent's own effect via
* {@link prepare} + {@link enter} + {@link announce} (see `dsh-agent-loop`'s
* `startOwned`).
*
* Create, enter, and announce a session owned by the calling fiber.
* @param id - the session id; omitted, the store mints `session-<n>`.
* @param options - seed events and/or creation metadata for the header.
* @param options - optional seed and header metadata.
* @returns the live session, already entered and announced.
* @throws if a session with `id` already exists, or if `meta.cwd` is a
* non-absolute path (storage backends key directories off it).
* @throws if the id exists or cwd is not absolute.
*/
create(id?: SessionId, options?: CreateSessionOptions): Session {
const session = this.prepare(id, options)
// Single effect owned by the calling fiber. Yield the detach BEFORE
// announcing so a throwing `session/created` listener rolls the attach back
// (the generator effect disposes already-yielded disposers on a throw)
// instead of leaking the store entry + onAppend.
// Yield detach before announcement so listener failure rolls back entry.
this.ctx.effect(function* (this: SessionStore) {
yield this.enter(session)
this.announce(session)
@@ -474,13 +364,8 @@ export class SessionStore extends Service {
}
/**
* Build a session WITHOUT entering it into the store — validate the id/cwd and
* construct the {@link Session} (with its immutable {@link SessionHeader}).
* Pairs with {@link enter} + {@link announce}: a caller that owns a composite
* `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
* effect so a fiber unload tears the session + agent down as a single ORDERED
* chain rather than as racing sibling effects — which would detach `onAppend`
* before the loop's closing `session/flush`, dropping the closing events.
* Build a session WITHOUT entering it into the store — validate the id/cwd and construct the
* {@link Session} (with its immutable {@link SessionHeader}).
*
* @param id - the session id; omitted, the store mints `session-<n>`.
* @param options - seed events and/or creation metadata for the header.
@@ -507,20 +392,8 @@ export class SessionStore extends Service {
}
/**
* Enter a {@link prepare}d session into the store: wire `onAppend` →
* `session/event` and add it to the store. Returns the DETACH disposer
* (`onAppend = undefined` + store removal). Does NOT emit `session/created` —
* the caller yields this disposer inside its effect and THEN calls
* {@link announce}, so a throwing `session/created` listener rolls the attach
* back instead of leaking it.
*
* Re-checks the id for a duplicate: `prepare` and `enter` are public
* cross-package primitives and a caller may interleave arbitrary work (or
* another create) between them, so a stale prepared session must NOT overwrite
* a live store entry of the same id — its detach disposer would later delete
* the REAL session. The {@link create} convenience and the agent factory call
* the two back-to-back so they never trip this, but the public seam cannot
* assume that.
* Enter a {@link prepare}d session into the store: wire `onAppend` → `session/event` and
* add it to the store.
*
* @param session - a {@link prepare}d session not yet in the store.
* @returns the detach disposer (`onAppend = undefined` + store removal).
@@ -528,11 +401,10 @@ export class SessionStore extends Service {
*/
enter(session: Session): () => void {
if (this.store.has(session.id)) throw new Error(`session "${session.id}" already exists`)
// The carrier is decided HERE, once, from the ENTERING context's scope tag
// (`this.ctx` is the caller's context — the tracker mechanism): every
// session/created|event|flush dispatch for this session uses it, so the
// session's whole event feed is scope-filtered consistently. The base is
// the session itself (scoped listeners' `this` is the session).
// The carrier is decided HERE, once, from the ENTERING context's scope tag (`this.ctx` is
// the caller's context — the tracker mechanism): every session/created|event|flush dispatch
// for this session uses it, so the session's whole event feed is scope-filtered
// consistently.
const carrier = scopeTarget(session, scopeOf(this.ctx))
this.carriers.set(session, carrier)
const emitCtx = this.ctx
@@ -604,14 +476,12 @@ export class SessionStore extends Service {
}
/**
* Create a live child session from a turn-enclosed prefix of a live source.
* `boundary` is an inclusive source event seq; omitted means the source's
* current last event. A non-empty selected slice must end at `turn/end`.
* Create a live child session from a turn-enclosed prefix of a live source. `boundary` is
* an inclusive source event seq; omitted means the source's current last event.
*
* @param source - Live source session object or id.
* @param boundary - Inclusive source event seq to fork through; omitted means
* the source's current last event, and omitted on an empty source forks an
* empty child.
* @param boundary - Inclusive source event seq to fork through; omitted means the
* source's current last event, and omitted on an empty source forks an empty child.
* @param childSessionId - Optional child session id; omitted delegates to
* `SessionStore`'s id policy.
* @returns The created live child session.

View File

@@ -1,15 +1,5 @@
/**
* JSON-serializability validation for session event data.
*
* The session event log is the durable source of truth (the event-sourcing / session-persistence RFCs): every
* `event.data` must round-trip losslessly through JSON so any persistence
* backend can store and reload it byte-identically. This invariant belongs to
* the log itself — `Session.append` enforces it at the source, so a
* non-serializable event never enters `session.events` and the live log can
* never diverge from what a backend can persist. Backends re-use the same
* predicate to validate their own `append(events)` entry point (replay/fork
* paths that do not go through a live `Session`).
*
* @module @deepseek-ai/dsh-session/json
*/
@@ -24,22 +14,9 @@
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
/**
* Whether `value` is losslessly JSON-serializable: only `null`, finite numbers,
* booleans, strings, plain arrays, and plain objects of such values. Rejects
* `BigInt`, function, symbol, `undefined`, non-finite numbers (`NaN`/`Infinity`,
* which `JSON.stringify` turns into `null`), and exotic objects (`Map`/`Set`/
* `Date`/class instances) — anything `JSON.stringify` would drop, throw on, or
* convert lossily. Sparse arrays are rejected too: a hole serializes to `null`,
* so `[1, , 3]` would not round-trip. Detects circular references (which would
* throw) and reports them as non-serializable rather than propagating the throw.
* Whether `value` is losslessly JSON-serializable: only `null`, finite numbers, booleans,
* strings, plain arrays, and plain objects of such values.
*
* Scope — matches `JSON.stringify` exactly: only an object's OWN ENUMERABLE
* STRING-keyed properties are inspected (`Object.values`). Symbol-keyed and
* non-enumerable properties are NOT examined, because `JSON.stringify` likewise
* drops them — they never reach the durable form, so a non-serializable value
* hiding under a symbol/non-enumerable key cannot make the round-trip lossy.
* Getters are invoked during the check (again as `JSON.stringify` would), so the
* contract is for plain data records, not objects with side-effecting accessors.
* @param value - the candidate event data to test.
* @param seen - objects on the current descent path, for circular-reference
* detection; the recursion threads it — callers omit it.

View File

@@ -1,37 +1,5 @@
/**
* Crash-recovery repair for an interrupted session log.
*
* A persistence backend flushes only at `turn/end`, so a crash can leave a
* durable log whose final turn never closed: real, fully-written events sit
* after the last `turn/end` with no closing boundary. A single turn can be huge
* in a long-horizon task (many steps, large tool output), so those events MUST
* be preserved — truncating the turn would silently destroy real work. Instead,
* on reload the backend CLOSES the orphaned turn by appending the minimal
* synthetic boundary events:
*
* 1. an error `tool/result` for every `tool-call` in the interrupted turn that
* never got its matching `tool/result` (so the rehydrated history is a
* VALID provider transcript — see below),
* 2. a `step/end` if a step was still open, then
* 3. a `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason.
*
* The marker records that the turn was cut short by a crash, not completed by
* the model. See the session-persistence RFC.
*
* Why the synthetic tool results matter: `deriveMessages()` renders the
* `tool-call` blocks inside a durable `assistant/message` but only emits a
* matching tool-result when a `tool/result` EVENT exists. A crash between the
* assistant message and its tool results (the loop runs the tools AFTER logging
* the assistant message, so a process killed mid-tool leaves the calls without
* results) would otherwise reload a history with a dangling assistant tool-call
* — which every provider rejects as an invalid transcript on the next request.
* Synthesizing an error result per orphaned call keeps resume safe.
*
* This module computes those synthetic closers from an event list; backends
* return them inline from `load` (so the reconstructed session is balanced and
* immediately usable) and persist them during that mutating load before any
* later append continues the log.
*
* @module @deepseek-ai/dsh-session/repair
*/
@@ -39,36 +7,19 @@ import type { CallId } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from './types.ts'
/**
* Scan `events` for an open turn/step at the tail and return the synthetic
* boundary events that close them, with `seq` continuing the log and `time`
* copied from the last real event (the closers stand in for the crash moment;
* reusing the last timestamp keeps them deterministic and never invents a
* "future" time). Returns an empty array when the log is already balanced
* (ends on a `turn/end`, or is empty) — the common, non-crash case.
* Scan `events` for an open turn/step at the tail and return the synthetic boundary events
* that close them, with `seq` continuing the log and `time` copied from the last real event
* (the closers stand in for the crash moment; reusing the last timestamp keeps them
* deterministic and never invents a "future" time).
*
* The closers, in order: an error `tool/result` for each unmatched `tool-call`
* in the interrupted turn, then a `step/end` if a step is open, then the
* `turn/end {interrupted}`. The tool-results come first so a step that issued
* tool calls is balanced (every call has a result) before its `step/end`.
*
* Only the LAST turn can be open: the invariants plugin guarantees a `turn/end`
* before any later `turn/start`, so an interior open turn is impossible in a
* valid committed log. Likewise at most one step is open within that turn.
* @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
* @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
*/
export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] {
let openTurn: number | null = null
let openStep: number | null = null
// Track tool calls vs. their results WITHIN the currently-open turn only: a
// call is "pending" until its matching tool/result arrives. Reset at every
// turn boundary so a committed earlier turn (already balanced) never leaks a
// phantom pending call into the interrupted-turn repair.
// Track pending tool calls with their callSeq (the seq of the `tool/call`
// event, captured for surface sourceEventSeqs provenance on the synthetic
// result). CallSeq is set from `tool/call` events; the assistant/message
// block scan may register a call first (it appears earlier in the log), and
// the later `tool/call` event fills in the seq.
// Track tool calls vs. their results WITHIN the currently-open turn only: a call is "pending"
// until its matching tool/result arrives.
const pendingCalls = new Map<CallId, { step: number; callSeq?: number }>()
for (const event of events) {
switch (event.type) {
@@ -97,10 +48,8 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
}
break
case 'tool/call':
// Capture the tool/call event seq for surface provenance on the
// synthesized tool/result. The entry may already exist (registered by
// the assistant/message above) or may be new (if the assistant/message
// came from a prior step that was already closed).
// Capture the tool/call event seq for surface provenance on the synthesized
// tool/result.
{
const entry = pendingCalls.get(event.data.callId)
if (entry) {
@@ -129,10 +78,9 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
const time = last.time
const closers: SessionEvent[] = []
// Synthesize an error tool/result for each tool-call left unanswered by the
// crash, so deriveMessages() yields a valid provider transcript on resume (a
// dangling assistant tool-call is rejected by every provider). Insertion
// order follows the Map (insertion = log order of the assistant messages).
// Synthesize an error tool/result for each tool-call left unanswered by the crash, so
// deriveMessages() yields a valid provider transcript on resume (a dangling assistant
// tool-call is rejected by every provider).
for (const [callId, { step, callSeq }] of pendingCalls) {
closers.push({
type: 'tool/result',

View File

@@ -1,14 +1,6 @@
/**
* Request-header reconstruction utilities: the pure fold/diff/apply trio over
* the `request/header` / `request/header-delta` session events. Anyone
* holding a session log reconstructs the {@link EpochHeader} any request was
* built under by folding these events in log order; the loop uses the same
* functions to decide whether a step's header changed and to encode the
* change. Deltas are an encoding optimization with a safety valve — the
* writer round-trip-verifies every delta before appending and falls back to
* a full snapshot when the encoding cannot express the change — so folding
* never needs error recovery on a well-formed log.
*
* Request-header reconstruction utilities: the pure fold/diff/apply trio over the
* `request/header` / `request/header-delta` session events.
* @module dsh-session/request-header
*/
@@ -114,13 +106,10 @@ function applyTools(prev: readonly ToolSchema[], delta: ToolsDelta): ToolSchema[
}
/**
* Field-wise equality over canonical headers — the cheap comparison the
* writer's round-trip guard runs (`applyHeaderDelta(prev, delta)` must equal
* the intended header) and the loop runs to skip logging an unchanged header.
* Tools compare per-schema IN ORDER (canonical JSON), so a pure reordering is
* correctly unequal; the session prefix compares as canonical JSON (both
* sides come from the same build path, so key order matches when the values
* do).
* Field-wise equality over canonical headers — the cheap comparison the writer's round-trip
* guard runs (`applyHeaderDelta(prev, delta)` must equal the intended header) and the loop
* runs to skip logging an unchanged header.
*
* @param a - one canonical header.
* @param b - the other.
* @returns whether config, system, tools (in order), and the session prefix all match.
@@ -139,13 +128,9 @@ function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] |
}
/**
* Compute the `request/header-delta` payload between two canonical headers,
* or undefined when they are equal. The caller MUST round-trip the result
* ({@link applyHeaderDelta} on `prev` deep-equals `next`) before logging it —
* the encoding cannot express every change (a pure tool reordering) — and
* fall back to a full `request/header` snapshot when the check fails.
* The session prefix is replaced whole (small advisory content, not worth
* diffing); an empty replacement array encodes the transition to "none".
* Compute the `request/header-delta` payload between two canonical headers, or undefined when
* they are equal.
*
* @param prev - the folded header the log currently implies.
* @param next - the header the next request will actually use.
* @returns the delta payload, or undefined when nothing changed.
@@ -182,15 +167,13 @@ export function applyHeaderDelta(prev: EpochHeader, delta: HeaderDelta): EpochHe
}
/**
* Fold the header events of a log (or any prefix of one) into the
* {@link EpochHeader} in force after the last of them: each
* `request/header` snapshot replaces the state, each `request/header-delta`
* amends it. The pure, offline form of reconstruction — external tooling and
* the dev invariant both use it; the live session tracks the same fold
* incrementally.
* Fold the header events of a log (or any prefix of one) into the {@link EpochHeader} in
* force after the last of them: each `request/header` snapshot replaces the state, each
* `request/header-delta` amends it.
*
* @param events - session events in log order (non-header events are skipped).
* @param from - a previously folded state to continue from (the live session's
* incremental cursor); omit to fold from nothing.
* @param from - a previously folded state to continue from (the live session's incremental
* cursor); omit to fold from nothing.
* @returns the folded header, or undefined when no header event exists yet.
*/
export function foldRequestHeader(events: readonly SessionEvent[], from?: EpochHeader): EpochHeader | undefined {

View File

@@ -1,36 +1,6 @@
/**
* Tool-pairing balance over a session's SURFACE: is a given cut point in the
* surface a safe edge for a collapsed region (e.g. compaction)?
*
* The invariant a consumer needs: a collapsed region must never separate an
* `assistant/message`'s `tool-call` blocks from their answering `tool/result`s
* — that would leave the rehydrated transcript with a dangling tool-call or an
* orphaned tool-result, which every provider rejects. (This is the
* compaction-time mirror of the crash-recovery imbalance that
* {@link interruptedTurnClosers} repairs on load.) Steps were once used as a
* proxy for this bracketing, but a compaction REWRITES the surface — it lands a
* replacement node at a high log seq whose SURFACE position is the head — so a
* scan over the LOG's `step/*` markers mis-reads such a node's neighbours. The
* pairing the invariant actually protects lives in the surface nodes' own
* content (a `tool-call` block's id, a `tool/result`'s `callId`), which travels
* with the node through any reshaping, so alignment is decided over the surface
* directly.
*
* A **cut** is a gap between two adjacent surface nodes (named by the node it
* sits immediately before), or the after-tail gap (`null`). Walking the surface
* head→tail and assigning each node a delta — `+1` per `tool-call` block on an
* `assistant/message`, `-1` per `tool/result`, `0` otherwise — the depth at a
* cut is the number of still-unanswered tool calls before it. A cut is
* **balanced** when that depth is `0`. A region `[start..end]` is safe to
* collapse iff BOTH its edges are balanced cuts: the cut before `start` and the
* cut after `end`. Nodes that belong to no step (a pre-step `user/message`, an
* inter-step `steering/message`, an injection `context/message`) carry no
* pairing, contribute `0`, and so are free boundaries — exactly as before, but
* now as a consequence of the balance rather than a special case. An open
* trailing step (an assistant whose `tool/result`s have not landed yet) keeps
* the depth positive through the tail, so no cut inside it is balanced — the
* old explicit open-step check falls out of the same counter.
*
* Tool-pairing balance over a session's surface: is a given cut point in the surface a safe
* edge for a collapsed region (e.g. compaction)?
* @module @deepseek-ai/dsh-session/tool-pairing
*/
@@ -57,33 +27,12 @@ function nodeDelta(event: SessionEvent): number {
}
/**
* Whether the surface prefix ending at the given cut has BALANCED tool-call /
* tool-result brackets — i.e. every `tool-call` block on the surface before the
* cut has its answering `tool/result` before the cut too, so the cut is a safe
* edge for a collapsed region (it cannot split an assistant↔result pair).
*
* `nodes` is the surface linked list in head→tail order (e.g.
* `session.surface.nodes`); `events` is the session log, used to look each
* node's event up by `seq`. `beforeSeq` names the cut by the surface node it
* sits immediately before; the after-tail cut (the whole surface) is `null`,
* as is any `beforeSeq` not present on the surface.
*
* A region `[start..end]` is collapsible iff both edges are balanced cuts: call
* `isToolPairingBalanced(nodes, events, start)` for the cut before `start`, and
* `isToolPairingBalanced(nodes, events, after)` — where `after` is `end`'s
* surface successor (`SurfaceNode.next`), or `null` when `end` is the tail —
* for the cut after `end`.
*
* Check that a surface cut does not split a tool call from its result.
* @param nodes - the surface linked list in head→tail order.
* @param events - the session log each node's `seq` indexes into.
* @param beforeSeq - names the cut (the node it sits immediately before);
* `null` — or any seq not on the surface — means the after-tail cut.
* @returns true when every `tool-call` before the cut is answered before it
* (the unanswered-call depth at the cut is zero).
* @throws if the surface prefix drives the unanswered-call depth negative — a
* `tool/result` with no preceding open `tool-call` on the surface. That is a
* corrupt surface (a structural invariant violation), surfaced loudly here
* rather than silently mis-classifying a boundary.
* @param beforeSeq - node immediately after the cut; absent from the surface means after-tail.
* @returns whether every call before the cut has its result before the cut.
* @throws if a result appears without a preceding open call.
*/
export function isToolPairingBalanced(
nodes: readonly SurfaceNode[],
@@ -93,14 +42,12 @@ export function isToolPairingBalanced(
let depth = 0
for (const node of nodes) {
if (node.seq === beforeSeq) return depth === 0
// node.seq is a surface-node seq, always a valid log index by construction.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
depth += nodeDelta(events[node.seq]!)
if (depth < 0) {
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
}
}
// Reached the after-tail cut (beforeSeq === null, or a seq not on the
// surface): the whole-surface prefix is balanced iff depth returned to 0.
// A missing cut node means the after-tail boundary.
return depth === 0
}

View File

@@ -14,19 +14,9 @@ export function SessionId(id: string): SessionId {
}
/**
* The on-disk session format version, stamped into every newly-written
* {@link SessionHeader} and enforced by every persistence backend on load. The
* single source of truth for the version — write sites and the load-time check
* all read it.
*
* It is **`0`** deliberately: while the harness is unreleased the on-disk format
* is **unstable / pre-release, with no compatibility implied**. Breaking changes
* to the persisted {@link SessionEventMap} shape (folding fields onto an event,
* removing a variant, …) happen freely and do NOT bump this — v0 absorbs all
* pre-release churn, and a backend simply REJECTS any log not at v0 (there is no
* migration; no persisted user data exists to preserve). A real, monotonically
* bumped version policy begins at the first tagged release, when a specific
* format boundary becomes worth distinguishing.
* The on-disk session format version, stamped into every newly-written {@link SessionHeader}
* and enforced by every persistence backend on load. The single source of truth for the
* version — write sites and the load-time check all read it.
*/
export const SESSION_FORMAT_VERSION = 0
@@ -55,13 +45,8 @@ export interface SessionHeader {
/** The session this one was forked from (seed lineage), if any. */
parentSession?: SessionId
/**
* How many leading events were INHERITED via a seed rather than produced by
* this session — the seed boundary. Set when a fork seeds a child with a
* prefix of the parent's log (= the seeded prefix length); absent/0 means the
* session produced all its own events. Persisted so a reload reconstructs the
* boundary instead of re-deriving it from the full stored log, and so a replay
* harness can skip the inherited prefix when deriving the child's OWN script
* (the seeded events are the parent's, not this child's model calls).
* How many leading events were INHERITED via a seed rather than produced by this session —
* the seed boundary.
*/
seedLength?: number
}
@@ -110,21 +95,7 @@ export interface TurnTriggerMap {
export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
/**
* Why a turn ended.
* Merge-extensible sum type.
*
* `max-tokens` mirrors the model-call `FinishReasonMap` variant (DeepSeek's
* `length`): the turn ended because a step hit the output-token ceiling, not
* because the model chose to stop. The agent-loop surfaces it via the rule
* "any `max-tokens` step in the turn makes the turn end `max-tokens`" (a
* continuation plugin can run further steps after one, but the cut-short fact
* still wins). It is distinct from `completed` so a consumer (e.g. the ACP
* bridge mapping to `StopReason: 'max_tokens'`) can tell a clean stop from a
* truncated one. The next variants to add — when an adapter/loop first emits
* them — are `refusal` and `max_turn_requests` (both named by the ACP RFC as ACP
* stop reasons); no current adapter produces a `refusal` finish (unknown
* DeepSeek finish reasons collapse to `error`), so it is deliberately omitted
* until one does.
* Why a turn ended. Merge-extensible sum type.
*/
export interface TurnEndReasonMap {
completed: { kind: 'completed' }
@@ -149,14 +120,8 @@ export interface TurnEndReasonMap {
*/
rejected: { kind: 'rejected'; reason: string }
/**
* The turn never ended on its own: the process crashed mid-turn and a
* persistence backend later closed the orphaned (open) turn on reload so the
* log stays balanced. SYNTHESIZED by the backend's crash-recovery repair — no
* loop ever emits this. Its events are real (they were durably appended before
* the crash) and are PRESERVED, not discarded: a single turn can be huge in a
* long-horizon task (many steps, large tool output), so truncating it would
* lose real work. The marker records that the turn was cut short, not that the
* model completed it. See the session-persistence RFC.
* The turn never ended on its own: the process crashed mid-turn and a persistence backend
* later closed the orphaned (open) turn on reload so the log stays balanced.
*/
interrupted: { kind: 'interrupted' }
}
@@ -253,24 +218,10 @@ export interface ToolsDelta {
}
/**
* The session event vocabulary — the append-only source of truth for an
* agent's whole interaction history. The LLM message history is *derived*
* from this log; nothing else is authoritative. Replay = re-derive from the
* same events; trace/telemetry = subscribe to the log.
*
* Merge-extensible: plugins declare extra event types via declaration merging
* (e.g. the compaction plugin adds `'compact/start'`, `'compact/summary'`,
* `'compact/end'`).
*
* Durability contract (what a persistence backend relies on): the durable log
* persists every event verbatim, INCLUDING `assistant/chunk` — `seq` must stay
* contiguous (`seq = log.length`), so chunks cannot be filtered out of the
* canonical log. All `event.data` must be JSON-serializable — `Session.append`
* (and the seed path in the constructor) enforces this at the source (throwing
* on non-serializable data), so a bad event never enters the log and
* `session.events` always equals what a backend can persist. Adding a new event
* type that carries non-serializable data, or that breaks the turn/step nesting
* the invariants plugin checks, is a breaking change to the on-disk format.
* The session event vocabulary — the append-only source of truth for an agent's whole
* interaction history. The LLM message history is *derived* from this log; nothing else is
* authoritative. Replay = re-derive from the same events; trace/telemetry = subscribe to the
* log.
*/
export interface SessionEventMap {
/**
@@ -293,14 +244,8 @@ export interface SessionEventMap {
/** A user-visible prompt (queued message drained at turn start). */
'user/message': { content: ContentBlock[]; source: MessageSource }
/**
* A queued prompt an `agent/prompt-submit` listener VETOED — the durable
* record of a blocked prompt and why. Appended in place of the `user/message`
* the prompt would have become, so the block survives replay even in a MIXED
* batch where another queued prompt is allowed (there the turn does not end
* `rejected`, so the boundary reason alone would not preserve it). `content`
* is the original prompt the listener rejected; `reason` is the veto text
* ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a
* blocked prompt produces no LLM message and never reaches `deriveMessages()`.
* A queued prompt an `agent/prompt-submit` listener VETOED — the durable record of a blocked
* prompt and why.
*/
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
/**
@@ -337,47 +282,24 @@ export interface SessionEventMap {
/** Steering content injected between steps of a running turn. */
'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource }
/**
* The agent's whole todo list, carried as a full snapshot and replaced
* wholesale on each write — the current list is the most recent `todo/write`
* (last-write-wins on replay, no fold). Appended by an owning agent via
* `session.append('todo/write', { todos })`.
*
* NOT a {@link SurfaceEventType}: it produces no LLM message and never reaches
* `deriveMessages()`, so it carries no `surfaceOp` and stays off the surface —
* it is durable, replayable UI state, distinct from the conversation history.
* It is a `SessionEventMap` member riding the existing `session/event` emit,
* not a first-class Cordis `interface Events` notification, so it has no
* cordis-catalog row.
* The agent's whole todo list, carried as a full snapshot and replaced wholesale on each
* write — the current list is the most recent `todo/write` (last-write-wins on replay, no
* fold). Appended by an owning agent via `session.append('todo/write', { todos })`.
*/
'todo/write': { todos: TodoItem[] }
/**
* Full snapshot of the {@link EpochHeader} the NEXT request is built under,
* with the {@link RequestHeaderReason} it was recorded whole. Appended by
* the loop inside the step, before dispatch, on a loop instance's first
* request-building step (`'initial'`/`'resume'`) or when a delta failed its
* round-trip guard (`'fallback'`); always records what the request actually
* used, post-`agent/request`. Anchors the header fold: reconstruction reads
* the latest snapshot and applies the deltas after it. NOT a
* {@link SurfaceEventType}: it produces no LLM message — it is the request
* envelope, logged so every request is a pure function of the session log
* (the reconstructability RFC).
* Full snapshot of the {@link EpochHeader} the NEXT request is built under, with the {@link
* RequestHeaderReason} it was recorded whole.
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
/**
* Amendment to the folded {@link EpochHeader}: at least one of a
* {@link SystemDelta}, a {@link ToolsDelta}, a whole replacement
* {@link LlmCallConfig} (four scalars — not worth diffing), or a whole
* replacement session prefix (`messagePrefix` — small advisory content,
* replaced whole; an EMPTY array encodes the transition to "none",
* mirroring the canonical form's absent field — the loop never produces
* one in practice: the prefix is composed once per instance and anchored
* by that instance's snapshot, so this arm exists for codec totality).
* Appended by the
* loop inside the step, before dispatch, when the header for this request
* differs from the fold of the log so far; the writer verifies
* `applyHeaderDelta(previous, delta)` reproduces the new header exactly and
* falls back to a `'fallback'` `request/header` snapshot when it cannot, so
* a logged delta ALWAYS round-trips. NOT a {@link SurfaceEventType}.
* Amendment to the folded {@link EpochHeader}: at least one of a {@link SystemDelta}, a
* {@link ToolsDelta}, a whole replacement {@link LlmCallConfig} (four scalars — not worth
* diffing), or a whole replacement session prefix (`messagePrefix` — small advisory content,
* replaced whole; an EMPTY array encodes the transition to "none", mirroring the canonical
* form's absent field — the loop never produces one in practice: the prefix is composed once
* per instance and anchored by that instance's snapshot, so this arm exists for codec
* totality).
*/
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] }
}

View File

@@ -1,11 +1,4 @@
/**
* Derived-message cache tests: the session projects each surface node exactly
* once (O(new nodes) per call), rebuilds on a surface rewrite (replace /
* invalidate — the replaceGeneration signal), returns a fresh array snapshot
* per call over shared frozen messages, and stays deep-equal to a from-scratch
* replay derivation at every step — the incremental==scratch property the
* reconstructability RFC's invariant enforces in dev at request time.
*/
/** Derived-message cache behavior against a from-scratch replay oracle. */
import { describe, expect, it } from 'vitest'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
@@ -28,7 +21,6 @@ describe('derived-message cache', () => {
userText(session, 'two')
session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'reply' }] }, { surfaceOp: 'append' })
expect(session.deriveMessages()).toEqual(scratch(session))
// An empty-content assistant/message (usage host) projects to nothing.
session.append('assistant/message', { turn: 1, step: 2, content: [], usage: { inputTokens: 1, outputTokens: 0 } }, { surfaceOp: 'append' })
expect(session.deriveMessages()).toEqual(scratch(session))
})
@@ -48,7 +40,6 @@ describe('derived-message cache', () => {
expect(session.deriveMessages()).toHaveLength(1)
expect(session.deriveMessages()).toEqual(scratch(session))
// The array a caller took before the replace is untouched.
expect(beforeReplace).toHaveLength(2)
})
@@ -61,7 +52,7 @@ describe('derived-message cache', () => {
const second = session.deriveMessages()
expect(first).toHaveLength(1)
expect(second).toHaveLength(2)
// Shared projection objects: the same frozen message instance, once ever.
// Array snapshots share their frozen message projections.
expect(second[0]).toBe(first[0])
expect(Object.isFrozen(first[0])).toBe(true)
})
@@ -74,7 +65,6 @@ describe('derived-message cache', () => {
session.surface.invalidate()
const after = session.deriveMessages()
expect(after).toEqual(before)
// A rebuild re-projects: fresh objects, same values.
expect(after[0]).not.toBe(before[0])
})
})
@@ -84,8 +74,7 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
const session = new Session(SessionId('per-event'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
// The fold path (deriveMessages) and the per-event path share the
// projection, so an external reconstructor cannot disagree with the cache.
// Full and per-event derivation share one projection.
expect(session.deriveEventMessage(event)).toEqual(session.deriveMessages().at(-1))
})

View File

@@ -1,16 +1,6 @@
/**
* Negative-path tests for the persistence log catalog generator
* (`scripts/gen-persistence-catalog.ts`).
*
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
* the freshness half is exercised by `pnpm run verify-persistence-catalog` in
* CI. What a freshness diff CANNOT prove is that the generator REJECTS
* malformed source the way it promises to — a member without description
* prose, a forbidden `@mode` tag, a non-literal member name, a duplicate event
* declaration, a missing or ambiguous `SurfaceEventType` union, a stale union
* member. These tests drive the exported collectors against synthetic fixture
* packages to prove each guard fires (and that well-formed declarations pass),
* mirroring the gen-cordis-catalog negative tests.
*/
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'

View File

@@ -13,10 +13,8 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEventMap, SessionEventType, SurfaceIntent } from '@deepseek-ai/dsh-session'
// An appendable event: its type/data plus, for surface-eligible types, the
// explicit surface intent the generator declares (mirroring how a real caller
// passes it). The intent is part of the generated fixture, NOT synthesized by
// `build`, so each arbitrary states the marker it produces.
// An appendable event: its type/data plus, for surface-eligible types, the explicit surface
// intent the generator declares (mirroring how a real caller passes it).
type Appendable = {
[T in SessionEventType]: { type: T; data: SessionEventMap[T]; intent?: SurfaceIntent }
}[SessionEventType]

View File

@@ -155,11 +155,9 @@ describe('interruptedTurnClosers', () => {
})
it('handles tool/call without a matching assistant/message entry gracefully', () => {
// A tool/call event exists in the log but no assistant/message registered
// the callId in pendingCalls (e.g., a plugin appended it directly, or the
// assistant/message from a prior step didn't have this call). The repair
// should still close the turn — it just won't synthesize a result for this
// call (there's nothing to answer).
// A tool/call event exists in the log but no assistant/message registered the callId in
// pendingCalls (e.g., a plugin appended it directly, or the assistant/message from a prior
// step didn't have this call).
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },

View File

@@ -81,9 +81,6 @@ describe('Session', () => {
const before = structuredClone(session.events)
// A misbehaving consumer tries to mutate the messages it was handed.
// Derived messages are frozen shared projections (cloned once off the
// log, then deep-frozen): every mutation attempt THROWS in strict mode —
// isolation by unrepresentability, not by per-call cloning.
const messages = session.deriveMessages()
const userBlock = messages[0]!.content[0]!
expect(() => { if (userBlock.type === 'text') userBlock.text = 'HACKED' }).toThrow(TypeError)
@@ -132,11 +129,8 @@ describe('Session', () => {
it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => {
const session = new Session(SessionId('s5b'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
// The typed overload makes surfaceOp mandatory only when the type argument is
// a SPECIFIC SurfaceEventType literal. A caller iterating raw events widens it
// to the SessionEventType union, where the conditional rest collapses to
// optional — the exact shape `for (const e of log) append(e.type, e.data)`
// produces. Reproduce that here and assert the runtime guard rejects it.
// The typed overload makes surfaceOp mandatory only when the type argument is a SPECIFIC
// SurfaceEventType literal.
const widenedType = 'user/message' as SessionEventType
expect(() => session.append(widenedType, { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }))
.toThrow(/surface-eligible and requires a surfaceOp marker/)
@@ -259,10 +253,8 @@ describe('SessionStore', () => {
})
it('enter() rejects a stale prepared session whose id is already live (no overwrite)', async () => {
// prepare()/enter() are public cross-package primitives that a caller may
// separate with arbitrary work. A stale prepared session must NOT overwrite
// a live store entry of the same id — its detach disposer would later delete
// the REAL session, breaking the store-uniqueness invariant.
// prepare()/enter() are public cross-package primitives that a caller may separate with
// arbitrary work.
const ctx = new Context()
await ctx.plugin(SessionStore)
const stale = ctx.sessions.prepare(SessionId('racy'))

View File

@@ -4,24 +4,7 @@ import { Session, SessionId, isToolPairingBalanced } from '../src/index.ts'
import type { SessionEvent, SurfaceNode } from '../src/index.ts'
/**
* Unit coverage for the tool-pairing balance check. It decides whether a CUT in
* the surface (a gap before a given surface node, or the after-tail gap) is a
* safe edge for a collapsed region (compaction): a region must never split an
* `assistant/message`'s tool-calls from their `tool/result`s. A cut is balanced
* when no unanswered tool-call sits before it on the surface. Nodes belonging to
* no step (pre-step user message, inter-step steering, injection context) are
* pairing-neutral, so their cuts are free boundaries.
*
* The fixtures are built through a real {@link Session} so the surface linked
* list is derived exactly as production does — including the non-monotonic
* surface a `replace` op leaves (a compaction checkpoint at a high log seq
* sitting at the surface head), which is the case the abandoned log-position
* scan mis-classified.
*
* Builders mirror the agent loop's real append order: queued user messages land
* BEFORE `step/start`; within a step the order is `assistant/message` then
* `tool/result`(s); injection turns are a bare `turn/start → context/message →
* turn/end` with no step.
* Unit coverage for the tool-pairing balance check.
*/
const SURFACE = { surfaceOp: 'append' as const }
@@ -182,10 +165,8 @@ describe('isToolPairingBalanced — multiple tool calls in one assistant message
})
describe('isToolPairingBalanced — a mid-step injection context/message', () => {
// A background task-done inject() lands a context/message INSIDE an open step,
// between the assistant (with a tool-call) and its tool/result. It is
// pairing-neutral, so the cut on EITHER side of it is unbalanced (the call is
// still open across it) — it is NOT a free boundary in this position.
// A background task-done inject() lands a context/message inside an open step, between the
// assistant (with a tool-call) and its tool/result.
function midStepInjection(): Session {
const s = new Session(SessionId('mid-inject'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -236,11 +217,7 @@ describe('isToolPairingBalanced on an injection turn (no step)', () => {
})
describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace op', () => {
// The case the log-position scan got wrong. After a compaction, a replacement
// user/message lands at a HIGH log seq but sits at the SURFACE head, beside
// the still-open step whose events follow it in the log. It carries no
// tool-call/result pair (just summarized prose), so it must be a balanced cut
// on BOTH sides regardless of its log neighbours.
// The case the log-position scan got wrong.
function checkpointHeadedSession(): Session {
const s = new Session(SessionId('checkpoint'))
// A closed turn with a tool step → surface [u1, asst(call), result].
@@ -291,10 +268,8 @@ describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace
})
it('end cut after the head checkpoint is balanced (it carries no tool pair)', () => {
// This is the exact assertion the log-position scan failed: the forward log
// scan from the checkpoint reached the open step's assistant/message and
// wrongly reported mid-step. The surface balance sees a neutral node whose
// following cut closes no open call.
// This is the exact assertion the log-position scan failed: the forward log scan from the
// checkpoint reached the open step's assistant/message and wrongly reported mid-step.
const s = checkpointHeadedSession()
expect(endBalanced(s, s.surface.nodes[0]!.seq)).toBe(true)
})

View File

@@ -1,15 +1,6 @@
/**
* System prompt assembly registry. Plugins contribute ordered text sections,
* tool schema providers, named prompt variables, and authoritative named
* protections; `assemble(context)` collates them through a waterfall that
* runs once per step, restores protected contributions, and `renderPrompt`
* interpolates `{{variable}}` references into the final text.
*
* The harness-owned prompt openers live here too: this plugin registers the
* static `harness:identity` section (order 100) and the deployment's
* `deployment:persona` section (order 0, from its `persona` config), so they
* exist for every agent regardless of which loop plugin drives it.
*
* System prompt assembly registry.
* Scope-filtered dispatch: keyed to `context.scope`.
* @module @deepseek-ai/dsh-system-prompt
*/
@@ -26,20 +17,14 @@ declare module 'cordis' {
interface Events {
/**
* Waterfall around prompt assembly — mutate or extend the
* {@link PromptAssembly} (sections + tools + variables) before it is
* rendered. Bound to the {@link SystemPrompt} service; call `next()` to
* delegate.
* @param assembly - the assembly built from the registered sections, tool
* providers, and variable providers; listeners may mutate it or return a
* replacement.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed
* by `context.scope` — a listener registered through `agent.ctx` fires only
* for that agent's assemblies; a plain plugin listener fires for every
* assembly (scope-less ones included, dispatched subject-less).
* @param context - the per-assembly {@link AssembleContext} the caller
* passed to {@link SystemPrompt.assemble} (e.g. which agent the prompt
* is for), so a listener can filter or extend per agent.
* Waterfall around prompt assembly — mutate or extend the {@link PromptAssembly}
* (sections + tools + variables) before it is rendered.
*
* @param assembly - the assembly built from the registered sections, tool providers,
* and variable providers; listeners may mutate it or return a replacement.
* @param context - the per-assembly {@link AssembleContext} the caller passed to {@link
* SystemPrompt.assemble} (e.g. which agent the prompt is for), so a listener can
* filter or extend per agent.
* @mode waterfall
*/
'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
@@ -124,17 +109,6 @@ export interface ToolProviderResult {
/**
* Canonical prompt contributions that survive the assembly waterfall.
*
* Protection is declarative by contribution name rather than an ordered
* callback: after every `system-prompt/assemble` listener has finished, the
* service restores each protected name to the exact presence and definition
* produced by its registries before the waterfall. Restored entries keep
* canonical order with one another and anchor before their first surviving
* later unprotected canonical neighbor (or at the end); the service does not
* undo a listener's reordering of unprotected entries. A name absent from that
* canonical assembly is removed from the result. This makes mode-dependent
* absence protectable too (for example, a native tool that intentionally stays
* off the wire in Code Mode).
*/
export interface PromptProtection {
/** Section names whose canonical registry output is authoritative. */
@@ -145,19 +119,6 @@ export interface PromptProtection {
/**
* The assembled prompt.
*
* Tool schemas are part of the assembly by design: "what the model is told it
* can do" is one coherent thing managed here, even though adapters transmit
* `tools` as a separate wire field rather than prompt text. They arrive in
* the canonical model-facing order (see {@link Config.toolOrder}).
*
* `variables` carries every registered prompt variable resolved against this
* assembly's context — key present means registered, `undefined` value means
* "no value for this assembly" (referencing it renders an error). Section
* texts are resolved but NOT yet interpolated; {@link renderPrompt} applies
* the variables, so waterfall listeners can still add sections or variables.
*
* Merge-extensible: plugins can declare extra fields on this interface.
*/
export interface PromptAssembly {
sections: AssembledSection[]
@@ -202,20 +163,9 @@ function validateToolOrder(toolOrder: string[] | undefined): string[] | undefine
}
/**
* Order collected tool schemas by the validated policy: with no configured
* list, plain lexicographic name order; with one, listed names take their
* listed position and every unlisted tool lands at the
* {@link TOOL_ORDER_REST} rest entry in lexicographic name order. A listed
* name outside `knownNames` — the providers' PRE-restriction name universe —
* throws: misconfiguration fails loud, and each assembly is the earliest
* moment the registered tool set exists to check against (tool plugins
* register after the service constructs, so load time is too early); the
* assembly rejects, failing the caller's turn before any model request. A
* listed name that is KNOWN but not collected (a tool restricted away for
* this assembly's scope) is a normal absence: its position simply
* contributes nothing — `toolOrder` stays compatible with per-agent
* `restrict()` masks. Never drops a collected tool, and both sorts are
* stable, so tools sharing a name keep their collection order.
* Order collected tool schemas by the validated policy: with no configured list, plain
* lexicographic name order; with one, listed names take their listed position and every
* unlisted tool lands at the {@link TOOL_ORDER_REST} rest entry in lexicographic name order.
*/
function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownNames: ReadonlySet<string>): ToolSchema[] {
const reserved = tools.find(tool => tool.name === TOOL_ORDER_REST)
@@ -240,10 +190,7 @@ function restoreProtected<T extends { name: string }>(
const restored = result.filter(entry => !protectedNames.has(entry.name))
for (const [index, entry] of canonical.entries()) {
if (!protectedNames.has(entry.name)) continue
// Protected entries are inserted in canonical order. Anchor each one
// before the first later UNPROTECTED canonical neighbor that survived the
// waterfall; if none survived, it belongs at the end. Looking only at
// unprotected neighbors avoids reversing adjacent protected entries.
// Protected entries are inserted in canonical order.
const following = new Set(
canonical.slice(index + 1)
.filter(candidate => !protectedNames.has(candidate.name))
@@ -263,58 +210,23 @@ function compareToolNames(a: ToolSchema, b: ToolSchema): number {
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */
export interface Config {
/**
* The deployment's persona — the ONE deployment-authored fragment of the
* system prompt, rendered as the order-0 `deployment:persona` section
* (after the harness identity, before all tool guidance). Every agent in
* the context shares it by default; a per-agent persona is a SCOPED section
* of the same name registered through that agent's `agent.ctx` (it shadows
* this one for that agent — the subagent seam's `persona` request field does
* exactly that). Template, not free-form text:
* every complete `{{…}}` group is interpreted strictly against the
* registered prompt variables (the shipped agent loop registers `{{model}}`
* and `{{cwd}}`), and there is no escape syntax for literal `{{…}}` prose
* yet (a deliberate deferral; see the prompt-variables RFC). Defaults to
* `''` — the empty section is dropped at render, so a persona-less
* deployment opens with the harness identity alone.
* The deployment's persona — the one deployment-authored fragment of the system prompt,
* rendered as the order-0 `deployment:persona` section (after the harness identity, before
* all tool guidance).
*/
persona?: string
/**
* Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed
* tools take their listed position, and tools absent from the list are
* inserted at the {@link TOOL_ORDER_REST} (`'<unlisted-tools>'`) entry in
* lexicographic name order. A configured list must contain the rest entry
* exactly once, no duplicate names, and no name without a registered tool —
* a misconfigured order blocks work instead of silently reaching a model
* request: shape violations throw at load, and an unregistered name rejects
* every assembly. `TOOL_ORDER_REST` is reserved for the list marker and may
* not be a collected tool name; such a provider output also rejects the
* assembly. The single assembly-time validation rejects either failure
* before any model request — the earliest moment the registered tool set
* exists to check against, since tool plugins register after this service
* constructs. When omitted, tools are ordered lexicographically by name.
* Applied to the tools
* {@link SystemPrompt.assemble} collects, BEFORE the
* `system-prompt/assemble` waterfall — like the sections' `order` sort, it
* canonicalizes what the registry contributed (registration order is a
* plugin-load artifact); a waterfall listener that mutates the tool list
* owns the determinism of what it emits. Rationale (and why not per-plugin
* weights): docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md.
* Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed tools take their
* listed position, and tools absent from the list are inserted at the {@link
* TOOL_ORDER_REST} (`'<unlisted-tools>'`) entry in lexicographic name order.
*/
toolOrder?: string[]
}
/**
* Renders the text part of an assembly: interpolates `{{variable}}`
* references in each section from `assembly.variables`, drops empty sections,
* and joins the rest with blank lines.
* Renders the text part of an assembly: interpolates `{{variable}}` references in each section
* from `assembly.variables`, drops empty sections, and joins the rest with blank lines.
*
* Strict by design (fail loud beats shipping a malformed prompt): a reference
* to an unregistered variable, to a registered variable with no value for
* this assembly, a complete `{{…}}` group that is not a well-formed variable
* name (e.g. `{{ model }}`), or a `{{` that does not open a complete group
* while a `}}` still follows (e.g. `{{{model}}}`, `{{a{b}}`) all throw. A
* lone `{{` with no `}}` anywhere after it is ordinary prose and passes
* through verbatim. Substituted values are never re-scanned.
* @param assembly - the assembly to render (typically the awaited result of
* {@link SystemPrompt.assemble}); only `sections` and `variables` are read.
* @returns the full system prompt text; `''` when every section renders empty
@@ -379,12 +291,9 @@ function interpolate(section: AssembledSection, variables: Record<string, string
export class SystemPrompt extends Service {
static Config: z<Config> = z.object({
persona: z.string().default(''),
// A schemastery array defaults to [] when omitted, but an omitted
// toolOrder must stay absent ("lexicographic order"), not become an
// explicitly-configured empty list (which is invalid — it lacks the
// rest entry). Forcing the default to undefined keeps the key out of the
// validated config; the cast is needed because .default() expects the
// array type.
// A schemastery array defaults to [] when omitted, but an omitted toolOrder must stay
// absent ("lexicographic order"), not become an explicitly-configured empty list (which is
// invalid — it lacks the rest entry).
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
})
@@ -402,12 +311,7 @@ export class SystemPrompt extends Service {
constructor(ctx: Context, public config: Config) {
super(ctx, 'systemPrompt')
this.toolOrder = validateToolOrder(config.toolOrder)
// The harness-owned openers. They live HERE (not on the loop plugin) so a
// deployment that swaps in a different loop keeps them: the identity is a
// harness fact stated ahead of everything, and the persona is the
// deployment's config, one section of the full prompt, never the whole.
// An empty persona still RESERVES the section name (one owner — a plugin
// re-registering it throws); renderPrompt drops the empty text.
// The harness-owned openers.
this.section({
name: 'harness:identity',
order: -100,
@@ -423,22 +327,8 @@ export class SystemPrompt extends Service {
}
/**
* Contribute a text section to the system prompt. Order is determined by
* `section.order` (ascending). The layer is decided by the CALLING context
* (`@deepseek-ai/dsh-scope`): a plain plugin context contributes globally; a
* scoped context (`agent.ctx`) contributes to that scope alone — and a
* scoped section SHADOWS a same-named global section for that scope's
* assemblies (most-specific-wins; this is how a per-agent persona overrides
* `deployment:persona`) unless that global name is protected: global
* protection reserves its section name against scoped shadows so the
* registration owner—not a later scope—defines the canonical value. The
* registry snapshots `name`, `order`, and `text` before checking/storing, so
* later caller-object mutation cannot rename a contribution. Throws
* if the SAME layer already has the name (a
* duplicate would silently double prompt text — e.g. a double-loaded tool
* plugin; the global-duplicate message names `agent.ctx` as the per-agent
* alternative). Removed when the calling fiber is disposed. Emits
* `system-prompt/change` on register/unregister.
* Contribute a text section to the system prompt.
*
* @param section - the section to contribute (name, order, text or provider).
* @returns the disposer that removes the section. The exact
* Cordis effect disposer (single-shot): composite (generator) effects may
@@ -481,26 +371,15 @@ export class SystemPrompt extends Service {
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.section()')
// The EXACT cordis effect disposer, not a wrapper: a composite (generator)
// effect that owns a teardown ORDER must be able to yield THIS function —
// cordis nests a disposer out of the fiber's concurrent sibling list by
// exact function identity, so a wrapper would silently break the nesting
// (the agents.register() lesson). Fire-and-forget callers may still
// discard the (always-resolved) promise.
// Return the exact Cordis disposer so generator effects preserve teardown nesting.
return dispose
}
/**
* Contribute a tool-schema provider, evaluated at each assembly call with
* that assembly's {@link AssembleContext} (so it reflects the live registry
* state AND the assembly's scope — see {@link ToolProviderResult} for the
* `schemas`/`knownNames` split). The layer is decided by the calling
* context: a scoped provider (registered through `agent.ctx`) is consulted
* only for that scope's assemblies. Removed when the calling fiber is
* disposed. A provider must not return a schema named
* {@link TOOL_ORDER_REST}; that name is reserved for
* {@link Config.toolOrder}'s rest entry and rejects the assembly. Emits
* `system-prompt/change`.
* Contribute a tool-schema provider, evaluated at each assembly call with that assembly's
* {@link AssembleContext} (so it reflects the live registry state AND the assembly's scope —
* see {@link ToolProviderResult} for the `schemas`/`knownNames` split).
*
* @param provider - evaluated at every {@link assemble} for fresh schemas.
* @returns the disposer that removes the provider. The exact
* Cordis effect disposer (single-shot): composite (generator) effects may
@@ -527,27 +406,13 @@ export class SystemPrompt extends Service {
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.tools()')
// The EXACT cordis effect disposer, not a wrapper: a composite (generator)
// effect that owns a teardown ORDER must be able to yield THIS function —
// cordis nests a disposer out of the fiber's concurrent sibling list by
// exact function identity, so a wrapper would silently break the nesting
// (the agents.register() lesson). Fire-and-forget callers may still
// discard the (always-resolved) promise.
// Return the exact Cordis disposer so generator effects preserve teardown nesting.
return dispose
}
/**
* Contribute a named prompt variable, referenced from section text as
* `{{name}}`. The provider is evaluated at each assembly with that
* assembly's {@link AssembleContext}; returning `undefined` means "no value
* for this assembly" (a section referencing it then fails to render — a
* deployment must not claim facts it does not have). The layer is decided
* by the calling context: a scoped variable (registered through
* `agent.ctx`) resolves only for that scope's assemblies and SHADOWS a
* same-named global variable there. Throws on a name that does not match
* `[a-z][a-z0-9_]*` (it could never be referenced) or one already
* registered in the SAME layer. Removed when the calling fiber is disposed;
* emits `system-prompt/change` on register/unregister.
* Contribute a named prompt variable, referenced from section text as `{{name}}`.
*
* @param name - the reference name (matches `[a-z][a-z0-9_]*`).
* @param provider - evaluated at every {@link assemble} for the value.
* @returns the disposer that removes the variable. The exact
@@ -581,29 +446,13 @@ export class SystemPrompt extends Service {
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.variable()')
// The EXACT cordis effect disposer, not a wrapper: a composite (generator)
// effect that owns a teardown ORDER must be able to yield THIS function —
// cordis nests a disposer out of the fiber's concurrent sibling list by
// exact function identity, so a wrapper would silently break the nesting
// (the agents.register() lesson). Fire-and-forget callers may still
// discard the (always-resolved) promise.
// Return the exact Cordis disposer so generator effects preserve teardown nesting.
return dispose
}
/**
* Protect named section/tool contributions from the assembly waterfall.
* The layer is decided by the calling context: a global protection applies
* to every assembly, while one registered through `agent.ctx` applies only
* to that agent's scope. The name's canonical registry/provider output is
* restored AFTER the whole waterfall, so listener registration order cannot
* strip, replace, duplicate, or fabricate it. Canonical absence is restored
* too: if the protected name is intentionally absent for an assembly, a
* listener-injected entry with that name is removed. The input arrays are
* snapshotted; an empty protection throws because it cannot affect output.
* Removed with the calling fiber and emits `system-prompt/change` on
* registration/unregistration. A global section protection also reserves the
* name against scoped section shadows; registering protection when such a
* shadow already exists fails loudly instead of protecting the wrong owner.
*
* @param protection - section and/or tool names whose canonical presence and definitions are authoritative.
* @returns the exact Cordis effect disposer that removes the protection.
*/
@@ -658,41 +507,16 @@ export class SystemPrompt extends Service {
}
/**
* Assemble the current prompt for one caller: the global layer merged with
* {@link AssembleContext.scope}'s layer (scoped sections/variables SHADOW
* same-named global ones — most-specific-wins) — section texts resolved
* against `context` and sorted by order across the union, tools collected
* from the global providers plus the scope's and put in the canonical
* model-facing order ({@link Config.toolOrder}, or lexicographic name order
* when unconfigured — provider registration order is a plugin-load artifact
* and never reaches the assembly; a configured order naming a tool outside
* the providers' `knownNames` universe rejects the assembly, while a known
* name restricted away for this scope is a normal absence), and every
* visible variable resolved against `context` into `assembly.variables`.
* Tool schemas are deep-cloned because adapters and request waterfalls may
* mutate schema objects. Runs through the `system-prompt/assemble`
* waterfall, giving listeners the opportunity to mutate or replace the
* assembly, then restores every visible {@link PromptProtection} from the
* pre-waterfall canonical assembly. Like the sections' `order` sort, tool
* canonicalization happens on the initial assembly; unprotected listener
* output owns its own determinism. Await the result before reading the
* assembly values — waterfall listeners may be async.
* Interpolation happens later, in {@link renderPrompt}.
* @param context - what this assembly is for (defaults to an empty context;
* see {@link AssembleContext}).
* Assemble global contributions with one scope, then run the assembly waterfall and protection.
* @param context - assembly subject and scope; defaults to an empty context.
* @returns the assembly after the waterfall has run.
*/
// async so the misconfigured-toolOrder throw in orderTools surfaces as a
// rejection: a Promise-returning method must not throw synchronously
// (`assemble().catch(...)` would miss it).
// Async ensures validation failures are promise rejections.
async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
const scope = context.scope
// Protection is a registry input too: snapshot which names are protected
// at assembly start. Registrations that land while an async waterfall is
// in flight affect the NEXT assembly, matching the other registries.
// Registrations arriving mid-assembly affect the next assembly.
const protectedNames = this.protectedNames(scope)
// Variables: global layer first, then the scope's layer OVERWRITES
// same-named entries (shadowing — a per-agent value wins for that agent).
// Scoped variables shadow global names.
const variables: Record<string, string | undefined> = {}
for (const [name, provider] of this.variableProviders) {
variables[name] = provider(context)
@@ -701,21 +525,13 @@ export class SystemPrompt extends Service {
for (const [name, provider] of scopedVariables ?? []) {
variables[name] = provider(context)
}
// Sections: merge by name, scoped REPLACING same-named global entries
// (most-specific-wins — the per-agent persona mechanism), then sort by
// order across the union. Registration order within a layer is preserved
// for equal orders (stable sort).
// Scoped sections shadow global names before the stable order sort.
const sectionByName = new Map<string, PromptSection>()
for (const section of this.sections) sectionByName.set(section.name, section)
for (const section of (scope === undefined ? [] : this.scopedSections.get(scope)) ?? []) {
sectionByName.set(section.name, section)
}
// Tools: consult the global providers plus the scope's, each with this
// assembly's context. `schemas` are what the model may see (already
// post-restriction, per provider); `knownNames` (defaulting to the
// schemas' names) form the pre-restriction universe `toolOrder` is
// validated against, so a restricted-away tool is a normal absence while
// a config typo still fails every assembly loudly.
// `knownNames` validates order before per-scope restrictions hide schemas.
const providers = [
...this.toolProviders,
...(scope === undefined ? [] : this.scopedToolProviders.get(scope)) ?? [],

View File

@@ -1,15 +1,5 @@
/**
* Code Mode: the `run_code` tool and its dispatch bridge. The model writes a
* TypeScript program; the bridge hands it to `ctx.codeRuntime` with one async
* binding per end capability visible to the calling agent, then serializes
* every binding call through a per-run queue onto `ToolRegistry.execute()`.
* Sub-calls therefore traverse the complete pre/guard/around/post/final-result
* pipeline exactly like native calls and carry the outer execution's opaque
* token for correlation. The bridge logs each sub-dispatch as a
* `tool/code-dispatch` session event and returns only the program's curated
* output. The registry itself decides WHEN this tool exists (its `mode`
* config); this module owns only the tool and the bridge.
*
* Code Mode: the `run_code` tool and its dispatch bridge.
* @module @deepseek-ai/dsh-tools/src/code-mode
*/
@@ -24,16 +14,11 @@ import type { ToolDefinition, ToolRegistry } from './index.ts'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* One bridged sub-dispatch from a `run_code` program: the parent
* `run_code` call id, the deterministic sub-call id
* (`<parent>:code:<n>`), the tool `name` with its JSON-normalized
* `arguments` — the exact value dispatched, normalized BEFORE dispatch,
* so this append can never fail on payload shape — whether the sub-call
* errored, and a bounded `resultSummary` of its model-facing text.
* Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter
* model context; persistence and UIs get every call. Appended inside the
* parent `run_code`'s execution (the bridge drains its queue before
* returning), so the turn-enclosure invariant holds by construction.
* One bridged sub-dispatch from a `run_code` program: the parent `run_code` call id, the
* deterministic sub-call id (`<parent>:code:<n>`), the tool `name` with its
* JSON-normalized `arguments` — the exact value dispatched, normalized before dispatch, so
* this append can never fail on payload shape — whether the sub-call errored, and a
* bounded `resultSummary` of its model-facing text.
*/
'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string }
}
@@ -89,16 +74,11 @@ function summarize(text: string): string {
}
/**
* JSON-normalize one binding call's argument into TWO independent parses of
* the same canonical text: `dispatched` goes to the tool, `logged` to the
* `tool/code-dispatch` event — identical by construction (the runtime's
* structured-clone boundary is wider than JSON; the session log accepts only
* JSON), and separate objects, so a tool mutating its args can neither
* desync the log from what was dispatched nor re-poison the append. A value
* that does not survive the round-trip (`undefined` — the log rejects it as
* event data — `BigInt`, a circular structure, a bare function) rejects that
* one call BEFORE dispatch with a model-correctable error: nothing ever
* executes unlogged.
* JSON-normalize one binding call's argument into TWO independent parses of the same canonical
* text: `dispatched` goes to the tool, `logged` to the `tool/code-dispatch` event — identical
* by construction (the runtime's structured-clone boundary is wider than JSON; the session log
* accepts only JSON), and separate objects, so a tool mutating its args can neither desync the
* log from what was dispatched nor re-poison the append.
*/
function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unknown } {
if (value === undefined) {
@@ -172,11 +152,9 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
exec.signal?.addEventListener('abort', onOuterAbort, { once: true })
let dispatches = 0
// The per-run serialization queue: every binding call chains onto the
// tail, so even `Promise.all` executes the underlying tool calls one at
// a time in submission order (the tool contract carries no
// concurrency-safety metadata yet). The fold keeps the tail non-rejecting
// so one failed dispatch never poisons the chain.
// The per-run serialization queue: every binding call chains onto the tail, so even
// `Promise.all` executes the underlying tool calls one at a time in submission order (the
// tool contract carries no concurrency-safety metadata yet).
let queue: Promise<void> = Promise.resolve()
const enqueue = <T>(task: () => Promise<T>): Promise<T> => {
const turn = queue.then(() => {
@@ -211,11 +189,9 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
signal: runController.signal,
})
const text = textOf(result.content)
// Sub-call `additionalContext` is deliberately DROPPED here: the
// loop's buffering (append after the step's tool/results) has no
// safe analogue from inside a running run_code — injecting now
// would break tool-call/result adjacency. Deferred until a real
// hook needs it through Code Mode.
// Sub-call `additionalContext` is deliberately DROPPED here: the loop's buffering
// (append after the step's tool/results) has no safe analogue from inside a running
// run_code — injecting now would break tool-call/result adjacency.
exec.agent?.session.append('tool/code-dispatch', {
parentCallId: exec.callId,
subCallId,
@@ -266,18 +242,8 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
signal: runController.signal,
})
} finally {
// Quiescence before returning, whether the runtime fulfilled or
// REJECTED (a backend that starts a binding call and then throws
// must not leak a live sub-dispatch past this settlement): fire
// the run-scoped abort (cancelling an in-flight sub-dispatch,
// abandoning queued ones), then await the queue's drain — an
// aborted sub-call still settles and logs its event INSIDE the
// open turn; nothing can append after we return. `queue` is the
// FOLDED tail (every link swallows its rejection into undefined),
// so this await cannot itself reject — an abandoned queued call
// can never mask the runtime's own failure, returned or thrown;
// rejections surface only on the per-call promises the program
// holds.
// Abort sub-dispatches and drain the folded queue before closing the turn.
// Binding failures remain observable through their individual promises.
runController.abort('run_code settled')
await queue
}
@@ -297,13 +263,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
exec.signal?.removeEventListener('abort', onOuterAbort)
}
},
// The program IS the title, the way command tools title their cards with
// the command: an execute-card's title is the one slot an ACP client
// always shows (Zed's execute cards render no body content and no raw
// input without a real terminal attached), so anywhere else the code
// would be invisible. Multi-line titles are the execute-card idiom —
// capable clients render them whole; others truncate to the first line
// and still hold the full program in rawInput.
// ACP execute cards use the program as their visible title.
presentCall: args => ({
card: 'generic',
title: args.code,

View File

@@ -1,18 +1,10 @@
/**
* Tool registry and execution pipeline. Plugins register tools; the registry
* feeds schemas into the system prompt, and `execute()` dispatches each call
* through `tools/pre-execute` (the extensible allow/deny gate) → monotonic
* registered guards → `tools/execute` (an around-dispatch wrapper for
* timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the
* result, attach context) → the observe-only `tools/result` notification.
*
* The registry also owns HOW its tools are presented to the model — its
* `mode` config: `'native'` (every tool as a wire function definition,
* today's behavior and the default), `'code'` (the registry's canonical wire
* contribution is one tool, `run_code`, plus a generated TypeScript SDK prompt section), or
* `'both'`. See `code-mode.ts` (the tool + dispatch bridge) and
* `ts-types.ts` (the SDK codegen); design in the Code Mode RFC.
*
* Tool registry and execution pipeline. Plugins register tools; the registry feeds schemas
* into the system prompt, and `execute()` dispatches each call through `tools/pre-execute`
* (the extensible allow/deny gate) → monotonic registered guards → `tools/execute` (an
* around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute`
* (inspect/replace the result, attach context) → the observe-only `tools/result` notification.
* Scope-filtered dispatch: keyed to `exec.agent`; agent-less calls reach global listeners.
* @module @deepseek-ai/dsh-tools
*/
@@ -83,82 +75,39 @@ declare module 'cordis' {
interface Events {
/**
* Waterfall BEFORE a tool runs — the gate where sandbox, permission, and
* hook plugins allow or deny a call (Claude Code's `PreToolUse`). Listeners
* receive `(exec, next)`: call `next()` to delegate to the default (allow),
* or return a {@link PreToolDecision} without calling `next()` to
* short-circuit. A `deny` skips dispatch and yields an `isError` result; the
* tool body never runs. Input rewrite is deliberately NOT offered here (see
* {@link PreToolDecision}); `ask` is serviced by the `ctx.approval` seam
* when one is mounted, and degrades to deny otherwise.
* The returned union is validated as an exact runtime shape before approval
* or guards run; a malformed JavaScript/casted decision fails closed as an
* `isError` result and the tool body never runs.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `exec.agent`: a
* listener registered through `agent.ctx` fires only for that agent's
* calls, while a plain plugin listener fires for every call (including
* agent-less ones, which dispatch subject-less).
* Waterfall before a tool runs — the gate where sandbox, permission, and hook plugins
* allow or deny a call (Claude Code's `PreToolUse`).
*
* @param exec - the pending call (name, parsed arguments, caller agent).
* @mode waterfall
*/
'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
/**
* Around-dispatch waterfall wrapping the registry's core tool dispatch,
* between the `tools/pre-execute` gate and the `tools/post-execute` seam. A
* listener receives `(exec, next)`: call `next()` to delegate to dispatch
* (returning its {@link ToolExecutionResult}, optionally wrapped), or return a
* replacement result without calling `next()` to short-circuit dispatch. The
* base `next()` IS the dispatch-with-normalization thunk — a thrown tool (or
* unknown tool) is already normalized to an `isError` result by the time a
* listener's `await next()` returns, so a wrapper never sees a raw throw from
* the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can
* set or replace the one mutable field, `exec.signal` (e.g. with a per-call
* deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity
* (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the
* pipeline so a wrapper cannot change which capability or scope was
* authorized. (Cordis `next()` ignores passed arguments and re-invokes
* downstream with the shared payload, so a wrapper changes `exec.signal` in
* place rather than passing a new object to `next()`.)
* Multiple listeners compose by registration order — an outer one wraps the
* inner ones plus dispatch.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by
* `exec.agent` — a listener registered through `agent.ctx` wraps only that
* agent's calls; a plain plugin listener wraps every call (including
* agent-less ones, which dispatch subject-less).
* Around-dispatch waterfall wrapping the registry's core tool dispatch, between the
* `tools/pre-execute` gate and the `tools/post-execute` seam.
*
* Scope-filtered dispatch: keyed to `exec.agent`; agent-less calls reach global listeners.
* @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
* @mode waterfall
*/
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
/**
* Waterfall AFTER a tool runs — where hook plugins inspect the result and
* accept it (optionally REPLACING the model-facing content, and/or attaching
* `additionalContext` for the next request) or block it with corrective
* `feedback` (Claude Code's `PostToolUse`). Listeners receive
* `(exec, result, next)`: call `next()` to delegate to the default (accept
* unchanged), or return a {@link PostToolDecision} to override. Core tool
* dispatch runs earlier as the base `next()` of the `tools/execute`
* waterfall, all inside `execute`'s outer try/catch (and the tool body keeps
* its own inner try/catch, so a thrown tool still reaches `post-execute` as an
* `isError` result).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by
* `exec.agent` — a listener registered through `agent.ctx` fires only for
* that agent's calls; a plain plugin listener fires for every call
* (including agent-less ones, which dispatch subject-less).
* Waterfall after a tool runs — where hook plugins inspect the result and accept it
* (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for
* the next request) or block it with corrective `feedback` (Claude Code's `PostToolUse`).
*
* Scope-filtered dispatch: keyed to `exec.agent`; agent-less calls reach global listeners.
* @param exec - the call that just ran (name, parsed arguments, caller agent).
* @param result - the dispatch outcome a listener may accept, replace, or block.
* @mode waterfall
*/
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
/**
* Awaited notification of the authoritative FINAL tool outcome, after the
* complete pre/execute/post pipeline, final lossless-JSON validation, and
* outer error normalization.
* Unlike the three waterfalls, this seam cannot transform the result: each
* listener receives the now-frozen execution object and a deep-frozen result
* snapshot; listener failures are contained and logged, and
* {@link ToolRegistry.execute} still returns the outcome.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by
* `exec.agent`, using the same carrier as the pipeline.
* Awaited notification of the authoritative final tool outcome, after the complete
* pre/execute/post pipeline, final lossless-JSON validation, and outer error
* normalization.
*
* Scope-filtered dispatch: keyed to `exec.agent`; agent-less calls reach global listeners.
* @param exec - the execution object that traversed the pipeline.
* @param result - a deep-frozen snapshot of the final returned result.
* @mode parallel
@@ -335,38 +284,14 @@ export interface ToolExecutionResult {
meta?: unknown
}
/**
* The decision a `tools/pre-execute` listener returns for one pending call.
* Maps onto Claude Code's `PreToolUse` `permissionDecision`.
*
* - `allow` proceeds to dispatch. (Input rewrite — changing `exec.arguments` —
* is deliberately NOT offered: `tool/call` and `assistant/message` are logged
* BEFORE execution and live consumers, e.g. the ACP bridge and `dsh-tool-bash`
* presentation, read the pre-execution arguments, so an execution-only rewrite
* would desync the UI from what RAN. That consistency redesign is its own
* `proposed` RFC; `TODO(pre-tool-input-rewrite)` anchors it at the call site.)
* - `deny` skips dispatch; the loop records an `isError` result carrying `reason`.
* - `ask` is the permission-prompt intent: serviced as a one-shot decision by
* the `ctx.approval` seam when one is mounted (`allowed-once` proceeds to
* dispatch; every other outcome denies), degrading to `deny` when none is.
*/
/** Pre-execution decision: dispatch, deny with a reason, or ask the approval seam. */
// TODO(pre-tool-input-rewrite): design logged argument rewriting before exposing it here.
export type PreToolDecision =
| { kind: 'allow' }
| { kind: 'deny'; reason: string }
| { kind: 'ask'; reason?: string }
/**
* The decision a `tools/post-execute` listener returns for one finished call.
* Maps onto Claude Code's `PostToolUse` decision.
*
* - `accept` keeps the call successful; optional `content` REPLACES the
* model-facing result (clean: `tool/result` is logged AFTER `execute()`
* returns, so a replaced result is the single source of truth for both derived
* history and UI). Optional `additionalContext` rides to the next request.
* - `block` turns the call into an `isError` result whose content is the
* corrective `feedback` (the model is told the call was rejected and why),
* optionally also attaching `additionalContext`.
*/
/** Post-execution decision: accept optional replacement content or block with feedback. */
export type PostToolDecision =
| { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext }
| { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext }
@@ -408,32 +333,16 @@ export type ToolPresentationMode = 'native' | 'code' | 'both'
/** Plugin config: how the registered tools are presented to the model. */
export interface Config {
/**
* The presentation mode. `'native'` (the default) contributes every
* visible end capability as a native wire function definition. Under
* `'code'` this registry contributes exactly ONE wire tool,
* `run_code`, plus the generated `tools:sdk` prompt section declaring every other tool as a
* TypeScript API the program calls. `'both'` contributes every native
* definition AND `run_code` + the SDK section. Non-native modes require a
* loaded `ctx.codeRuntime` whose `language` is `'typescript'` — a missing
* or mismatched runtime rejects every prompt assembly with an actionable
* error (misconfiguration fails loud, before any model request). A
* configured `systemPrompt.toolOrder` naming native tools likewise rejects
* every assembly under `'code'` (those names are no longer contributed) —
* a deployment switching modes updates its order config or drops it.
* The presentation mode. `'native'` (the default) contributes every visible end capability
* as a native wire function definition.
*/
mode?: ToolPresentationMode
}
/**
* A per-scope restriction over the GLOBAL tool surface, registered via
* {@link ToolRegistry.restrict}. `allow` keeps only the listed global tools;
* `deny` removes the listed ones; both present = allow first, then deny.
* Restrictions never touch scoped registrations — a tool registered through
* the same scope is an explicit grant that bypasses them (which is what keeps
* e.g. a structured-output capture tool alive under an allow-list). The
* reserved `run_code` presentation transport is likewise outside capability
* filtering, and naming it explicitly is rejected. Multiple restrictions on
* one scope compose by intersection: every one must admit.
* A per-scope restriction over the global tool surface, registered via {@link
* ToolRegistry.restrict}. `allow` keeps only the listed global tools; `deny` removes the
* listed ones; both present = allow first, then deny.
*/
export interface ToolRestriction {
/** Global tool names that stay visible; everything else is removed. */
@@ -458,25 +367,9 @@ interface ToolGuardRegistration {
}
/**
* Tool registry (`ctx.tools`): tool plugins register definitions; the agent
* loop executes calls through the `tools/pre-execute` → guards →
* `tools/execute` → `tools/post-execute` → `tools/result` pipeline. The
* registry contributes its schemas into the system-prompt assembly — WHICH
* schemas is governed by its `mode` config
* (see {@link Config.mode}); under a non-native mode it also owns the reserved
* `run_code` presentation transport and the `tools:sdk` prompt section.
*
* Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a
* plain plugin context is GLOBAL (visible to every agent); one through a
* scoped context (`agent.ctx`) is filed in that scope's layer — visible to
* that agent alone, disposed with the scope, and SHADOWING a global tool of
* the same name for that agent (most-specific-wins; within one layer a
* duplicate name still throws). {@link restrict} masks the global layer per
* scope. One visibility function ({@link visible}) feeds prompt assembly,
* {@link get}, and {@link execute} — and, under a non-native mode, the SDK
* section and `run_code`'s bindings — so what the model is shown, what a
* presenter renders, what a program can call, and what dispatches can never
* disagree.
* Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes
* calls through the `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute` →
* `tools/result` pipeline.
*/
export class ToolRegistry extends Service {
static inject = ['systemPrompt']
@@ -501,11 +394,7 @@ export class ToolRegistry extends Service {
// The schema already defaulted an omitted mode; the ?? narrows the
// optional-input type for direct (non-Loader) construction in tests.
this.mode = config.mode ?? 'native'
// `run_code` is presentation infrastructure, not an end capability. It
// therefore does not enter the global layer: per-agent restrictions must
// not remove it, and a scoped registration must not shadow it. The
// visibility resolver appends this reserved definition after resolving
// the filterable global/scoped capability layers.
// `run_code` is presentation infrastructure, not an end capability.
this.codeTransport = this.mode === 'native'
? undefined
: deepFreeze(createRunCodeTool(this, () => this.requireCodeRuntime()))
@@ -514,43 +403,20 @@ export class ToolRegistry extends Service {
ctx.systemPrompt.section({
name: 'tools:sdk',
order: SDK_SECTION_ORDER,
// A lazy thunk over the live registry, per assembly CONTEXT:
// regenerated at each assembly over the CALLING SCOPE's visible set
// (scoped tools join, restricted globals vanish — the SDK declares
// exactly what that agent's programs can call), in lexicographic
// tool order, so an unchanged tool set renders byte-identical text
// (prefix-cache-friendly) and a mid-session registration surfaces
// exactly like a native-mode tool change.
// Regenerate the scoped tool SDK on every assembly in stable lexical order.
text: (context) => {
this.requireCodeRuntime()
return renderToolsSdk(this.schemas(context.scope).filter(schema => schema.name !== RUN_CODE_NAME))
},
})
// These are presentation infrastructure, not optional end capabilities.
// Protect them at their owner: assembly listeners may still transform
// ordinary tools and prose, but cannot silently leave Code Mode without
// its only wire transport or the SDK that tells the model how to use it.
ctx.systemPrompt.protect({ sections: ['tools:sdk'], tools: [RUN_CODE_NAME] })
}
}
/**
* The registry's contribution to the wire tool list, per {@link Config.mode},
* as ONE SCOPE sees it (scoped layer joins, shadowing and restrictions
* applied — {@link schemas}). Because `PromptAssembly.tools` is what the
* loop's request header snapshots, the mode's collapse is logged and
* reconstructable for free. Under a non-native mode this is also the loud
* misconfiguration gate: no usable code runtime → every assembly rejects
* before any model request.
*
* The `knownNames` universe distinguishes the two ways a tool can be off
* the wire: a per-scope RESTRICTION is runtime state, so `knownNames` stays
* pre-restriction and a restricted-away tool in `toolOrder` is a normal
* absence — while the MODE collapse is deployment config, so under
* `mode: 'code'` the universe is `[run_code]` and a `toolOrder` naming a
* native tool is dead configuration that fails every assembly loud. Under
* `mode: 'both'`, the provider adds the reserved transport to the
* capability-only {@link knownNames} universe for `toolOrder` validation.
* The registry's contribution to the wire tool list, per {@link Config.mode}, as one SCOPE
* sees it (scoped layer joins, shadowing and restrictions applied — {@link schemas}).
*/
private wireSchemas(scope?: ScopeKey): ToolProviderResult {
if (this.mode === 'native') return { schemas: this.schemas(scope), knownNames: this.knownNames(scope) }
@@ -582,20 +448,8 @@ export class ToolRegistry extends Service {
}
/**
* Register a tool. The layer is decided by the CALLING context: a plain
* plugin context registers globally; a scoped context (`agent.ctx`)
* registers into that scope's layer — visible to that agent alone, disposed
* with the scope, and shadowing a same-named global tool for that agent.
* Throws if the SAME layer already has the name (cross-layer name twins are
* the shadowing feature, not an error; the global-duplicate message names
* `agent.ctx` as the per-agent alternative), or if a non-native mode reserves
* the `run_code` name for its presentation transport. The visible schema set
* flows into prompt assembly automatically. Registration validates and
* clones the JSON parameters, copies scalar fields, binds each callback once
* to the caller's definition as its method receiver, and freezes the stored
* snapshot; later mutation or callback replacement on the input object does
* not rewrite the registry. Disposed with the calling fiber. Emits
* `tools/change` on register/unregister.
* Register a tool.
*
* @param definition - the tool's schema plus its execute (and optional
* presentation) functions.
* @returns the disposer that unregisters the tool. The exact
@@ -605,11 +459,6 @@ export class ToolRegistry extends Service {
register(definition: ToolDefinition): () => Promise<void> | void {
const scope = scopeOf(this.ctx)
// A schema crosses the same model/log boundary as execution arguments.
// Validate BEFORE cloning because structuredClone silently turns some
// forbidden values (for example class instances) into plain records, then
// validate the detached value again to contain hostile getters that change
// between inspection and snapshotting. A frozen Map is still mutable, so
// deepFreeze alone is not a sufficient registration boundary.
if (!isJsonValue(definition.parameters)) {
throw new TypeError('tool parameters must be losslessly JSON-serializable')
}
@@ -643,11 +492,10 @@ export class ToolRegistry extends Service {
: `tool "${snapshot.name}" is already registered in this scope`)
}
layer.set(snapshot.name, snapshot)
// Yield the rollback BEFORE emitting `tools/change`: a generator effect
// collects each yielded disposer before the next step runs, so a throwing
// `tools/change` listener removes the tool instead of leaking it (a leak
// would wedge the duplicate-name check until restart). The duplicate
// throw above fires before any mutation — it leaks nothing.
// Yield the rollback before emitting `tools/change`: a generator effect collects each
// yielded disposer before the next step runs, so a throwing `tools/change` listener
// removes the tool instead of leaking it (a leak would wedge the duplicate-name check
// until restart).
yield () => {
layer.delete(snapshot.name)
// An emptied scope layer is dropped so a disposed scope leaves no
@@ -657,31 +505,13 @@ export class ToolRegistry extends Service {
}
this.ctx.emit('tools/change')
}.bind(this), 'tools.register()')
// The EXACT cordis effect disposer, not a wrapper: a composite (generator)
// effect that owns a teardown ORDER must be able to yield THIS function —
// cordis nests a disposer out of the fiber's concurrent sibling list by
// exact function identity, so a wrapper would silently break the nesting
// (the agents.register() lesson). Fire-and-forget callers may still
// discard the (always-resolved) promise.
// Return the exact Cordis disposer so generator effects preserve teardown nesting.
return dispose
}
/**
* Restrict the GLOBAL tool surface for the calling scope. Must be called
* through a scoped context (`agent.ctx`) — restricting "everyone" is not a
* thing (throw), and an empty filter (neither `allow` nor `deny`) is a no-op
* that can only be a bug (throw — the materialized-empty-config trap).
* Validates every listed name against the scope's CURRENT pre-restriction
* name universe ({@link knownNames}) and throws on an unknown one (fail loud
* beats a typo silently filtering nothing) — register restrictions after the
* global tools they mask exist (the agent-creation `setup` window satisfies
* this). A non-native mode's reserved `run_code` presentation transport is
* not a filterable capability; naming it explicitly throws, while omitting
* it from an allow-list cannot remove it. The filter is SNAPSHOT at
* registration: later caller mutation of the arrays changes nothing.
* Multiple restrictions compose by intersection. Scoped registrations
* bypass restrictions (explicit grants win). Disposed with the calling
* fiber (revocable independently); emits `tools/change`.
* Restrict the global tool surface for the calling scope.
*
* @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).
* @returns the disposer that lifts this restriction. The exact
* Cordis effect disposer (single-shot): composite (generator) effects may
@@ -722,12 +552,7 @@ export class ToolRegistry extends Service {
}
this.ctx.emit('tools/change')
}.bind(this), 'tools.restrict()')
// The EXACT cordis effect disposer, not a wrapper: a composite (generator)
// effect that owns a teardown ORDER must be able to yield THIS function —
// cordis nests a disposer out of the fiber's concurrent sibling list by
// exact function identity, so a wrapper would silently break the nesting
// (the agents.register() lesson). Fire-and-forget callers may still
// discard the (always-resolved) promise.
// Return the exact Cordis disposer so generator effects preserve teardown nesting.
return dispose
}
@@ -856,14 +681,9 @@ export class ToolRegistry extends Service {
}
/**
* The model-facing schemas of everything `scope` can see — exactly the
* fields (`name`, `description`, `parameters`) sent to the model via the
* system-prompt assembly. Constructed EXPLICITLY rather than by stripping
* known non-schema members: a `ToolDefinition` also carries `execute` and the
* optional `presentCall`/`presentResult` UI callbacks, and those (especially
* the functions) must never leak into a model request. An allowlist can't
* drift when a new non-schema member is added to the definition; a denylist
* (rest-destructure) would silently leak it.
* The model-facing schemas of everything `scope` can see — exactly the fields (`name`,
* `description`, `parameters`) sent to the model via the system-prompt assembly.
*
* @param scope - the viewing scope (the agent); omitted = the global view.
* @returns one deep-cloned schema per visible tool.
*/
@@ -895,26 +715,12 @@ export class ToolRegistry extends Service {
}
/**
* Execute one tool call through the `tools/pre-execute` → guards →
* `tools/execute` (around dispatch) → `tools/post-execute` → `tools/result`
* pipeline. `pre-execute` is the extensible gate
* (allow/deny/ask), `tools/execute` wraps core dispatch (a timeout/retry/metrics
* seam), and `post-execute` is the inspect/transform seam; core dispatch sits
* as the base `next()` of the `tools/execute` waterfall. The whole thing is
* wrapped in one outer try/catch so a throwing listener (in any waterfall)
* becomes an `isError` result instead of failing the turn; the tool body ALSO
* keeps its own inner try/catch, so a thrown tool becomes an `isError` result
* that `tools/execute` and `post-execute` listeners can still inspect. If the
* tool is not registered (or not visible to the calling agent — a
* restricted-away global is exactly as absent as a nonexistent one), the
* result is an `isError` carrying a `UNKNOWN_TOOL` structured error. A thrown
* {@link HarnessError} surfaces its `{ name, code }` on the result. Before
* the final observe-only notification, the authoritative outcome must survive
* a lossless JSON round trip; an invalid outcome is normalized to an error.
* A malformed runtime/casted `tools/pre-execute` decision likewise normalizes
* to an error before approval, guards, or the tool body.
* Caller-owned arguments must survive lossless-JSON validation before and
* after cloning; a violation normalizes to an error before policy or dispatch.
* Execute one tool call through the `tools/pre-execute` → guards → `tools/execute` (around
* dispatch) → `tools/post-execute` → `tools/result` pipeline. `pre-execute` is the
* extensible gate (allow/deny/ask), `tools/execute` wraps core dispatch (a
* timeout/retry/metrics seam), and `post-execute` is the inspect/transform seam; core
* dispatch sits as the base `next()` of the `tools/execute` waterfall.
*
* @param exec - the single-use call input; its identity is snapshotted and
* protected before policy runs.
* @returns the final result after every waterfall; failures resolve as
@@ -925,11 +731,8 @@ export class ToolRegistry extends Service {
try {
execution = this.prepareExecution(exec)
} catch (error: unknown) {
// Contract-violating non-JSON or non-cloneable arguments cannot enter a
// pipeline whose logged and executed forms must agree. Still publish one
// scoped final outcome, using an immutable identity shell, so result
// observers retain their every-call guarantee without seeing the invalid
// value.
// Contract-violating non-JSON or non-cloneable arguments cannot enter a pipeline whose
// logged and executed forms must agree.
execution = Object.freeze({
token: createExecutionToken(),
callId: exec.callId,
@@ -945,11 +748,8 @@ export class ToolRegistry extends Service {
}
let result: ToolExecutionResult
try {
// Validate the authoritative FINAL result, not merely the tool body's
// intermediate return. Post-policy may replace content or attach context,
// and every one of these fields is session-bound. Reject anything that
// cannot round-trip losslessly through the durable JSON log before the
// observe-only `tools/result` commit point sees success.
// Validate the authoritative final result, not merely the tool body's intermediate
// return.
result = this.snapshotExecutionResult(execution, await this.executePipeline(execution))
} catch (error: unknown) {
// Outer backstop: a throwing pre/post-execute listener, guard, or the
@@ -1002,10 +802,7 @@ export class ToolRegistry extends Service {
/** Run the transformable pipeline; {@link execute} owns final normalization and notification. */
private async executePipeline(exec: ToolExecution): Promise<ToolExecutionResult> {
// --- Gate: tools/pre-execute. An `ask` resolves through the optional
// approval seam (or degrades to deny) before the monotonic guards run. The
// carrier keys dispatch by exec.agent, so an `agent.ctx` listener gates only
// its own agent's calls (agent-less calls are subject-less).
// --- Gate: tools/pre-execute.
const carrier = scopeTarget(this, exec.agent)
const gate = this.snapshotPreDecision(await this.ctx.waterfall(
carrier, 'tools/pre-execute', exec,
@@ -1026,14 +823,7 @@ export class ToolRegistry extends Service {
return await this.postExecute(exec, denied)
}
// --- Around-dispatch: tools/execute. The base `next` is the dispatch-
// with-normalization thunk — the tool body's own try/catch turns a throw
// into an isError result so a wrapper (and post-execute) can inspect it;
// an unknown tool routes through the same catch. A `tools/execute` listener
// (e.g. a timeout plugin) wraps this thunk: it may replace `exec.signal`
// before delegating and inspect the normalized result after. Dispatched with the
// same carrier as the gate, so an `agent.ctx` wrapper wraps only its own
// agent's calls. ---
// --- Around-dispatch: tools/execute.
const result = this.snapshotExecutionResult(exec, await this.ctx.waterfall(
carrier, 'tools/execute', exec,
async (): Promise<ToolExecutionResult> => {
@@ -1117,15 +907,7 @@ export class ToolRegistry extends Service {
}
/**
* Resolve an `ask` decision to allow/deny through the approval seam. The
* seam is consumed opportunistically with `ctx.get('approval')` — a
* deployment that composes no ApprovalService keeps the historical degrade
* to deny, and an unmount mid-session degrades the same way on the next ask.
* An agent-less execution also degrades: without an agent there is no
* session to audit to and no UI to route to. Otherwise the outcome maps
* one-to-one — `allowed-once` proceeds; the three non-grants deny with
* distinct reasons so the model can tell a human "no" from an absent
* approval channel.
* Resolve an `ask` decision to allow/deny through the approval seam.
*/
private async serviceAsk(
exec: ToolExecution,
@@ -1163,14 +945,7 @@ export class ToolRegistry extends Service {
* Runs inside `execute`'s outer try/catch (a throwing listener → isError).
*/
private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult> {
// Snapshot the protected outcome BEFORE the waterfall. A listener receives
// the same `result` reference, so a post-waterfall read of `result.callId`/
// `.isError`/`.error` could carry a listener's mutation — violating the
// authoritative-call-id requirement and the "preserve the dispatched
// isError/error" contract. The decision is the ONLY sanctioned channel for a
// listener to change the outcome (block, or accept-with-replacement); the
// call id is always the authoritative `exec.callId`. Deep cloning protects
// nested content, error, and meta data from in-place listener mutation.
// Snapshot the protected outcome before the waterfall.
const dispatched = this.snapshotExecutionResult(exec, result)
const decision = structuredClone(await this.ctx.waterfall(
scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,
@@ -1214,10 +989,8 @@ export class ToolRegistry extends Service {
...result.additionalContext !== undefined ? { additionalContext: result.additionalContext } : {},
...result.meta !== undefined ? { meta: result.meta } : {},
}
// Validate BEFORE cloning: structuredClone turns some forbidden exotic or
// class instances into plain objects, which would hide a lossy JSON
// boundary violation. Validate the detached clone again to contain hostile
// getters whose value changes between inspection and snapshotting.
// Validate before cloning: structuredClone turns some forbidden exotic or class instances
// into plain objects, which would hide a lossy JSON boundary violation.
if (!isJsonValue(candidate)) {
throw new TypeError('tools/execute must return a losslessly JSON-serializable ToolExecutionResult')
}

View File

@@ -1,31 +1,7 @@
/**
* Structured-output JSON Schema subset: the vocabulary a caller uses to demand
* a machine-readable result from a subagent (`SubagentStartRequest.outputSchema`)
* or a workflow `agent()` call.
*
* This is deliberately NOT full JSON Schema. The schema travels verbatim to the
* model as a forced tool's `parameters`, and the value the model produces is
* validated here — so every accepted keyword must be one this module actually
* enforces. Accepting a keyword we don't enforce would validate less than the
* schema promises (accepted-then-ignored), so anything outside the subset is
* REJECTED LOUD by {@link assertSupportedOutputSchema} instead. The subset:
*
* - `type` — a single string (`object`/`array`/`string`/`number`/`integer`/
* `boolean`/`null`); type ARRAYS (`["string","null"]`) are rejected.
* - `properties`/`required`/`additionalProperties` (boolean) on objects; every
* `required` key must be declared in `properties`. `additionalProperties`
* absent keeps standard JSON Schema semantics (extra keys allowed).
* - `items` on arrays (absent ⇒ any JSON items).
* - `enum` (non-empty, scalars only) and `const` (scalar) on scalar types.
* - Annotations `description`/`title`/`default`/`examples` are allowed and
* ignored (they constrain nothing), except that they must still be JSON data
* — the schema is serialized onto the wire, so a non-JSON annotation would be
* silently mangled.
*
* Values checked by {@link validateStructuredValue} are expected to be plain
* host-realm JSON data (model tool-call arguments are parsed wire JSON; a
* caller holding foreign-realm data materializes it first).
*
* Structured-output JSON Schema subset: the vocabulary a caller uses to demand a
* machine-readable result from a subagent (`SubagentStartRequest.outputSchema`) or a workflow
* `agent()` call.
* @module dsh-tools/json-schema
*/

View File

@@ -1,20 +1,7 @@
/**
* Tool render-intent vocabulary: the provider-neutral types a tool declares via
* `ToolDefinition.presentCall`/`ToolDefinition.presentResult` to say
* how ONE of its calls renders in a UI (an editor's tool-call card, a CLI log
* line). A UI bridge switches on the `card` tag to map each intent to its own
* wire shape, so a UI never special-cases tool names.
*
* This is the UI-facing surface of `dsh-tools`, kept separate from the registry
* and execution core in `index.ts`: this module owns ONLY presentation
* vocabulary and references none of the execution types, so the dependency runs
* one way (`index.ts` imports these views for the `ToolDefinition` method
* signatures). The opaque `meta` presentation channel is execution plumbing and
* lives with the registry in `index.ts`, not here.
*
* See the render-intent-union RFC
* (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md).
*
* `ToolDefinition.presentCall`/`ToolDefinition.presentResult` to say how one of its calls
* renders in a UI (an editor's tool-call card, a CLI log line).
* @module @deepseek-ai/dsh-tools/src/presentation
*/
@@ -186,16 +173,8 @@ export interface TerminalResultView {
}
/**
* A completed file mutation rendered as an inline diff card, the *result-time*
* analogue of {@link DiffCallView}. Set by a tool whose `execute` applied a file
* change (e.g. `write`, `edit`): `diffs` are the change to show — typically the
* APPLIED hunks computed from the before/after content (one entry per hunk, each
* with surrounding context lines), so the editor shows the real change in place;
* a tool with no before-image (e.g. a file create) may instead give a whole-file
* diff (`oldText: null`). A `tool_call_update`'s content REPLACES the call's
* content in an editor, so a mutation tool returns this even when it duplicates
* the call-time snippet — otherwise the model-facing result text would replace
* (clobber) the pending diff card.
* A completed file mutation rendered as an inline diff card, the *result-time* analogue of
* {@link DiffCallView}.
*/
export interface DiffResultView {
card: 'diff'

View File

@@ -1,21 +1,5 @@
/**
* Typed tool-parameter schema DSL.
*
* Plugin authors write per-property specs with `required: true` as a boolean
* (the `SchemaSpec` type). A type-level helper (`InferArgs`) maps a SchemaSpec
* to the TS argument type. At runtime, `schemaSpecToJsonSchema()` converts a
* SchemaSpec to standard JSON Schema (`type: 'object'`, `properties`,
* `required` array) for the wire format sent to the model.
*
* # Why a custom DSL and not schemastery?
*
* Schemastery is a validation/transformation library (StandardSchema v1) used
* for plugin Config. Tool parameters need JSON Schema specifically (the LLM
* wire format), not validation. A lightweight DSL focused on JSON Schema
* generation, with type inference for the tool's `execute` args, gives plugin
* authors the best DX with the smallest surface area. Schemastery would add
* unnecessary indirection and wouldn't cleanly produce JSON Schema.
*
* @module dsh-tools/schema
*/
@@ -263,15 +247,10 @@ function checkSpec(spec: SchemaSpec, value: unknown, path: string): string[] {
}
/**
* Validate model-generated `args` against a {@link SchemaSpec}, returning a
* list of human-readable violation messages (empty = valid). Total — never
* throws, regardless of how malformed `args` is.
* Validate model-generated `args` against a {@link SchemaSpec}, returning a list of
* human-readable violation messages (empty = valid). Total — never throws, regardless of how
* malformed `args` is.
*
* Semantics mirror {@link schemaSpecToJsonSchema} exactly: the top level must
* be a non-array object; required keys come only from `required: true`; extra
* keys are allowed (no `additionalProperties: false`); `default` is not
* applied; an `object`/`array` prop without `properties`/`items` only
* type-checks; `enum` is membership (strings only).
* @param spec - the declared parameter schema to validate against.
* @param args - the model-generated arguments, however malformed.
* @returns the violation messages in declaration order; empty means valid.
@@ -330,29 +309,6 @@ export interface DefineToolOptions<S extends SchemaSpec> {
/**
* Define a tool with a typed parameter schema.
*
* Use this instead of constructing a raw {@link ToolDefinition} for all
* first-party tools. The `parameters` use the boolean-required style
* (`required: true` as a per-property flag), and `execute` receives typed
* args derived from the schema.
*
* ```ts
* const tool = defineTool({
* name: 'read_file',
* description: 'Read a file from disk.',
* parameters: {
* path: { type: 'string', required: true, description: 'Absolute file path' },
* offset: { type: 'number' },
* limit: { type: 'number', description: 'Max lines to read' },
* },
* async execute(args) {
* // args: { path: string; offset?: number; limit?: number }
* },
* })
* ```
*
* Raw JSON-Schema tool definitions (from MCP servers) are still accepted
* by `ToolRegistry.register()` directly — `defineTool` is sugar for
* first-party plugin authors.
* @param options - the tool's name, description, typed parameter schema,
* execute body, and optional presenters.
* @returns a registry-ready {@link ToolDefinition}: its `execute` validates the
@@ -378,10 +334,7 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
async execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> {
// Validate the model-generated args before the typed body runs. On
// mismatch we throw ToolArgsError; the registry turns it into an
// isError result so the model can self-correct. After this guard, the
// cast to InferArgs<S> reflects the validated shape.
// Validate the model-generated args before the typed body runs.
const violations = validateArgs(options.parameters, args)
if (violations.length > 0) throw new ToolArgsError(violations)
return userExecute(args as InferArgs<S>, exec)

View File

@@ -1,17 +1,8 @@
/**
* Code Mode codegen: the pure projection from registered tool schemas to the
* TypeScript SDK text the model programs against (the `tools:sdk` prompt
* section). Sibling of `json-schema.ts` — `schemas()` (native function
* calling) and this module (the generated `declare const tools` surface) are
* two projections of the same store.
*
* TOTAL by design: {@link jsonSchemaToTs} maps the JSON-Schema subset the
* `defineTool` DSL emits and degrades every construct outside it (`$ref`,
* `oneOf`, `integer`, future MCP shapes, …) to `unknown` without ever
* throwing — codegen must never be the thing that fails an assembly.
* Deterministic: a fixed tool set renders byte-identical text (tools in
* lexicographic name order), so the section is prefix-cache-friendly.
*
* Code Mode codegen: the pure projection from registered tool schemas to the TypeScript SDK
* text the model programs against (the `tools:sdk` prompt section). Sibling of
* `json-schema.ts` — `schemas()` (native function calling) and this module (the generated
* `declare const tools` surface) are two projections of the same store.
* @module @deepseek-ai/dsh-tools/src/ts-types
*/
@@ -33,10 +24,8 @@ function pad(indent: number): string {
/** A one-line JSDoc block for a schema `description`, or no lines when there is none. */
function docLines(description: unknown, indent: number): string[] {
if (typeof description !== 'string' || description.length === 0) return []
// Keep the doc a single-line comment per property: descriptions are prose
// (possibly with newlines); collapse whitespace so the rendered SDK stays
// stable and compact. A comment-closer inside the description is escaped so
// it cannot terminate the generated JSDoc early.
// Keep the doc a single-line comment per property: descriptions are prose (possibly with
// newlines); collapse whitespace so the rendered SDK stays stable and compact.
const collapsed = description.replace(/\s+/g, ' ').trim()
return [`${pad(indent)}/** ${collapsed.replaceAll('*/', String.raw`*\/`)} */`]
}

View File

@@ -358,10 +358,8 @@ describe('the run_code dispatch bridge', () => {
return { logs: [], value: 'done' }
}
// Model a timeout-style outer wrapper: it temporarily installs a signal,
// delegates, then restores the exact prior shape. A nested result observer
// is observe-only and must not receive the live outer execution object;
// freezing the correlation value it sees therefore cannot break restore.
// Model a timeout-style outer wrapper: it temporarily installs a signal, delegates, then
// restores the exact prior shape.
ctx.on('tools/execute', async (exec, next) => {
if (exec.name !== RUN_CODE_NAME) return next()
const previous = exec.signal
@@ -585,11 +583,8 @@ describe('the run_code dispatch bridge', () => {
},
}))
runtime.behavior = async (request) => {
// Start a sub-dispatch, keep its rejection held, and fail the run once
// the tool is genuinely in flight — a seam error AFTER work has begun.
// The bridge's settlement still owes quiescence: without the finally,
// run_code would return now and the slow tool would finish (and log)
// afterwards.
// Start a sub-dispatch, keep its rejection held, and fail the run once the tool is
// genuinely in flight — a seam error after work has begun.
request.bindings[0]!.functions.slow!({ id: 'orphan' }).catch(() => 'held')
await inFlight
throw new Error('backend exploded')

View File

@@ -1,17 +1,5 @@
/**
* Guarantee tests for the tool-schema catalog generator
* (`scripts/gen-tool-catalog.ts`).
*
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
* the freshness half is exercised by `pnpm run verify-tool-catalog` in CI. What
* a freshness diff CANNOT prove is (a) that BOOTING the tool plugins yields the
* shipped schema — the whole reason this generator boots instead of parsing
* source (a runtime-spread enum resolves to its literal members) — and (b) that
* the completeness guard REJECTS a tool package missing from the boot manifest,
* the property that replaces the AST pass's "nothing silently omitted". These
* tests drive the exported `collectToolCatalog` / `assertManifestComplete` /
* `render` directly, mirroring the negative-path style of the cordis-catalog
* generator tests.
* Guarantee tests for the tool-schema catalog generator (`scripts/gen-tool-catalog.ts`).
*/
import { describe, expect, it } from 'vitest'
@@ -62,11 +50,8 @@ describe('gen-tool-catalog collectToolCatalog', () => {
})
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`. Booting yields only the default name; the note is how a
// reader learns the fork alias the model also sees. Without it the catalog
// would silently under-report the shipped tool surface.
// `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`.
const catalog = await collectToolCatalog()
const subagent = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-subagent')
expect(subagent?.schemas.map(s => s.name)).toEqual(['subagent'])

View File

@@ -485,11 +485,7 @@ describe('ToolRegistry', () => {
})
it('a post-execute listener cannot mutate any nested part of the dispatched result', async () => {
// The decision is the ONLY sanctioned channel to change the outcome. A
// listener that reaches in and mutates the passed result reference (flipping
// isError, rewriting callId, attaching a bogus error) must NOT affect what
// execute() returns — the registry snapshots the authoritative fields before
// the waterfall and rebuilds from the snapshot + decision.
// The decision is the only sanctioned channel to change the outcome.
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async (_exec, next) => {
@@ -972,16 +968,9 @@ describe('ToolRegistry', () => {
})
it('register() returns the EXACT effect disposer: a composite yield nests the teardown in order', async () => {
// The registry-disposer convention (set by agents.register): the returned
// function IS the cordis effect disposer, so a composite (generator)
// effect that yields it has the unregistration run at that yield's LIFO
// position on owner unload. A wrapper would leave the inner effect
// disposing as a CONCURRENT SIBLING of the composite; the async probe
// below (disposed first, LIFO) yields the event loop exactly like the
// agent factory's stop-and-drain link, and a sibling unregistration fires
// in that window — the probe would observe the tool already gone. Pins
// the convention for the whole register-method family (system-prompt
// registrars, registerProvider, setFactory share the same return).
// The registry-disposer convention (set by agents.register): the returned function IS the
// cordis effect disposer, so a composite (generator) effect that yields it has the
// unregistration run at that yield's LIFO position on owner unload.
const ctx = await setup()
const order: string[] = []
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
@@ -1681,10 +1670,9 @@ describe('defineTool presentation (presentCall / presentResult)', () => {
presentCall: args => ({ card: 'generic', title: args.path }),
presentResult: (args, result) => ({ card: 'generic', title: args.path, content: result.content }),
})
// Unlike execute (which throws ToolArgsError on a mismatch), the display
// methods soft-validate and fall back to undefined so a UI never crashes
// replaying an old/foreign log entry. The ToolDefinition methods take
// `unknown`, so malformed shapes pass without a cast.
// Unlike execute (which throws ToolArgsError on a mismatch), the display methods
// soft-validate and fall back to undefined so a UI never crashes replaying an old/foreign
// log entry.
expect(tool.presentCall?.({})).toBeUndefined()
expect(tool.presentResult?.({ wrong: 1 }, { content: [], isError: false })).toBeUndefined()
})

View File

@@ -1,20 +1,7 @@
/**
* Cordis-free local-filesystem I/O for `@deepseek-ai/dsh-fs-local`. Kept
* separate from the service class (mirroring `dsh-bash-local`'s `run.ts`) so
* the raw stat/read/write/edit mechanics can be unit-tested without a Context.
*
* This is the PROVIDER layer: it hands back decoded whole-file text (validated
* UTF-8, binary rejected) — never line windows or numbered lines, which are
* model-facing read policy owned by `@deepseek-ai/dsh-fs-policy`. Large files
* stream their text in chunks so a huge file never has to be held whole in
* memory; the binary/NUL sample and cross-chunk UTF-8 decoding stay here.
*
* Writes are atomic: content goes to a temp file opened exclusively (`wx`,
* `0o600`, so a pre-existing path can never be clobbered and write-in-progress
* bytes stay owner-only) inside a randomly-named private staging directory
* (`0o700`) next to the target, then `rename`d over the target. Edits are
* read-modify-write over the same atomic primitive.
*
* Cordis-free local-filesystem I/O for `@deepseek-ai/dsh-fs-local`. Kept separate from the
* service class (mirroring `dsh-bash-local`'s `run.ts`) so the raw stat/read/write/edit
* mechanics can be unit-tested without a Context.
* @module @deepseek-ai/dsh-fs-local/fsio
*/
@@ -121,14 +108,8 @@ export interface LocalDirEntry {
}
/**
* Resolve a path to its absolute display path and realpath identity. Relative
* paths are based on `cwd`. When the file itself does not yet exist, the
* `targetKey` realpaths the nearest EXISTING ancestor directory and re-appends
* the still-missing suffix, so a not-yet-created file gets the same stable key
* it will have after creation — even when an ancestor (e.g. `cwd`) is a symlink
* and intermediate directories are created by the write. Two input paths
* reaching the same file via symlinks share one key. Falls back to the absolute
* path only when no ancestor (not even the filesystem root) can be resolved.
* Resolve a path to its absolute display path and realpath identity.
*
* @param cwd - base directory a relative `path` resolves against.
* @param path - absolute or relative path; empty/whitespace-only throws `FS_NOT_FOUND`.
* @returns the absolute display path plus the realpath-derived stable target key.
@@ -363,15 +344,11 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow
}
/**
* Atomically write `content` to `absolutePath`: create parent dirs, write to a
* randomly-named temp file opened exclusively (`wx`, `0o600`) inside a private
* (`0o700`) staging directory, fsync, optionally chmod to the final mode while
* still private, then rename over the target. `mode` (when given) preserves an
* existing file's permissions across the replace.
* @param absolutePath - the final destination (typically a target key); missing parent dirs are created.
* Atomically replace a file through a private, synced staging file in the same directory.
* @param absolutePath - destination; missing parent directories are created.
* @param content - the full UTF-8 text to write.
* @param mode - final file mode applied before the rename (an existing file's, to preserve permissions); undefined leaves `0o600`.
* @param signal - aborts the write (`FS_ABORTED`); checked before the rename, so the target is never left torn.
* @param mode - final mode, or `0o600` when omitted.
* @param signal - cancellation checked before the final rename.
* @param internals - test seam for pinning temp names and observing the staged file.
*/
export async function writeFileAtomic(
@@ -492,12 +469,9 @@ export async function readForEdit(
}
/**
* Best-effort read of a file's current text for a before/after diff basis, used
* by an overwrite. Returns the LF-normalized decoded content, or `null` when the
* file is binary or not valid UTF-8 — a write must succeed regardless of the
* prior bytes, so an undiffable prior file simply yields no contextual-hunk basis
* (the caller treats `null` the same as an absent file: the result renders a
* whole-file diff rather than an applied hunk).
* Best-effort read of a file's current text for a before/after diff basis, used by an
* overwrite.
*
* @param absolutePath - the file to read (typically a target key); it must exist.
* @param signal - aborts the read (`FS_ABORTED`).
* @returns the LF-normalized text, or null for a binary or non-UTF-8 file.
@@ -515,12 +489,11 @@ export async function readTextForDiff(absolutePath: string, signal?: AbortSignal
}
/**
* Apply a literal replacement to LF-normalized content. Throws
* `FS_EDIT_NOT_FOUND` on empty `oldString` or zero matches and
* `FS_AMBIGUOUS_EDIT` on multiple matches when `replaceAll` is false. Returns
* the edited content (still LF-normalized) and the replacement count.
* Apply a literal replacement to LF-normalized content.
*
* @param content - the current file content, already LF-normalized.
* @param oldString - literal text to find; CRLF inside it is normalized to LF before matching.
* @param oldString - literal text to find; CRLF inside it is normalized to LF before
* matching.
* @param newString - literal replacement text, normalized the same way.
* @param replaceAll - replace every match instead of requiring exactly one.
* @param displayPath - the caller-facing path used in error messages.

View File

@@ -1,15 +1,7 @@
/**
* Local-filesystem implementation of the `ctx.fs` provider seam.
* {@link LocalFileSystem} subclasses {@link FileSystem} and backs the seven
* text-storage primitives with the host filesystem via
* {@link module:@deepseek-ai/dsh-fs-local/fsio}. Path resolution uses
* `realpath`, so the stable `targetKey` is the real file identity (two input
* paths reaching the same file through symlinks share one key, and writes land
* on the link target — preserving the link).
*
* Future sandboxed/remote/virtual backends are sibling packages implementing
* the same interface; loading this one populates `ctx.fs`.
*
* Local-filesystem implementation of the `ctx.fs` provider seam. {@link LocalFileSystem}
* subclasses {@link FileSystem} and backs the seven text-storage primitives with the host
* filesystem via {@link module:@deepseek-ai/dsh-fs-local/fsio}.
* @module @deepseek-ai/dsh-fs-local
*/
@@ -156,18 +148,10 @@ export class LocalFileSystem extends FileSystem {
// createIfAbsent onto an existing file: a blind overwrite — require a read first.
throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')
}
// expected === undefined: unconditional create-or-overwrite (the bare
// provider) — no version guard, no read-first requirement. Still atomic
// (the per-target lock is unconditional), so the write is never torn.
// No expectation means an unconditional but still atomic write.
// Capture the prior text (the before/after diff basis) BEFORE the write.
// `null` for a create (no existing file) OR an existing-but-undiffable
// file (binary/invalid-UTF-8) — a null `before` gives no contextual-hunk
// basis, so a consumer falls back to a whole-file diff (the tool still
// renders a result-time diff card, not the raw result text).
// TODO(overwrite-diff-bound): this reads the whole prior file into memory
// for a UI-only diff; bound the pre-read and fall back to no contextual
// basis above a size threshold (see the applied-hunk-diffs RFC non-goals).
// Preserve prior text for contextual diffs; null falls back to a whole-file diff.
// TODO(overwrite-diff-bound): cap this UI-only pre-read for large files.
const before = existing ? await readTextForDiff(target.targetKey, signal) : null
await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals)
const after = await probe(target.targetKey)
@@ -191,10 +175,8 @@ export class LocalFileSystem extends FileSystem {
): Promise<FsEditOutcome> {
return this.withLock(target.targetKey, async () => {
const existing = await probe(target.targetKey)
// Stale guard BEFORE literal matching: an edit based on an old read reports
// Stale guard before literal matching: an edit based on an old read reports
// FS_STALE_VERSION, not FS_EDIT_NOT_FOUND/FS_AMBIGUOUS_EDIT against newer content.
// A missing target reports FS_STALE_VERSION on BOTH paths (guarded and
// unconditional) — one "cannot edit this target now" code.
if (!existing) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
if (existing.type !== 'file') throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
// expected === undefined: unconditional edit of the current content — no

View File

@@ -1,45 +1,7 @@
/**
* The fs-policy PLUGIN: observed-state, read-before-edit, and
* "write/edit must be based on the version you read" — added on top of the
* `ctx.fs` provider seam through the `fs/*` event gate, NOT through a method
* service. This plugin registers NO `ctx.fsPolicy` service and exposes no
* `read`/`write`/`edit`/`resolve` methods; it influences the world only by
* deciding the `fs/write-intent`/`fs/edit-intent` waterfalls and
* recording on `fs/observed`. That is what keeps `@deepseek-ai/dsh-tool-fs`
* (the executor) free of any method coupling to the policy layer — removing
* this plugin gracefully loses the policy and leaves the unconstrained bare
* provider, rather than breaking the tool at a service-injection boundary.
*
* ## Observed state IS the prior-observation record
*
* State lives here as `WeakMap<owner, Map<targetKey, { version }>>`. An entry
* exists iff the owner has read, written, OR edited that target (every success
* emits `fs/observed`), so its presence means "this owner has observed this
* target at this version". This is what lets a create-then-edit or
* edit-then-edit sequence work without an intervening re-read: the mutation
* refreshes the recorded version to its own result. The owner is derived
* structurally from `{ agent?: { session? } }` and held weakly, so a collected
* session frees its state; disposal drops everything (HMR safety).
*
* ## Freshness via provider CAS, not stat
*
* This plugin does NO filesystem I/O. "Have you observed this file?" is a
* `WeakMap` lookup (no record ⇒ `FS_NOT_OBSERVED`). "Is the version you read
* still current?" is decided INSIDE `ctx.fs.editText`/`writeText`, in the same
* atomic lock that performs the mutation — this plugin only supplies the
* observed version as the CAS basis. Stat-ing and comparing here would open a
* TOCTOU gap the provider lock has to back up anyway, so it is deliberately
* avoided.
*
* ## Single-slot, first-wins
*
* The `fs/write-intent`/`fs/edit-intent` listeners do NOT call
* `next()`: each fully decides its single slot. The slot is first-wins by
* registration order — this plugin owning it is the default-deployment
* convention, not an event-enforced invariant (a decider registered before /
* `prepend`ed would win instead). This is not a composable authorization chain;
* layered permission/audit/sandbox interception belongs on `tools/execute`.
*
* The fs-policy plugin: observed-state, read-before-edit, and "write/edit must be based on the
* version you read" — added on top of the `ctx.fs` provider seam through the `fs/*` event
* gate, not through a method service.
* @module @deepseek-ai/dsh-fs-policy
*/
@@ -145,15 +107,10 @@ export function apply(ctx: Context): void {
// holds (a throw rejects, never escapes synchronously through the waterfall).
ctx.on('fs/write-intent', (target, actor) => Promise.resolve().then(() => gate.writeIntent(target, actor)))
// fs/edit-intent: occupy the single decision slot — do NOT call next().
// Deferred the same way so an FS_NOT_OBSERVED throw becomes a rejected promise
// the edit tool's `await ctx.waterfall(...)` surfaces as its isError result.
// fs/edit-intent: occupy the single decision slot — do not call next().
ctx.on('fs/edit-intent', (target, actor) => Promise.resolve().then(() => gate.editIntent(target, actor)))
// fs/observed: synchronous, side-effect-only WeakMap write. The tool emits
// this with a plain (unguarded) ctx.emit, so this listener MUST NOT throw —
// a throw would surface as the tool's isError result for a mutation that
// already succeeded. A WeakMap.set honors that contract.
// fs/observed: synchronous, side-effect-only WeakMap write.
ctx.on('fs/observed', (target, version, actor) => {
gate.observe(target, version, actor)
})

View File

@@ -1,13 +1,5 @@
/**
* Tests for the fs-policy PLUGIN: it registers no service, only the
* three `fs/*` listeners. We dispatch those events directly (the unbound
* waterfalls the tool would dispatch, and the `fs/observed` emit) and assert the
* decisions: createIfAbsent vs replaceIfVersion, FS_NOT_OBSERVED for an unread
* edit, observed-state-as-prior-observation (read/write/edit all record),
* multi-owner isolation, single-slot first-wins, and disposal/HMR release.
*
* No `ctx.fs` provider is needed — the plugin does no filesystem I/O; it only
* decides intents and records versions on its own WeakMap.
* Tests for the fs-policy plugin: it registers no service, only the three `fs/*` listeners.
*/
import { describe, expect, it } from 'vitest'

View File

@@ -1,59 +1,8 @@
/**
* The filesystem provider seam (`ctx.fs`): an abstract service defining the
* text-storage primitives a backend provides — resolve a path into a stable
* target, stat its metadata, read/stream its text, write it atomically with an
* explicit intent, and apply a guarded literal edit — without saying HOW.
* Implementations subclass {@link FileSystem} and register themselves as the
* `fs` service; `@deepseek-ai/dsh-fs-local` (the host filesystem) is the first.
* Future implementations swap in sandboxed, remote, virtual, or project-scoped
* backends without touching the model-facing tool schemas
* (`@deepseek-ai/dsh-tool-fs`).
*
* The split mirrors the bash seam (`BashExecutor`/`LocalBashExecutor`). See the
* capability-seam RFC for why a swappable capability is three (here four)
* packages.
*
* ## This is a provider seam, not the policy layer
*
* `ctx.fs` is deliberately close to fsspec-style storage primitives. It owns
* UTF-8 decoding, binary/NUL rejection, atomic full-file writes, and the
* literal-edit critical section — but NOT line windows, numbered lines,
* rendered footers, or observed-state. Read windowing lives in the model-facing
* tool (`@deepseek-ai/dsh-tool-fs`); observed-state and read-before-write/edit
* are policy a plugin (`@deepseek-ai/dsh-fs-policy`) adds through the `fs/*`
* event gate. So a sandboxed/remote backend inherits no model-facing observation
* policy it has no business carrying.
*
* `editText` stays on this seam (not composed in the policy layer from a read
* plus a write) because version guard + literal match + atomic rewrite must
* stay inside one mutation critical section for correct error attribution and
* one-wins/one-stale concurrency, and a remote backend may implement it as a
* native compare-and-edit.
*
* ## The version guard is OPTIONAL — additive policy, not subtractive
*
* `ctx.fs` on its own is a complete, unconstrained text-storage seam: `read`
* reads, `write` unconditionally creates-or-overwrites, `edit` unconditionally
* replaces literal text in the current content. Both mutations take their
* version guard as an OPTIONAL argument — omit it for the unconstrained
* bare-provider behavior, supply it to guard against a concurrent change. The
* mutation runs inside the backend's per-target lock either way, so an
* unconditional write/edit is still atomic; "unconditional" drops the *version*
* precondition, not the atomicity. Observed-state, read-before-edit, and
* version-guarded write/edit are NOT provider behavior — they are policy a
* plugin (`@deepseek-ai/dsh-fs-policy`) adds on top by supplying the guard.
*
* ## The fs policy events live here, not in the policy plugin
*
* This package owns the `fs/write-intent`, `fs/edit-intent`, and
* `fs/observed` event vocabulary (see {@link Events}). The emitter is
* `@deepseek-ai/dsh-tool-fs` and the default listener is
* `@deepseek-ai/dsh-fs-policy`; the events live in the one package both
* already depend on, so the emitter shares a vocabulary with the policy listener
* without depending on the policy plugin. The events carry only `dsh-fs`
* vocabulary plus an opaque `object` actor — no model-facing concepts (line
* windows, numbered lines) and no agent/session owner structure leak down.
*
* The filesystem provider seam (`ctx.fs`): an abstract service defining the text-storage
* primitives a backend provides — resolve a path into a stable target, stat its metadata,
* read/stream its text, write it atomically with an explicit intent, and apply a guarded
* literal edit — without saying how.
* @module @deepseek-ai/dsh-fs
*/
@@ -92,44 +41,26 @@ declare module 'cordis' {
interface Events {
/**
* Single-slot decision: produce the write intent for the next
* {@link FileSystem.writeText}. The tool dispatches this as an unbound
* waterfall (no `this`) and supplies a default thunk returning `undefined`
* (unconditional create-or-overwrite — the bare provider). The
* `@deepseek-ai/dsh-fs-policy` policy listener returns `createIfAbsent`
* (unobserved actor) or `{ kind: 'replaceIfVersion', version: vObserved }`
* (observed) and does NOT call `next()` — one decision, not a composable
* chain. The slot is first-wins: the first non-`next()` decider (registration
* order, or `prepend`) occupies it; a second decider is a misconfiguration,
* not layering. `actor` is the opaque tool-execution context, never read here.
* Single-slot decision: produce the write intent for the next {@link
* FileSystem.writeText}.
*
* @param target - the resolved target about to be written.
* @param actor - the opaque tool-execution context the decider keys off.
* @mode waterfall
*/
'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
/**
* Single-slot decision: produce the optional version guard for the next
* {@link FileSystem.editText}. The tool dispatches this as an unbound
* waterfall and supplies a default thunk returning `undefined` (unconditional
* edit of the current content — the bare provider; no `stat`). The
* `@deepseek-ai/dsh-fs-policy` policy listener returns
* `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset
* or has not observed the target. Does NOT call `next()`: one decision,
* first-wins (see {@link Events.'fs/write-intent'}).
* Single-slot decision: produce the optional version guard for the next {@link
* FileSystem.editText}.
*
* @param target - the resolved target about to be edited.
* @param actor - the opaque tool-execution context the decider keys off.
* @mode waterfall
*/
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
/**
* Record that an actor observed a target at a version, after a successful
* read/write/edit. Fire-and-forget (plain `emit`). A listener MUST be 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 surfaces as the tool's `isError` result, and cordis `emit` does not
* await listener promises — async or fallible audit/telemetry does not
* belong here. No listener ⇒ nothing recorded. `actor` is the opaque
* tool-execution context.
* Record that an actor observed a target at a version, after a successful read/write/edit.
*
* @param target - the target that was read/written/edited.
* @param version - the version the actor now holds as its observation.
* @param actor - the observing tool-execution context; undefined records nothing useful.
@@ -140,34 +71,9 @@ declare module 'cordis' {
}
/**
* Abstract filesystem provider service. Subclass, implement the seven storage
* primitives, and load the subclass as a plugin — it registers as `ctx.fs` (one
* implementation per context; loading a second throws, cordis' standard
* duplicate-service behavior).
*
* Semantics every backend must honor:
* - {@link resolve} returns a stable {@link FsTarget}; the same underlying file
* reached by different input paths must yield the same `targetKey` so stale
* guards and target lookup agree across paths (e.g. through symlinks).
* - {@link stat} returns {@link FsInfo} metadata (never content) or `undefined`
* when the target is absent.
* - {@link readText}/{@link streamText} read the whole regular text file (the
* stream for large files); both own regular-file checks, UTF-8 decoding,
* binary/NUL rejection, and `FS_NOT_TEXT`.
* - {@link listDir} returns direct children of a directory in stable name order
* with resolved child targets and cheap metadata only. It never reads file
* contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw
* `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and
* other backend I/O failures throw `FS_IO_ERROR`.
* - {@link writeText} is atomic temp-file + rename. `expected` is OPTIONAL:
* omit it for an unconditional create-or-overwrite (the bare-provider default),
* or supply a {@link FsWriteIntent} to guard the write.
* - {@link editText} verifies `expected.version` BEFORE literal matching (so a
* stale edit reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND`/
* `FS_AMBIGUOUS_EDIT` against newer content), then applies literal replacement
* and writes atomically — all inside one mutation critical section. `expected`
* is OPTIONAL: omit it for an unconditional edit of the current content (a
* missing target still reports `FS_STALE_VERSION`).
* Abstract filesystem provider service. Subclass, implement the seven storage primitives, and
* load the subclass as a plugin — it registers as `ctx.fs` (one implementation per context;
* loading a second throws, cordis' standard duplicate-service behavior).
*/
export abstract class FileSystem extends Service {
constructor(ctx: Context) {
@@ -175,18 +81,10 @@ export abstract class FileSystem extends Service {
}
/**
* Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May
* perform I/O (a remote/sandboxed backend may need a round-trip to map a path
* to a stable identity), hence async even though the local backend only
* normalizes + realpaths.
* Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May perform I/O (a
* remote/sandboxed backend may need a round-trip to map a path to a stable identity), hence
* async even though the local backend only normalizes + realpaths.
*
* `opts.cwd` is the base directory a RELATIVE `path` resolves against; an
* absolute `path` ignores it. Omitted ⇒ the backend's own default base (the
* local backend uses its configured `cwd`). The CALLER supplies this — the
* seam does not read a session or agent — so a tool can resolve against the
* caller's per-session workspace (`exec.agent.session.header.cwd`) without the
* provider depending on `dsh-agent`/`dsh-session`. Mirrors how `dsh-tool-bash`
* defaults a bash `workdir` to the session cwd.
* @param path - the path to resolve; relative paths resolve against `opts.cwd`.
* @param opts - `cwd` overrides the backend's default base for relative paths.
* @returns the stable target; the same file yields the same `targetKey`.
@@ -243,11 +141,8 @@ export abstract class FileSystem extends Service {
abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
/**
* Apply a literal edit to an existing UTF-8 text file. When `expected` is
* supplied, verifies `expected.version` as the stale guard BEFORE literal
* matching; OMITTING it edits the current content unconditionally (no version
* guard). Either way applies the replacement and writes atomically — one
* mutation critical section — and a missing target reports `FS_STALE_VERSION`.
* Apply a literal edit to an existing UTF-8 text file.
*
* @param target - the resolved target to edit.
* @param edit - the literal search/replace request.
* @param expected - the version guard; omit for an unconditional edit.

View File

@@ -1,21 +1,7 @@
/**
* Vocabulary for the filesystem provider seam (`ctx.fs`): the opaque
* target/version identities, the metadata `stat` returns, the write-intent
* and outcome shapes, the literal-edit request/outcome, and the typed error
* taxonomy.
*
* These types are shared by every backend (`@deepseek-ai/dsh-fs-local` and
* future sandboxed/remote backends) and by the policy layer
* (`@deepseek-ai/dsh-fs-policy`). They are deliberately a *text-storage*
* vocabulary half a level above byte-level fsspec: `readText`/`streamText` hand
* back decoded text, never raw bytes. Host-path assumptions stay out — `targetKey`
* and `version` are opaque branded tokens, and `displayPath` is the only field a
* consumer may show.
*
* Model-facing concepts (line windows, numbered lines, observed-state) do NOT
* live here; they belong to the consumer tool and the policy plugin
* (`@deepseek-ai/dsh-tool-fs` / `@deepseek-ai/dsh-fs-policy`).
*
* Vocabulary for the filesystem provider seam (`ctx.fs`): the opaque target/version
* identities, the metadata `stat` returns, the write-intent and outcome shapes, the
* literal-edit request/outcome, and the typed error taxonomy.
* @module @deepseek-ai/dsh-fs/types
*/
@@ -104,17 +90,11 @@ export interface FsDirEntry {
}
/**
* The explicit intent of a guarded {@link FileSystem.writeText} call.
* `createIfAbsent` creates a missing target and rejects an existing one with
* `FS_NOT_OBSERVED` (the path the policy plugin uses when the owner has no prior
* read). `replaceIfVersion` replaces only when the target exists at the observed
* version; a missing target or a version mismatch throws `FS_STALE_VERSION`.
*
* `writeText` takes this OPTIONALLY: omitting `expected` is the third,
* unconstrained state — an unconditional create-or-overwrite (the bare
* provider). The union itself carries only the two GUARDED intents; "no guard"
* is expressed by omission, so the write and edit mutations share one symmetric
* shape (`expected?`: omit = unconditional, present = guarded).
* The explicit intent of a guarded {@link FileSystem.writeText} call. `createIfAbsent` creates
* a missing target and rejects an existing one with `FS_NOT_OBSERVED` (the path the policy
* plugin uses when the owner has no prior read). `replaceIfVersion` replaces only when the
* target exists at the observed version; a missing target or a version mismatch throws
* `FS_STALE_VERSION`.
*/
export type FsWriteIntent =
| { kind: 'createIfAbsent' }

View File

@@ -1,14 +1,5 @@
/**
* Result-time contextual-diff computation for the `write`/`edit` tools. Turns a
* before/after pair of file texts into one {@link FileDiff} per applied hunk —
* each hunk's `oldText`/`newText` reconstructed from the unified-diff lines with
* ±{@link DIFF_CONTEXT} surrounding context lines, matching how claude-agent-acp
* renders an editor inline diff.
*
* This is display-only presentation vocabulary (a UI concern), so it lives in
* the model-facing tool, NOT the `dsh-fs` storage seam — the backend returns
* only the raw before/after text (storage facts) and the tool computes the diff.
*
* Result-time contextual-diff computation for the `write`/`edit` tools.
* @module @deepseek-ai/dsh-tool-fs/src/diff
*/
@@ -29,18 +20,11 @@ export const DIFF_CONTEXT = 3
export type FsDiffMeta = { diffs: FileDiff[] }
/**
* Compute one {@link FileDiff} per hunk between `before` and `after`, each
* carrying the applied change plus {@link DIFF_CONTEXT} context lines. Returns an
* empty array when the texts are identical (no hunks). For a scattered
* `replace_all` edit the patch yields multiple hunks, so multiple `FileDiff`s
* come back — matching the editor rendering one diff block per site.
* Compute one {@link FileDiff} per hunk between `before` and `after`, each carrying the
* applied change plus {@link DIFF_CONTEXT} context lines.
*
* Each hunk's `oldText` is its `-` (removed) and context lines joined by `\n`;
* `newText` is its `+` (added) and context lines. A hunk with no old lines
* (a pure insertion) reports `oldText: null` (nothing to diff against), mirroring
* the call-time card's new-file convention. The unified-diff "\ No newline at end
* of file" markers are dropped — they annotate the patch, not file content.
* @param path - the path stamped on every produced diff (the model-facing `file_path`; the bridge relativizes it).
* @param path - the path stamped on every produced diff (the model-facing `file_path`; the
* bridge relativizes it).
* @param before - the file text before the change (the backend's LF-normalized diff basis).
* @param after - the file text after the change, on the same basis.
* @returns one diff per applied hunk, in file order; empty when the texts are identical.
@@ -81,14 +65,9 @@ function isFileDiff(value: unknown): value is FileDiff {
}
/**
* Narrow an opaque `tool/result` `meta` back to this tool's {@link FileDiff}
* hunks, or `undefined` when it is absent/malformed. `presentResult` runs on
* arbitrary logged `meta` (possibly from an older shape or a hand-edited log), so
* it validates defensively rather than trusting the payload — a bad `meta` yields
* `undefined`, and the caller decides the fallback (edit → the generic result
* rendering; write → an args-derived whole-file diff), never a thrown presenter.
* @param meta - the opaque `tool/result` meta payload (live or replayed from the session log).
* @returns the validated non-empty hunk list, or undefined for an absent/empty/malformed payload.
* Narrow opaque live or replayed result metadata to non-empty file diffs.
* @param meta - result metadata.
* @returns validated hunks, or `undefined` for absent or malformed data.
*/
export function diffsFromMeta(meta: unknown): FileDiff[] | undefined {
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined

View File

@@ -1,14 +1,6 @@
/**
* The model-facing `edit` tool: update an existing UTF-8 text file by replacing
* literal text, requiring a unique match by default. The tool is the executor:
* it dispatches the `fs/edit-intent` waterfall to obtain the optional
* version guard, calls `ctx.fs.editText` directly, and emits `fs/observed`. The
* default thunk returns `undefined` (unconditional edit of the current content
* — the bare provider); a policy plugin (`@deepseek-ai/dsh-fs-policy`)
* occupies the single decision slot, returning `{ version: vObserved }` or
* throwing `FS_NOT_OBSERVED` for an unread file. The tool stats ZERO times
* either way; a missing target is reported by the provider as `FS_STALE_VERSION`.
*
* The model-facing `edit` tool: update an existing UTF-8 text file by replacing literal text,
* requiring a unique match by default.
* @module @deepseek-ai/dsh-tool-fs/src/edit
*/
@@ -96,22 +88,16 @@ export function applyEditTool(ctx: Context): void {
)
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)
// The result-time applied-hunk diff (before→after with context lines). An
// edit always changes content (parseEditArgs requires old_string to differ
// and editText matches at least once), so there is always at least one hunk.
// The bridge renders these as an inline diff that supersedes the call-time
// snippet; the display path is the model-facing `file_path` (the bridge
// relativizes it).
// The result-time applied-hunk diff (before→after with context lines).
const diffs = computeHunkDiffs(input.filePath, outcome.before, outcome.after)
return {
content: [{ type: 'text', text: formatEditOutput(target.displayPath, input.replaceAll) }],
meta: { diffs },
}
},
// Pure display: a diff card of the literal replacement (old_string →
// new_string), derived from the call args. `oldText: old_string || null`
// matches claude-agent-acp's Edit arm; new_string is a required arg here, so
// it maps straight to newText. A follow-along location points at the file.
// Pure display: a diff card of the literal replacement (old_string → new_string), derived
// from the call args. `oldText: old_string || null` matches claude-agent-acp's Edit arm;
// new_string is a required arg here, so it maps straight to newText.
presentCall(args): DiffCallView {
return {
card: 'diff',
@@ -121,9 +107,6 @@ export function applyEditTool(ctx: Context): void {
}
},
// Result-time display: the applied contextual-diff hunks carried on `meta`.
// On success with diffs, a `diff` result card supersedes the call-time
// snippet; on error (nothing applied) or malformed meta, fall through to the
// generic "updated successfully" rendering.
presentResult(args, result: ToolResult): DiffResultView | undefined {
if (result.isError) return undefined
const diffs = diffsFromMeta(result.meta)

View File

@@ -1,24 +1,6 @@
/**
* The model-facing filesystem tool suite (`read`, `write`, `edit`) over the
* `ctx.fs` provider seam. This single plugin registers all three tools.
*
* ## The tool is the executor; policy is an event gate
*
* The tool reads/writes/edits through `ctx.fs` DIRECTLY and owns model-facing
* concerns only — tool names, JSON schemas, argument validation, prompt
* sections, read windowing, result formatting. It does NOT inject a policy
* service. Instead, on each write/edit it dispatches a single-slot waterfall
* (`fs/write-intent`/`fs/edit-intent`) to obtain the OPTIONAL version guard, and
* after every read/write/edit it emits `fs/observed` with a plain (unguarded)
* `ctx.emit`. A policy plugin (`@deepseek-ai/dsh-fs-policy`) occupies the
* decision slot and listens for `fs/observed` to add observed-state +
* read-before-edit + version-guarded write/edit; a deployment that loads these
* tools is expected to also load it. With no policy plugin the waterfalls fall
* through to their `undefined` default (the unconstrained bare provider) and
* `fs/observed` is unheard — the tool still functions. This package never
* imports `node:fs`, `node:path`, or an `@deepseek-ai/dsh-fs-local`
* implementation.
*
* The model-facing filesystem tool suite (`read`, `write`, `edit`) over the `ctx.fs` provider
* seam. This single plugin registers all three tools.
* @module @deepseek-ai/dsh-tool-fs
*/

View File

@@ -1,18 +1,7 @@
/**
* Cordis-free read rendering for `@deepseek-ai/dsh-tool-fs`: turn a file's
* decoded text into a bounded, line-numbered window (offset/limit, byte cap,
* per-line truncation) and format it as the model-facing text block. This is
* the `read` tool's RENDERING detail — not a storage primitive, not freshness
* policy — so it lives apart from the tool's I/O and event wiring as a pure,
* independently-testable module (no cordis, no filesystem).
*
* The provider (`ctx.fs.readText`/`streamText`) hands back already-decoded text
* (UTF-8 validated, binary rejected); {@link buildWindow} only scans that text
* for newlines and builds the requested window. A capped line buffer means a
* newline-free giant line can never balloon memory even when streamed.
* {@link formatReadOutput} turns the resulting {@link FileReadOutcome} into the
* `<path>/<content>` envelope the model sees.
*
* Cordis-free read rendering for `@deepseek-ai/dsh-tool-fs`: turn a file's decoded text into a
* bounded, line-numbered window (offset/limit, byte cap, per-line truncation) and format it as
* the model-facing text block.
* @module @deepseek-ai/dsh-tool-fs/read-render
*/
@@ -114,11 +103,7 @@ function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string
/**
* Build a bounded, line-numbered window from a file's decoded text chunks.
* Accepts an `AsyncIterable<string>` (a chunked `streamText`) or an
* `Iterable<string>` (a whole-file `readText` wrapped as `[text]`), so one code
* path serves both. Scans for newlines with a capped line buffer (a newline-free
* giant line is truncated, never buffered past `request.maxLineLength`),
* enforces the byte cap, and throws `FS_NOT_FOUND` for an offset past EOF.
*
* @param chunks - decoded text chunks in file order; chunk boundaries carry no meaning.
* @param request - the resolved window; the caller has already applied its defaults and caps.
* @param displayPath - the caller-facing path used in the offset-out-of-range error.

View File

@@ -1,14 +1,6 @@
/**
* The model-facing `read` tool: inspect a UTF-8 text file and return
* line-numbered content with pagination guidance. The tool is the executor — it
* stats and reads through `ctx.fs` directly, builds the line window
* ({@link module:@deepseek-ai/dsh-tool-fs/read-render}), and emits `fs/observed`
* so a policy plugin (`@deepseek-ai/dsh-fs-policy`) can record the read. With
* no policy plugin the emit is simply unheard. This module owns the
* model-facing schema, argument validation, and the read I/O; the rendering
* (windowing + formatting) lives in `read-render.ts` and the
* freshness/observation policy is not its concern.
*
* The model-facing `read` tool: inspect a UTF-8 text file and return line-numbered content
* with pagination guidance.
* @module @deepseek-ai/dsh-tool-fs/src/read
*/
@@ -98,9 +90,6 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
// One stat: type check + size routing + the version recorded as observed.
// A writer racing between this stat and the read can at worst make a LATER
// guarded edit spuriously FS_STALE_VERSION (fail-closed: re-read; editText
// re-checks the version in its lock).
const info = await ctx.fs.stat(target, exec.signal)
if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
@@ -128,12 +117,9 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
ctx.emit('fs/observed', target, info.version, exec)
return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }]
},
// Pure display: a generic card titled by the file with the read window
// appended (`Read foo.txt (5 - 8)`), `read` kind (icon), and a follow-along
// location whose line is the read's offset (defaulting to 1). The window is
// derived from the RAW args (offset/limit as the model passed them), NOT the
// tool's defaulted 1/configured limit, so an unbounded read shows a bare
// title (and the presenter stays a pure function of args, config-free).
// Pure display: a generic card titled by the file with the read window appended (`Read
// foo.txt (5 - 8)`), `read` kind (icon), and a follow-along location whose line is the
// read's offset (defaulting to 1).
presentCall(args): GenericCallView {
const { offset, limit } = args
const window = limit !== undefined && limit > 0

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