Merge remote-tracking branch 'origin/master' into codex/skill-system

This commit is contained in:
Yichen Jiang
2026-07-09 23:11:10 +08:00
438 changed files with 24091 additions and 1723 deletions

View File

@@ -11,14 +11,18 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
| [`core/`](core/README.md) | Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface |
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
| [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface |
| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface |
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface |
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) + the app packages | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, app packages, user-interaction seam, ask-user tool | Product — stable surface |
| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations |
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free |
@@ -28,6 +32,6 @@ The split is the point: a package's group says whether it is part of the product
The inter-package dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI).
The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it — swapping the loop means shipping a different bundle, not rewiring every extension. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
Each package has its own `README.md` with purpose, service API, events, extension points, and deliberate non-goals (TODOs).

View File

@@ -23,6 +23,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
@@ -30,6 +31,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -18,6 +18,7 @@ import { Context } from 'cordis'
import z from 'schemastery'
import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { DEFAULT_GRACE_MS, runBash } from './run.ts'
import type { RunInternals, RunningBash } from './run.ts'
@@ -114,8 +115,12 @@ export class LocalBashExecutor extends BashExecutor {
* values and never re-default.
*/
resolve(request: BashExecRequest): BashExecSpec {
if (request.timeoutMs !== undefined) assertPositiveFinite('request.timeoutMs', request.timeoutMs)
const timeoutMs = Math.min(request.timeoutMs ?? this.config.timeoutMs, this.config.maxTimeoutMs)
const timeoutMs = clampTimeout(
request.timeoutMs,
this.config.timeoutMs,
this.config.maxTimeoutMs,
'bash-local: request.timeoutMs',
)
return {
command: request.command,
workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
@@ -132,29 +137,39 @@ export class LocalBashExecutor extends BashExecutor {
}
async run(spec: BashExecSpec): Promise<BashRunResult> {
// One fused deadline drives both the timeout and upstream cancellation;
// runBash listens on d.signal and runs the SIGTERM→grace→SIGKILL kill.
// `using` clears the timer across the awaited process lifetime.
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
const outcome = await runBash({
command: spec.command,
cwd: spec.workdir,
timeoutMs: spec.timeoutMs,
maxOutputBytes: this.config.maxOutputBytes,
graceMs: this.config.graceMs,
signal: spec.signal,
signal: d.signal,
stdin: spec.stdin,
env: spec.env,
}, this.internals).done
return { ...outcome, timeoutMs: spec.timeoutMs }
// 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.
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). spec.timeoutMs is ignored
// here by design.
// 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).
const running = runBash({
command: spec.command,
cwd: spec.workdir,
timeoutMs: 0,
maxOutputBytes: this.config.maxOutputBytes,
graceMs: this.config.graceMs,
signal: spec.signal,
@@ -174,8 +189,10 @@ export class LocalBashExecutor extends BashExecutor {
stdoutOffset: 0,
stderrOffset: 0,
done: running.done.then((outcome) => {
// Abort-killed tasks report as killed, not completed.
if (task.status === 'running') task.status = outcome.aborted ? 'killed' : 'completed'
// Abort-killed tasks report as killed, not completed. Background runs
// forward only the upstream signal (no timeout), so its aborted state
// is the authoritative "was this cancelled" signal.
if (task.status === 'running') task.status = spec.signal?.aborted === true ? 'killed' : 'completed'
task.exitCode = outcome.exitCode
task.signal = outcome.signal
this.notifyTaskDone(task)

View File

@@ -6,6 +6,12 @@
* 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
@@ -56,6 +62,8 @@ export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
* 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.
*/
export function childEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {}
@@ -69,13 +77,17 @@ export function childEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
export interface SpawnSpec {
command: string
cwd: string
/** Kill the process group after this many milliseconds. 0 = no timeout. */
timeoutMs: number
/** Per-stream in-memory cap; overflow spills to disk (tail kept in memory). */
maxOutputBytes: number
/** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
graceMs: number
/** Abort signal — kills the process group when fired. */
/**
* Abort signal — kills the process group when it fires. The executor owns
* timing: `run()` passes a fused timeout/cancel deadline signal (see
* `@deepseek-ai/dsh-timeout`), `start()` passes the bare upstream signal.
* runBash only listens and kills; it does NOT classify why (the executor
* reads the signal's reason afterward).
*/
signal?: AbortSignal | undefined
/**
* Bytes to write to the child's stdin, then close it. Absent (or empty)
@@ -92,12 +104,15 @@ export interface SpawnSpec {
env?: Record<string, string> | undefined
}
/** Raw outcome of one closed process (before result shaping). */
/**
* Raw outcome of one closed process (before result shaping). Deliberately
* carries NO timeout/cancel classification: runBash kills on abort but does not
* decide why — the executor's `run()`/`start()` reads the deadline signal it
* owns to classify `timedOut`/`aborted` (see the package README).
*/
export interface SpawnOutcome {
exitCode: number | null
signal: NodeJS.Signals | null
timedOut: boolean
aborted: boolean
stdout: CollectedOutput
stderr: CollectedOutput
}
@@ -147,6 +162,14 @@ export class OutputCollector {
private readonly spillDir: string,
) {}
/**
* Ingest one stream chunk, counting it toward the whole-stream total. On
* first overflow of the in-memory cap a spill file is opened and every chunk
* (already-collected ones included) is appended there from then on; the
* in-memory tail then drops whole chunks from its head (or the head of a
* single over-cap chunk) until it fits the cap again.
* @param chunk - the raw bytes from one stream 'data' event.
*/
push(chunk: Buffer): void {
this.total += chunk.length
const overflows = this.bytes + chunk.length > this.maxBytes
@@ -190,7 +213,10 @@ export class OutputCollector {
// the bottom of this file) and `totalBytes` is read only by a test. The live
// background-poll path goes through `readFrom()`, so inline snapshot() into
// finalize() and drop or privatize the totalBytes getter.
/** Read the collected tail without finalizing (the final-result snapshot). */
/**
* Read the collected tail without finalizing (the final-result snapshot).
* @returns the retained tail text, the truncation flag, and the spill path when one was created.
*/
snapshot(): CollectedOutput {
return {
text: Buffer.concat(this.chunks).toString('utf8'),
@@ -209,6 +235,8 @@ export class OutputCollector {
* pushed since `fromByte`. When `fromByte` has already slid out of the
* in-memory tail window, the read is `lossy` — it returns the whole
* retained tail and the gap is only recoverable from the spill file.
* @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
* @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created.
*/
readFrom(fromByte: number): { text: string; nextOffset: number; lossy: boolean; spillPath?: string } {
const windowStart = this.total - this.bytes
@@ -223,7 +251,12 @@ export class OutputCollector {
}
}
/** Close the spill file (if any) and return the final output. */
/**
* Close the spill file (if any) and return the final output. A failed close
* (delayed writeback fault) stops advertising the spill path — the file may
* be missing its tail — but still returns the in-memory result.
* @returns the final collected output: tail text, truncation flag, and the spill path when intact.
*/
finalize(): CollectedOutput {
if (this.spillFd !== undefined) {
try {
@@ -249,6 +282,8 @@ export class OutputCollector {
* 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).
* @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.
*/
export function killGroup(pid: number, sig: NodeJS.Signals): void {
if (pid <= 0) return
@@ -290,6 +325,9 @@ export interface RunningBash {
* 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()`.
*/
export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash {
const spillDir = internals.spillDir ?? privateSpillDir()
@@ -318,9 +356,6 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) })
child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) })
let timedOut = false
let aborted = false
let killTimer: NodeJS.Timeout | undefined
let graceTimer: NodeJS.Timeout | undefined
// pid is undefined when the spawn itself fails (bad cwd, missing binary);
@@ -333,17 +368,12 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs)
}
if (spec.timeoutMs > 0) {
killTimer = setTimeout(() => {
timedOut = true
kill()
}, spec.timeoutMs)
}
const onAbort = (): void => {
aborted = true
kill()
}
// 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.
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
@@ -376,14 +406,11 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
resolve({
exitCode,
signal,
timedOut,
aborted,
stdout: stdout.finalize(),
stderr: stderr.finalize(),
})
})
function cleanup(): void {
if (killTimer !== undefined) clearTimeout(killTimer)
if (graceTimer !== undefined) clearTimeout(graceTimer)
spec.signal?.removeEventListener('abort', onAbort)
}

View File

@@ -40,12 +40,14 @@ async function readUntil(
): Promise<BashTaskRead> {
const deadline = Date.now() + timeoutMs
let last: BashTaskRead | undefined
let delta = ''
while (Date.now() < deadline) {
last = bash.readOutput(id)
if (last.delta.includes(expected)) return last
delta += last.delta
if (delta.includes(expected)) return { ...last, delta }
await new Promise(resolve => setTimeout(resolve, 20))
}
throw new Error(`task ${id} output did not include ${JSON.stringify(expected)}; last delta was ${JSON.stringify(last?.delta ?? '')}`)
throw new Error(`task ${id} output did not include ${JSON.stringify(expected)}; output was ${JSON.stringify(delta)}, last delta was ${JSON.stringify(last?.delta ?? '')}`)
}
describe('LocalBashExecutor.run', () => {
@@ -90,8 +92,8 @@ describe('LocalBashExecutor.run', () => {
it('kill escalation uses the configured graceMs (a TERM-trapping task dies by SIGKILL)', async () => {
const { bash } = await setup() // setup pins graceMs: 200 via config
const task = bash.start(bash.resolve({ command: 'trap \'\' TERM; sleep 60' }))
await new Promise(resolve => setTimeout(resolve, 100))
const task = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done' }))
await readUntil(bash, task.id, 'ready\n')
bash.kill(task.id)
await task.done
expect(task.signal).toBe('SIGKILL')
@@ -101,6 +103,8 @@ describe('LocalBashExecutor.run', () => {
const { bash } = await setup({ timeoutMs: 60_000 })
const result = await bash.run(bash.resolve({ command: 'sleep 60', timeoutMs: 100 }))
expect(result.timedOut).toBe(true)
// Mutually exclusive: a timeout classifies as timedOut, never also aborted.
expect(result.aborted).toBe(false)
expect(result.timeoutMs).toBe(100)
})
@@ -111,6 +115,20 @@ describe('LocalBashExecutor.run', () => {
setTimeout(() => { controller.abort() }, 50)
const result = await pending
expect(result.aborted).toBe(true)
// Mutually exclusive: an upstream cancel classifies as aborted, never also timedOut.
expect(result.timedOut).toBe(false)
})
it('classifies a self-killed command as neither timed out nor aborted', async () => {
// The command kills itself (SIGTERM) with no timeout and no upstream abort:
// the deadline signal never fires, so both classifications are false — the
// fused-signal classification reports the cause that cut the command short,
// and here nothing the executor owns did.
const { bash } = await setup({ timeoutMs: 60_000 })
const result = await bash.run(bash.resolve({ command: 'kill -TERM $$' }))
expect(result.signal).toBe('SIGTERM')
expect(result.timedOut).toBe(false)
expect(result.aborted).toBe(false)
})
it('rejects on spawn failure (bad workdir)', async () => {

View File

@@ -26,7 +26,6 @@ function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]>
return {
command,
cwd: process.cwd(),
timeoutMs: 0,
maxOutputBytes: 64_000,
graceMs: 3_000,
...overrides,
@@ -56,13 +55,25 @@ async function waitForStdout(running: RunningBash, expected: string, timeoutMs =
throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`)
}
async function waitForPidFile(path: string, timeoutMs = 5_000): Promise<number> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
const pid = Number(readFileSync(path, 'utf8').trim())
if (Number.isSafeInteger(pid) && pid > 0) return pid
} catch {
// The child shell has not written the pid file yet.
}
await new Promise(resolve => setTimeout(resolve, 20))
}
throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`)
}
describe('runBash', () => {
it('captures stdout on success', async () => {
const result = await runBash(spec('echo hello')).done
expect(result.exitCode).toBe(0)
expect(result.signal).toBeNull()
expect(result.timedOut).toBe(false)
expect(result.aborted).toBe(false)
expect(result.stdout.text).toBe('hello\n')
expect(result.stdout.truncated).toBe(false)
expect(result.stderr.text).toBe('')
@@ -97,17 +108,22 @@ describe('runBash', () => {
expect(result.stdout.text.trim()).toMatch(/\/tmp$/)
})
it('kills with SIGTERM on timeout', async () => {
it('kills the process group with SIGTERM when the signal fires', async () => {
// runBash owns no timer: it kills on abort. The executor drives the timeout
// by firing this signal via a deadline (see executor.spec.ts); here we
// assert the kill itself lands as SIGTERM.
const controller = new AbortController()
const start = Date.now()
const result = await runBash(spec('sleep 60', { timeoutMs: 100 })).done
const running = runBash(spec('sleep 60', { signal: controller.signal }))
setTimeout(() => { controller.abort('deadline') }, 100)
const result = await running.done
expect(Date.now() - start).toBeLessThan(5_000)
expect(result.timedOut).toBe(true)
expect(result.signal).toBe('SIGTERM')
expect(result.exitCode).toBeNull()
})
it('escalates to SIGKILL when SIGTERM is trapped', async () => {
const running = runBash(spec('trap \'\' TERM; echo ready; sleep 60', { graceMs: 200 }))
const running = runBash(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 }))
await waitForStdout(running, 'ready\n')
running.kill()
const result = await running.done
@@ -119,8 +135,7 @@ describe('runBash', () => {
// group must take the sleep down with bash.
const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`)
const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
await new Promise(resolve => setTimeout(resolve, 300))
const grandchild = Number(readFileSync(pidFile, 'utf8').trim())
const grandchild = await waitForPidFile(pidFile)
expect(grandchild).toBeGreaterThan(0)
running.kill()
@@ -134,7 +149,6 @@ describe('runBash', () => {
const running = runBash(spec('sleep 60', { signal: controller.signal }))
setTimeout(() => { controller.abort('user cancelled') }, 50)
const result = await running.done
expect(result.aborted).toBe(true)
expect(result.signal).toBe('SIGTERM')
})
@@ -211,7 +225,6 @@ describe('stdin and extra env (set by in-process plugins)', () => {
const big = 'x'.repeat(1024 * 1024)
const result = await runBash(spec('exit 7', { stdin: big })).done
expect(result.exitCode).toBe(7)
expect(result.aborted).toBe(false)
})
})
@@ -339,11 +352,11 @@ describe('abort edge cases', () => {
.toThrow(/aborted before spawn: aborted/)
})
it('reports an externally self-killed command without the timeout marker', async () => {
it('reports the terminating signal of an externally self-killed command', async () => {
// runBash reports the raw signal; whether it counts as timeout/cancel is the
// executor's classification (a self-kill is neither) — see executor.spec.ts.
const result = await runBash(spec('kill -TERM $$')).done
expect(result.signal).toBe('SIGTERM')
expect(result.timedOut).toBe(false)
expect(result.aborted).toBe(false)
})
})
@@ -396,10 +409,9 @@ describe('review fixes: env scrubbing and spill hardening', () => {
it('honors AbortSignal on background-style runs (no timeout)', async () => {
const controller = new AbortController()
const running = runBash(spec('sleep 60', { timeoutMs: 0, signal: controller.signal }))
const running = runBash(spec('sleep 60', { signal: controller.signal }))
setTimeout(() => { controller.abort() }, 50)
const result = await running.done
expect(result.aborted).toBe(true)
expect(result.signal).toBe('SIGTERM')
})
})

View File

@@ -20,6 +20,9 @@
{
"path": "../../util/brand"
},
{
"path": "../../util/timeout"
},
{
"path": "../../bash/bash"
}

View File

@@ -11,7 +11,11 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
/** Identifies one background task within an executor (generated `bash-N`). */
export type BashTaskId = Branded<'BashTaskId'>
/** Brand a string as a {@link BashTaskId}. */
/**
* Brand a string as a {@link BashTaskId}.
* @param id - the raw task-id string (the executor generates `bash-N`).
* @returns the same string, branded; no validation is performed.
*/
export function BashTaskId(id: string): BashTaskId {
return id as BashTaskId
}
@@ -26,7 +30,12 @@ export function BashTaskId(id: string): BashTaskId {
*/
export type OwnerToken = Branded<'OwnerToken'>
/** Brand a string as an {@link OwnerToken}. */
/**
* Brand a string as an {@link OwnerToken}. Only the consuming boundary
* (`dsh-tool-bash`) should cast its own id vocabulary in — see the type's doc.
* @param id - the consumer's raw owner identity (the tool layer passes the owning agent's session id).
* @returns the same string, branded; no validation is performed.
*/
export function OwnerToken(id: string): OwnerToken {
return id as OwnerToken
}

View File

@@ -99,6 +99,8 @@ function streamText(output: CollectedOutput): string {
* 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.
* @param result - the completed foreground run from the executor.
* @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): string {
const out = streamText(result.stdout)

View File

@@ -0,0 +1,10 @@
# code-runtime/ — code-execution capability family
The code-execution capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's Code Mode, specified alongside the seam in the [Code Mode RFC](../../docs/rfc/proposed/feature/2026-06-15-code-mode.md). **Product** packages.
| Package | Role | ctx key |
|---|---|---|
| `code-runtime/` | Abstract code-execution seam (interface + vocabulary) | `ctx.codeRuntime` |
| [`code-runtime-worker/`](code-runtime-worker/README.md) | Worker-thread backend: fresh worker per run, TypeScript via host-side type-strip (annotations advisory, never type-checked), port-bridged bindings, budget/heap containment | registers `ctx.codeRuntime` |
The interface lives at `code-runtime/code-runtime/`; the shipped backend at `code-runtime/code-runtime-worker/`. Backends differ by execution substrate (worker thread, process, container) and by source language — both readonly descriptors on the service — and register `ctx.codeRuntime` without touching the interface or its consumer; that split is what makes a hardened backend a drop-in later.

View File

@@ -0,0 +1,32 @@
# @deepseek-ai/dsh-code-runtime-worker
Worker-thread implementation of the [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam: `WorkerCodeRuntime` runs each program in ONE fresh Node `worker_threads.Worker` — TypeScript in, type-stripped host-side, bindings bridged over the message port, `{ value, logs, error? }` out. **Containment, not a security boundary**: trust posture is bash-equivalent by design (the [Code Mode RFC](../../../docs/rfc/proposed/feature/2026-06-15-code-mode.md) § Trust posture), with containment bash does not have — separate isolate, empty environment, heap cap, hard termination.
## Config
```yaml
- id: code-runtime
name: '@deepseek-ai/dsh-code-runtime-worker'
config:
computeMs: 60000 # busy-time budget (measured event-loop active time)
maxWallMs: 600000 # wall-clock ceiling; never pauses for anything
maxLogBytes: 65536 # shared byte budget for captured log text
maxValueBytes: 32768 # rendered-completion-value cap
maxOldGenerationSizeMb: 512 # worker heap cap (resourceLimits)
```
Every field is validated (positive numbers) and defaulted; there are no other tunables.
## Design
- **One fresh worker per run, no pooling** — a program's world dies with its worker: no cross-run state to log, state bleed unrepresentable, runs reconstructable from the session log alone.
- **Type-strip host-side, in execution context** — the program is wrapped in an async-function shell, stripped with `node:module`'s `stripTypeScriptTypes` (erasable syntax only — `enum`/namespaces are rejected as a program `exception` and no worker spawns), and sliced back out byte-positioned; it then executes as the body of an `AsyncFunction`, so top-level `await`/`return` work.
- **The port assumes a hostile peer** — model code can reach `parentPort` and forge traffic, so every inbound message is shape-validated and REBUILT before anything reads it (`null`, primitives, junk types, and malformed payloads drop without a throw; forged extra fields never ride along), the host answers each call id at most once, resolves binding names as OWN properties only (a forged `constructor` cannot walk a prototype chain), drops post-settlement replies, and converts a non-cloneable binding resolution into an error reply. Forged `log`/`done` messages cannot bypass the caps: one host-side ledger bounds everything that lands in `logs`, and the completion value is re-capped host-side. Worker-side namespaces are null-prototype with `defineProperty`, so `__proto__`-shaped binding names are ordinary keys.
- **Two independent budgets, because the peer is hostile** — `computeMs` meters the worker's MEASURED busy time (`worker.performance.eventLoopUtilization()` polling): a hot loop cannot hide behind a pending decoy dispatch, and a program awaiting a slow tool accrues nothing. `maxWallMs` backstops what busy time cannot see (awaiting a promise nobody resolves). Both funnel into `worker.terminate()`, which ends hot synchronous loops too; heap overflow surfaces as the worker's OOM exit (`kind: 'worker-exit'`).
- **Logs stream eagerly** — console/stdout/stderr entries cross the port as they happen, so a timed-out or killed program still shows what it printed. ONE shared `maxLogBytes` ledger bounds everything: streamed entries, forged port traffic, and pipe bytes that bypass the patched streams (appended after), with the overflow marked in-band once.
- **Empty environment** — the worker gets `env: {}` and `execArgv: []`: no ambient credentials (stronger than the scrubbed-env rule for spawned commands) and no inherited loader flags.
- **Dispose to quiescence** — teardown fails in-flight runs as `abort` and AWAITS each worker's exit before resolving.
## The worker entry, unbuilt and built
`worker.ts` is deliberately erasable-only TypeScript with type-only cross-package imports: unbuilt (vitest/tsx), the host spawns `src/worker.ts` directly and Node's native type stripping loads it; built, the entry ships as the sibling bundle `lib/worker.js` (its own tsdown entry). The built path is pinned by `tests/built-lib.e2e.ts`, the real-load-path guard from [docs/testing.md](../../../docs/testing.md).

View File

@@ -0,0 +1,36 @@
{
"name": "@deepseek-ai/dsh-code-runtime-worker",
"description": "Worker-thread implementation of the DeepSeek Harness code-execution seam",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/worker.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-code-runtime": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-code-runtime": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,301 @@
/**
* 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.
*
* @module @deepseek-ai/dsh-code-runtime-worker/src/bootstrap
*/
import { inspect } from 'node:util'
import { serialize } from 'node:v8'
import type { CodeLogEntry } from '@deepseek-ai/dsh-code-runtime'
import { logTruncationMarker } from './protocol.ts'
import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
/** The port surface the bootstrap needs — satisfied by `parentPort` and by the tests' fake. */
export interface BootstrapPort {
postMessage(message: WorkerToHost): void
on(event: 'message', listener: (message: ReplyMessage) => void): void
}
/**
* A writable stream's `write` slot, as the bootstrap patches it (see
* {@link captureStreamWrites}). Method-typed so the real
* `process.stdout`/`process.stderr` (narrower chunk parameters) remain
* assignable.
*/
export interface PatchableStream {
write(chunk: unknown, ...rest: unknown[]): boolean
}
/**
* Ordered log capture under one shared byte budget, delivered to a sink as
* each entry lands (the real sink streams entries over the port eagerly, so
* captured output survives a mid-run termination). Once the budget is
* exhausted it emits exactly one in-band marker entry (on the `stderr`
* diagnostics channel) and silently drops everything after — the cap is a
* blast-radius bound, so "how much was lost" intentionally stays unmeasured.
*/
export class LogBuffer {
private remaining: number
private truncated = false
// Explicit fields, not constructor parameter properties: this module loads
// under Node's native strip-only mode, which rejects non-erasable syntax —
// and parameter properties are non-erasable.
private readonly maxBytes: number
private readonly sink: (entry: CodeLogEntry) => void
constructor(maxBytes: number, sink: (entry: CodeLogEntry) => void) {
this.maxBytes = maxBytes
this.sink = sink
this.remaining = maxBytes
}
/**
* Emit one entry to the sink, charging its text against the budget (drops + marks once exhausted).
* @param entry - the log entry to deliver.
*/
push(entry: CodeLogEntry): void {
if (this.truncated) return
const cost = Buffer.byteLength(entry.text, 'utf8')
if (cost > this.remaining) {
this.truncated = true
this.sink({ source: 'stderr', text: logTruncationMarker(this.maxBytes) })
return
}
this.remaining -= cost
this.sink(entry)
}
}
/** The five console methods the shim captures, in the seam's level vocabulary. */
const CONSOLE_LEVELS = ['log', 'info', 'warn', 'error', 'debug'] as const
/**
* A `console` replacement whose five leveled methods render their arguments
* `util.inspect`-style (matching real console formatting closely enough for
* a model to recognize its own output) into the buffer. Only these five
* exist — the program gets a deliberately small console, not Node's full
* surface.
* @param logs - the buffer every rendered line is pushed into.
* @returns the five-method console object handed to the program.
*/
export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS)[number], (...args: unknown[]) => void> {
const render = (args: unknown[]): string =>
args.map(arg => typeof arg === 'string' ? arg : inspect(arg, INSPECT_OPTIONS)).join(' ')
const shim = Object.create(null) as Record<(typeof CONSOLE_LEVELS)[number], (...args: unknown[]) => void>
for (const level of CONSOLE_LEVELS) {
shim[level] = (...args: unknown[]) => { logs.push({ source: 'console', level, text: render(args) }) }
}
return shim
}
/**
* 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.
* @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.
* @returns the restore function (the in-process tests un-patch; the real
* worker never needs to).
*/
export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream, source: 'stdout' | 'stderr'): () => void {
// The slot's VALUE is stored for restore and reassigned — never invoked
// detached, so the unbound-method concern does not apply.
// eslint-disable-next-line @typescript-eslint/unbound-method
const original = stream.write
stream.write = (chunk: unknown, ...rest: unknown[]): boolean => {
logs.push({ source, text: typeof chunk === 'string' ? chunk : String(chunk) })
// Node's optional-encoding shape: the callback is whichever of the next
// two positions holds a function (a non-function there is the encoding).
const callback = [rest[0], rest[1]].find(
(arg): arg is (error?: Error | null) => void => typeof arg === 'function',
)
if (callback) queueMicrotask(() => { callback(null) })
return true
}
return () => { stream.write = original }
}
/** Bounded inspect options: deep enough to be useful, bounded so a pathological value cannot explode the rendering. */
const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 } as const
/**
* The longest prefix of `text` whose UTF-8 encoding fits `maxBytes`, cut at
* a code-point boundary (never mid-surrogate-pair). The byte caps are BYTE
* caps — `String.prototype.slice` counts UTF-16 code units, up to 3× smaller
* than what a multibyte string actually costs across the boundary.
* @param text - the string to bound.
* @param maxBytes - the UTF-8 byte budget the prefix must fit.
* @returns the prefix (all of `text` when it already fits).
*/
export function truncateUtf8Bytes(text: string, maxBytes: number): string {
if (Buffer.byteLength(text, 'utf8') <= maxBytes) return text
let bytes = 0
let end = 0
for (const char of text) {
const cost = Buffer.byteLength(char, 'utf8')
if (bytes + cost > maxBytes) break
bytes += cost
end += char.length
}
return text.slice(0, end)
}
/**
* 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.
* @param value - the program's completion value.
* @param maxValueBytes - the byte cap for the value.
* @returns the done-message fragment: `{}` for `undefined`, else `{ value }`.
*/
export function prepareValue(value: unknown, maxValueBytes: number): { value?: unknown } {
if (value === undefined) return {}
if (typeof value === 'string') {
if (Buffer.byteLength(value, 'utf8') <= maxValueBytes) return { value }
} else {
let size: number | undefined
try {
size = serialize(value).byteLength
} catch {
// Only the verdict matters: the value has parts the structured-clone
// algorithm rejects (functions, classes, …) and must cross as its
// rendering instead.
size = undefined
}
if (size !== undefined && size <= maxValueBytes) return { value }
}
const rendered = typeof value === 'string' ? value : inspect(value, INSPECT_OPTIONS)
const capped = Buffer.byteLength(rendered, 'utf8') > maxValueBytes
? `${truncateUtf8Bytes(rendered, maxValueBytes)}… [truncated]`
: rendered
return { value: capped }
}
/** One awaited binding call's settlement handles, keyed by call id in the pending map. */
export interface PendingCall {
resolve(value: unknown): void
reject(error: Error): void
}
/**
* Route host replies into the pending-call map: each reply settles its call
* at most once, and a reply for an unknown id (stray, or a duplicate answer
* to an id already settled) is ignored. Shared wiring between
* {@link runWorkerMain} and the tests that exercise {@link makeNamespaces}
* standalone.
* @param port - the port whose `message` events carry the replies.
* @param pending - the id-keyed map of unsettled binding calls.
*/
export function wireReplies(port: BootstrapPort, pending: Map<number, PendingCall>): void {
port.on('message', (message: ReplyMessage) => {
const entry = pending.get(message.id)
if (!entry) return
pending.delete(message.id)
if (message.ok) entry.resolve(message.value)
else entry.reject(new Error(message.message))
})
}
/**
* 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.
* @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.
* @param nextId - the shared mutable id counter (worker-issued correlation ids).
* @returns one namespace object per declaration, in declaration order.
*/
export function makeNamespaces(
data: Pick<WorkerBootData, 'namespaces'>,
port: BootstrapPort,
pending: Map<number, PendingCall>,
nextId: { value: number },
): Record<string, unknown>[] {
return data.namespaces.map(({ global, names }) => {
const namespace = Object.create(null) as Record<string, unknown>
for (const name of names) {
Object.defineProperty(namespace, name, {
enumerable: true,
value: (args: unknown): Promise<unknown> => new Promise((resolve, reject) => {
const id = nextId.value++
pending.set(id, { resolve, reject })
try {
port.postMessage({ type: 'call', id, global, name, args })
} catch (error: unknown) {
pending.delete(id)
reject(new Error(`binding arguments must be structured-cloneable: ${error instanceof Error ? error.message : String(error)}`))
}
}),
})
}
return namespace
})
}
/**
* 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).
* @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).
*/
export async function runWorkerMain(
port: BootstrapPort,
data: WorkerBootData,
streams: { stdout: PatchableStream; stderr: PatchableStream },
): Promise<void> {
const logs = new LogBuffer(data.maxLogBytes, (entry) => { port.postMessage({ type: 'log', entry }) })
captureStreamWrites(logs, streams.stdout, 'stdout')
captureStreamWrites(logs, streams.stderr, 'stderr')
const pending = new Map<number, PendingCall>()
wireReplies(port, pending)
const nextId = { value: 1 }
const namespaces = makeNamespaces(data, port, pending, nextId)
const consoleShim = makeConsoleShim(logs)
let done: DoneMessage
try {
// The async function constructor, reached through an instance because
// `AsyncFunction` is not a global. The program body is strict-mode.
/* v8 ignore next -- the arrow exists only to reach the AsyncFunction constructor; it is never invoked. */
const AsyncFunction = (async () => {}).constructor as new (...args: string[]) => (...fnArgs: unknown[]) => Promise<unknown>
const fn = new AsyncFunction(...data.namespaces.map(namespace => namespace.global), 'console', `'use strict';\n${data.code}`)
const value = await fn(...namespaces, consoleShim)
done = { type: 'done', ...prepareValue(value, data.maxValueBytes) }
} catch (error: unknown) {
const message = error instanceof Error ? error.stack ?? error.message : String(error)
done = { type: 'done', error: { message } }
}
port.postMessage(done)
}

View File

@@ -0,0 +1,441 @@
/**
* 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.
*
* @module @deepseek-ai/dsh-code-runtime-worker
*/
import { Worker } from 'node:worker_threads'
import { stripTypeScriptTypes } from 'node:module'
import { Context } from 'cordis'
import z from 'schemastery'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeBindingFunction, CodeLogEntry, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import { prepareValue, truncateUtf8Bytes } from './bootstrap.ts'
import { logTruncationMarker } from './protocol.ts'
import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
export type { BootstrapPort, PatchableStream } from './bootstrap.ts'
export type { CallMessage, DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
/** Plugin config: every execution cap, changeable from `cordis.yml` (no hardcoded tunables). */
export interface Config {
/**
* Busy-time budget in milliseconds: the run fails with kind `'timeout'`
* once the worker's MEASURED event-loop active time
* (`worker.performance.eventLoopUtilization()`) exceeds this. Metering
* measured busy time — not wall time, not host-side pending-call
* bookkeeping — is what makes the budget both fair (a program awaiting a
* slow tool accrues nothing) and ungameable (a hot loop accrues whether
* or not a decoy dispatch is in flight).
*/
computeMs?: number
/**
* Wall-clock ceiling in milliseconds; never pauses for anything. The
* backstop for what busy-time cannot see (a program awaiting a promise
* nobody will resolve).
*/
maxWallMs?: number
/** Shared byte budget for captured log text (console + raw stream writes), truncation marked in-band. */
maxLogBytes?: number
/**
* Byte cap for the completion value, measured by its real cross-boundary
* size (string bytes, or structured-clone wire size); an oversized or
* non-cloneable value crosses as a capped string rendering.
*/
maxValueBytes?: number
/** The worker's max old-generation heap in MiB (`resourceLimits`); overflow kills the worker, surfacing as kind `'worker-exit'`. */
maxOldGenerationSizeMb?: number
}
/** {@link Config} after schemastery fills the defaults (every field present). */
type ResolvedConfig = Required<Config>
/**
* How often the host samples the worker's event-loop utilization for the
* `computeMs` budget. An internal cadence, not config: the only effect of
* the interval is budget-expiry granularity (a run can overshoot by up to
* one interval), and nothing a deployment could tune here improves that
* without burning host CPU.
*/
const ELU_POLL_INTERVAL_MS = 25
/** ECMAScript reserved words that cannot be async-function parameter names — rejected as binding globals. */
const RESERVED_WORDS = new Set([
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in',
'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof',
'var', 'void', 'while', 'with', 'yield', 'let', 'static', 'implements', 'interface', 'package',
'private', 'protected', 'public', 'arguments', 'eval',
])
/** Valid async-function parameter name (the binding global becomes one). */
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
/**
* The shell a program is wrapped in for the type-strip, matching the
* grammatical context it will execute in (an async function body, where
* top-level `return` and `await` are legal — a bare module parse would
* reject the `return`). Strip mode is position-preserving (removed syntax
* becomes whitespace, nothing shifts), so the wrapper survives the strip
* byte-identical and the body slices back out with the model's own
* line/column positions intact.
*/
const STRIP_WRAP = { prefix: 'async function __dsh_program__() {\n', suffix: '\n}' } as const
/** One in-flight run's host-side state, tracked for disposal. */
interface LiveRun {
worker: Worker
settle(failure: CodeRunFailure): void
finished: Promise<void>
}
/**
* The worker entry module. Source runs unbuilt (`src/worker.ts`, loadable
* directly on this repo's Node range via native type stripping — the file
* is erasable-only with type-only relative imports); the built package
* ships it as a sibling bundle (`lib/worker.js`, its own tsdown entry).
* The URL *pathname*'s extension says which world this module is in —
* pathname, because dev-time module runners (vitest) may suffix
* `import.meta.url` with a query string; relative resolution drops it.
*/
/* v8 ignore next -- the './worker.js' arm is the built-lib world, unreachable unbuilt by construction; the built-lib e2e pins it. */
const WORKER_URL = new URL(new URL(import.meta.url).pathname.endsWith('.ts') ? './worker.ts' : './worker.js', import.meta.url)
/** Render an unknown thrown value as a message, `Error` or not. */
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/** The log sources / console levels the seam vocabulary admits, as runtime sets for inbound-message validation. */
const LOG_SOURCES = new Set<string>(['console', 'stdout', 'stderr'])
const LOG_LEVELS = new Set<string>(['log', 'info', 'warn', 'error', 'debug'])
/**
* Runtime shape gate for inbound port traffic. The peer runs MODEL CODE and
* can post anything — `null`, primitives, objects with poisoned fields — so
* the compile-time `WorkerToHost` type means nothing here: everything is
* re-validated and REBUILT field by field (a forged extra field never rides
* along; a non-number call id can never be echoed into a reply). Junk returns
* `undefined` and is dropped — a throw in the host's `message` listener would
* crash the host process.
*/
function parseWorkerMessage(raw: unknown): WorkerToHost | undefined {
if (typeof raw !== 'object' || raw === null) return undefined
const m = raw as Record<string, unknown>
switch (m.type) {
case 'call': {
if (typeof m.id !== 'number' || typeof m.global !== 'string' || typeof m.name !== 'string') return undefined
return { type: 'call', id: m.id, global: m.global, name: m.name, args: m.args }
}
case 'log': {
const entry = m.entry
if (typeof entry !== 'object' || entry === null) return undefined
const e = entry as Record<string, unknown>
if (typeof e.text !== 'string') return undefined
if (typeof e.source !== 'string' || !LOG_SOURCES.has(e.source)) return undefined
if (e.level !== undefined && (typeof e.level !== 'string' || !LOG_LEVELS.has(e.level))) return undefined
return {
type: 'log',
entry: {
source: e.source as CodeLogEntry['source'],
...e.level !== undefined ? { level: e.level as Exclude<CodeLogEntry['level'], undefined> } : {},
text: e.text,
},
}
}
case 'done': {
if (m.error === undefined) return { type: 'done', ...m.value !== undefined ? { value: m.value } : {} }
const error = m.error
if (typeof error !== 'object' || error === null) return undefined
const message = (error as Record<string, unknown>).message
if (typeof message !== 'string') return undefined
return { type: 'done', ...m.value !== undefined ? { value: m.value } : {}, error: { message } }
}
default: return undefined
}
}
/**
* Headroom the host's value re-cap grants over `maxValueBytes`: exactly the
* truncation suffix {@link prepareValue} appends, so a value the WORKER
* already capped (byte-exact prefix + this marker) passes through unchanged
* instead of being marked twice.
*/
const VALUE_RENDER_SLACK = Buffer.byteLength('… [truncated]', 'utf8')
/**
* The shipped {@link CodeRuntime} backend (`ctx.codeRuntime`). Registers as
* the `codeRuntime` service; every cap comes from validated config. See the
* module doc for the containment model and the class JSDoc on the seam for
* the contract this implements (error-as-field, hostile-peer port,
* no cross-run state, dispose to quiescence).
*/
export class WorkerCodeRuntime extends CodeRuntime {
static Config: z<Config> = z.object({
computeMs: z.number().default(60_000),
maxWallMs: z.number().default(600_000),
maxLogBytes: z.number().default(65_536),
maxValueBytes: z.number().default(32_768),
maxOldGenerationSizeMb: z.number().default(512),
})
readonly language = 'typescript'
readonly isolation = 'worker-thread'
private readonly config: ResolvedConfig
private readonly live = new Set<LiveRun>()
private disposed = false
constructor(ctx: Context, config: Config) {
super(ctx)
// Schemastery filled the defaults; the cast records that. Positivity is a
// semantic check the schema's plain number type does not carry.
this.config = config as ResolvedConfig
for (const [key, value] of Object.entries(this.config)) {
if (!(Number.isFinite(value) && value > 0)) throw new Error(`dsh-code-runtime-worker: config.${key} must be a positive number, got ${String(value)}`)
}
ctx.effect(() => () => this.teardown(), 'worker code-runtime teardown')
}
/**
* Dispose to quiescence: mark the service unusable, fail every in-flight
* run as aborted, and AWAIT each worker's exit so no worker outlives the
* fiber.
*/
private async teardown(): Promise<void> {
this.disposed = true
const runs = [...this.live]
for (const run of runs) run.settle({ kind: 'abort', message: 'runtime disposed' })
await Promise.all(runs.map(run => run.finished))
}
/**
* Execute one program in a fresh worker. Program outcomes — including a
* type-strip syntax error, which never spawns a worker — resolve with
* `result.error`; the method rejects only for seam misuse (a disposed
* runtime, an invalid binding namespace).
* @param request - the program, its bindings, and the abort signal.
* @returns the run's outcome per the seam contract.
*/
async run(request: CodeRunRequest): Promise<CodeRunResult> {
if (this.disposed) throw new Error('dsh-code-runtime-worker: run() after disposal')
const bindings = this.validateBindings(request)
if (request.signal?.aborted) {
return { logs: [], error: { kind: 'abort', message: String(request.signal.reason) } }
}
let code: string
try {
const stripped = stripTypeScriptTypes(STRIP_WRAP.prefix + request.program + STRIP_WRAP.suffix)
code = stripped.slice(STRIP_WRAP.prefix.length, stripped.length - STRIP_WRAP.suffix.length)
} catch (error: unknown) {
// A program that does not survive the type-strip (syntax error,
// non-erasable syntax like `enum`) is a program failure, reported the
// same way a thrown exception would be — and no worker ever spawns.
return { logs: [], error: { kind: 'exception', message: messageOf(error) } }
}
return await this.execute(request, code, bindings)
}
/** Reject (seam misuse) malformed binding namespaces: non-identifier or reserved globals, duplicates, and the `console` collision. */
private validateBindings(request: CodeRunRequest): Map<string, Record<string, CodeBindingFunction>> {
const bindings = new Map<string, Record<string, CodeBindingFunction>>()
for (const namespace of request.bindings) {
if (!IDENTIFIER.test(namespace.global) || RESERVED_WORDS.has(namespace.global)) {
throw new Error(`dsh-code-runtime-worker: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`)
}
if (namespace.global === 'console' || bindings.has(namespace.global)) {
throw new Error(`dsh-code-runtime-worker: duplicate binding global ${JSON.stringify(namespace.global)}`)
}
bindings.set(namespace.global, namespace.functions)
}
return bindings
}
/** Spawn the worker for one validated, type-stripped run and drive it to settlement. */
private execute(
request: CodeRunRequest,
code: string,
bindings: Map<string, Record<string, CodeBindingFunction>>,
): Promise<CodeRunResult> {
const bootData: WorkerBootData = {
code,
namespaces: [...bindings].map(([global, functions]) => ({ global, names: Object.keys(functions) })),
maxLogBytes: this.config.maxLogBytes,
maxValueBytes: this.config.maxValueBytes,
}
const worker = new Worker(WORKER_URL, {
workerData: bootData,
// 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.
execArgv: [],
resourceLimits: { maxOldGenerationSizeMb: this.config.maxOldGenerationSizeMb },
// Backstop capture: the bootstrap patches JS-level writes into its own
// ordered buffer, so these pipes normally stay silent; anything that
// still arrives (native-level writes) is appended after the done logs.
stdout: true,
stderr: true,
})
return new Promise<CodeRunResult>((resolve) => {
let settled = false
const answered = new Set<number>()
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.
let logBudget = this.config.maxLogBytes
let logsTruncated = false
const admit = (entry: CodeLogEntry, sink: CodeLogEntry[]): void => {
if (logsTruncated) return
const cost = Buffer.byteLength(entry.text, 'utf8')
if (cost > logBudget) {
logsTruncated = true
sink.push({ source: 'stderr', text: logTruncationMarker(this.config.maxLogBytes) })
return
}
logBudget -= cost
sink.push(entry)
}
// No settled guard: `finish` snapshots the arrays when it resolves, so
// a chunk flushing after settlement mutates only the discarded buffers,
// and the ledger bounds that growth until the pipes close.
const captureStray = (source: 'stdout' | 'stderr') => (chunk: Buffer) => {
admit({ source, text: chunk.toString('utf8') }, strayLogs)
}
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.
let finishResolve!: () => void
const finished = new Promise<void>((done) => { finishResolve = done })
const finish = (result: Omit<CodeRunResult, 'logs'>): void => {
if (settled) return
settled = true
clearInterval(eluTimer)
clearTimeout(wallTimer)
request.signal?.removeEventListener('abort', onAbort)
this.live.delete(live)
void worker.terminate().then(() => {
finishResolve()
resolve({ ...result, logs: [...logs, ...strayLogs] })
})
}
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.
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) } } : {},
})
}
const onCall = (message: WorkerToHost): void => {
if (message.type !== 'call' || settled) return
// Hostile-peer rules: a duplicate id is ignored, an unknown name is
// answered with a failure, and a binding throw/reject becomes the
// program-side rejection — contained here, never a host crash.
if (answered.has(message.id)) return
answered.add(message.id)
const reply = (payload: ReplyMessage): void => {
if (settled) return
try {
worker.postMessage(payload)
} catch {
// The reply value failed structured clone; renegotiate as an error
// reply, which is always clone-plain. Nothing else throws here.
worker.postMessage({ type: 'reply', id: message.id, ok: false, message: 'binding resolution is not structured-cloneable' })
}
}
const record = bindings.get(message.global)
// Own-property lookup only: a forged name like 'constructor' or
// 'hasOwnProperty' must not walk the record's prototype chain and
// reach a callable the consumer never declared.
const fn = record && Object.hasOwn(record, message.name) ? record[message.name] : undefined
if (typeof fn !== 'function') {
reply({ type: 'reply', id: message.id, ok: false, message: `unknown binding ${JSON.stringify(`${message.global}.${message.name}`)}` })
return
}
void (async () => {
try {
reply({ type: 'reply', id: message.id, ok: true, value: await fn(message.args) })
} catch (error: unknown) {
reply({ type: 'reply', id: message.id, ok: false, message: messageOf(error) })
}
})()
}
worker.on('message', (raw: unknown) => {
// Parse before touching: the peer can post ANY shape, and a throw in
// this listener would crash the host process. Junk drops silently.
const message = parseWorkerMessage(raw)
if (!message) return
if (message.type === 'log' && !settled) admit(message.entry, logs)
onCall(message)
onDone(message)
})
worker.on('error', (error: Error) => {
finish({ error: { kind: 'worker-exit', message: `worker error: ${error.message}` } })
})
worker.on('exit', (exitCode: number) => {
finish({ error: { kind: 'worker-exit', message: `worker exited with code ${exitCode} before completing` } })
})
// The compute budget reads the worker's own measured busy time, so a
// hot loop expires it no matter what dispatches are in flight, while a
// program idling on a slow binding accrues nothing.
const eluTimer = setInterval(() => {
const elu = worker.performance.eventLoopUtilization()
if (elu.active > this.config.computeMs) {
finish({ error: { kind: 'timeout', message: `compute budget exhausted (${this.config.computeMs}ms busy)` } })
}
}, ELU_POLL_INTERVAL_MS)
const wallTimer = setTimeout(() => {
finish({ error: { kind: 'timeout', message: `wall-clock ceiling reached (${this.config.maxWallMs}ms)` } })
}, this.config.maxWallMs)
const onAbort = (): void => {
finish({ error: { kind: 'abort', message: String(request.signal?.reason) } })
}
request.signal?.addEventListener('abort', onAbort, { once: true })
const live: LiveRun = {
worker,
finished,
settle: (failure: CodeRunFailure) => { finish({ error: failure }) },
}
this.live.add(live)
})
}
}
export default WorkerCodeRuntime

View File

@@ -0,0 +1,78 @@
/**
* 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.
*
* @module @deepseek-ai/dsh-code-runtime-worker/src/protocol
*/
import type { CodeLogEntry } from '@deepseek-ai/dsh-code-runtime'
/** What the host hands the worker at spawn, via `workerData`. */
export interface WorkerBootData {
/** The type-stripped (plain JS) program body. */
code: string
/** Binding namespaces to materialize: the global name plus the function names (functions themselves stay host-side). */
namespaces: { global: string; names: string[] }[]
/** Shared byte budget for captured log text; exceeding it drops further entries after one in-band marker. */
maxLogBytes: number
/** Byte cap for the rendered completion value (see the value-preparation contract in bootstrap.ts). */
maxValueBytes: number
}
/** Worker → host: one bridged binding call. */
export interface CallMessage {
type: 'call'
/** Worker-issued correlation id; the host answers each id at most once and ignores duplicates. */
id: number
/** The namespace global the call targets. */
global: string
/** The function name within the namespace. */
name: string
/** The single argument, structured-clone-plain. */
args: unknown
}
/** Worker → host: one captured log entry, streamed eagerly so output survives a mid-run termination (timeout, abort, OOM). */
export interface LogMessage {
type: 'log'
entry: CodeLogEntry
}
/**
* Worker → host: the program settled. `error` carries a program exception
* (the only failure the bootstrap itself can report — budgets, aborts, and
* substrate death are observed host-side). `value` is present only on a
* clean completion that produced one (already size-capped and
* clone-safe per the bootstrap's value preparation). Logs are NOT carried
* here — they streamed eagerly as {@link LogMessage}s.
*/
export interface DoneMessage {
type: 'done'
value?: unknown
error?: { message: string }
}
/** Every message the worker sends. */
export type WorkerToHost = CallMessage | LogMessage | DoneMessage
/** Host → worker: the answer to one {@link CallMessage}. */
export type ReplyMessage =
| { type: 'reply'; id: number; ok: true; value: unknown }
| { type: 'reply'; id: number; ok: false; message: string }
/**
* The in-band marker entry text announcing that log capture stopped at the
* byte budget. Shared wire vocabulary: the worker's LogBuffer emits it when
* ITS budget exhausts, and the host emits the identical text when its own
* ledger drops an entry first (forged port traffic, stray pipe bytes) — so
* a truncated run reads the same however the cap was hit.
* @param maxBytes - the configured `maxLogBytes` the marker names.
* @returns the marker line.
*/
export function logTruncationMarker(maxBytes: number): string {
return `[dsh-code-runtime-worker] log capture truncated at ${maxBytes} bytes`
}

View File

@@ -0,0 +1,20 @@
/**
* 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.
*
* @module @deepseek-ai/dsh-code-runtime-worker/src/worker
*/
import { parentPort, workerData } from 'node:worker_threads'
import { runWorkerMain } from './bootstrap.ts'
import type { WorkerBootData } from './protocol.ts'
// A worker always has a parent port; guard loudly rather than run detached.
if (!parentPort) throw new Error('dsh-code-runtime-worker: worker entry loaded outside a worker thread')
await runWorkerMain(parentPort, workerData as WorkerBootData, { stdout: process.stdout, stderr: process.stderr })

View File

@@ -0,0 +1,273 @@
import { describe, expect, it } from 'vitest'
import { EventEmitter } from 'node:events'
import { LogBuffer, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareValue, runWorkerMain, truncateUtf8Bytes, wireReplies } from '@deepseek-ai/dsh-code-runtime-worker/src/bootstrap.ts'
import type { BootstrapPort, PatchableStream, PendingCall } from '@deepseek-ai/dsh-code-runtime-worker/src/bootstrap.ts'
import type { ReplyMessage, WorkerToHost } from '@deepseek-ai/dsh-code-runtime-worker/src/protocol.ts'
import type { CodeLogEntry } from '@deepseek-ai/dsh-code-runtime'
/**
* An in-process stand-in for the worker's parentPort: the test plays the
* HOST side — inspect what the bootstrap posted, feed replies back — so
* every line of worker-side logic runs under coverage without spawning an
* isolate (real-worker behavior is pinned by runtime.spec.ts).
*/
class FakePort implements BootstrapPort {
sent: WorkerToHost[] = []
private readonly emitter = new EventEmitter()
/** Host-scripted responder; return undefined to leave the call pending. */
respond: (message: WorkerToHost) => ReplyMessage | undefined = () => undefined
postMessage(message: WorkerToHost): void {
this.sent.push(message)
const reply = this.respond(message)
if (reply) queueMicrotask(() => this.emitter.emit('message', reply))
}
on(event: 'message', listener: (message: ReplyMessage) => void): void {
this.emitter.on(event, listener)
}
deliver(message: ReplyMessage): void {
this.emitter.emit('message', message)
}
logs(): CodeLogEntry[] {
return this.sent.filter(message => message.type === 'log').map(message => message.entry)
}
done(): WorkerToHost | undefined {
return this.sent.find(message => message.type === 'done')
}
}
function fakeStreams(): { stdout: PatchableStream; stderr: PatchableStream } {
return { stdout: { write: () => true }, stderr: { write: () => true } }
}
const BOOT = { maxLogBytes: 65_536, maxValueBytes: 32_768 }
describe('LogBuffer', () => {
it('streams entries to the sink until the byte budget, then emits one marker and drops the rest', () => {
const seen: CodeLogEntry[] = []
const buffer = new LogBuffer(10, entry => seen.push(entry))
buffer.push({ source: 'console', level: 'log', text: '12345' })
buffer.push({ source: 'console', level: 'log', text: '123456' })
buffer.push({ source: 'console', level: 'log', text: 'dropped' })
expect(seen.map(entry => entry.text)).toEqual([
'12345',
'[dsh-code-runtime-worker] log capture truncated at 10 bytes',
])
})
})
describe('makeConsoleShim', () => {
it('captures the five levels and renders non-strings inspect-style', () => {
const seen: CodeLogEntry[] = []
const shim = makeConsoleShim(new LogBuffer(1_000, entry => seen.push(entry)))
shim.log('plain', { a: 1 })
shim.info('i')
shim.warn('w')
shim.error('e')
shim.debug('d')
expect(seen.map(entry => entry.level)).toEqual(['log', 'info', 'warn', 'error', 'debug'])
expect(seen[0]?.text).toBe('plain { a: 1 }')
expect(seen.every(entry => entry.source === 'console')).toBe(true)
})
})
describe('captureStreamWrites', () => {
it('redirects writes into the buffer and restores on request', () => {
const seen: CodeLogEntry[] = []
const buffer = new LogBuffer(1_000, entry => seen.push(entry))
let underlying = ''
const stream: PatchableStream = { write: (chunk: unknown) => { underlying += String(chunk); return true } }
const restore = captureStreamWrites(buffer, stream, 'stdout')
stream.write('captured', 'utf8')
stream.write(Buffer.from('bytes'))
restore()
stream.write('after')
expect(seen.map(entry => entry.text)).toEqual(['captured', 'bytes'])
expect(seen[0]).toMatchObject({ source: 'stdout' })
expect(underlying).toBe('after')
})
it('invokes the write callback asynchronously, in both optional-encoding shapes', async () => {
const buffer = new LogBuffer(1_000, () => {})
const stream: PatchableStream = { write: () => true }
captureStreamWrites(buffer, stream, 'stdout')
const calls: (Error | null | undefined)[] = []
stream.write('two-arg', (error?: Error | null) => calls.push(error))
stream.write('three-arg', 'utf8', (error?: Error | null) => calls.push(error))
// Node's contract: the callback fires after the write call returns.
expect(calls).toEqual([])
await new Promise<void>(resolve => stream.write('awaited flush', resolve))
expect(calls).toEqual([null, null])
})
it('still fires the callback for a write the exhausted budget drops', async () => {
const buffer = new LogBuffer(4, () => {})
const stream: PatchableStream = { write: () => true }
captureStreamWrites(buffer, stream, 'stdout')
stream.write('this write overflows the budget and is dropped')
await new Promise<void>(resolve => stream.write('also dropped', resolve))
})
})
describe('prepareValue', () => {
it('omits undefined, passes small cloneable values raw', () => {
expect(prepareValue(undefined, 100)).toEqual({})
expect(prepareValue({ a: [1, 'two'] }, 100)).toEqual({ value: { a: [1, 'two'] } })
})
it('replaces a non-cloneable value with its rendering', () => {
const { value } = prepareValue({ fn: () => 1 }, 1_000)
expect(typeof value).toBe('string')
expect(value).toContain('fn')
})
it('replaces an oversized value with a truncation-marked capped rendering', () => {
const { value } = prepareValue('x'.repeat(50), 10)
expect(value).toBe(`${'x'.repeat(10)}… [truncated]`)
})
it('measures a container by its structured-clone wire size, not its bounded rendering', () => {
// The bounded inspect rendering of a huge array is tiny ("... N more
// items"), but its real cross-boundary size is not — the cap must catch
// it, replacing the value with that bounded rendering.
const huge = new Array(50_000).fill(7)
const { value } = prepareValue(huge, 1_000)
expect(typeof value).toBe('string')
expect(value).toContain('more items')
})
it('caps a multibyte string by UTF-8 bytes, not UTF-16 length', () => {
// 4 code units but 12 UTF-8 bytes: a length-counting cap would pass the
// full string through untruncated.
expect(prepareValue('€€€€', 4)).toEqual({ value: '€… [truncated]' })
})
it('caps a multibyte rendering by UTF-8 bytes too', () => {
// Wire size (24-byte string inside an array) exceeds the cap, so the
// value crosses as its rendering — whose truncation must also be
// byte-exact: "[ '" (3 bytes) + two € (6 bytes) = 9; a third € would
// overflow the 10-byte budget.
expect(prepareValue(['€€€€€€€€'], 10)).toEqual({ value: "[ '€€… [truncated]" })
})
})
describe('truncateUtf8Bytes', () => {
it('returns a fitting string whole', () => {
expect(truncateUtf8Bytes('fits', 4)).toBe('fits')
})
it('cuts at a code-point boundary, never mid-surrogate-pair', () => {
// Each 😀 is one code point, two code units, four UTF-8 bytes: a 5-byte
// budget fits exactly one — and never leaves a lone surrogate behind.
const cut = truncateUtf8Bytes('😀😀', 5)
expect(cut).toBe('😀')
expect(Buffer.byteLength(truncateUtf8Bytes('😀😀', 3), 'utf8')).toBe(0)
})
})
describe('makeNamespaces', () => {
it('exposes prototype-colliding names as ordinary own properties', async () => {
const port = new FakePort()
port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: true, value: `${message.name}-ok` } : undefined
const pending = new Map<number, PendingCall>()
wireReplies(port, pending)
const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['__proto__', 'constructor', 'toString'] }] }, port, pending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
expect(Object.getPrototypeOf(tools)).toBeNull()
await expect(tools['__proto__']?.({})).resolves.toBe('__proto__-ok')
await expect(tools['constructor']?.({})).resolves.toBe('constructor-ok')
await expect(tools['toString']?.({})).resolves.toBe('toString-ok')
})
it('rejects a non-cloneable argument without leaking the pending entry', async () => {
let firstCall = true
const throwingPort: BootstrapPort = {
// First call throws an Error (the real DataCloneError shape), the
// second a bare string — the rejection renders both.
postMessage: () => {
if (firstCall) { firstCall = false; throw new Error('DataCloneError-ish') }
throw 'raw-clone-failure'
},
on: () => {},
}
const pending = new Map<number, PendingCall>()
const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['x'] }] }, throwingPort, pending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
await expect(tools.x?.(() => 1)).rejects.toThrow(/structured-cloneable: DataCloneError-ish/)
await expect(tools.x?.(() => 1)).rejects.toThrow(/structured-cloneable: raw-clone-failure/)
expect(pending.size).toBe(0)
})
})
describe('runWorkerMain', () => {
it('runs a program end-to-end: bindings, console, return value', async () => {
const port = new FakePort()
port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: true, value: (message.args as { n: number }).n * 2 } : undefined
await runWorkerMain(port, {
...BOOT,
code: 'const doubled = await tools.double({ n: 21 }); console.log("got", doubled); return { doubled };',
namespaces: [{ global: 'tools', names: ['double'] }],
}, fakeStreams())
expect(port.logs()).toEqual([{ source: 'console', level: 'log', text: 'got 42' }])
expect(port.done()).toEqual({ type: 'done', value: { doubled: 42 } })
})
it('reports a thrown program error on the done message', async () => {
const port = new FakePort()
await runWorkerMain(port, { ...BOOT, code: 'throw new Error("boom")', namespaces: [] }, fakeStreams())
const done = port.done()
expect(done?.type).toBe('done')
expect(done?.type === 'done' ? done.error?.message : undefined).toContain('boom')
expect(done?.type === 'done' ? done.value : undefined).toBeUndefined()
})
it('renders non-Error throws and stack-less Errors on the done message', async () => {
const rawPort = new FakePort()
await runWorkerMain(rawPort, { ...BOOT, code: 'throw "raw-throw"', namespaces: [] }, fakeStreams())
expect(rawPort.done()).toEqual({ type: 'done', error: { message: 'raw-throw' } })
const barePort = new FakePort()
await runWorkerMain(barePort, { ...BOOT, code: 'const e = new Error("bare"); e.stack = undefined; throw e', namespaces: [] }, fakeStreams())
expect(barePort.done()).toEqual({ type: 'done', error: { message: 'bare' } })
})
it('surfaces a host failure reply as a program-side rejection it can catch', async () => {
const port = new FakePort()
port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: false, message: 'denied by host' } : undefined
await runWorkerMain(port, {
...BOOT,
code: 'try { await tools.x({}) } catch (error) { return `caught: ${error.message}` }',
namespaces: [{ global: 'tools', names: ['x'] }],
}, fakeStreams())
expect(port.done()).toEqual({ type: 'done', value: 'caught: denied by host' })
})
it('ignores replies for unknown pending ids', async () => {
const port = new FakePort()
port.respond = (message) => {
if (message.type !== 'call') return undefined
// Deliver a stray reply first; the real one follows.
port.deliver({ type: 'reply', id: 9_999, ok: true, value: 'stray' })
return { type: 'reply', id: message.id, ok: true, value: 'real' }
}
await runWorkerMain(port, {
...BOOT,
code: 'return await tools.x({})',
namespaces: [{ global: 'tools', names: ['x'] }],
}, fakeStreams())
expect(port.done()).toEqual({ type: 'done', value: 'real' })
})
it('captures raw stream writes through the patched process streams', async () => {
const port = new FakePort()
const streams = fakeStreams()
await runWorkerMain(port, { ...BOOT, code: 'return 1', namespaces: [] }, streams)
streams.stdout.write('never seen — already restored? no: patch persists in worker')
// The patch stays installed for the worker's lifetime; writes during the
// program landed in order. Here the program wrote nothing via streams, so
// only the post-run write above went through the patched slot.
expect(port.logs().at(-1)).toMatchObject({ source: 'stdout' })
})
})

View File

@@ -0,0 +1,55 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
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.
*/
const pkgDir = fileURLToPath(new URL('..', import.meta.url))
const built = ['lib/index.js', 'lib/worker.js'].every(file => existsSync(join(pkgDir, file)))
&& existsSync(join(pkgDir, '../code-runtime/lib/index.js'))
describe.skipIf(!built)('built lib real load path (plain node)', () => {
it('runs a TypeScript program with a binding through lib/index.js and its lib/worker.js entry', async () => {
const script = `
const { Context } = await import('cordis')
const { WorkerCodeRuntime } = await import('@deepseek-ai/dsh-code-runtime-worker')
const ctx = new Context()
await ctx.plugin(WorkerCodeRuntime, {})
const result = await ctx.codeRuntime.run({
program: 'const doubled: number = await tools.double({ n: 21 }); console.log("halfway", doubled); return doubled;',
bindings: [{ global: 'tools', functions: { double: async args => args.n * 2 } }],
})
console.log(JSON.stringify(result))
process.exit(0)
`
const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
const exitCode = await new Promise<number | null>(resolve => child.on('close', resolve))
expect(exitCode, `stderr:\n${stderr}`).toBe(0)
const lastLine = stdout.trim().split('\n').at(-1) ?? ''
const result = JSON.parse(lastLine) as { value?: unknown; logs: { source: string; level?: string; text: string }[]; error?: unknown }
expect(result.error).toBeUndefined()
expect(result.value).toBe(42)
expect(result.logs).toContainEqual({ source: 'console', level: 'log', text: 'halfway 42' })
})
})

View File

@@ -0,0 +1,451 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker'
import type { Config } from '@deepseek-ai/dsh-code-runtime-worker'
import type { CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
/**
* Integration suite over REAL worker threads (no mocks — workers are cheap
* and local, per docs/testing.md's real-over-mock policy). Each test builds
* a fresh context so budgets can be tuned per case.
*/
async function setup(config: Config = {}) {
const ctx = new Context()
await ctx.plugin(WorkerCodeRuntime, config)
const runtime = ctx.codeRuntime as WorkerCodeRuntime
return { ctx, runtime }
}
/** Convenience: one namespace `tools` with the given functions. */
function tools(functions: Record<string, (args: unknown) => Promise<unknown>>) {
return [{ global: 'tools', functions }]
}
describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
it('registers with the seam descriptors', async () => {
const { runtime } = await setup()
expect(runtime.language).toBe('typescript')
expect(runtime.isolation).toBe('worker-thread')
})
it('runs TypeScript (erasable syntax), captures console/stdout in order, returns the value', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
interface Point { x: number; y: number }
const p: Point = { x: 1, y: 2 } as Point;
console.log('point', p);
process.stdout.write('raw-out\\n');
console.warn('careful');
return p.x + p.y;
`,
bindings: [],
})
expect(result.error).toBeUndefined()
expect(result.value).toBe(3)
expect(result.logs.map(entry => [entry.source, entry.level ?? null])).toEqual([
['console', 'log'],
['stdout', null],
['console', 'warn'],
])
expect(result.logs[0]?.text).toBe('point { x: 1, y: 2 }')
})
it('bridges binding calls both ways and rejects the program-side call on a host rejection', async () => {
const { runtime } = await setup()
const calls: unknown[] = []
const result = await runtime.run({
program: `
const first = await tools.echo({ n: 1 });
let caught = '';
try { await tools.fail({}) } catch (error) { caught = error.message }
let caughtRaw = '';
try { await tools.failRaw({}) } catch (error) { caughtRaw = error.message }
return { first, caught, caughtRaw };
`,
bindings: tools({
echo: async (args) => { calls.push(args); return { echoed: args } },
fail: async () => { throw new Error('nope') },
// A non-Error throw: the host renders it, the program still catches.
failRaw: async () => { throw 'raw-nope' },
}),
})
expect(result.error).toBeUndefined()
expect(result.value).toEqual({ first: { echoed: { n: 1 } }, caught: 'nope', caughtRaw: 'raw-nope' })
expect(calls).toEqual([{ n: 1 }])
})
it('reports non-erasable syntax as an exception without spawning a worker', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'enum E { A }\nreturn 1', bindings: [] })
expect(result.error?.kind).toBe('exception')
expect(result.error?.message).toMatch(/enum|strip/i)
})
it('reports a runtime throw as an exception with the message', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'throw new Error("kaboom")', bindings: [] })
expect(result.error?.kind).toBe('exception')
expect(result.error?.message).toContain('kaboom')
})
it('gives the program an EMPTY environment', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'return JSON.stringify(process.env)', bindings: [] })
expect(result.value).toBe('{}')
})
it('replaces a non-cloneable return value with a string rendering', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'return { f: () => 1 }', bindings: [] })
expect(typeof result.value).toBe('string')
})
it('completes a program that returns nothing with no value at all', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'const x = 1', bindings: [] })
expect(result.error).toBeUndefined()
expect('value' in result).toBe(false)
})
it('keeps logs streamed before a failure', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: 'console.log("before"); throw new Error("after-log")',
bindings: [],
})
expect(result.error?.kind).toBe('exception')
expect(result.logs.map(entry => entry.text)).toContain('before')
})
})
describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
it('ends a hot loop at the compute budget — including behind a pending decoy dispatch', async () => {
const { runtime } = await setup({ computeMs: 300, maxWallMs: 30_000 })
const result = await runtime.run({
// The decoy: fire a call at a never-resolving binding WITHOUT awaiting,
// then spin. Host-side pending-call bookkeeping would pause a naive
// budget here; measured busy time cannot be fooled.
program: 'void tools.slow({}); for (;;) {}',
bindings: tools({ slow: () => new Promise(() => {}) }),
})
expect(result.error?.kind).toBe('timeout')
expect(result.error?.message).toContain('compute budget')
}, 15_000)
it('does not charge time spent awaiting a slow binding against the compute budget', async () => {
const { runtime } = await setup({ computeMs: 250, maxWallMs: 30_000 })
const result = await runtime.run({
program: 'return await tools.slow({})',
bindings: tools({ slow: () => new Promise(resolve => setTimeout(() => { resolve('slow-done') }, 700)) }),
})
expect(result.error).toBeUndefined()
expect(result.value).toBe('slow-done')
}, 15_000)
it('ends an idle-forever run at the wall-clock ceiling', async () => {
const { runtime } = await setup({ computeMs: 30_000, maxWallMs: 400 })
const result = await runtime.run({
program: 'await tools.never({}); return 1',
bindings: tools({ never: () => new Promise(() => {}) }),
})
expect(result.error?.kind).toBe('timeout')
expect(result.error?.message).toContain('wall-clock ceiling')
}, 15_000)
it('reports an abort mid-run and stops the worker', async () => {
const { runtime } = await setup()
const controller = new AbortController()
setTimeout(() => { controller.abort('user-cancel') }, 150)
const result = await runtime.run({ program: 'for (;;) {}', bindings: [], signal: controller.signal })
expect(result.error).toEqual({ kind: 'abort', message: 'user-cancel' })
}, 15_000)
it('reports a pre-aborted signal without spawning', async () => {
const { runtime } = await setup()
const controller = new AbortController()
controller.abort('too-late')
const result = await runtime.run({ program: 'return 1', bindings: [], signal: controller.signal })
expect(result.error).toEqual({ kind: 'abort', message: 'too-late' })
})
it('drops a binding resolution that lands after the run settled', async () => {
const { runtime } = await setup()
const controller = new AbortController()
let replyDelivered!: Promise<void>
const result = await runtime.run({
program: 'void tools.late({}); for (;;) {}',
bindings: tools({
// Anchored on invocation: abort 100ms after the call reaches the
// host, resolve 400ms after — by then the run has settled, so the
// resolution's reply hits the post-settlement drop.
late: () => new Promise((resolve) => {
setTimeout(() => { controller.abort('cancel-now') }, 100)
replyDelivered = new Promise(done => setTimeout(() => { resolve('too-late'); done() }, 400))
}),
}),
signal: controller.signal,
})
expect(result.error).toEqual({ kind: 'abort', message: 'cancel-now' })
// Let the late resolution actually fire so its reply executes instead of
// being cancelled with the test.
await replyDelivered
}, 15_000)
it('contains an OOM under resourceLimits as worker-exit, host process healthy', async () => {
const { runtime } = await setup({ maxOldGenerationSizeMb: 32 })
const result = await runtime.run({
program: 'const hog = []; for (;;) hog.push(new Array(1e6).fill(1));',
bindings: [],
})
expect(result.error?.kind).toBe('worker-exit')
// And the host is fine: run something else.
const after = await runtime.run({ program: 'return "alive"', bindings: [] })
expect(after.value).toBe('alive')
}, 30_000)
it('truncates runaway log output at the byte budget with an in-band marker', async () => {
const { runtime } = await setup({ maxLogBytes: 300 })
const result = await runtime.run({
program: 'for (let i = 0; i < 1000; i++) console.log("spam line", i); return 1',
bindings: [],
})
expect(result.logs.at(-1)?.text).toContain('truncated at 300 bytes')
const total = result.logs.reduce((sum, entry) => sum + Buffer.byteLength(entry.text, 'utf8'), 0)
expect(total).toBeLessThan(1_000)
})
it('caps an oversized return value with a truncation marker', async () => {
const { runtime } = await setup({ maxValueBytes: 64 })
const result = await runtime.run({ program: 'return "y".repeat(10_000)', bindings: [] })
expect(result.value).toBe(`${'y'.repeat(64)}… [truncated]`)
})
it('caps a multibyte return value by UTF-8 bytes, not string length', async () => {
// 4 code units, 12 UTF-8 bytes: a length-counting cap would let the full
// string cross. The worker's byte-exact capped rendering then passes the
// host re-cap unchanged (cap + marker is exactly the granted slack).
const { runtime } = await setup({ maxValueBytes: 4 })
const result = await runtime.run({ program: 'return "€€€€"', bindings: [] })
expect(result.value).toBe('€… [truncated]')
})
it('completes a program that awaits its write callback, capturing the chunk', async () => {
// Node's write(chunk[, encoding][, callback]) contract: dropping the
// callback would leave this promise pending until the wall ceiling and
// misreport a completed program as a timeout.
const { runtime } = await setup({ maxWallMs: 2_000 })
const result = await runtime.run({
program: 'await new Promise(resolve => process.stdout.write("flushed", resolve)); return "done"',
bindings: [],
})
expect(result.error).toBeUndefined()
expect(result.value).toBe('done')
expect(result.logs).toContainEqual({ source: 'stdout', text: 'flushed' })
})
it('caps a huge container whose bounded rendering is small (wire size, not rendering, is what counts)', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'return new Array(50_000).fill(7)', bindings: [] })
expect(result.error).toBeUndefined()
expect(typeof result.value).toBe('string')
expect(result.value).toContain('more items')
})
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.
program: `
const write = (text) => Object.getPrototypeOf(process.stdout).write.call(process.stdout, text);
write('abcd');
await new Promise(resolve => setTimeout(resolve, 150));
write('ef');
await new Promise(resolve => setTimeout(resolve, 100));
return 1;
`,
bindings: [],
})
expect(result.error).toBeUndefined()
expect(result.logs).toContainEqual({ source: 'stdout', text: 'abcd' })
expect(result.logs.map(entry => entry.text)).not.toContain('ef')
}, 15_000)
})
describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
it('survives forged port traffic: unknown binding names, duplicate ids, junk shapes', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
parentPort.postMessage({ type: 'call', id: 7777, global: 'tools', name: 'missing', args: {} });
parentPort.postMessage({ type: 'call', id: 7777, global: 'tools', name: 'missing', args: {} });
parentPort.postMessage({ type: 'call', id: 7778, global: 'tools', name: 'constructor', args: {} });
parentPort.postMessage({ type: 'junk' });
return await tools.real({});
`,
bindings: tools({ real: async () => 'still-works' }),
})
expect(result.error).toBeUndefined()
expect(result.value).toBe('still-works')
})
it('survives arbitrary junk on the port: non-objects, junk types, malformed calls, logs, and dones', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
for (const junk of [
null, 42, 'junk', [],
{ type: 'nope' },
{ type: 'call' },
{ type: 'call', id: 'x', global: 'tools', name: 'real', args: {} },
{ type: 'call', id: 1e9, global: 7, name: 'real', args: {} },
{ type: 'call', id: 1e9, global: 'tools', name: 7, args: {} },
{ type: 'log' },
{ type: 'log', entry: null },
{ type: 'log', entry: { source: 'stdout', text: 7 } },
{ type: 'log', entry: { source: 'nope', text: 'x' } },
{ type: 'log', entry: { source: 'console', level: 'nope', text: 'x' } },
{ type: 'log', entry: { source: 'console', level: 7, text: 'x' } },
{ type: 'done', error: 5 },
{ type: 'done', error: { message: 5 } },
]) parentPort.postMessage(junk);
return await tools.real({});
`,
bindings: tools({ real: async () => 'still-works' }),
})
expect(result.error).toBeUndefined()
expect(result.value).toBe('still-works')
expect(result.logs).toEqual([])
})
it('caps forged log floods and forged done values at the configured budgets, dropping forged extra fields', async () => {
const { runtime } = await setup({ maxLogBytes: 200, maxValueBytes: 64 })
const result = await runtime.run({
// Forged messages bypass the worker-side LogBuffer and prepareValue
// entirely — only the host-side ledger and re-cap stand between model
// code and an unbounded result.
program: `
const { parentPort } = await import('node:worker_threads');
for (let i = 0; i < 50; i++) parentPort.postMessage({ type: 'log', entry: { source: 'stdout', text: 'F'.repeat(100), forged: true } });
parentPort.postMessage({ type: 'done', value: 'V'.repeat(100000) });
for (;;) {}
`,
bindings: [],
})
expect(typeof result.value).toBe('string')
const value = result.value as string
expect(value.startsWith('V'.repeat(64))).toBe(true)
expect(value.endsWith('… [truncated]')).toBe(true)
expect(value.length).toBeLessThan(120)
const marker = '[dsh-code-runtime-worker] log capture truncated at 200 bytes'
const total = result.logs.reduce((sum, entry) => sum + Buffer.byteLength(entry.text, 'utf8'), 0)
expect(total).toBeLessThanOrEqual(200 + Buffer.byteLength(marker, 'utf8'))
expect(result.logs.at(-1)?.text).toBe(marker)
expect(result.logs.every(entry => !('forged' in entry))).toBe(true)
})
it('accepts a forged done carrying both value and error (self-sabotage, contained)', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
parentPort.postMessage({ type: 'done', value: 'lied', error: { message: 'fake failure' } });
for (;;) {}
`,
bindings: [],
})
expect(result.value).toBe('lied')
expect(result.error).toEqual({ kind: 'exception', message: 'fake failure' })
})
it('byte-bounds forged multibyte error text at the host', async () => {
// Forged error text bypasses the worker entirely; the host bound is a
// BYTE bound (two € = 6 bytes fit an 8-byte cap, a third would not).
const { runtime } = await setup({ maxValueBytes: 8 })
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
parentPort.postMessage({ type: 'done', error: { message: '€'.repeat(1000) } });
for (;;) {}
`,
bindings: [],
})
expect(result.error).toEqual({ kind: 'exception', message: '€€' })
})
it('answers a binding whose resolution cannot be cloned with a failure reply', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: 'try { await tools.bad({}) } catch (error) { return error.message }',
bindings: tools({ bad: async () => (() => 1) }),
})
expect(result.value).toContain('not structured-cloneable')
})
it('exposes binding names that collide with Object.prototype as ordinary functions', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: 'return [await tools["__proto__"]({}), await tools["constructor"]({}), typeof tools["hasOwnProperty"]]',
// Computed keys: a literal `'__proto__': …` entry would SET the record's
// prototype instead of declaring a binding of that name.
bindings: tools({ ['__proto__']: async () => 'proto-ok', ['constructor']: async () => 'ctor-ok' }),
})
expect(result.value).toEqual(['proto-ok', 'ctor-ok', 'undefined'])
})
})
describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
it('rejects invalid binding globals loudly (identifier, reserved word, duplicate, console)', async () => {
const { runtime } = await setup()
const cases: [string, RegExp][] = [
['not valid!', /not a usable identifier/],
['await', /not a usable identifier/],
['console', /duplicate binding global/],
]
for (const [global, message] of cases) {
await expect(runtime.run({ program: 'return 1', bindings: [{ global, functions: {} }] })).rejects.toThrow(message)
}
await expect(runtime.run({
program: 'return 1',
bindings: [{ global: 'tools', functions: {} }, { global: 'tools', functions: {} }],
})).rejects.toThrow(/duplicate binding global/)
})
it('rejects config values that are not positive numbers', async () => {
const ctx = new Context()
await expect(ctx.plugin(WorkerCodeRuntime, { computeMs: -1 })).rejects.toThrow(/positive number/)
})
it('keeps runs isolated: no state survives from one run to the next', async () => {
const { runtime } = await setup()
await runtime.run({ program: 'globalThis.leak = "value"; return 1', bindings: [] })
const second = await runtime.run({ program: 'return typeof globalThis.leak', bindings: [] })
expect(second.value).toBe('undefined')
})
it('disposal aborts in-flight runs, awaits worker exit, and rejects later runs', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(WorkerCodeRuntime)
const runtime = ctx.codeRuntime as WorkerCodeRuntime
const inflight: Promise<CodeRunResult> = runtime.run({ program: 'for (;;) {}', bindings: [] })
// Give the worker a moment to actually start spinning.
await new Promise(resolve => setTimeout(resolve, 200))
await fiber.dispose()
const result = await inflight
expect(result.error).toEqual({ kind: 'abort', message: 'runtime disposed' })
await expect(runtime.run({ program: 'return 1', bindings: [] })).rejects.toThrow(/after disposal/)
}, 15_000)
it('removes ctx.codeRuntime when the providing fiber disposes (HMR safety)', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(WorkerCodeRuntime)
expect(ctx.get('codeRuntime')).toBeInstanceOf(WorkerCodeRuntime)
await fiber.dispose()
expect(ctx.get('codeRuntime')).toBeUndefined()
})
})

View File

@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../code-runtime"
}
]
}

View File

@@ -0,0 +1,35 @@
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.
*/
export default defineConfig([
{
entry: ['lib/types/index.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
{
entry: ['lib/types/worker.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
])

View File

@@ -0,0 +1,19 @@
# @deepseek-ai/dsh-code-runtime
The **code-execution seam**: an abstract `CodeRuntime` service (`ctx.codeRuntime`) defining WHAT a code runtime does — run one model-written program against a set of host-provided async bindings and report `{ value, logs, error? }` — without saying HOW.
This package is the interface third of the capability (the bash trio is the template — see [capability seams](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): implementations subclass `CodeRuntime` and register the service; the consumer is the tool registry's Code Mode, which generates the model-facing SDK and bridges tool dispatch — both specified in the [Code Mode RFC](../../../docs/rfc/proposed/feature/2026-06-15-code-mode.md), whose first implementation is a Node worker-thread backend. The runtime knows nothing about tools or sessions: it is handed named async functions and a program string, and everything tool-shaped stays with the consumer.
## Service API (`ctx.codeRuntime`)
| Member | Semantics |
|---|---|
| `run(request)` | Execute one program against the request's bindings. **Resolves with an error FIELD for every program outcome** — parse/transform failure, thrown exception, budget expiry, abort, substrate death (`CodeRunFailure`'s orthogonal `kind` taxonomy); it rejects only for caller misuse of the seam itself (e.g. a run submitted after disposal). The program runs as the body of an async function: top-level `await`/`return` work, and the completion value becomes `result.value` when it survives the serialization boundary. |
| `language` | Readonly descriptor: the source language `run` expects (`'typescript'` is the well-known value). Informational, not gating — a consumer that generates language-specific presentation switches on it and fails loud on a language it cannot present. |
| `isolation` | Readonly descriptor: the execution substrate (`'worker-thread'`, `'process'`, `'container'`). A label for deployments and diagnostics, **not a security claim**. |
Semantics every implementation must honor (contract details in the class JSDoc): binding calls bridge to the caller's functions verbatim with structured-cloneable arguments/resolutions; the program is treated as a hostile peer (arbitrary binding names are own properties, malformed traffic never crashes the host); no state survives between runs; disposal terminates in-flight runs AND awaits their exit before completing.
## Vocabulary
`CodeRunRequest` (`program`, `bindings`, `signal?`) carries everything the runtime acts on — defaulting (time budgets, output caps) is the implementation's validated config, never a hidden `??` inside `run()`. `bindings` is a list of `CodeBindingNamespace`s (`global` + `functions`), each exposed to the program as one global object of async callables. `CodeRunResult` reports the completion `value?`, the ordered `logs` (`CodeLogEntry`: `console`/`stdout`/`stderr` source, console `level`, capped text), and the `error?` (`CodeRunFailure`: `kind` + model-feedable `message`). See `src/types.ts` for the full contracts.

View File

@@ -0,0 +1,30 @@
{
"name": "@deepseek-ai/dsh-code-runtime",
"description": "Abstract code-execution seam (ctx.codeRuntime) for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,93 @@
/**
* 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/proposed/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.
*
* @module @deepseek-ai/dsh-code-runtime
*/
import { Context, Service } from 'cordis'
import type { CodeRunRequest, CodeRunResult } from './types.ts'
export type {
CodeBindingFunction,
CodeBindingNamespace,
CodeLogEntry,
CodeRunFailure,
CodeRunRequest,
CodeRunResult,
} from './types.ts'
declare module 'cordis' {
interface Context {
codeRuntime: CodeRuntime
}
}
/**
* 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()`).
*/
export abstract class CodeRuntime extends Service {
/**
* The source language {@link run} expects `program` to be written in, as a
* lowercase identifier. Informational, not gating — a consumer that
* generates language-specific presentation (typed SDK stubs, usage
* instructions) switches on it and fails loud on a language it cannot
* present. Well-known value: `'typescript'`.
*/
abstract readonly language: string
/**
* The execution substrate, as a lowercase identifier. Informational, not
* gating — a descriptor so deployments and diagnostics can tell backends
* apart, not a security claim. Well-known values: `'worker-thread'`,
* `'process'`, `'container'`.
*/
abstract readonly isolation: string
constructor(ctx: Context) {
super(ctx, 'codeRuntime')
}
/**
* Execute one program against the request's bindings and capture what it
* emitted. See the class doc for the resolution contract (error is a result
* field; rejection means seam misuse only).
* @param request - the program, its bindings, and the abort signal; the
* request carries everything the runtime acts on, with no hidden defaults.
* @returns the run's outcome: completion value (when transferable), the
* ordered log capture, and the failure (if any).
*/
abstract run(request: CodeRunRequest): Promise<CodeRunResult>
}
export default CodeRuntime

View File

@@ -0,0 +1,105 @@
/**
* Vocabulary types for the code-execution seam: what a caller hands a
* {@link ../index.ts | CodeRuntime} and what it gets back. Pure types — no
* runtime code lives here.
*
* @module @deepseek-ai/dsh-code-runtime/src/types
*/
/**
* One host-side function exposed to the program as an async callable. The
* runtime bridges calls to it (possibly across a serialization boundary), so
* `args` and the resolution value MUST be structured-cloneable; a runtime
* rejects a non-cloneable value with a descriptive error rather than
* corrupting the run. A rejection of this function surfaces inside the
* program as a rejection of the corresponding call.
*/
export type CodeBindingFunction = (args: unknown) => Promise<unknown>
/**
* A named group of {@link CodeBindingFunction}s the runtime exposes to the
* program as one global object (e.g. `tools`). Function names are arbitrary
* strings — a runtime must treat names like `__proto__` or `constructor` as
* ordinary own properties (null-prototype construction), never as prototype
* collisions.
*/
export interface CodeBindingNamespace {
/** The global identifier the program sees (must be a valid JS identifier). */
global: string
/** The callable members, keyed by the exact name the program calls. */
functions: Record<string, CodeBindingFunction>
}
/**
* One run: the program source plus everything the runtime acts on. Per the
* explicit-over-implicit convention, defaulting (time budgets, output caps)
* is the implementation's validated config — a request carries no optional
* tuning knobs for a hidden `??` to fill in.
*/
export interface CodeRunRequest {
/**
* The program source, in the runtime's {@link ../index.ts | language}. It
* runs as the body of an async function: top-level `await` and `return`
* are available, and the completion value becomes
* {@link CodeRunResult.value}.
*/
program: string
/** Host functions exposed to the program, one global object per namespace. */
bindings: CodeBindingNamespace[]
/**
* Abort the run: the runtime stops the program (hard, even mid-loop) and
* resolves with a {@link CodeRunFailure} of kind `'abort'`. In-flight
* binding calls are the CALLER's to settle — the runtime only stops asking.
*/
signal?: AbortSignal
}
/**
* One captured output entry, in emission order. `source` says which channel
* produced it: the program's `console` (shimmed by the runtime), or a stray
* write to the underlying stdout/stderr streams.
*/
export interface CodeLogEntry {
/** Which channel produced the text. */
source: 'console' | 'stdout' | 'stderr'
/** The console method used; present only when `source` is `'console'`. */
level?: 'log' | 'info' | 'warn' | 'error' | 'debug'
/** The captured text (possibly truncated by the implementation's caps, marked in-band). */
text: string
}
/**
* Why a run failed. The kinds are orthogonal outcomes reported independently
* (per docs/defensive-patterns.md): a budget expiry is not an exception, an
* abort is not a timeout, and a substrate death is neither.
*
* - `'exception'` — the program threw or failed to parse/transform.
* - `'timeout'` — an implementation-owned budget expired; the message says which.
* - `'abort'` — {@link CodeRunRequest.signal} fired.
* - `'worker-exit'` — the execution substrate died without settling (e.g. OOM).
*/
export interface CodeRunFailure {
/** The failure class (see the interface doc for each kind's meaning). */
kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'
/** Human-readable detail, suitable for feeding back to a model to self-correct. */
message: string
}
/**
* The outcome of one run. An error is a FIELD on a resolved result, never a
* rejection of `run()` — reporting a failed program is the caller's job, not
* an exception path.
*/
export interface CodeRunResult {
/**
* The program's completion value (its top-level `return`), when it ran to
* completion and the value survived the runtime's serialization boundary;
* a non-transferable value is replaced by a string rendering, and a failed
* or value-less run leaves this absent.
*/
value?: unknown
/** Everything the program emitted, in order (capped by the implementation). */
logs: CodeLogEntry[]
/** Present iff the run failed; see {@link CodeRunFailure} for the taxonomy. */
error?: CodeRunFailure
}

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
/**
* Minimal concrete runtime: records requests, "executes" by invoking every
* binding once in declaration order, and lets tests script the outcome. The
* seam package ships no implementation, so the contract is exercised through
* the smallest subclass that honors it.
*/
class StubRuntime extends CodeRuntime {
readonly language = 'typescript'
readonly isolation = 'in-process-stub'
requests: CodeRunRequest[] = []
nextResult: CodeRunResult = { logs: [] }
async run(request: CodeRunRequest): Promise<CodeRunResult> {
this.requests.push(request)
if (request.signal?.aborted) {
return { logs: [], error: { kind: 'abort', message: String(request.signal.reason) } }
}
for (const namespace of request.bindings) {
for (const fn of Object.values(namespace.functions)) {
await fn({ from: 'stub' })
}
}
return this.nextResult
}
}
async function setup() {
const ctx = new Context()
await ctx.plugin(StubRuntime)
const runtime = ctx.codeRuntime as StubRuntime
return { ctx, runtime }
}
describe('CodeRuntime service seam', () => {
it('registers as ctx.codeRuntime and serves the abstract API', async () => {
const { runtime } = await setup()
expect(runtime.language).toBe('typescript')
expect(runtime.isolation).toBe('in-process-stub')
const calls: unknown[] = []
const result = await runtime.run({
program: 'return 1',
bindings: [{ global: 'tools', functions: { probe: async args => void calls.push(args) } }],
})
expect(result).toEqual({ logs: [] })
expect(calls).toEqual([{ from: 'stub' }])
expect(runtime.requests).toHaveLength(1)
})
it('reports a failed run as an error field on a resolved result, never a rejection', async () => {
const { runtime } = await setup()
runtime.nextResult = {
logs: [{ source: 'console', level: 'error', text: 'boom' }],
error: { kind: 'exception', message: 'boom' },
}
const result = await runtime.run({ program: 'throw new Error("boom")', bindings: [] })
expect(result.error).toEqual({ kind: 'exception', message: 'boom' })
expect(result.value).toBeUndefined()
})
it('reports a pre-aborted signal as an abort failure', async () => {
const { runtime } = await setup()
const controller = new AbortController()
controller.abort('cancelled')
const result = await runtime.run({ program: 'return 1', bindings: [], signal: controller.signal })
expect(result.error).toEqual({ kind: 'abort', message: 'cancelled' })
})
it('is removed from the context when the providing fiber disposes (HMR safety)', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(StubRuntime)
expect(ctx.get('codeRuntime')).toBeInstanceOf(StubRuntime)
await fiber.dispose()
expect(ctx.get('codeRuntime')).toBeUndefined()
})
it('rejects a second implementation in the same context (duplicate service)', async () => {
const { ctx } = await setup()
await expect(ctx.plugin(StubRuntime)).rejects.toThrow(/registered/)
})
})

View File

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

View File

@@ -8,7 +8,7 @@ This is the implementation tier of the compaction capability — see the [interf
The abstract contract states only WHAT compaction does; this backend owns every HOW decision:
- **Token estimation** — `estimateContentTokens()`: chars divided by the `charsPerToken` config (default 4) with per-block structural overhead (`text`/`reasoning` = `ceil(len/charsPerToken) + 4`, `tool-call` from name + arguments, `tool-result` recursive, unknown blocks via JSON length).
- **Token estimation** — `estimateContentTokens()`: chars divided by the `charsPerToken` config (default 4) with per-block structural overhead (`text`/`reasoning` = `ceil(len/charsPerToken) + 4`, `tool-call` from name + arguments, `tool-result` recursive, unknown blocks via JSON length). The pressure gate estimates the NEXT request via `estimatePressure()`: the session prefix (the `agent/session-prefix` product — composed by the loop BEFORE the pre-step seam and handed through it, so the gate counts the prefix this instance will actually send in front of the history, never a stale logged one) + the derived history + the system prompt.
- **Retention policy** — `compactIfNeeded()` walks the surface nodes tail→head summing per-node token estimates, and retains the smallest tail-run of WHOLE units (a closed step, or a single no-step node such as a pre-step `user/message` or inter-step `steering/message`) whose total reaches `retainTokens`; everything older is compacted. Retention is **turn-agnostic** — turn boundaries play no role, so a single runaway turn that alone exceeds the window compacts its OWN early closed steps rather than being retained verbatim (the failure mode that motivated dropping turn-protection: a tool-heavy turn must stay compactable or the harness dies exactly when compaction is needed). The only structural guard is **tool-pairing balance**: the compacted region's edges are balanced cuts on the surface (no unanswered tool-call crosses either edge), so it never splits a step's `assistant/message` tool-calls from their `tool/result`s. When the only compactable content left is an un-splittable open tail step, it declines (returns `null`) and retries once an older step closes. **Single-unit overflow is out of scope, by design**: if one retained unit (a single closed step, or a large 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. `compactRegion()` enforces tool-pairing balance strictly, throwing on a boundary that would split a step. `dsh-session` exports `isToolPairingBalanced` for the check.
- **Dynamic convergence** — no static summary-length config pretends to bound what the model will write. If framing/estimator/system overhead leaves the compacted surface above threshold, `compactIfNeeded()` re-compacts the head checkpoint up to `compactionRetries` extra times; if it still cannot get below threshold, it throws. A summary whose estimated stored size is not smaller than the shadowed content fails closed before it mutates the surface.
- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request is a direct one-shot `ctx.llm.stream()` call — NOT a loop step, so it does not run `agent/request` (that seam shapes the loop's conversation requests); the model comes from `summarizationModel` falling back to the agent's own, and per-call routing happens at `llm/stream` like any other direct call. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[tool-call: name(args)]`, `[tool-result: …]`, …) so the summarizer is told what existed rather than silently dropping it.

View File

@@ -30,7 +30,7 @@
*/
import { Context } from 'cordis'
import { CompactService } from '@deepseek-ai/dsh-compact'
import { CompactService, renderTranscript } from '@deepseek-ai/dsh-compact'
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
@@ -183,11 +183,11 @@ export class BasicCompactService extends CompactService {
// 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.
ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, fullSystemPrompt: string, signal: AbortSignal) => {
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, signal)
const result = await this.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)
if (result) {
const after = this.estimateTokens(agent.session.deriveMessages(), fullSystemPrompt)
const after = this.estimatePressure(agent.session, fullSystemPrompt, sessionPrefix)
ctx.logger.info(
`compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` +
`(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` +
@@ -216,6 +216,11 @@ export class BasicCompactService extends CompactService {
* Estimate the token count of content blocks — chars divided by the
* `charsPerToken` config, with per-block overhead. Override in a subclass to
* plug in a real tokenizer.
*
* @param blocks - the blocks to estimate; `tool-result` blocks recurse into
* their nested content, and unknown (merge-extended) types fall back to
* their JSON-stringified length.
* @returns the estimated token count.
*/
estimateContentTokens(blocks: readonly ContentBlock[]): number {
const { charsPerToken } = this.config
@@ -246,6 +251,11 @@ export class BasicCompactService extends CompactService {
/**
* Estimate token count for a single session event. Returns 0 for non-message
* event types (boundaries, chunks, usage, errors, compact markers).
*
* @param event - any session event; only the message-bearing types carry
* content to count.
* @returns the estimated token count of the event's content, or 0 for a
* non-message event.
*/
estimateEventTokens(event: SessionEvent): number {
switch (event.type) {
@@ -260,7 +270,14 @@ export class BasicCompactService extends CompactService {
}
}
/** Estimate total tokens across a list of messages plus optional system prompt. */
/**
* Estimate total tokens across a list of messages plus optional system prompt.
*
* @param messages - the derived conversation messages; each adds a fixed
* role-framing overhead on top of its content estimate.
* @param systemPrompt - counted at chars / `charsPerToken` when provided.
* @returns the estimated token footprint of the whole request.
*/
estimateTokens(messages: readonly Message[], systemPrompt?: string): number {
let total = 0
for (const msg of messages) {
@@ -293,6 +310,13 @@ export class BasicCompactService extends CompactService {
* 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.
*
* @param text - plain-text rendering of the conversation region to condense.
* @param agent - supplies the fallback model and the session id stamped on
* the call; throws when neither it nor the config names a model.
* @param signal - optional abort signal, forwarded into the model call.
* @returns the text-only summary blocks plus the call envelope used
* (`model`, and `maxTokens` when the summarizer has a cap).
*/
async summarize(
text: string, agent: Agent, signal?: AbortSignal,
@@ -335,11 +359,23 @@ export class BasicCompactService extends CompactService {
// ---- Core API (implements the abstract contract) ----
/**
* The sole token-pressure gate: estimate the current surface-derived history,
* and if it exceeds the threshold (`contextWindow * thresholdRatio`), compact
* 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.
* 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
@@ -363,13 +399,14 @@ export class BasicCompactService extends CompactService {
override async compactIfNeeded(
agent: Agent,
fullSystemPrompt: string,
sessionPrefix: readonly Message[],
signal: AbortSignal,
): Promise<CompactionResult | null> {
const session = agent.session
const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio)
let result: CompactionResult | null = null
for (let attempt = 0; attempt <= this.config.compactionRetries; attempt++) {
const totalTokens = this.estimateTokens(session.deriveMessages(), fullSystemPrompt)
const totalTokens = this.estimatePressure(session, fullSystemPrompt, sessionPrefix)
if (totalTokens < threshold) return result
const range = this._compactableRange(session)
@@ -383,7 +420,7 @@ export class BasicCompactService extends CompactService {
result = await this.compactRegion(session, range.start, range.end, agent, signal)
}
const totalTokens = this.estimateTokens(session.deriveMessages(), fullSystemPrompt)
const totalTokens = this.estimatePressure(session, fullSystemPrompt, sessionPrefix)
if (totalTokens < threshold) return result
throw new Error(
@@ -392,6 +429,20 @@ export class BasicCompactService extends CompactService {
)
}
/**
* Estimated token pressure of the NEXT request: the session prefix
* (`EpochHeader.messagePrefix` — request-only messages the loop sends in
* front of the derived history, composed before the pre-step seam and
* handed to the gate), the derived history, and the system prompt.
* @param session - the session whose next request is being estimated.
* @param fullSystemPrompt - the assembled system prompt (counts toward pressure).
* @param sessionPrefix - the instance's composed session prefix (counts toward pressure).
* @returns the estimated token total the next request will carry.
*/
estimatePressure(session: Session, fullSystemPrompt: string, sessionPrefix: readonly Message[]): number {
return this.estimateTokens([...sessionPrefix, ...session.deriveMessages()], fullSystemPrompt)
}
override async compactRegion(
session: Session,
start: number,
@@ -459,7 +510,7 @@ export class BasicCompactService extends CompactService {
try {
// --- Extract text and summarize ---
const text = this._extractText(session, shadowedSeqs)
const text = renderTranscript(session.events, shadowedSeqs)
const { summary, model, maxTokens } = await this.summarize(text, agent, signal)
// Estimate token count of the shadowed content for provenance.
@@ -655,101 +706,6 @@ export class BasicCompactService extends CompactService {
}
return null
}
/**
* Extract plain-text conversation from a set of surface node seqs, for
* feeding into the summarization model. Walks the seqs in the order given
* (surface order, as `compactRegion` slices the surface-node list) so the
* summary follows the conversation as the model sees it — which, after a
* `replace`, is NOT ascending log-seq order (a high-seq summary node heads the
* surface before older retained lower-seq nodes).
*/
private _extractText(session: Session, seqs: number[]): string {
const lines: string[] = []
// Walk seqs in the order given (surface order, as compactRegion slices the
// surface-node list) — NOT ascending log-seq order. After a replace the
// summary node carries a fresh high seq while sitting at the head of the
// surface before older retained lower-seq nodes, so a log-order scan would
// feed the transcript out of order and break the checkpoint-merge prompt.
for (const seq of seqs) {
const event = session.events[seq]
/* v8 ignore next -- seq is a surface-node seq, always a valid log index by construction */
if (!event) continue
switch (event.type) {
case 'user/message': {
const text = this._blocksToText(event.data.content)
if (text) lines.push(`User: ${text}`)
break
}
case 'assistant/message': {
const text = this._blocksToText(event.data.content)
if (text) lines.push(`Assistant: ${text}`)
break
}
case 'tool/result': {
const text = this._blocksToText(event.data.content)
const label = event.data.isError ? 'Tool error' : 'Tool result'
if (text) lines.push(`${label} (call ${event.data.callId}): ${text}`)
break
}
case 'context/message': {
const text = this._blocksToText(event.data.content)
if (text) lines.push(`[Context: ${text}]`)
break
}
case 'steering/message': {
const text = this._blocksToText(event.data.content)
if (text) lines.push(`[Steering: ${text}]`)
break
}
// SessionEventMap is merge-extensible — unknown types are
// non-message events that carry no extractable text.
/* v8 ignore next 2 -- seqs only name surface nodes, always one of the 5 handled SurfaceEventTypes; unreachable */
default:
break
}
}
return lines.join('\n\n')
}
/**
* Render content blocks to a single plain-text string for the summarization
* prompt. Text and reasoning contribute their text; every other block type
* contributes a type-tagged placeholder (`[tool-call: name(args)]`,
* `[tool-result: …]`, …) so the summarizer is told what non-text content
* existed in the region rather than silently losing it. Blocks join with
* newlines; empty-text blocks contribute nothing.
*/
private _blocksToText(blocks: readonly ContentBlock[]): string {
const parts: string[] = []
for (const block of blocks) {
switch (block.type) {
case 'text':
if (block.text) parts.push(block.text)
break
case 'reasoning':
if (block.text) parts.push(`[reasoning: ${block.text}]`)
break
case 'tool-call':
parts.push(`[tool-call: ${block.name}(${block.arguments})]`)
break
case 'tool-result': {
const inner = this._blocksToText(block.content)
parts.push(inner ? `[tool-result: ${inner}]` : '[tool-result]')
break
}
// ContentBlockMap is merge-extensible — render an unknown block as a
// bare type-tagged placeholder so a plugin-added block type is still
// signalled to the summarizer rather than dropped.
default:
parts.push(`[${(block as ContentBlock).type}]`)
}
}
return parts.join('\n')
}
}
export default BasicCompactService

View File

@@ -54,6 +54,9 @@ export type ResolvedConfig = Required<BasicCompactConfig>
* 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.
*/
export function resolveConfig(config: BasicCompactConfig): ResolvedConfig {
const resolved: ResolvedConfig = { auto: true, charsPerToken: 4, ...config }

View File

@@ -557,6 +557,24 @@ describe('BasicCompactService.compactIfNeeded', () => {
expect(result!.shadowedSeqs.length).toBeGreaterThan(0)
})
it('counts the session prefix toward pressure (every request carries it in front of the history)', async () => {
const svc = createTestService({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 10 })
const session = multiTurnSession(3, 1) // 6 derived messages ≈ 84 estimated tokens — under the 100 threshold alone
expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull()
// The loop composes the agent/session-prefix product before the pre-step
// seam and hands it to the gate; it rides every request, so pressure must
// include it — the same history now crosses the threshold.
const sessionPrefix: Message[] = [
{ role: 'user', content: [{ type: 'text', text: `opener one.${LONG_FIXTURE_TEXT}` }] },
{ role: 'user', content: [{ type: 'text', text: `opener two.${LONG_FIXTURE_TEXT}` }] },
]
const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL, sessionPrefix)
expect(result).not.toBeNull()
// The prefix itself is NOT history: compaction shadowed surface nodes only.
expect(sessionPrefix).toHaveLength(2)
})
it('returns the first compaction result when a zero-retry pass converges after the loop', async () => {
// With compactionRetries=0 there is no next-loop threshold check after the
// first mutation, so the success path is the post-loop `return result`.
@@ -982,8 +1000,9 @@ function compactIfNeeded(
fullSystemPrompt: string,
model: string,
signal: AbortSignal,
sessionPrefix: readonly Message[] = [],
) {
return svc.compactIfNeeded(stubAgent(session, model), fullSystemPrompt, signal)
return svc.compactIfNeeded(stubAgent(session, model), fullSystemPrompt, sessionPrefix, signal)
}
function compactRegion(
@@ -1151,7 +1170,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => {
/** Fire the agent/pre-step serial checkpoint as the loop does. */
function firePreStep(ctx: Context, agent: Agent, step: number, fullSystemPrompt: string): Promise<unknown> {
return ctx.serial('agent/pre-step', agent, 1, step, fullSystemPrompt, SIGNAL)
return ctx.serial('agent/pre-step', agent, 1, step, fullSystemPrompt, [], SIGNAL)
}
it('compacts (mutating the surface) when over threshold', async () => {
@@ -1253,7 +1272,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
const session = multiTurnSession(5, 1)
const agent = stubAgent(session, 'agent-model')
await ctx.serial('agent/pre-step', agent, 1, 1, '', SIGNAL)
await ctx.serial('agent/pre-step', agent, 1, 1, '', [], SIGNAL)
expect(adapter.lastOptions?.model).toBe('routed-model')
expect(session.events.some(e => e.type === 'compact/summary')).toBe(true)
@@ -1278,7 +1297,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
})
})
describe('BasicCompactService._extractText branches', () => {
describe('BasicCompactService transcript rendering (delegated to dsh-compact)', () => {
it('renders reasoning, context, and steering messages', async () => {
const svc = createTestService()
const s = new Session(SessionId('rich'))
@@ -1392,7 +1411,7 @@ describe('BasicCompactService edge cases', () => {
const session = multiTurnSession(4, 1)
const agent = stubAgent(session, 'test-model')
await ctx.serial('agent/pre-step', agent, 1, 1, '', SIGNAL)
await ctx.serial('agent/pre-step', agent, 1, 1, '', [], SIGNAL)
expect(session.events.some(e => e.type === 'compact/summary')).toBe(true)
// The surface was mutated; the head message is the framed summary checkpoint.
expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' })
@@ -1472,7 +1491,7 @@ describe('BasicCompactService edge cases', () => {
const agent = stubAgent(session, 'test-model')
const before = session.surface.nodes.length
await ctx.serial('agent/pre-step', agent, 1, 1, '', SIGNAL)
await ctx.serial('agent/pre-step', agent, 1, 1, '', [], SIGNAL)
// The failure was swallowed; the surface is untouched and a warning logged.
expect(session.surface.nodes.length).toBe(before)
expect(session.events.some(e => e.type === 'compact/summary')).toBe(false)
@@ -1489,7 +1508,7 @@ describe('BasicCompactService edge cases', () => {
const agent = stubAgent(session, 'test-model')
const bigSystem = 'x'.repeat(900) // ceil(900/4)=225 > threshold 200
await ctx.serial('agent/pre-step', agent, 1, 1, bigSystem, SIGNAL)
await ctx.serial('agent/pre-step', agent, 1, 1, bigSystem, [], SIGNAL)
expect(session.events.some(e => e.type === 'compact/start')).toBe(false)
expect(svc.summarizeCalls.length).toBe(0)
})

View File

@@ -6,7 +6,7 @@ This package is the interface tier of the compaction capability, split so each c
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` |
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) |
| `@deepseek-ai/dsh-compact-basic` (deferred) | a backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization |
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
@@ -18,7 +18,7 @@ Both methods are **abstract** — the backend owns the entire strategy (token es
| Member | Semantics |
|---|---|
| `compactIfNeeded(agent, fullSystemPrompt, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-step` checkpoint supplies the agent, assembled `fullSystemPrompt`, and turn `signal`. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. |
| `compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-step` checkpoint supplies the agent, assembled `fullSystemPrompt`, composed `sessionPrefix` (request-only messages every request carries but the derived history omits — the pressure estimate must count them), and turn `signal`. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. |
| `compactRegion(session, start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. |
`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The session being compacted comes from the agent context; the turn that the `compact/*` events belong to is recoverable from the log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value.
@@ -43,7 +43,7 @@ Compaction is serialized via a log-recorded lock: `compactRegion` refuses to sta
## Events
The `compact/*` events extend `SessionEventMap` (merge-extensible) via declaration merging — they are session events, not cordis `Events`, and all three are log-only (no `surfaceOp`). Per-event payloads and semantics are in the generated [persistence log event catalog](../../../docs/persistence-catalog/log-events.md).
The `compact/*` events extend `SessionEventMap` (merge-extensible) via declaration merging — they are session events, not cordis `Events`, and all three are log-only (no `surfaceOp`). Per-event payloads and semantics are in the generated [persistence log event catalog](../../../docs/persistence-catalog.md).
## Implementing a backend

View File

@@ -22,10 +22,12 @@
*/
import { Context, Service } from 'cordis'
import type { Message } from '@deepseek-ai/dsh-llm'
import type { Session } from '@deepseek-ai/dsh-session'
import type { CompactionResult } from './types.ts'
export type { CompactionResult } from './types.ts'
export { renderContentBlocks, renderTranscript } from './render.ts'
/** Minimal agent context compaction needs without depending on the agent package. */
export interface CompactAgentContext {
@@ -68,16 +70,20 @@ export abstract class CompactService extends Service {
/**
* Check token pressure and compact if the conversation is too large.
*
* Estimates the current surface-derived history size (including the system
* prompt), and if it exceeds the backend's threshold, compacts an older range
* 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:
* - **Surface-derived history only.** The decision is made against the history
* derived from the session surface — the only thing compaction can act on.
* Non-surface context injected downstream (into the request `messages` by a
* later listener) is out of this accounting by construction.
* - **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
@@ -88,10 +94,14 @@ export abstract class CompactService extends Service {
* - **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.
* 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
@@ -101,6 +111,7 @@ export abstract class CompactService extends Service {
abstract compactIfNeeded(
agent: CompactAgentContext,
fullSystemPrompt: string,
sessionPrefix: readonly Message[],
signal: AbortSignal,
): Promise<CompactionResult | null>

View File

@@ -0,0 +1,118 @@
/**
* 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.
*
* @module @deepseek-ai/dsh-compact/render
*/
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.
*
* @param blocks - the content blocks to render.
* @returns the newline-joined plain-text rendering; empty string when nothing renders.
*/
export function renderContentBlocks(blocks: readonly ContentBlock[]): string {
const parts: string[] = []
for (const block of blocks) {
switch (block.type) {
case 'text':
if (block.text) parts.push(block.text)
break
case 'reasoning':
if (block.text) parts.push(`[reasoning: ${block.text}]`)
break
case 'tool-call':
parts.push(`[tool-call: ${block.name}(${block.arguments})]`)
break
case 'tool-result': {
const inner = renderContentBlocks(block.content)
parts.push(inner ? `[tool-result: ${inner}]` : '[tool-result]')
break
}
// ContentBlockMap is merge-extensible — render an unknown block as a
// bare type-tagged placeholder so a plugin-added block type is still
// signalled to the reader rather than dropped.
default:
parts.push(`[${(block as ContentBlock).type}]`)
}
}
return parts.join('\n')
}
/**
* 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.
*
* @param events - the session log the seqs index into (`session.events`).
* @param seqs - the surface-node seqs to render, in surface order.
* @returns the transcript, entries joined by blank lines; empty string when nothing renders.
*/
export function renderTranscript(events: readonly SessionEvent[], seqs: readonly number[]): string {
const lines: string[] = []
for (const seq of seqs) {
const event = events[seq]
if (!event) continue
switch (event.type) {
case 'user/message': {
const text = renderContentBlocks(event.data.content)
if (text) lines.push(`User: ${text}`)
break
}
case 'assistant/message': {
const text = renderContentBlocks(event.data.content)
if (text) lines.push(`Assistant: ${text}`)
break
}
case 'tool/result': {
const text = renderContentBlocks(event.data.content)
const label = event.data.isError ? 'Tool error' : 'Tool result'
if (text) lines.push(`${label} (call ${event.data.callId}): ${text}`)
break
}
case 'context/message': {
const text = renderContentBlocks(event.data.content)
if (text) lines.push(`[Context: ${text}]`)
break
}
case 'steering/message': {
const text = renderContentBlocks(event.data.content)
if (text) lines.push(`[Steering: ${text}]`)
break
}
default:
break
}
}
return lines.join('\n\n')
}

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CompactService } from '@deepseek-ai/dsh-compact'
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
import type { Message } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { CompactAgentContext } from '@deepseek-ai/dsh-compact'
@@ -18,6 +19,7 @@ class StubCompactService extends CompactService {
override async compactIfNeeded(
_agent: CompactAgentContext,
_fullSystemPrompt: string,
_sessionPrefix: readonly Message[],
signal: AbortSignal,
): Promise<CompactionResult | null> {
this.lastSignal = signal
@@ -78,7 +80,7 @@ describe('CompactService seam', () => {
const ctx = new Context()
const svc = new StubCompactService(ctx)
const session = new Session(SessionId('s'))
expect(await svc.compactIfNeeded(stubAgent(session), '', new AbortController().signal)).toBeNull()
expect(await svc.compactIfNeeded(stubAgent(session), '', [], new AbortController().signal)).toBeNull()
})
it('compact/* events merge into SessionEventMap and are log-only', async () => {
@@ -107,7 +109,7 @@ describe('CompactService seam', () => {
await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'), controller.signal)
expect(svc.lastSignal).toBe(controller.signal)
await svc.compactIfNeeded(stubAgent(session), '', controller.signal)
await svc.compactIfNeeded(stubAgent(session), '', [], controller.signal)
expect(svc.lastSignal).toBe(controller.signal)
})
})

View File

@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest'
import { renderContentBlocks, renderTranscript } from '@deepseek-ai/dsh-compact'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { CallId } from '@deepseek-ai/dsh-llm'
function session(): Session {
return new Session(SessionId('render-spec'))
}
describe('renderContentBlocks', () => {
it('renders text blocks verbatim and skips empty ones', () => {
expect(renderContentBlocks([
{ type: 'text', text: 'hello' },
{ type: 'text', text: '' },
{ type: 'text', text: 'world' },
])).toBe('hello\nworld')
})
it('wraps reasoning, skipping empty reasoning', () => {
expect(renderContentBlocks([
{ type: 'reasoning', text: 'think' },
{ type: 'reasoning', text: '' },
])).toBe('[reasoning: think]')
})
it('renders tool-call as a name(args) placeholder', () => {
expect(renderContentBlocks([
{ type: 'tool-call', id: CallId('c1'), name: 'read', arguments: '{"filePath":"a"}' },
])).toBe('[tool-call: read({"filePath":"a"})]')
})
it('renders tool-result with nested content, and bare when empty', () => {
expect(renderContentBlocks([
{ type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'ok' }] },
{ type: 'tool-result', toolCallId: CallId('c2'), content: [] },
])).toBe('[tool-result: ok]\n[tool-result]')
})
it('renders an unknown (merge-extended) block type as a bare type tag', () => {
const unknown = { type: 'image', data: 'zzz' } as unknown as ContentBlock
expect(renderContentBlocks([unknown])).toBe('[image]')
})
it('returns the empty string for no blocks', () => {
expect(renderContentBlocks([])).toBe('')
})
})
describe('renderTranscript', () => {
it('renders each surface event type with its label, in the seq order given', () => {
const s = session()
const user = s.append('user/message', {
content: [{ type: 'text', text: 'fix the bug' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const assistant = s.append('assistant/message', {
turn: 0, step: 0,
content: [{ type: 'text', text: 'looking' }],
}, { surfaceOp: 'append' })
const result = s.append('tool/result', {
turn: 0, step: 0, callId: CallId('c1'),
content: [{ type: 'text', text: 'exit 0' }],
isError: false,
}, { surfaceOp: 'append' })
const context = s.append('context/message', {
content: [{ type: 'text', text: 'file changed' }],
source: { kind: 'plugin', plugin: 'fs' },
}, { surfaceOp: 'append' })
const steering = s.append('steering/message', {
turn: 0,
content: [{ type: 'text', text: 'stop that' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
expect(renderTranscript(s.events, [user.seq, assistant.seq, result.seq, context.seq, steering.seq])).toBe([
'User: fix the bug',
'Assistant: looking',
'Tool result (call c1): exit 0',
'[Context: file changed]',
'[Steering: stop that]',
].join('\n\n'))
})
it('labels an error tool result "Tool error"', () => {
const s = session()
const result = s.append('tool/result', {
turn: 0, step: 0, callId: CallId('c9'),
content: [{ type: 'text', text: 'boom' }],
isError: true,
}, { surfaceOp: 'append' })
expect(renderTranscript(s.events, [result.seq])).toBe('Tool error (call c9): boom')
})
it('renders NON-log-order seqs in the order given (surface order after a replace)', () => {
const s = session()
const first = s.append('user/message', {
content: [{ type: 'text', text: 'first' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const second = s.append('user/message', {
content: [{ type: 'text', text: 'second' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
expect(renderTranscript(s.events, [second.seq, first.seq])).toBe('User: second\n\nUser: first')
})
it('skips events that render to nothing, non-message events, and seqs with no event', () => {
const s = session()
const empty = s.append('user/message', {
content: [{ type: 'text', text: '' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const emptyAssistant = s.append('assistant/message', {
turn: 0, step: 0,
content: [{ type: 'text', text: '' }],
}, { surfaceOp: 'append' })
const emptyResult = s.append('tool/result', {
turn: 0, step: 0, callId: CallId('c3'),
content: [{ type: 'text', text: '' }],
isError: false,
}, { surfaceOp: 'append' })
const emptyContext = s.append('context/message', {
content: [{ type: 'text', text: '' }],
source: { kind: 'plugin', plugin: 'fs' },
}, { surfaceOp: 'append' })
const emptySteering = s.append('steering/message', {
turn: 0,
content: [{ type: 'text', text: '' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
// A log-only (non-surface) event type: contributes nothing to a transcript.
const lock = s.append('compact/start', { turn: 0 })
expect(renderTranscript(s.events, [
empty.seq, emptyAssistant.seq, emptyResult.seq, emptyContext.seq, emptySteering.seq, lock.seq, 9999,
])).toBe('')
})
})

View File

@@ -0,0 +1,7 @@
# packages/cordis — the self-referential runtime toolset
Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service surface, mount model-written plugins, and dispose them again. Design home: [the toolset RFC](../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
| Package | Role | ctx key |
|---|---|---|
| [`tool-cordis/`](tool-cordis/README.md) | The `cordis_inspect` / `cordis_mount` / `cordis_unmount` tools: read the runtime, evaluate model-written plugin code in a `node:vm` sandbox, and manage the dynamic mounts under one group fiber | registers on `ctx.tools` |

View File

@@ -0,0 +1,33 @@
# @deepseek-ai/dsh-tool-cordis
The self-referential cordis toolset: three model-facing tools over the live runtime the agent runs inside. Design home — sandbox semantics, mount lifecycle, cross-mount composition, the generated API catalog, standing decisions: [the toolset RFC](../../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
## What it does
- `cordis_inspect` — read-only report over the runtime: services, the loaded-plugin list, registered tools, the dynamic-mount table, and the catalog-backed `api` / `events` references.
- `cordis_mount` — evaluates model-written JavaScript (the body of an async function) in a `node:vm` sandbox; the code must `return` a cordis plugin, which is mounted under the `cordis-dynamic` group fiber and tracked as `dyn-<n>`.
- `cordis_unmount` — disposes one mount by id, returning only after quiescence.
Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-catalog.md).
## Trust stance
The sandbox isolates the global context only — it is not a security boundary. No Node API is provided: `require`, the timers, and `fetch` are callable traps that throw a redirect to the cordis alternative (`ctx.fs` / `ctx.web` / `ctx.bash` / `inject: ['timer']` + `ctx.setTimeout`); `process` and `Buffer` are `undefined`; `globalThis` writes stay inside. These traps steer honest code onto the cordis services; they do not contain a mount that goes looking — the host-realm helpers on the sandbox global (`harness`, `console`, `btoa`) are reachable functions, so mount code can reach the host realm and Node through one of them, which is fine because `ctx` is fully privileged anyway. The `ctx` a mounted plugin's `apply` receives is a whitelist façade — register tools, observe events, provide/consume services, use timers; framework internals (`ctx.root`, `ctx.fiber`, `ctx.extend`, `ctx.plugin`, …) are withheld — but the capabilities it does expose reach the real runtime, so load this plugin as deliberately as you would grant a bash tool.
## Config
| Field | Default | Meaning |
|---|---|---|
| `vmTimeoutMs` | `5000` | Bound on the SYNCHRONOUS portion of mount-code evaluation; an async body escapes it |
## The generated API catalog
`src/api-catalog.ts` is generated by `scripts/gen-cordis-api.ts` from the same AST walk as [docs/cordis-catalog](../../../docs/cordis-catalog/services.md) and freshness-gated by `pnpm run verify-cordis-api` (in `doc-sync`) — never edit it by hand. `cordis_inspect` intersects it with the live service store at call time.
## Rendering
All three tools render `generic` cards (`read` / `execute` / `delete`); `cordis_mount` carries the mount code as `rawInput`. Presenters are pure functions of the args; results keep the default text rendering.
## Export shape
Namespace plugin: named exports `name` / `inject` / `Config` / `apply`, no default export ([docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)).

View File

@@ -0,0 +1,42 @@
{
"name": "@deepseek-ai/dsh-tool-cordis",
"description": "Self-referential cordis toolset: inspect the live runtime, mount and dispose model-written plugins",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
"cordis": "^4.0.0-rc.6",
"@cordisjs/plugin-timer": "workspace:^"
}
}

View File

@@ -0,0 +1,910 @@
/**
* Generated by scripts/gen-cordis-api.ts — do not edit by hand; run
* `pnpm run gen-cordis-api` to regenerate (freshness-gated by
* `pnpm run verify-cordis-api` in doc-sync).
*
* The machine-readable cordis API catalog `cordis_inspect` serves to the
* model: harness services (summary + public method signatures), harness
* events (mode + signature), and the inherited `ctx` surface. Produced by
* the same AST walk as docs/cordis-catalog, so this data and the rendered
* docs cannot diverge.
*
* @module @deepseek-ai/dsh-tool-cordis/api-catalog
*/
/** One harness `ctx.<key>` service: its one-line summary and public method signatures. */
export interface ServiceApiEntry {
/** The `ctx.<key>` name, e.g. `tools`. */
key: string
/** First sentence of the service class JSDoc. */
summary: string
/** Public method signatures, bodies stripped, in source order. */
methods: readonly string[]
}
/** One harness event: its dispatch mode, exact signature, and one-line summary. */
export interface EventApiEntry {
/** The scoped event name, e.g. `agent/status`. */
name: string
/** The dispatch mode from the declaration's `@mode` tag. */
mode: string
/** The exact listener signature, whitespace-normalized. */
signature: string
/** First sentence of the event JSDoc. */
summary: string
}
/** One inherited (cordis core + loader/hmr/timer) `ctx` member group with its summary. */
export interface InheritedApiEntry {
/** The `ctx` member name(s), e.g. `ctx.on / ctx.once`. */
name: string
/** One-line summary of what the member does. */
summary: string
}
/** One named type shape the service signatures reference. */
export interface TypeApiEntry {
/** The exported type/interface name, e.g. `BashRunResult`. */
name: string
/** The full declaration text, comments stripped. */
declaration: string
}
/** Every harness `ctx.<key>` service, sorted by key. */
export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'agentLoop',
summary: 'The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loops, and registers them in `ctx.agents`.',
methods: [
'create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, \'cwd\'> = {}): ReactLoopAgent',
'createAgent(options: CreateAgentOptions): AgentHandle',
'async resume(options: ResumeAgentOptions): Promise<AgentHandle>',
],
},
{
key: 'agents',
summary: 'Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package.',
methods: [
'setFactory(factory: AgentFactory): () => void',
'create(options: CreateAgentOptions): AgentHandle',
'async resume(options: ResumeAgentOptions): Promise<AgentHandle>',
'register(agent: Agent): () => void',
'get(id: AgentId): Agent | undefined',
'list(): Agent[]',
],
},
{
key: 'bash',
summary: 'Abstract bash execution service.',
methods: [
'abstract resolve(request: BashExecRequest): BashExecSpec',
'abstract run(spec: BashExecSpec): Promise<BashRunResult>',
'abstract start(spec: BashExecSpec): BashTask',
'abstract get(id: BashTaskId): BashTask | undefined',
'abstract ownerOf(id: BashTaskId): OwnerToken | undefined',
'abstract list(): BashTask[]',
'abstract readOutput(id: BashTaskId): BashTaskRead',
'abstract kill(id: BashTaskId): boolean',
'onTaskDone(listener: BashTaskListener): () => void',
],
},
{
key: 'codeRuntime',
summary: 'Abstract code-execution service.',
methods: [
'abstract run(request: CodeRunRequest): Promise<CodeRunResult>',
],
},
{
key: 'compact',
summary: 'Abstract compaction service.',
methods: [
'abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise<CompactionResult | null>',
'abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>',
],
},
{
key: 'fs',
summary: 'Abstract filesystem provider service.',
methods: [
'abstract resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>',
'abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>',
'abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>',
'abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>',
'abstract listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>',
'abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>',
'abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>',
],
},
{
key: 'llm',
summary: 'The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.',
methods: [
'registerAdapter(models: string[], adapter: LlmAdapter): () => void',
'models(): string[]',
'stream(options: GenerateOptions): AsyncIterable<StreamChunk>',
],
},
{
key: 'sessionPersistence',
summary: 'Abstract durable session-persistence service.',
methods: [
'abstract create(meta: SessionHeader): Promise<void>',
'abstract append(id: SessionId, events: readonly SessionEvent[]): Promise<void>',
'abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>',
'abstract list(): Promise<SessionHeader[]>',
],
},
{
key: 'sessions',
summary: 'In-memory session store (`ctx.sessions`).',
methods: [
'create(id?: SessionId, options?: CreateSessionOptions): Session',
'prepare(id?: SessionId, options?: CreateSessionOptions): Session',
'enter(session: Session): () => void',
'announce(session: Session): void',
'get(id: SessionId): Session | undefined',
'list(): Session[]',
'fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session',
],
},
{
key: 'skills',
summary: 'Registry of skill providers.',
methods: [
'registerProvider(provider: SkillProvider): () => void',
'register(skill: SkillRegistration): () => void',
'async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]>',
'async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined>',
'async renderModelListing(options: SkillLookupOptions = {}): Promise<string>',
],
},
{
key: 'subagents',
summary: 'The `subagents` service: a registry of named SubagentProviders and a capability-checked start surface.',
methods: [
'registerProvider(provider: SubagentProvider): () => void',
'getProvider(name: string): SubagentProvider | undefined',
'list(): string[]',
'start(name: string, request: SubagentStartRequest): SubagentRun',
],
},
{
key: 'systemPrompt',
summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls `assemble(context)` once per step.',
methods: [
'section(section: PromptSection): () => void',
'tools(provider: () => ToolSchema[]): () => void',
'variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void',
'async assemble(context: AssembleContext = {}): Promise<PromptAssembly>',
],
},
{
key: 'tools',
summary: 'Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → `tools/execute` → `tools/post-execute` pipeline.',
methods: [
'register(definition: ToolDefinition): () => void',
'get(name: string): ToolDefinition | undefined',
'schemas(): ToolSchema[]',
'async execute(exec: ToolExecution): Promise<ToolExecutionResult>',
],
},
{
key: 'userInteraction',
summary: '`ctx.userInteraction`: one active UI provider plus an `ask()` surface.',
methods: [
'registerProvider(provider: UserInteractionProvider): () => void',
'async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>',
],
},
{
key: 'web',
summary: 'The web access service.',
methods: [
'registerSearchProvider(provider: WebSearchProvider): () => void',
'registerFetchProvider(provider: WebFetchProvider): () => void',
'async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>',
'async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>',
],
},
]
/** Every harness event, sorted by name. */
export const EVENT_API: readonly EventApiEntry[] = [
{
name: 'agent/created',
mode: 'emit',
signature: '\'agent/created\'(agent: Agent): void',
summary: 'An agent was registered in the AgentRegistry and is ready to receive messages.',
},
{
name: 'agent/disposed',
mode: 'emit',
signature: '\'agent/disposed\'(agent: Agent): void',
summary: 'An agent was disposed and removed from the registry; its fiber and any in-flight turn have been torn down.',
},
{
name: 'agent/error',
mode: 'emit',
signature: '\'agent/error\'(agent: Agent, turn: number, step: number, error: Error): void',
summary: 'A step or turn errored.',
},
{
name: 'agent/pre-step',
mode: 'serial',
signature: '\'agent/pre-step\'(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`.',
},
{
name: 'agent/prompt-submit',
mode: 'waterfall',
signature: '\'agent/prompt-submit\'(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.',
},
{
name: 'agent/queued',
mode: 'emit',
signature: '\'agent/queued\'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void',
summary: 'A message entered the agent\'s inbox (queued or steering).',
},
{
name: 'agent/request',
mode: 'waterfall',
signature: '\'agent/request\'(agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>',
summary: 'Waterfall: shape the step\'s call configuration — model switching, sampling overrides — by returning a replacement LlmCallConfig (the frozen seed is the config the loop would otherwise use).',
},
{
name: 'agent/session-prefix',
mode: 'waterfall',
signature: '\'agent/session-prefix\'(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.',
},
{
name: 'agent/session-start',
mode: 'emit',
signature: '\'agent/session-start\'(agent: Agent, source: SessionStartSource): void',
summary: 'The agent\'s session lifecycle began, fired once before its first turn.',
},
{
name: 'agent/status',
mode: 'emit',
signature: '\'agent/status\'(agent: Agent, status: AgentStatus): void',
summary: 'Agent status changed (`idle` ⇄ `running`, or → `disposed`).',
},
{
name: 'agent/step-result',
mode: 'waterfall',
signature: '\'agent/step-result\'(agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>',
summary: 'Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …).',
},
{
name: 'agent/turn-continuation',
mode: 'waterfall',
signature: '\'agent/turn-continuation\'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>',
summary: 'Waterfall: override the turn-continuation decision via a typed ContinuationDecision.',
},
{
name: 'fs/edit-intent',
mode: 'waterfall',
signature: '\'fs/edit-intent\'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>',
summary: 'Single-slot decision: produce the optional version guard for the next FileSystem.editText.',
},
{
name: 'fs/observed',
mode: 'emit',
signature: '\'fs/observed\'(target: FsTarget, version: FsVersion, actor: object | undefined): void',
summary: 'Record that an actor observed a target at a version, after a successful read/write/edit.',
},
{
name: 'fs/write-intent',
mode: 'waterfall',
signature: '\'fs/write-intent\'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>',
summary: 'Single-slot decision: produce the write intent for the next FileSystem.writeText.',
},
{
name: 'llm/stream',
mode: 'waterfall',
signature: '\'llm/stream\'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>',
summary: 'Waterfall around every streaming model call (retry, replay, routing).',
},
{
name: 'session/created',
mode: 'emit',
signature: '\'session/created\'(session: Session): void',
summary: 'A session was created in the store.',
},
{
name: 'session/event',
mode: 'emit',
signature: '\'session/event\'(session: Session, event: SessionEvent): void',
summary: 'An event was appended to a session log (sync, fire-and-forget).',
},
{
name: 'session/flush',
mode: 'parallel',
signature: '\'session/flush\'(session: Session): Promise<void> | void',
summary: 'Awaited durability checkpoint.',
},
{
name: 'skill/provider-added',
mode: 'emit',
signature: '\'skill/provider-added\'(provider: SkillProvider): void',
summary: 'A skill provider became resolvable in the `ctx.skills` registry.',
},
{
name: 'skill/provider-removed',
mode: 'emit',
signature: '\'skill/provider-removed\'(name: string): void',
summary: 'A skill provider left the registry because its plugin fiber was disposed.',
},
{
name: 'subagent/end',
mode: 'emit',
signature: '\'subagent/end\'(info: SubagentRunEndInfo): void',
summary: 'A subagent run settled — emitted when SubagentRun.result resolves (any stop reason).',
},
{
name: 'subagent/provider-added',
mode: 'emit',
signature: '\'subagent/provider-added\'(provider: SubagentProvider): void',
summary: 'A provider became resolvable in the SubagentService registry.',
},
{
name: 'subagent/provider-removed',
mode: 'emit',
signature: '\'subagent/provider-removed\'(name: string): void',
summary: 'A provider left the registry (its plugin\'s fiber was disposed — an unload or an HMR reload).',
},
{
name: 'subagent/start',
mode: 'emit',
signature: '\'subagent/start\'(info: SubagentRunInfo): void',
summary: 'A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins.',
},
{
name: 'system-prompt/assemble',
mode: 'waterfall',
signature: '\'system-prompt/assemble\'(this: SystemPrompt, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>',
summary: 'Waterfall around prompt assembly — mutate or extend the PromptAssembly (sections + tools + variables) before it is rendered.',
},
{
name: 'system-prompt/change',
mode: 'emit',
signature: '\'system-prompt/change\'(): void',
summary: 'A section, tool provider, or variable provider was registered or unregistered (the assembly inputs changed).',
},
{
name: 'tools/change',
mode: 'emit',
signature: '\'tools/change\'(): void',
summary: 'A tool was registered or unregistered (the available tool set changed).',
},
{
name: 'tools/execute',
mode: 'waterfall',
signature: '\'tools/execute\'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>',
summary: 'Around-dispatch waterfall wrapping the registry\'s core tool dispatch, between the `tools/pre-execute` gate and the `tools/post-execute` seam.',
},
{
name: 'tools/post-execute',
mode: 'waterfall',
signature: '\'tools/post-execute\'(this: 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`).',
},
{
name: 'tools/pre-execute',
mode: 'waterfall',
signature: '\'tools/pre-execute\'(this: 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`).',
},
]
/** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */
export const TYPE_API: readonly TypeApiEntry[] = [
{
name: 'Agent',
declaration: 'export interface Agent {\n readonly id: AgentId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: SendOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise<void>;\n}',
},
{
name: 'AgentFactory',
declaration: 'export interface AgentFactory {\n createAgent(options: CreateAgentOptions): AgentHandle;\n resume(options: ResumeAgentOptions): Promise<AgentHandle>;\n}',
},
{
name: 'AgentHandle',
declaration: 'export interface AgentHandle {\n agent: Agent;\n dispose(): Promise<void>;\n}',
},
{
name: 'AgentId',
declaration: 'export type AgentId = Branded<\'AgentId\'>;',
},
{
name: 'AgentOptions',
declaration: 'export interface AgentOptions {\n model?: string;\n}',
},
{
name: 'AgentStatus',
declaration: 'export type AgentStatus = \'idle\' | \'running\' | \'disposed\';',
},
{
name: 'AskUserQuestionAnswer',
declaration: 'export interface AskUserQuestionAnswer {\n answers: AskUserQuestionAnswerItem[];\n}',
},
{
name: 'AskUserQuestionAnswerItem',
declaration: 'export interface AskUserQuestionAnswerItem {\n id: string;\n selected: string[];\n custom?: string;\n}',
},
{
name: 'AskUserQuestionItem',
declaration: 'export interface AskUserQuestionItem {\n id: string;\n question: string;\n header?: string;\n options?: AskUserQuestionOption[];\n multiSelect?: boolean;\n}',
},
{
name: 'AskUserQuestionOption',
declaration: 'export interface AskUserQuestionOption {\n label: string;\n description?: string;\n}',
},
{
name: 'AskUserQuestionRequest',
declaration: 'export interface AskUserQuestionRequest {\n questions: AskUserQuestionItem[];\n agent?: Agent;\n signal?: AbortSignal;\n}',
},
{
name: 'AssembleContext',
declaration: 'export interface AssembleContext {\n}',
},
{
name: 'AssembledSection',
declaration: 'export interface AssembledSection {\n name: string;\n order: number;\n text: string;\n}',
},
{
name: 'BashExecRequest',
declaration: 'export interface BashExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n owner?: OwnerToken | undefined;\n}',
},
{
name: 'BashExecSpec',
declaration: 'export interface BashExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n owner: OwnerToken | undefined;\n}',
},
{
name: 'BashRunResult',
declaration: 'export interface BashRunResult {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: CollectedOutput;\n stderr: CollectedOutput;\n}',
},
{
name: 'BashTask',
declaration: 'export interface BashTask {\n readonly id: BashTaskId;\n readonly command: string;\n status: BashTaskStatus;\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n readonly done: Promise<void>;\n}',
},
{
name: 'BashTaskId',
declaration: 'export type BashTaskId = Branded<\'BashTaskId\'>;',
},
{
name: 'BashTaskListener',
declaration: 'export type BashTaskListener = (task: BashTask) => void;',
},
{
name: 'BashTaskRead',
declaration: 'export interface BashTaskRead {\n task: BashTask;\n delta: string;\n lossy: boolean;\n stdoutSpillPath?: string;\n stderrSpillPath?: string;\n}',
},
{
name: 'BashTaskStatus',
declaration: 'export type BashTaskStatus = \'running\' | \'completed\' | \'killed\';',
},
{
name: 'Branded',
declaration: 'export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n};',
},
{
name: 'CallId',
declaration: 'export type CallId = Branded<\'CallId\'>;',
},
{
name: 'CodeBindingFunction',
declaration: 'export type CodeBindingFunction = (args: unknown) => Promise<unknown>;',
},
{
name: 'CodeBindingNamespace',
declaration: 'export interface CodeBindingNamespace {\n global: string;\n functions: Record<string, CodeBindingFunction>;\n}',
},
{
name: 'CodeLogEntry',
declaration: 'export interface CodeLogEntry {\n source: \'console\' | \'stdout\' | \'stderr\';\n level?: \'log\' | \'info\' | \'warn\' | \'error\' | \'debug\';\n text: string;\n}',
},
{
name: 'CodeRunFailure',
declaration: 'export interface CodeRunFailure {\n kind: \'exception\' | \'timeout\' | \'abort\' | \'worker-exit\';\n message: string;\n}',
},
{
name: 'CodeRunRequest',
declaration: 'export interface CodeRunRequest {\n program: string;\n bindings: CodeBindingNamespace[];\n signal?: AbortSignal;\n}',
},
{
name: 'CodeRunResult',
declaration: 'export interface CodeRunResult {\n value?: unknown;\n logs: CodeLogEntry[];\n error?: CodeRunFailure;\n}',
},
{
name: 'CollectedOutput',
declaration: 'export interface CollectedOutput {\n text: string;\n truncated: boolean;\n spillPath?: string;\n}',
},
{
name: 'CompactAgentContext',
declaration: 'export interface CompactAgentContext {\n session: Session;\n options: {\n model?: string;\n };\n}',
},
{
name: 'CompactionResult',
declaration: 'export interface CompactionResult {\n startSeq: number;\n summarySeq: number;\n endSeq: number;\n summary: ContentBlock[];\n shadowedRange: {\n start: number;\n end: number;\n };\n shadowedSeqs: number[];\n shadowedTokenCount: number;\n}',
},
{
name: 'ContentBlockMap',
declaration: 'export interface ContentBlockMap {\n \'text\': TextBlock;\n \'reasoning\': ReasoningBlock;\n \'tool-call\': ToolCallBlock;\n \'tool-result\': ToolResultBlock;\n}',
},
{
name: 'ContentBlockType',
declaration: 'export type ContentBlockType = keyof ContentBlockMap;',
},
{
name: 'CreateAgentOptions',
declaration: 'export interface CreateAgentOptions {\n agentId: AgentId;\n sessionId: SessionId;\n meta?: {\n cwd?: string;\n parentSession?: SessionId;\n seedLength?: number;\n };\n seed?: SessionEvent[];\n agentOptions?: AgentOptions;\n}',
},
{
name: 'CreateSessionOptions',
declaration: 'export interface CreateSessionOptions {\n seed?: SessionEvent[];\n meta?: {\n cwd?: string;\n parentSession?: SessionId;\n createdAt?: number;\n seedLength?: number;\n };\n}',
},
{
name: 'DiffCallView',
declaration: 'export interface DiffCallView {\n card: \'diff\';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n}',
},
{
name: 'DiffResultView',
declaration: 'export interface DiffResultView {\n card: \'diff\';\n title?: string;\n diffs: FileDiff[];\n}',
},
{
name: 'FileDiff',
declaration: 'export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n}',
},
{
name: 'FileLocation',
declaration: 'export interface FileLocation {\n path: string;\n line?: number;\n}',
},
{
name: 'FinishReason',
declaration: 'export type FinishReason = FinishReasonMap[keyof FinishReasonMap];',
},
{
name: 'FinishReasonMap',
declaration: 'export interface FinishReasonMap {\n \'stop\': {\n kind: \'stop\';\n };\n \'tool-calls\': {\n kind: \'tool-calls\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n \'aborted\': {\n kind: \'aborted\';\n };\n \'error\': {\n kind: \'error\';\n message: string;\n code?: string;\n };\n}',
},
{
name: 'FsDirEntry',
declaration: 'export interface FsDirEntry {\n name: string;\n type: \'file\' | \'directory\' | \'other\';\n target: FsTarget;\n version?: FsVersion;\n size?: number;\n}',
},
{
name: 'FsEditOutcome',
declaration: 'export interface FsEditOutcome {\n version: FsVersion;\n before: string;\n after: string;\n}',
},
{
name: 'FsEditRequest',
declaration: 'export interface FsEditRequest {\n oldString: string;\n newString: string;\n replaceAll: boolean;\n}',
},
{
name: 'FsInfo',
declaration: 'export interface FsInfo {\n version: FsVersion;\n type: \'file\' | \'directory\' | \'other\';\n size?: number;\n}',
},
{
name: 'FsTarget',
declaration: 'export interface FsTarget {\n targetKey: FsTargetKey;\n displayPath: string;\n}',
},
{
name: 'FsTargetKey',
declaration: 'export type FsTargetKey = Branded<\'FsTargetKey\'>;',
},
{
name: 'FsVersion',
declaration: 'export type FsVersion = Branded<\'FsVersion\'>;',
},
{
name: 'FsWriteIntent',
declaration: 'export type FsWriteIntent = {\n kind: \'createIfAbsent\';\n} | {\n kind: \'replaceIfVersion\';\n version: FsVersion;\n};',
},
{
name: 'FsWriteOutcome',
declaration: 'export interface FsWriteOutcome {\n operation: \'create\' | \'update\';\n version: FsVersion;\n before: string | null;\n after: string;\n}',
},
{
name: 'GenerateOptions',
declaration: 'export interface GenerateOptions {\n model: string;\n messages: Message[];\n system?: string;\n tools?: ToolSchema[];\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n signal?: AbortSignal;\n sessionId?: Branded<\'SessionId\'>;\n}',
},
{
name: 'GenericCallView',
declaration: 'export interface GenericCallView {\n card: \'generic\';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n}',
},
{
name: 'GenericResultView',
declaration: 'export interface GenericResultView {\n card: \'generic\';\n title?: string;\n content?: ContentBlock[];\n}',
},
{
name: 'HookContext',
declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n}',
},
{
name: 'Message',
declaration: 'export interface Message {\n role: \'system\' | \'user\' | \'assistant\';\n content: ContentBlock[];\n}',
},
{
name: 'MessageSource',
declaration: 'export type MessageSource = MessageSourceMap[keyof MessageSourceMap];',
},
{
name: 'MessageSourceMap',
declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n}',
},
{
name: 'OwnerToken',
declaration: 'export type OwnerToken = Branded<\'OwnerToken\'>;',
},
{
name: 'PromptAssembly',
declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record<string, string | undefined>;\n}',
},
{
name: 'PromptSection',
declaration: 'export interface PromptSection {\n name: string;\n order: number;\n text: string | ((context: AssembleContext) => string);\n}',
},
{
name: 'ReasoningBlock',
declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}',
},
{
name: 'ResumeAgentOptions',
declaration: 'export interface ResumeAgentOptions {\n agentId: AgentId;\n resumeSessionId: SessionId;\n agentOptions?: AgentOptions;\n}',
},
{
name: 'SendOptions',
declaration: 'export interface SendOptions {\n source?: MessageSource;\n}',
},
{
name: 'SessionEvent',
declaration: 'export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];',
},
{
name: 'SessionEventMap',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': {\n turn: number;\n content: ContentBlock[];\n source: MessageSource;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: E /* …truncated — full shape in source */',
},
{
name: 'SessionEventType',
declaration: 'export type SessionEventType = keyof SessionEventMap;',
},
{
name: 'SessionForkSource',
declaration: 'export type SessionForkSource = Session | SessionId;',
},
{
name: 'SessionHeader',
declaration: 'export interface SessionHeader {\n version: number;\n id: SessionId;\n createdAt: number;\n cwd?: string;\n parentSession?: SessionId;\n seedLength?: number;\n}',
},
{
name: 'SessionId',
declaration: 'export type SessionId = Branded<\'SessionId\'>;',
},
{
name: 'SkillCandidate',
declaration: 'export interface SkillCandidate extends SkillSummary {\n rank: number;\n locator: unknown;\n path?: string;\n metadata?: Record<string, unknown>;\n}',
},
{
name: 'SkillDefinition',
declaration: 'export interface SkillDefinition extends SkillSummary {\n content: string;\n path?: string;\n metadata?: Record<string, unknown>;\n}',
},
{
name: 'SkillLookupOptions',
declaration: 'export interface SkillLookupOptions {\n cwd?: string | undefined;\n}',
},
{
name: 'SkillProvider',
declaration: 'export interface SkillProvider {\n name: string;\n list(options: SkillLookupOptions): Promise<SkillCandidate[]>;\n get(candidate: SkillCandidate, options: SkillLookupOptions): Promise<SkillDefinition | undefined>;\n}',
},
{
name: 'SkillRegistration',
declaration: 'export type SkillRegistration = Omit<SkillDefinition, \'provider\'> & {\n provider?: string;\n};',
},
{
name: 'SkillResourceBase',
declaration: 'export type SkillResourceBase = {\n kind: \'directory\';\n path: string;\n} | {\n kind: \'url\';\n url: string;\n} | {\n kind: \'opaque\';\n description: string;\n};',
},
{
name: 'SkillSource',
declaration: 'export type SkillSource = \'project-dsh\' | \'project-agents\' | \'runtime\' | \'user-dsh\' | \'user-agents\' | \'custom\' | (string & {});',
},
{
name: 'SkillSummary',
declaration: 'export interface SkillSummary {\n name: string;\n description: string;\n whenToUse?: string;\n disableModelInvocation?: boolean;\n source: SkillSource;\n provider: string;\n resourceBase?: SkillResourceBase;\n}',
},
{
name: 'StreamChunk',
declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n};',
},
{
name: 'StructuredOutputSchema',
declaration: 'export type StructuredOutputSchema = StructuredSchemaNode & {\n type: \'object\';\n};',
},
{
name: 'StructuredScalar',
declaration: 'export type StructuredScalar = string | number | boolean | null;',
},
{
name: 'StructuredSchemaNode',
declaration: 'export interface StructuredSchemaNode {\n type: StructuredSchemaType;\n properties?: Record<string, StructuredSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: StructuredSchemaNode;\n enum?: StructuredScalar[];\n const?: StructuredScalar;\n description?: string;\n title?: string;\n default?: unknown;\n examples?: unknown;\n}',
},
{
name: 'StructuredSchemaType',
declaration: 'export type StructuredSchemaType = \'object\' | \'array\' | \'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\';',
},
{
name: 'SubagentCapabilities',
declaration: 'export interface SubagentCapabilities {\n outputSchema: boolean;\n depthLimit: boolean;\n toolFilter: boolean;\n}',
},
{
name: 'SubagentProvider',
declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentStartRequest): SubagentRun;\n}',
},
{
name: 'SubagentResult',
declaration: 'export interface SubagentResult {\n output: ContentBlock[];\n structured?: unknown;\n stopReason: SubagentStopReason;\n}',
},
{
name: 'SubagentRun',
declaration: 'export interface SubagentRun {\n readonly id: AgentId;\n readonly result: Promise<SubagentResult>;\n cancel(reason?: string): void;\n dispose(): Promise<void>;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): SubagentRun;\n}',
},
{
name: 'SubagentStartRequest',
declaration: 'export interface SubagentStartRequest {\n prompt: ContentBlock[];\n parent: Agent;\n signal?: AbortSignal;\n agentOptions?: AgentOptions;\n outputSchema?: StructuredOutputSchema;\n maxDepth?: number;\n toolFilter?: {\n allow?: string[];\n deny?: string[];\n };\n}',
},
{
name: 'SubagentStopReason',
declaration: 'export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonMap];',
},
{
name: 'SubagentStopReasonMap',
declaration: 'export interface SubagentStopReasonMap {\n completed: \'completed\';\n aborted: \'aborted\';\n error: \'error\';\n \'max-tokens\': \'max-tokens\';\n refusal: \'refusal\';\n}',
},
{
name: 'SurfaceEventType',
declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'context/message\' | \'steering/message\';',
},
{
name: 'SurfaceOp',
declaration: 'export type SurfaceOp = \'append\' | {\n op: \'replace\';\n start: number;\n end: number;\n};',
},
{
name: 'TerminalCallView',
declaration: 'export interface TerminalCallView {\n card: \'terminal\';\n title: string;\n description?: string;\n cwd?: string;\n}',
},
{
name: 'TerminalResultView',
declaration: 'export interface TerminalResultView {\n card: \'terminal\';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n}',
},
{
name: 'TodoItem',
declaration: 'export interface TodoItem {\n content: string;\n status: \'pending\' | \'in_progress\' | \'completed\';\n}',
},
{
name: 'TokenUsage',
declaration: 'export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n}',
},
{
name: 'ToolCallBlock',
declaration: 'export interface ToolCallBlock {\n type: \'tool-call\';\n id: CallId;\n name: string;\n arguments: string;\n}',
},
{
name: 'ToolCallKind',
declaration: 'export type ToolCallKind = \'read\' | \'edit\' | \'delete\' | \'move\' | \'search\' | \'execute\' | \'fetch\' | \'other\';',
},
{
name: 'ToolCallView',
declaration: 'export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;',
},
{
name: 'ToolDefinition',
declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn>;\n timeoutMs?: number;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}',
},
{
name: 'ToolErrorInfo',
declaration: 'export interface ToolErrorInfo {\n name: string;\n code: string;\n}',
},
{
name: 'ToolExecuteReturn',
declaration: 'export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n};',
},
{
name: 'ToolExecution',
declaration: 'export interface ToolExecution {\n callId: CallId;\n name: string;\n arguments: unknown;\n agent?: Agent;\n signal?: AbortSignal;\n}',
},
{
name: 'ToolExecutionResult',
declaration: 'export interface ToolExecutionResult {\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContext?: HookContext;\n meta?: unknown;\n}',
},
{
name: 'ToolResult',
declaration: 'export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n}',
},
{
name: 'ToolResultBlock',
declaration: 'export interface ToolResultBlock {\n type: \'tool-result\';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n}',
},
{
name: 'ToolResultView',
declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;',
},
{
name: 'ToolSchema',
declaration: 'export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n}',
},
{
name: 'TurnEndReason',
declaration: 'export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];',
},
{
name: 'TurnEndReasonMap',
declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n reason?: string;\n };\n error: {\n kind: \'error\';\n step: number;\n message: string;\n code?: string;\n };\n disposed: {\n kind: \'disposed\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n rejected: {\n kind: \'rejected\';\n reason: string;\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}',
},
{
name: 'TurnTrigger',
declaration: 'export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];',
},
{
name: 'TurnTriggerMap',
declaration: 'export interface TurnTriggerMap {\n message: {\n kind: \'message\';\n source: MessageSource;\n };\n injection: {\n kind: \'injection\';\n source: MessageSource;\n };\n}',
},
{
name: 'UserInteractionProvider',
declaration: 'export interface UserInteractionProvider {\n ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>;\n}',
},
{
name: 'WebExecContext',
declaration: 'export interface WebExecContext {\n readonly signal?: AbortSignal;\n}',
},
{
name: 'WebFetchBody',
declaration: 'export type WebFetchBody = {\n readonly kind: \'html\';\n readonly content: string;\n} | {\n readonly kind: \'text\';\n readonly content: string;\n};',
},
{
name: 'WebFetchProvider',
declaration: 'export interface WebFetchProvider {\n readonly id: string;\n status(): WebProviderStatus;\n fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>;\n}',
},
{
name: 'WebFetchRequest',
declaration: 'export interface WebFetchRequest {\n readonly url: string;\n readonly timeoutMs?: number;\n}',
},
{
name: 'WebFetchResult',
declaration: 'export interface WebFetchResult {\n readonly providerId: string;\n readonly url: string;\n readonly statusCode: number;\n readonly body: WebFetchBody;\n readonly truncated: boolean;\n}',
},
{
name: 'WebProviderStatus',
declaration: 'export type WebProviderStatus = {\n readonly available: true;\n} | {\n readonly available: false;\n readonly reason: \'missing-credential\' | \'misconfigured\';\n};',
},
{
name: 'WebSearchProvider',
declaration: 'export interface WebSearchProvider {\n readonly id: string;\n status(): WebProviderStatus;\n search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>;\n}',
},
{
name: 'WebSearchRequest',
declaration: 'export interface WebSearchRequest {\n readonly query: string;\n readonly maxResults?: number;\n}',
},
{
name: 'WebSearchResult',
declaration: 'export interface WebSearchResult {\n readonly providerId: string;\n readonly query: string;\n readonly content?: string;\n readonly sources: readonly WebSearchSource[];\n readonly truncated: boolean;\n}',
},
{
name: 'WebSearchSource',
declaration: 'export interface WebSearchSource {\n readonly url: string;\n readonly title?: string;\n readonly snippet?: string;\n readonly publishedAt?: string;\n}',
},
]
/** The inherited `ctx` surface (cordis core + loader/hmr/timer), in curated order. */
export const INHERITED_CTX_API: readonly InheritedApiEntry[] = [
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).' },
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).' },
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.' },
{ name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.' },
{ name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.' },
{ name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).' },
{ name: 'ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger', summary: 'Ambient handles onto the running context graph.' },
{ name: 'ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick).' },
{ name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).' },
{ name: 'ctx.hmr', summary: 'The hot-module-reload watcher (present under the hmr plugin).' },
]

View File

@@ -0,0 +1,39 @@
/**
* 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).
*
* @module @deepseek-ai/dsh-tool-cordis/fiber-state
*/
import type { FiberState as FiberStateEnum } from 'cordis'
/** Value mirror of the cordis `FiberState` const enum (see the module doc for why a mirror exists). */
export const FiberState = {
PENDING: 0 as FiberStateEnum.PENDING,
LOADING: 1 as FiberStateEnum.LOADING,
ACTIVE: 2 as FiberStateEnum.ACTIVE,
FAILED: 3 as FiberStateEnum.FAILED,
DISPOSED: 4 as FiberStateEnum.DISPOSED,
UNLOADING: 5 as FiberStateEnum.UNLOADING,
} as const
/** The cordis `FiberState` enum type, re-exported so mirror consumers need one import. */
export type FiberState = FiberStateEnum
/** Human-readable label for each {@link FiberState}, keyed by member (inlining-safe — no reverse mapping). */
export const STATE_LABELS: Record<FiberState, string> = {
[FiberState.PENDING]: 'pending',
[FiberState.LOADING]: 'loading',
[FiberState.ACTIVE]: 'active',
[FiberState.FAILED]: 'failed',
[FiberState.DISPOSED]: 'disposed',
[FiberState.UNLOADING]: 'unloading',
}

View File

@@ -0,0 +1,447 @@
/**
* 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
*/
import { Context } from 'cordis'
import type { Plugin } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolDefinition, ToolExecuteReturn } from '@deepseek-ai/dsh-tools'
const DYNAMIC_TOOL = Symbol('tool-cordis.dynamic-tool')
const SCHEMA_TYPES = new Set<unknown>(['string', 'number', 'boolean', 'object', 'array'])
const VALID_TYPES = '\'string\' | \'number\' | \'boolean\' | \'object\' | \'array\''
type DynamicToolDefinition = ToolDefinition & { [DYNAMIC_TOOL]: true }
type DynamicToolMarker = { [DYNAMIC_TOOL]?: unknown }
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Object.prototype.toString.call(value) === '[object Object]'
}
/**
* Normalize a sandbox-provided `parameters` value into a fresh host-realm
* SchemaSpec. Accepts the DSL directly, or the JSON-Schema-style
* `{ type: 'object', properties, required: […] }` wrapper models write by
* prior — the wrapper unwraps and its `required` array becomes per-property
* flags (see the module doc).
*/
function normalizeSchemaSpec(value: unknown, path = 'parameters'): Record<string, unknown> {
if (!isPlainRecord(value)) {
throw new Error(`harness.defineTool ${path} must be a SchemaSpec object`)
}
let entries = value
const requiredNames = new Set<unknown>()
if (value.type === 'object' && isPlainRecord(value.properties)) {
if (Array.isArray(value.required)) {
for (const name of value.required) requiredNames.add(name)
}
entries = value.properties
}
const spec: Record<string, unknown> = {}
for (const [key, prop] of Object.entries(entries)) {
spec[key] = normalizeSchemaProp(prop, `${path}.${key}`, requiredNames.has(key))
}
return spec
}
/** Normalize one property: `integer` → `number`, `required: false` → absent, nested wrappers unwrapped recursively. */
function normalizeSchemaProp(value: unknown, path: string, forceRequired = false): Record<string, unknown> {
if (!isPlainRecord(value)) {
throw new Error(`harness.defineTool ${path} must be a SchemaSpec property object`)
}
const type = value.type === 'integer' ? 'number' : value.type
if (!SCHEMA_TYPES.has(type)) {
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES} (got ${JSON.stringify(value.type)})`)
}
// On an object property a JSON-Schema-style `required` ARRAY names required
// children (handled by the nested unwrap below); everywhere else `required`
// must be a boolean, and `false` simply reads as optional.
const nestedRequiredArray = type === 'object' && Array.isArray(value.required)
if (value.required !== undefined && typeof value.required !== 'boolean' && !nestedRequiredArray) {
throw new Error(`harness.defineTool ${path}.required must be a boolean when present`)
}
const prop: Record<string, unknown> = { type }
if (forceRequired || value.required === true) prop.required = true
if (typeof value.description === 'string') prop.description = value.description
if (Array.isArray(value.enum)) prop.enum = [...value.enum as unknown[]]
if (value.default !== undefined) prop.default = value.default
if (value.properties !== undefined) {
if (type !== 'object') {
throw new Error(`harness.defineTool ${path}.properties is only valid for type "object"`)
}
// Re-wrap so the nested unwrap applies a nested `required` array too.
prop.properties = normalizeSchemaSpec(
{ type: 'object', properties: value.properties, required: value.required },
`${path}.properties`,
)
}
if (value.items !== undefined) {
if (type !== 'array') {
throw new Error(`harness.defineTool ${path}.items is only valid for type "array"`)
}
prop.items = normalizeSchemaProp(value.items, `${path}.items`)
}
return prop
}
function markDynamicTool(tool: ToolDefinition): DynamicToolDefinition {
Object.defineProperty(tool, DYNAMIC_TOOL, { value: true })
return tool as DynamicToolDefinition
}
function assertDynamicTool(tool: unknown): asserts tool is DynamicToolDefinition {
if (!isPlainRecord(tool) || (tool as DynamicToolMarker)[DYNAMIC_TOOL] !== true) {
throw new Error('dynamic tool registration must use a tool returned by harness.defineTool(...)')
}
}
/**
* Structurally a content block, checked AFTER the JSON round-trip: a plain
* object carrying a string `type` tag. Deliberately nothing deeper — the
* ContentBlock union is merge-extensible (an unknown tag must pass), and every
* downstream consumer dispatches on `type` and falls through unknowns.
*/
function isContentBlockShape(value: unknown): boolean {
return isPlainRecord(value) && typeof value.type === 'string'
}
/**
* How much of an invalid execute return the teaching error echoes back — a
* huge blob would burn the model turn the error is trying to save.
*/
const RETURN_PREVIEW_LIMIT = 120
/**
* Compact JSON preview of an invalid execute return for the teaching error
* (`String(…)` for the un-stringifiable undefined case), truncated to
* {@link RETURN_PREVIEW_LIMIT}.
*/
function describeReturn(value: unknown): string {
// JSON.stringify is TYPED as always returning string, but it yields
// undefined for an undefined input (the routed forgot-return case) — the
// assertion widens the type back to the runtime truth.
const json = JSON.stringify(value) as string | undefined
if (json === undefined) return String(value)
return json.length > RETURN_PREVIEW_LIMIT ? `${json.slice(0, RETURN_PREVIEW_LIMIT)}` : json
}
/**
* Validate a round-tripped `execute` return against the two shapes
* {@link ToolExecuteReturn} allows: an ARRAY of content blocks, or
* `{ content: blocks, meta? }`. 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 —
* so a wrong shape fails THIS call with a teaching error instead.
*/
function assertExecuteReturn(value: unknown): ToolExecuteReturn {
if (Array.isArray(value) && value.every(isContentBlockShape)) {
return value as ToolExecuteReturn
}
if (isPlainRecord(value) && Array.isArray(value.content) && value.content.every(isContentBlockShape)) {
return value as ToolExecuteReturn
}
throw new Error(
`execute returned ${describeReturn(value)} — a tool's execute must return an ARRAY of content blocks, never a bare string:\n`
+ ' ✓ return [{ type: \'text\', text: someString }]\n'
+ ' ✓ return { content: [{ type: \'text\', text: someString }], meta: anyJsonValue }',
)
}
/**
* 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.
* @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.
*/
export function sandboxDefineTool(options: Parameters<typeof defineTool>[0]): ToolDefinition {
const parameters = normalizeSchemaSpec((options as { parameters?: unknown }).parameters)
const tool = defineTool({ ...options, parameters } as Parameters<typeof defineTool>[0])
const execute = tool.execute.bind(tool)
return markDynamicTool({
...tool,
async execute(args, exec) {
// JSON.stringify yields NO JSON for an undefined (or function/symbol)
// return despite its string-typed signature — route that into
// assertExecuteReturn's teaching error rather than letting JSON.parse
// throw its cryptic '"undefined" is not valid JSON'.
const json = JSON.stringify(await execute(args, exec)) as string | undefined
return assertExecuteReturn(json === undefined ? undefined : JSON.parse(json) as unknown)
},
})
}
/**
* The `harness.registerTool` handed into the sandbox: registers a
* marker-verified dynamic tool on the given context's registry.
* @param ctx - the (guarded) context whose `tools` service receives the tool.
* @param tool - a definition produced by {@link sandboxDefineTool}; anything else is rejected.
* @returns the registry disposer for the registration.
*/
export function sandboxRegisterTool(ctx: Context, tool: unknown): () => void {
assertDynamicTool(tool)
return ctx.tools.register(tool)
}
/**
* 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.
*/
const CTX_VERBS = new Set(['on', 'once', 'provide', 'timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce'])
/**
* The tool-registry façade: `register` (marker-guarded) plus READ-ONLY
* metadata (`schemas`, and `get` returning a schema view, never the live
* `ToolDefinition`). Exposing the raw definition would hand mount code the
* tool's `execute` function, letting it call another tool directly and bypass
* `ToolRegistry.execute` — the pre/post-execute waterfall (permission gates,
* accounting) and result normalization. So `get` returns the same
* name/description/parameters view as `schemas()`, and nothing invocable.
*/
function sandboxTools(ctx: Context): Record<string, unknown> {
return {
register: (tool: unknown): (() => void) => sandboxRegisterTool(ctx, tool),
schemas: () => ctx.tools.schemas(),
get: (name: string) => ctx.tools.schemas().find(schema => schema.name === name),
}
}
/**
* Reject any injected-service return that is a cordis `Context`. Harness
* services return data, never a context; a value that is one would be a
* fresh, unguarded handle back into the runtime — the exact escape the façade
* exists to close — so it fails loud instead of reaching sandbox code.
*/
function denyContext(value: unknown, service: string): unknown {
if (value instanceof Context) {
throw new Error(
`service "${service}" returned a cordis Context, which the sandbox does not expose. `
+ 'Operate through your own plugin ctx (ctx.on / ctx.provide / ctx.tools.register) '
+ 'and the services you inject — never another context.',
)
}
return value
}
/**
* Wrap an injected service so its methods forward to the real instance but
* their return values pass through {@link denyContext}. Non-function members
* (plain data) pass through as-is; a returned Promise is guarded on resolve.
*/
function guardedService(service: object, name: string): unknown {
return new Proxy(service, {
get(target, prop) {
const value = Reflect.get(target, prop, target) as unknown
if (typeof value !== 'function') return denyContext(value, name)
return (...args: unknown[]): unknown => {
const result = Reflect.apply(value, target, args) as unknown
if (result instanceof Promise) return result.then(v => denyContext(v, name))
return denyContext(result, name)
}
},
})
}
/**
* The service names a plugin declared in `inject`, as a lookup set. Whatever
* declaration style the plugin used — an `inject: ['bash', 'tools']` array or
* the `{ required, optional }` object form — cordis resolves it into a single
* name-keyed map on the fiber before `apply` runs (`{ bash: null, tools: null }`),
* so the gate just reads that map's keys. A mount may reach only the services
* it declared — that is what lets cordis park the mount when a declared
* provider unmounts.
*/
function declaredInjects(ctx: Context): Set<string> {
return new Set(Object.keys(ctx.fiber.inject))
}
/**
* 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.
*/
function sandboxContext(ctx: Context): Context {
const tools = sandboxTools(ctx)
const declared = declaredInjects(ctx)
// A framework member or an undeclared service — distinguish the two so the
// error teaches the right fix (declare it in inject vs it is withheld).
const denyRead = (prop: string): never => {
if (ctx.get(prop) !== undefined) {
throw new Error(
`service "${prop}" is not injected. Declare it: inject: ['${prop}', …] on your plugin, `
+ 'so cordis parks this mount if the provider is later unmounted.',
)
}
throw new Error(
`sandbox ctx does not expose "${prop}". Available: ctx.tools.register / ctx.on / ctx.provide / `
+ 'the timer helpers (ctx.setTimeout, ctx.interval, …) and any service you declared in inject. '
+ '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.
const readService = (name: string): unknown => {
if (name === 'tools') return tools
if (!declared.has(name)) return denyRead(name)
const service = denyContext(ctx.get(name), name)
if (service === null || (typeof service !== 'object' && typeof service !== 'function')) return service
return guardedService(service, name)
}
const get = (name: string): unknown => readService(name)
return new Proxy({}, {
get(_target, prop) {
if (prop === 'tools') return tools
if (prop === 'get') return get
if (typeof prop !== 'string') return undefined
// Lazy verb forwarder — reads `ctx[verb]` only when called, so a plugin
// that never uses a timer never triggers the timer mixin's inject check
// (cordis raises its own "without inject" error there for undeclared timer use).
if (CTX_VERBS.has(prop)) {
return (...args: unknown[]): unknown => {
const method = ctx[prop as keyof Context]
return Reflect.apply(method as (...a: unknown[]) => unknown, ctx, args)
}
}
return readService(prop)
},
// A façade is not the real ctx; block writes rather than let mount code
// stash state on a throwaway object and think it persisted.
set(_target, prop) {
throw new Error(`sandbox ctx is read-only; cannot assign "${String(prop)}"`)
},
// `in` reflects reachability: the façade surface plus DECLARED services
// (whether or not currently live). Does not resolve/wrap — no throw.
has: (_target, prop) => prop === 'tools' || prop === 'get'
|| (typeof prop === 'string' && (CTX_VERBS.has(prop) || declared.has(prop))),
}) as unknown as Context
}
/**
* Narrow an arbitrary sandbox return value to a mountable cordis plugin: a
* function, or an object with an `apply` function. (A bare function passes the
* first arm, so the object arm never sees `Function.prototype.apply`.)
* @param value - whatever the mount code returned.
* @returns whether the value is mountable via `ctx.plugin`.
*/
export function isPlugin(value: unknown): value is Plugin {
if (typeof value === 'function') return true
return typeof value === 'object' && value !== null
&& typeof (value as { apply?: unknown }).apply === 'function'
}
/**
* 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.
* @param plugin - the plugin the mount code returned.
* @returns an equivalent plugin whose `apply` sees the sandbox context façade.
*/
export function guardedPlugin(plugin: Plugin): Plugin {
if (typeof plugin === 'function') {
const functionPlugin = plugin as (ctx: Context, config?: unknown) => unknown
return {
name: pluginName(plugin),
apply(ctx: Context, config?: unknown) {
return functionPlugin(sandboxContext(ctx), config)
},
}
}
const objectPlugin = plugin as { apply(ctx: Context, config?: unknown): unknown }
return {
...plugin,
apply(ctx: Context, config?: unknown) {
return objectPlugin.apply(sandboxContext(ctx), config)
},
}
}
/**
* Display name for a mounted plugin: its `name` property, else anonymous.
* @param plugin - the plugin the mount code returned.
* @returns the human-readable name used in mount results and inspect output.
*/
export function pluginName(plugin: Plugin): string {
const named = (plugin as { name?: unknown }).name
if (typeof named === 'string' && named.length > 0) return named
return '<anonymous>'
}

View File

@@ -0,0 +1,236 @@
/**
* 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).
*
* @module @deepseek-ai/dsh-tool-cordis
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { STATE_LABELS } from './fiber-state.ts'
import { isPlugin, pluginName } from './guard.ts'
import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools } from './inspect.ts'
import { missingServices, mountDynamic } from './mount.ts'
import type { DynamicMount } from './mount.ts'
import { presentInspectCall, presentMountCall, presentUnmountCall } from './present.ts'
import { createSandbox, evaluateMountCode } from './sandbox.ts'
export const name = 'tool-cordis'
export const inject = ['tools']
/** Config for the tool-cordis plugin: the sandbox evaluation bound. */
export interface Config {
/**
* Milliseconds the SYNCHRONOUS portion of mount code may run in the vm
* before evaluation is aborted (default 5000). An async body escapes this
* bound — see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md for the trust stance.
*/
vmTimeoutMs?: number
}
/** Schemastery validator for {@link Config}: `vmTimeoutMs` must be at least 1 (defaults to 5000). */
export const Config: z<Config> = z.object({
vmTimeoutMs: z.number().min(1).default(5000),
})
/** {@link Config} with every defaulted field present, as schemastery resolves it at load. */
type ResolvedConfig = Required<Config>
/**
* Mount the three cordis tools on `ctx.tools` and create the `cordis-dynamic`
* group fiber every dynamic mount hangs under.
* @param ctx - the plugin context (`tools` injected).
* @param config - the schemastery-resolved {@link 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.
const group = ctx.plugin({ name: 'cordis-dynamic', apply: () => {} })
const mounts = new Map<string, DynamicMount>()
let nextId = 1
ctx.tools.register(defineTool({
name: 'cordis_inspect',
description:
'Inspect the live cordis runtime that is running THIS agent. Read-only. '
+ 'Sections: `services` (every provided ctx service and the plugin fiber that owns it), '
+ '`plugins` (a flat list of the loaded plugins with their lifecycle states), '
+ '`tools` (the model-facing tools currently registered, i.e. what you can call), '
+ '`dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), '
+ '`api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), '
+ '`events` (every harness event with its dispatch mode and exact signature — pick listener targets here). '
+ 'Omit `what` to get all six sections.',
parameters: {
what: {
type: 'string',
enum: ['services', 'plugins', 'tools', 'dynamic', 'api', 'events'],
description: 'Limit the report to one section. Omit for all sections.',
},
},
execute(args): Promise<{ type: 'text'; text: string }[]> {
const sections: [heading: string, body: () => string[]][] = [
['services', () => describeServices(ctx)],
['plugins', () => describePlugins(ctx)],
['tools', () => describeTools(ctx)],
['dynamic', () => describeDynamic(ctx, mounts)],
['api', () => describeApi(ctx)],
['events', () => describeEvents()],
]
const selected = sections.filter(([heading]) => args.what === undefined || args.what === heading)
const text = selected
.map(([heading, body]) => `## ${heading}\n${body().join('\n')}`)
.join('\n\n')
return Promise.resolve([{ type: 'text', text }])
},
presentCall: presentInspectCall,
}))
ctx.tools.register(defineTool({
name: 'cordis_mount',
description:
'Mount a NEW cordis plugin into the live runtime that is running THIS agent '
+ '(self-modification). `code` runs as the body of an async JavaScript function '
+ 'in an isolated sandbox and MUST `return` a plugin. Two forms: '
+ 'FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register '
+ 'tools, listen to events, and provide services, but reaching ANY service (e.g. '
+ 'ctx.bash) throws; use it only when you need no services. '
+ 'OBJECT form `return { name?, inject: [\'bash\', \'llm\', …], apply(ctx) { … } }` '
+ '— declares dependencies, and cordis activates the plugin only after the '
+ 'services exist; PREFER this form. You may reach ONLY the services you list in '
+ 'inject: an undeclared service throws even if it exists, because an undeclared '
+ 'dependency would not be cleaned up if its provider is unmounted. '
+ 'BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists '
+ 'method signatures AND the type shapes of their arguments/returns (do not guess a '
+ 'field\'s type; e.g. a bash run\'s stdout is an object, not a string). '
+ 'Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe '
+ 'events (see cordis_inspect what:"events"), or call '
+ '`harness.registerTool(ctx, harness.defineTool({ name, description, parameters: '
+ '{ text: { type: \'string\', required: true } }, async execute(args) { … } }))` '
+ 'to give yourself a new tool — it becomes callable on your NEXT step. '
+ 'Tool parameters: each key IS a property — { type: \'string\'|\'number\'|\'boolean\'|\'object\'|\'array\', '
+ 'required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style '
+ '{ type: \'object\', properties, required: […] } wrapper and type \'integer\' are also accepted and normalized. A '
+ 'tool\'s `execute` MUST return an ARRAY of content blocks, e.g. `return '
+ '[{ type: \'text\', text: someString }]` — never a bare string. '
+ 'Mounts can COMPOSE: one plugin may `ctx.provide(\'name\', value)` a service and '
+ 'another may declare `inject: [\'name\']` to consume it — the consumer stays pending '
+ 'until the provider exists and returns to pending when the provider is unmounted. '
+ 'Everything registered inside `apply` is cleaned up automatically on unmount. '
+ 'Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness '
+ 'terminal), `harness.defineTool`, `harness.registerTool`, '
+ '`btoa`, `atob`, `TextEncoder`, `TextDecoder`. '
+ 'Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, '
+ 'never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect '
+ 'errors; `process` and `Buffer` are undefined. Instead use inject: [\'fs\'] + ctx.fs for '
+ 'files, inject: [\'web\'] + ctx.web for HTTP, inject: [\'bash\'] + ctx.bash for processes, '
+ 'and inject: [\'timer\'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, '
+ 'auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. '
+ 'Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). '
+ 'Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a '
+ 'trailing `next` callback which MUST be called — returning without `next()` '
+ 'VETOES the call; prefer plain notification events unless you intend to '
+ 'intercept. (2) Never await something that only resolves after the current '
+ 'turn (your code runs INSIDE a tool call of that turn — it would deadlock). '
+ '(3) Your `ctx` is a restricted façade: you can register tools, observe '
+ 'events, provide/consume services, and use timers, but framework internals '
+ '(ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a '
+ 'security boundary though — the services you inject (e.g. ctx.bash) reach the '
+ 'real runtime.',
parameters: {
code: {
type: 'string',
required: true,
description: 'Body of an async JS function; must `return` the plugin to mount.',
},
},
async execute(args) {
const id = `dyn-${nextId++}`
const sandbox = createSandbox(id)
const evaluated = await evaluateMountCode(sandbox, args.code, id, vmTimeoutMs)
if (!isPlugin(evaluated)) {
if (evaluated === undefined) {
throw new Error(
'mount code returned `undefined` — did you forget `return`?\n'
+ ' ✓ return (ctx) => { … }\n'
+ ' ✓ return { name: \'…\', inject: […], apply(ctx) { … } }',
)
}
throw new Error(
'mount code must `return` a plugin: a function, or an object with an `apply(ctx)` method',
)
}
const fiber = await mountDynamic(group, evaluated)
mounts.set(id, { fiber, pluginName: pluginName(evaluated) })
// A settled fiber that is not ACTIVE is waiting on unsatisfied inject —
// legal cordis semantics (it activates when the service appears), so keep
// it mounted but tell the model what it is waiting for.
const missing = missingServices(ctx, fiber)
const state = STATE_LABELS[fiber.state]
const note = missing.length > 0
? ` — waiting for service(s): ${missing.join(', ')} (activates when provided)`
: ''
return [{ type: 'text', text: `mounted ${id} (plugin "${pluginName(evaluated)}", state: ${state}${note})` }]
},
presentCall: presentMountCall,
}))
ctx.tools.register(defineTool({
name: 'cordis_unmount',
description:
'Dispose a plugin previously mounted with cordis_mount, by id. All its '
+ 'registrations (event listeners, tools, services) are cleaned up through '
+ 'the cordis effect lifecycle. Returns only after disposal has fully '
+ 'completed (quiescence, not just a request to stop).',
parameters: {
id: {
type: 'string',
required: true,
description: 'The dynamic mount id returned by cordis_mount (e.g. "dyn-1").',
},
},
async execute(args) {
const mount = mounts.get(args.id)
if (!mount) {
throw new Error(`no dynamic plugin with id "${args.id}" (list mounts with cordis_inspect what:"dynamic")`)
}
await mount.fiber.dispose()
mounts.delete(args.id)
return [{ type: 'text', text: `unmounted ${args.id} (plugin "${mount.pluginName}")` }]
},
presentCall: presentUnmountCall,
}))
}

View File

@@ -0,0 +1,191 @@
/**
* 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.
*
* @module @deepseek-ai/dsh-tool-cordis/inspect
*/
import type { Context, Fiber } from 'cordis'
import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts'
import type { EventApiEntry, InheritedApiEntry, ServiceApiEntry, TypeApiEntry } from './api-catalog.ts'
import { FiberState, STATE_LABELS } from './fiber-state.ts'
import { missingServices } from './mount.ts'
import type { DynamicMount } from './mount.ts'
/** The live service registrations from `ctx.reflect.store` (map + filter keeps the possibly-undefined index read branch-free). */
function liveImpls(ctx: Context): { name: string; fiber: Fiber }[] {
const store = ctx.reflect.store
return Object.getOwnPropertySymbols(store)
.map(key => store[key])
.filter((impl): impl is NonNullable<typeof impl> => impl !== undefined)
}
/** Whether `fiber` is `root` itself or mounted anywhere inside `root`'s subtree. */
function withinFiber(fiber: Fiber, root: Fiber): boolean {
let current = fiber
while (true) {
if (current === root) return true
const parent = current.parent.fiber
if (parent === current) return false
current = parent
}
}
/** The service names provided by a mount's fiber subtree, sorted. */
function providedBy(ctx: Context, fiber: Fiber): string[] {
return liveImpls(ctx)
.filter(impl => withinFiber(impl.fiber, fiber))
.map(impl => impl.name)
.sort()
}
/**
* The `services` section: every provided ctx service with its owning fiber,
* annotating non-active owners with their lifecycle state.
* @param ctx - the runtime to enumerate.
* @returns one line per service, or a single placeholder line when none are provided.
*/
export function describeServices(ctx: Context): string[] {
const lines = liveImpls(ctx).map((impl) => {
const active = impl.fiber.state === FiberState.ACTIVE
return `- ${impl.name} (provided by ${impl.fiber.name}${active ? '' : `, ${STATE_LABELS[impl.fiber.state]}`})`
})
return lines.length > 0 ? lines : ['(no services provided)']
}
/**
* The `plugins` section: a flat list of every fiber the registry knows, one
* line per fiber with its lifecycle state, sorted by plugin name (a plugin
* mounted more than once repeats — one line per instance). Dynamic mounts are
* listed like any other plugin; their ids live in the `dynamic` section.
* @param ctx - the runtime whose registry is enumerated.
* @returns one line per loaded plugin fiber.
*/
export function describePlugins(ctx: Context): string[] {
const fibers: Fiber[] = []
for (const runtime of ctx.registry.values()) {
for (const fiber of runtime.fibers) fibers.push(fiber)
}
return fibers
.sort((a, b) => a.name.localeCompare(b.name))
.map(fiber => `- ${fiber.name} [${STATE_LABELS[fiber.state]}]`)
}
/**
* The `tools` section: the model-facing tool names currently registered.
* @param ctx - the runtime whose tool registry is read.
* @returns one line per registered tool.
*/
export function describeTools(ctx: Context): string[] {
return ctx.tools.schemas().map(schema => `- ${schema.name}`)
}
/**
* The `dynamic` section: one line per mount with id, plugin name, lifecycle
* state, the services its subtree provides, and — for a pending mount — the
* services it waits for.
* @param ctx - the runtime the mounts live in.
* @param mounts - the tracked mounts, in mount order.
* @returns one line per mount, or a single placeholder line when none exist.
*/
export function describeDynamic(ctx: Context, mounts: ReadonlyMap<string, DynamicMount>): string[] {
if (mounts.size === 0) return ['(no dynamic plugins mounted)']
return [...mounts].map(([id, mount]) => {
const provides = providedBy(ctx, mount.fiber)
const waiting = missingServices(ctx, mount.fiber)
const providesNote = provides.length > 0 ? ` — provides: ${provides.join(', ')}` : ''
const waitingNote = waiting.length > 0 ? ` — waiting for: ${waiting.join(', ')}` : ''
return `- ${id}: ${mount.pluginName} [${STATE_LABELS[mount.fiber.state]}]${providesNote}${waitingNote}`
})
}
/**
* The transitive closure of catalogued type shapes referenced (word-bounded)
* by the seed texts — the runtime scoping that keeps the `api` section to the
* shapes the LIVE signatures actually mention.
*/
function typeClosure(seeds: string[], types: readonly TypeApiEntry[]): TypeApiEntry[] {
const included = new Map<string, TypeApiEntry>()
let frontier = seeds
while (frontier.length > 0) {
const next: string[] = []
for (const entry of types) {
if (included.has(entry.name)) continue
const pattern = new RegExp(`\\b${entry.name}\\b`)
if (frontier.some(text => pattern.test(text))) {
included.set(entry.name, entry)
next.push(entry.declaration)
}
}
frontier = next
}
return [...included.values()].sort((a, b) => a.name.localeCompare(b.name))
}
/**
* 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.
* @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).
* @returns the section lines.
*/
export function describeApi(
ctx: Context,
api: readonly ServiceApiEntry[] = SERVICE_API,
inherited: readonly InheritedApiEntry[] = INHERITED_CTX_API,
types: readonly TypeApiEntry[] = TYPE_API,
): string[] {
const live = new Map<string, string>()
for (const impl of liveImpls(ctx)) live.set(impl.name, impl.fiber.name)
const lines: string[] = []
const liveMethodTexts: string[] = []
for (const entry of api) {
if (!live.has(entry.key)) continue
lines.push(`- ${entry.key}${entry.summary}`)
for (const method of entry.methods) {
lines.push(` ${method}`)
liveMethodTexts.push(method)
}
}
const catalogued = new Set(api.map(entry => entry.key))
for (const [name, fiber] of [...live].sort(([a], [b]) => a.localeCompare(b))) {
if (!catalogued.has(name)) lines.push(`- ${name} (provided by ${fiber}, no catalog entry)`)
}
const notRunning = api.filter(entry => !live.has(entry.key)).map(entry => entry.key)
if (notRunning.length > 0) lines.push(`not running (loadable services with no live provider): ${notRunning.join(', ')}`)
const shapes = typeClosure(liveMethodTexts, types)
if (shapes.length > 0) {
lines.push('type shapes (referenced by the signatures above — read these before assuming a field is a string):')
for (const shape of shapes) {
for (const declLine of shape.declaration.split('\n')) lines.push(` ${declLine}`)
}
}
lines.push('inherited ctx API:')
for (const entry of inherited) lines.push(`- ${entry.name}${entry.summary}`)
return lines
}
/**
* The `events` section: every harness event with its dispatch mode, one-line
* summary, and exact signature, closed by the waterfall caution.
* @param events - the event catalog (the generated one by default; injectable for tests).
* @returns the section lines.
*/
export function describeEvents(events: readonly EventApiEntry[] = EVENT_API): string[] {
const lines = events.flatMap(event => [
`- ${event.name} [${event.mode}] — ${event.summary}`,
` ${event.signature}`,
])
lines.push('waterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain.')
return lines
}

View File

@@ -0,0 +1,64 @@
/**
* Dynamic-mount lifecycle over the `cordis-dynamic` group fiber: settle a
* sandbox-produced plugin as a child fiber (never leaving a failed fiber
* mounted), and report the services a settled-but-pending fiber still waits
* for. Disposal needs no helper — a mount unwinds through an ordinary awaited
* `fiber.dispose()`, because everything the plugin registered is an effect on
* its fiber.
*
* @module @deepseek-ai/dsh-tool-cordis/mount
*/
import type { Context, Fiber, Plugin } from 'cordis'
import { guardedPlugin } from './guard.ts'
/** One tracked dynamic mount: the fiber plus the display name captured at mount time. */
export interface DynamicMount {
/** The child fiber under the `cordis-dynamic` group. */
fiber: Fiber
/** The plugin's display name at mount time (its `name`, else `<anonymous>`). */
pluginName: string
}
/**
* 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.
* @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`).
*/
export async function mountDynamic(group: Fiber, plugin: Plugin): Promise<Fiber> {
await group.await()
const fiber = group.ctx.plugin(guardedPlugin(plugin))
try {
await fiber.await()
} catch (error) {
await fiber.dispose()
const message = error instanceof Error ? error.message : String(error)
// The commonest startup collision is remounting a NEW version of a tool
// while the old mount still holds the name — teach the replace recipe.
if (message.includes('already registered')) {
throw new Error(
`${message} — to REPLACE something an earlier mount registered, first cordis_unmount that mount's id `
+ '(find it with cordis_inspect what:"dynamic"), then mount the new version.',
)
}
throw error instanceof Error ? error : new Error(message)
}
return fiber
}
/**
* The services a fiber declared in `inject` that do not exist yet — a settled
* fiber that is not active is waiting on exactly these (legal cordis
* semantics: it activates when the service appears).
* @param ctx - the context to resolve service existence against.
* @param fiber - the mount fiber whose `inject` declarations are checked.
* @returns the missing service names, in declaration order.
*/
export function missingServices(ctx: Context, fiber: Fiber): string[] {
return Object.keys(fiber.inject).filter(service => ctx.get(service) === undefined)
}

View File

@@ -0,0 +1,51 @@
/**
* ACP render intents for the three cordis tools — all `generic` cards, decided
* up front as part of the tool design. Presenters are pure functions of the
* call arguments (they run on replay too): no I/O, no session state, no clock.
* No `presentResult` overrides exist — the tools' text results are their
* correct completed rendering.
*
* @module @deepseek-ai/dsh-tool-cordis/present
*/
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
/**
* The `cordis_inspect` call card: a read, titled with the requested section.
* @param args - the validated call arguments.
* @returns the generic card the ACP bridge renders.
*/
export function presentInspectCall(args: { what?: string }): GenericCallView {
return {
card: 'generic',
kind: 'read',
title: args.what === undefined ? 'Inspect cordis runtime' : `Inspect cordis runtime: ${args.what}`,
}
}
/**
* The `cordis_mount` call card: an execute carrying the mount code as raw input.
* @param args - the validated call arguments.
* @returns the generic card the ACP bridge renders.
*/
export function presentMountCall(args: { code: string }): GenericCallView {
return {
card: 'generic',
kind: 'execute',
title: 'Mount plugin into live cordis runtime',
rawInput: { code: args.code },
}
}
/**
* The `cordis_unmount` call card: a delete, titled with the mount id.
* @param args - the validated call arguments.
* @returns the generic card the ACP bridge renders.
*/
export function presentUnmountCall(args: { id: string }): GenericCallView {
return {
card: 'generic',
kind: 'delete',
title: `Unmount ${args.id}`,
}
}

View File

@@ -0,0 +1,201 @@
/**
* 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.
*
* @module @deepseek-ai/dsh-tool-cordis/sandbox
*/
import { createContext, runInContext } from 'node:vm'
import { sandboxDefineTool, sandboxRegisterTool } from './guard.ts'
/**
* A write-through console for one sandbox, tagging every line with the mount
* id. Write-through (host stdout/stderr), NOT buffered into the tool result:
* a mounted listener fires long after the mount call returned, and its output
* must land somewhere the user can see — for the stdio demo, the terminal.
*/
function taggedConsole(id: string): Record<'log' | 'info' | 'warn' | 'error' | 'debug', (...args: unknown[]) => void> {
const tag = `[cordis:${id}]`
const log = (...args: unknown[]): void => { console.log(tag, ...args) }
const error = (...args: unknown[]): void => { console.error(tag, ...args) }
return { log, info: log, warn: log, debug: log, 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.
*/
const DUAL_REALM_INSTANCEOF_PRELUDE = `
(hostIntrinsics) => {
'use strict'
const ordinary = Function.prototype[Symbol.hasInstance]
for (const name of Object.keys(hostIntrinsics)) {
const VmCtor = globalThis[name]
const HostCtor = hostIntrinsics[name]
if (typeof VmCtor !== 'function' || typeof HostCtor !== 'function') continue
Object.defineProperty(VmCtor, Symbol.hasInstance, {
value: (instance) => ordinary.call(VmCtor, instance) || ordinary.call(HostCtor, instance),
configurable: true,
})
}
}
`
/** Run {@link DUAL_REALM_INSTANCEOF_PRELUDE} in a freshly created sandbox, handing it the host intrinsics to pair up. */
function patchDualRealmInstanceof(sandbox: object): void {
const patch = runInContext(DUAL_REALM_INSTANCEOF_PRELUDE, sandbox) as (intrinsics: Record<string, unknown>) => void
patch({ Object, Array, Function, Error, TypeError, RangeError, SyntaxError, Promise, RegExp, Date, Map, Set })
}
const TIMER_REDIRECT
= 'Node timers are unavailable. Use the cordis timer service instead: declare inject: [\'timer\'] on your plugin '
+ 'and call ctx.setTimeout / ctx.setInterval — those are fiber effects, cleaned up automatically on unmount.'
/**
* The callable Node APIs the sandbox deliberately disables, each mapped to the
* cordis alternative its trap error names. Only FUNCTION-shaped globals are
* trapped — a data-shaped global like `process` stays `undefined`, because a
* throwing accessor would detonate the common `typeof process` feature probe
* at resolution time.
*/
const NODE_API_REDIRECTS: Record<string, string> = {
require:
'Node modules are unavailable. Use the cordis services on ctx instead — e.g. inject: [\'fs\'] for files, '
+ '[\'web\'] for HTTP, [\'bash\'] for processes; cordis_inspect what:"api" lists what THIS runtime provides.',
setTimeout: TIMER_REDIRECT,
setInterval: TIMER_REDIRECT,
setImmediate: TIMER_REDIRECT,
clearTimeout: TIMER_REDIRECT,
clearInterval: TIMER_REDIRECT,
fetch:
'Network access goes through the cordis web service: declare inject: [\'web\'] and call ctx.web '
+ '(see cordis_inspect what:"api" for its methods).',
}
/** Build the trap functions for {@link NODE_API_REDIRECTS}: calling one throws the redirect. */
function nodeApiTraps(): Record<string, () => never> {
const traps: Record<string, () => never> = {}
for (const [name, redirect] of Object.entries(NODE_API_REDIRECTS)) {
traps[name] = () => {
throw new Error(`${name} is not available in the mount sandbox — ${redirect}`)
}
}
return traps
}
/**
* Build the vm context one `cordis_mount` call evaluates in: the tagged
* console, the `harness` registration helpers, the encoding primitives, the
* Node-API traps, and the dual-realm `instanceof` patch, already
* `createContext`-ed.
* @param id - the mount id (`dyn-<n>`), used as the console tag and filename stem.
* @returns the contextified sandbox object to pass to {@link evaluateMountCode}.
*/
export function createSandbox(id: string): object {
const sandbox = {
...nodeApiTraps(),
console: taggedConsole(id),
harness: { defineTool: sandboxDefineTool, registerTool: sandboxRegisterTool },
// Web APIs absent from fresh vm contexts — made available so the model
// can encode/decode base64 without Buffer (which is also absent). Host
// closures over Buffer, never Buffer itself.
btoa: (s: string) => Buffer.from(s, 'utf-8').toString('base64'),
atob: (s: string) => Buffer.from(s, 'base64').toString('utf-8'),
TextEncoder,
TextDecoder,
}
createContext(sandbox)
patchDualRealmInstanceof(sandbox)
return sandbox
}
/**
* Cross-realm SyntaxError detection: a compile failure inside `runInContext`
* constructs its error in the SANDBOX realm, so a host `instanceof
* SyntaxError` is silently false — the `name` property is the realm-safe tag.
*/
function isSyntaxError(error: unknown): error is Error {
return typeof error === 'object' && error !== null && (error as { name?: unknown }).name === 'SyntaxError'
}
/**
* The parse-failure context a vm `SyntaxError` carries: the vm prints the
* offending source line and a caret before the message, which is exactly what
* a model needs to self-correct — surface it instead of the bare message.
* Falls back to `String(error)` when the stack carries no such prelude.
* @param error - the `SyntaxError` (host- or sandbox-realm) thrown while compiling mount code.
* @returns the stack prefix up to and including the `SyntaxError: …` line.
*/
export function syntaxErrorContext(error: Error): string {
const lines = (error.stack ?? '').split('\n')
const messageIndex = lines.findIndex(line => line.startsWith('SyntaxError'))
if (messageIndex === -1) return String(error)
return lines.slice(0, messageIndex + 1).join('\n')
}
/**
* 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).
* @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`).
* @param vmTimeoutMs - the synchronous evaluation bound in milliseconds.
* @returns whatever the code returned, still un-narrowed (the mount lifecycle checks plugin shape).
*/
export async function evaluateMountCode(sandbox: object, code: string, id: string, vmTimeoutMs: number): Promise<unknown> {
try {
return await runInContext(
`(async () => {\n${code}\n})()`,
sandbox,
{ filename: `cordis-mount-${id}.js`, timeout: vmTimeoutMs },
)
} catch (error) {
if (!isSyntaxError(error)) throw error
const context = syntaxErrorContext(error)
// Scope the TypeScript heuristic to the OFFENDING line, not the whole
// code: an ` as ` inside an ordinary description string must not turn a
// plain syntax error into a misleading remove-annotations message.
const offendingLine = context.split('\n')[1] ?? ''
if (/\bas\b/.test(offendingLine)) {
throw new Error(
`mount code failed to parse:\n${context}\n`
+ 'The sandbox runs plain JavaScript, not TypeScript. Remove type annotations:\n'
+ ' ✗ { type: \'text\' as const, text: x }\n'
+ ' ✓ { type: \'text\', text: x }',
)
}
throw new Error(
`mount code failed to parse:\n${context}\n`
+ 'Note: `code` runs as the BODY of an async function (line numbers are offset by the 1-line wrapper). '
+ 'Check bracket balance — ending the returned plugin object with `});` closes a call that was never opened; '
+ 'a plain `return { … }` ends with `}` (an optional `;`), never `)`.',
)
}
}

View File

@@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest'
import { call, CONSUMER_CODE, PROVIDER_CODE, setup, text } from './helpers.ts'
/**
* Cross-mount composition through ordinary cordis provide/inject semantics:
* one mount provides a service, another injects it, and mount ids stay the
* lifecycle handles. Every assertion is against the WORLD — the registry, the
* service store, real tool dispatch — not the tool's own summary line.
*/
describe('cross-mount provide/inject', () => {
it('provider first: the consumer activates immediately and its tool reaches the provided service', async () => {
const ctx = await setup()
const provider = await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
expect(text(provider)).toContain('state: active')
const consumer = await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
expect(consumer.isError).toBe(false)
expect(text(consumer)).toContain('state: active')
// The vm-realm service value is callable across mounts, and the result
// normalizes into the host realm like any dynamic tool result.
const greeted = await call(ctx, 'greet', { name: 'harness' })
expect(greeted.isError).toBe(false)
expect(text(greeted)).toBe('hi harness')
})
it('consumer first: stays pending naming the missing service, then activates when the provider mounts', async () => {
const ctx = await setup()
const consumer = await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
expect(consumer.isError).toBe(false)
expect(text(consumer)).toContain('state: pending')
expect(text(consumer)).toContain('waiting for service(s): greeter')
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('waiting for: greeter')
expect(ctx.tools.get('greet')).toBeUndefined()
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
expect(ctx.tools.get('greet')).toBeDefined()
expect(text(await call(ctx, 'greet', { name: 'late' }))).toBe('hi late')
})
it('unmounting the provider sends the consumer back to pending and unwinds its registrations', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1
await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) // dyn-2
expect(ctx.tools.get('greet')).toBeDefined()
const unmounted = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
expect(unmounted.isError).toBe(false)
expect(ctx.tools.get('greet')).toBeUndefined()
const report = text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))
expect(report).toContain('dyn-2: greeter-consumer [pending] — waiting for: greeter')
})
it('re-providing the service re-runs the consumer through the same guard (active again, tool back)', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1
await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) // dyn-2
await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
expect(ctx.tools.get('greet')).toBeUndefined()
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-3
expect(ctx.tools.get('greet')).toBeDefined()
expect(text(await call(ctx, 'greet', { name: 'again' }))).toBe('hi again')
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('dyn-2: greeter-consumer [active]')
})
it('a duplicate provide fails loud with the owning fiber named, and the failed mount is disposed', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
const duplicate = await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
expect(duplicate.isError).toBe(true)
expect(text(duplicate)).toContain('has been registered')
const report = text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))
expect(report).toContain('dyn-1: greeter-provider')
expect(report).not.toContain('dyn-2')
})
it('inspect surfaces the linkage: provides on the provider row, the service in services and api sections', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
const dynamic = text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))
expect(dynamic).toContain('dyn-1: greeter-provider [active] — provides: greeter')
const services = text(await call(ctx, 'cordis_inspect', { what: 'services' }))
expect(services).toContain('- greeter (provided by greeter-provider)')
const api = text(await call(ctx, 'cordis_inspect', { what: 'api' }))
expect(api).toContain('- greeter (provided by greeter-provider, no catalog entry)')
})
it('a primitive (or null) provided value passes through the façade unwrapped, on both read paths', async () => {
const ctx = await setup()
const provider = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'answer-provider',
apply(ctx) {
ctx.provide('answer', 42)
ctx.provide('nothing', null)
},
}
`,
})
expect(provider.isError).toBe(false)
const consumer = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'answer-consumer',
inject: ['answer', 'nothing', 'tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'answer',
description: 'Read the provided primitive services.',
parameters: {},
async execute() {
return [{ type: 'text', text: ctx.answer + '/' + ctx.get('answer') + '/' + ctx.nothing }]
},
}))
},
}
`,
})
expect(consumer.isError).toBe(false)
expect(text(consumer)).toContain('state: active')
expect(text(await call(ctx, 'answer', {}))).toBe('42/42/null')
})
it('unmounting the consumer leaves the provider and its service intact', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1
await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) // dyn-2
await call(ctx, 'cordis_unmount', { id: 'dyn-2' })
expect(ctx.tools.get('greet')).toBeUndefined()
const services = text(await call(ctx, 'cordis_inspect', { what: 'services' }))
expect(services).toContain('- greeter (provided by greeter-provider)')
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('dyn-1: greeter-provider [active]')
})
})

View File

@@ -0,0 +1,104 @@
import { Context } from 'cordis'
import Timer from '@cordisjs/plugin-timer'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { ToolDefinition, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import * as tool from '../src/index.ts'
/**
* Shared spec helpers: a real `SystemPrompt` + `ToolRegistry` + timer +
* tool-cordis tree (only the model is absent — the code strings below stand in
* for what it would write), plus the canonical mount-code fixtures the suites
* share.
*/
/** Mount the plugin on a fresh context with a real ToolRegistry and the timer service. */
export async function setup(config?: tool.Config): Promise<Context> {
const ctx = new Context()
await ctx.plugin(Timer)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(tool, config)
return ctx
}
let callCounter = 0
/** Execute a registered tool through the real registry pipeline. */
export function call(ctx: Context, name: string, args: unknown): Promise<ToolExecutionResult> {
return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args })
}
/** Concatenated text blocks of one tool result. */
export function text(result: ToolExecutionResult): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
/** Mount code for a listener plugin: logs on every `tools/change`. */
export const LISTENER_CODE = `
return {
name: 'change-logger',
apply(ctx) {
ctx.on('tools/change', () => console.log('tools changed'))
},
}
`
/** Mount code for a self-made tool: registers `reverse_text` via the sandbox's harness helpers. */
export const REVERSE_TOOL_CODE = `
return {
name: 'reverse-text',
inject: ['tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'reverse_text',
description: 'Reverse a string.',
parameters: { text: { type: 'string', required: true } },
async execute(args) {
return [{ type: 'text', text: args.text.split('').reverse().join('') }]
},
}))
},
}
`
/** Mount code providing a `greeter` service other mounts can inject. */
export const PROVIDER_CODE = `
return {
name: 'greeter-provider',
apply(ctx) {
ctx.provide('greeter', { greet: (name) => 'hi ' + name })
},
}
`
/** Mount code consuming the `greeter` service through inject, exposing it as a tool. */
export const CONSUMER_CODE = `
return {
name: 'greeter-consumer',
inject: ['greeter', 'tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'greet',
description: 'Greet someone via the greeter service.',
parameters: { name: { type: 'string', required: true } },
async execute(args) {
return [{ type: 'text', text: ctx.greeter.greet(args.name) }]
},
}))
},
}
`
/** A registrable no-op tool the tests use to trigger a real `tools/change`. */
export function dummyTool(name: string): ToolDefinition {
return {
name,
description: 'test trigger',
parameters: { type: 'object' as const, properties: {} },
async execute(): Promise<[]> {
return []
},
}
}

View File

@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest'
import type { Context, Fiber } from 'cordis'
import { FiberState } from '../src/fiber-state.ts'
import { describeApi, describeEvents, describePlugins, describeServices } from '../src/inspect.ts'
import { call, LISTENER_CODE, setup, text } from './helpers.ts'
/**
* The `cordis_inspect` sections: rendered against the real runtime through the
* tool, plus direct renderer calls for the states a minimal harness cannot
* reach (empty service store, same-named sibling fibers, a fully-live catalog).
*/
describe('cordis_inspect', () => {
it('reports all six sections by default', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_inspect', {})
expect(result.isError).toBe(false)
const report = text(result)
for (const heading of ['services', 'plugins', 'tools', 'dynamic', 'api', 'events']) {
expect(report).toContain(`## ${heading}`)
}
// The services section sees the real providers; the plugins list shows
// this plugin and its dynamic group flat; the tools section lists the
// cordis tools.
expect(report).toContain('- tools (provided by ToolRegistry)')
expect(report).toContain('- tool-cordis [active]')
expect(report).toContain('- cordis-dynamic [active]')
expect(report).toContain('- cordis_mount')
expect(report).toContain('(no dynamic plugins mounted)')
})
it('limits the report to one section via `what`', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_inspect', { what: 'tools' })
const report = text(result)
expect(report).toContain('## tools')
expect(report).not.toContain('## services')
expect(report).not.toContain('## plugins')
})
it('shows a mount in the dynamic section and in the flat plugins list', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
const report = text(await call(ctx, 'cordis_inspect', {}))
expect(report).toContain('- dyn-1: change-logger [active]')
expect(report).toContain('- change-logger [active]')
})
it('renders the api section from the generated catalog intersected with the LIVE runtime', async () => {
const ctx = await setup()
const report = text(await call(ctx, 'cordis_inspect', { what: 'api' }))
// Live catalogued services render summary + signatures.
expect(report).toContain('- tools — ')
expect(report).toContain('register(definition: ToolDefinition)')
expect(report).toContain('- systemPrompt — ')
// Catalogued services with no live provider are listed tersely.
expect(report).toMatch(/not running \(loadable services with no live provider\): .*bash/)
// The type shapes the LIVE signatures reference follow (closure over the
// generated TYPE_API — a consumer can see field types, not just names).
expect(report).toContain('type shapes (referenced by the signatures above')
expect(report).toContain('export interface ToolExecution')
// A type only reachable through a NOT-live service (e.g. bash) is scoped out.
expect(report).not.toContain('export interface BashRunResult')
// The inherited ctx surface closes the section.
expect(report).toContain('inherited ctx API:')
expect(report).toContain('- ctx.effect — ')
})
it('renders the events section with mode badges, signatures, and the waterfall caution', async () => {
const ctx = await setup()
const report = text(await call(ctx, 'cordis_inspect', { what: 'events' }))
expect(report).toContain('- tools/change [emit]')
expect(report).toContain('- tools/pre-execute [waterfall]')
expect(report).toMatch(/'agent\/status'\(/)
expect(report).toContain('returning without next() vetoes the chain')
})
})
describe('inspect renderers (direct)', () => {
it('describeServices reports an empty store as such, and labels a non-active provider', () => {
const empty = { reflect: { store: {} } } as unknown as Context
expect(describeServices(empty)).toEqual(['(no services provided)'])
const pendingFiber = { state: FiberState.PENDING, name: 'half-loaded' } as unknown as Fiber
const store: Record<symbol, unknown> = {}
store[Symbol('impl')] = { name: 'thing', fiber: pendingFiber }
const ctx = { reflect: { store } } as unknown as Context
expect(describeServices(ctx)).toEqual(['- thing (provided by half-loaded, pending)'])
})
it('describePlugins lists every fiber flat, sorted by name, one line per instance', () => {
const fiber = (name: string): Fiber => ({ name, state: FiberState.ACTIVE }) as unknown as Fiber
const ctx = {
registry: { values: () => [{ fibers: [fiber('beta'), fiber('alpha')] }, { fibers: [fiber('alpha')] }] },
} as unknown as Context
expect(describePlugins(ctx)).toEqual([
'- alpha [active]',
'- alpha [active]',
'- beta [active]',
])
})
it('describeApi omits the not-running line and type shapes when nothing applies', async () => {
const ctx = await setup()
const lines = describeApi(ctx, [{ key: 'tools', summary: 'The registry.', methods: ['register(x): void'] }], [], [])
expect(lines[0]).toBe('- tools — The registry.')
expect(lines[1]).toBe(' register(x): void')
expect(lines.join('\n')).not.toContain('not running')
expect(lines.join('\n')).not.toContain('type shapes')
})
it('describeEvents renders an empty catalog as just the waterfall caution', () => {
expect(describeEvents([])).toEqual([
'waterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain.',
])
})
})

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import * as ToolCordis from '../src/index.ts'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { REVERSE_TOOL_CODE } from './helpers.ts'
/**
* Full-loop integration: a scripted mock model mounts a plugin that registers
* a NEW tool, calls that tool on the very next step (tool schemas are
* reassembled per step — the real loop proves the self-extension contract),
* and unmounts it again. Only the model is mocked; the sandbox, the fiber
* tree, and the session log are real.
*/
async function harness(adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(ToolCordis)
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
}
})
})
}
describe('cordis tools through the agent loop', () => {
it('mounts a tool, calls it on the next step, and unmounts it — all as real tool/call events', async () => {
const adapter = new MockAdapter([
toolCallResponse('call-1', 'cordis_mount', { code: REVERSE_TOOL_CODE }, 'Extending myself.'),
toolCallResponse('call-2', 'reverse_text', { text: 'harness' }),
toolCallResponse('call-3', 'cordis_unmount', { id: 'dyn-1' }),
textResponse('Done.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('it-cordis'), { model: 'mock' })
agent.send([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }])
await waitForIdle(ctx, agent)
const log = agent.session.events
const calls = log.filter(event => event.type === 'tool/call').map(event => event.data.name)
expect(calls).toEqual(['cordis_mount', 'reverse_text', 'cordis_unmount'])
const results = log.filter(event => event.type === 'tool/result')
expect(results.map(event => event.data.isError)).toEqual([false, false, false])
const reversed = results[1]!.data.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
expect(reversed).toBe('ssenrah')
// After the unmount the self-made tool is gone from the registry.
expect(ctx.tools.get('reverse_text')).toBeUndefined()
})
})

View File

@@ -0,0 +1,575 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isJsonValue } from '@deepseek-ai/dsh-session'
import { syntaxErrorContext } from '../src/sandbox.ts'
import { call, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
/**
* The `cordis_mount` success/failure family: real plugins land on a genuine
* cordis fiber tree, their registrations are observable through the real
* registry/event bus, and every rejection path teaches the fix.
*/
afterEach(() => {
vi.restoreAllMocks()
})
describe('cordis_mount', () => {
it('mounts a listener plugin that observes real events, tagged-logging through to the host console', async () => {
const ctx = await setup()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const result = await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
expect(result.isError).toBe(false)
expect(text(result)).toContain('mounted dyn-1 (plugin "change-logger", state: active)')
// Fire a REAL tools/change by registering a tool; the mounted listener logs.
ctx.tools.register(dummyTool('trigger_a'))
expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'tools changed')
})
it('mounts a bare-function plugin as <anonymous>, and a named function under its name', async () => {
const ctx = await setup()
const anonymous = await call(ctx, 'cordis_mount', { code: 'return (ctx) => { ctx.on(\'tools/change\', () => {}) }' })
expect(anonymous.isError).toBe(false)
expect(text(anonymous)).toContain('plugin "<anonymous>"')
const named = await call(ctx, 'cordis_mount', { code: 'return function watcher(ctx) {}' })
expect(text(named)).toContain('plugin "watcher"')
})
it('lets the agent give ITSELF a new tool, immediately callable through the registry', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
expect(result.isError).toBe(false)
expect(ctx.tools.schemas().map(schema => schema.name)).toContain('reverse_text')
const reversed = await call(ctx, 'reverse_text', { text: 'harness' })
expect(reversed.isError).toBe(false)
expect(text(reversed)).toBe('ssenrah')
})
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.
const ctx = await setup()
await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
const reversed = await call(ctx, 'reverse_text', { text: 'harness' })
expect(isJsonValue({ content: reversed.content, isError: reversed.isError })).toBe(true)
})
it('threads the { content, meta } object return form through to the registry result', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', {
code: `
return {
name: 'meta-return',
inject: ['tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'meta_tool',
description: 'attaches a private presentation payload',
parameters: {},
async execute() {
return { content: [{ type: 'text', text: 'ok' }], meta: { kind: 'demo' } }
},
}))
},
}
`,
})
const result = await call(ctx, 'meta_tool', {})
expect(result.isError).toBe(false)
expect(text(result)).toBe('ok')
expect(result.meta).toEqual({ kind: 'demo' })
})
it.each([
['a bare string', 'return \'ok\'', '"ok"'],
['an object whose content is a string', 'return { content: \'ok\' }', '{"content":"ok"}'],
['an array of non-objects', 'return [\'ok\']', '["ok"]'],
['blocks missing the type tag', 'return [{ text: \'hi\' }]', '[{"text":"hi"}]'],
['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.
const ctx = await setup()
await call(ctx, 'cordis_mount', {
code: `
return {
name: 'bad-return',
inject: ['tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'bad_return_tool',
description: 'returns a wrong shape',
parameters: {},
async execute() { ${returnStatement} },
}))
},
}
`,
})
const result = await call(ctx, 'bad_return_tool', {})
expect(result.isError).toBe(true)
expect(result.content).toHaveLength(1)
expect(result.content[0]!.type).toBe('text')
expect(text(result)).toContain(`execute returned ${preview}`)
expect(text(result)).toContain('must return an ARRAY of content blocks')
expect(text(result)).toContain('✓ return { content: [{ type: \'text\', text: someString }], meta: anyJsonValue }')
})
it('truncates a huge invalid execute return in the teaching error', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', {
code: `
return {
name: 'huge-return',
inject: ['tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'huge_return_tool',
description: 'returns a huge wrong shape',
parameters: {},
async execute() { return 'x'.repeat(500) },
}))
},
}
`,
})
const result = await call(ctx, 'huge_return_tool', {})
expect(result.isError).toBe(true)
expect(text(result)).toContain('…')
expect(text(result)).not.toContain('x'.repeat(200))
})
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.
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'json-schema-tool',
inject: ['tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'json_schema_tool',
description: 'written in the JSON-Schema dialect',
parameters: {
type: 'object',
properties: {
text: { type: 'string', description: 'the text' },
count: { type: 'integer', default: 1 },
mode: { type: 'string', enum: ['fast', 'slow'] },
extra: { type: 'string', required: false },
},
required: ['text'],
},
async execute(args) { return [{ type: 'text', text: args.text + ':' + (args.count ?? 0) }] },
}))
},
}
`,
})
expect(result.isError).toBe(false)
// The registered schema is canonical JSON Schema derived from the DSL:
// the required array survived, integer became number, extra is optional.
const schema = ctx.tools.schemas().find(s => s.name === 'json_schema_tool')!
const parameters = schema.parameters as { properties: Record<string, { type: string; enum?: string[] }>; required?: string[] }
expect(parameters.required).toEqual(['text'])
expect(parameters.properties.count!.type).toBe('number')
expect(parameters.properties.mode!.enum).toEqual(['fast', 'slow'])
// Arg validation enforces the normalized spec: text required, extra not.
expect((await call(ctx, 'json_schema_tool', { count: 2 })).isError).toBe(true)
expect(text(await call(ctx, 'json_schema_tool', { text: 'ok', count: 2 }))).toBe('ok:2')
})
it('normalizes a nested object property carrying a JSON-Schema required array', async () => {
// On an object PROPERTY, a JSON-Schema-style `required` array names the
// required children — the nested unwrap converts it just like the top level.
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'nested-json-schema',
inject: ['tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'nested_json_schema_tool',
description: 'nested dialect',
parameters: {
cfg: { type: 'object', properties: { label: { type: 'string' } }, required: ['label'] },
},
async execute(args) { return [{ type: 'text', text: args.cfg.label }] },
}))
},
}
`,
})
expect(result.isError).toBe(false)
const schema = ctx.tools.schemas().find(s => s.name === 'nested_json_schema_tool')!
const cfg = (schema.parameters as { properties: { cfg: { required?: string[] } } }).properties.cfg
expect(cfg.required).toEqual(['label'])
expect(text(await call(ctx, 'nested_json_schema_tool', { cfg: { label: 'hi' } }))).toBe('hi')
})
it.each([
['parameters: 42', 'must be a SchemaSpec object'],
['parameters: { text: 42 }', 'parameters.text must be a SchemaSpec property object'],
['parameters: { text: { type: \'str\' } }', 'parameters.text must declare a valid type: \'string\' | \'number\' | \'boolean\' | \'object\' | \'array\' (got "str")'],
['parameters: { text: { type: \'string\', required: \'yes\' } }', 'parameters.text.required must be a boolean when present'],
['parameters: { text: { type: \'string\', properties: {} } }', 'parameters.text.properties is only valid for type "object"'],
['parameters: { text: { type: \'string\', items: { type: \'string\' } } }', 'parameters.text.items is only valid for type "array"'],
])('rejects a malformed SchemaSpec (%s) with a teaching error', async (parameters, message) => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'bad-schema',
inject: ['tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'bad_schema_tool',
description: 'bad',
${parameters},
async execute() { return [] },
}))
},
}
`,
})
expect(result.isError).toBe(true)
expect(text(result)).toContain(message)
})
it('accepts a nested object/array SchemaSpec (the DSL recursion)', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'nested-schema',
inject: ['tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'nested_schema_tool',
description: 'nested',
parameters: {
item: { type: 'object', required: true, properties: { label: { type: 'string', required: true } } },
tags: { type: 'array', items: { type: 'string' } },
},
async execute(args) { return [{ type: 'text', text: args.item.label }] },
}))
},
}
`,
})
expect(result.isError).toBe(false)
const echoed = await call(ctx, 'nested_schema_tool', { item: { label: 'ok' }, tags: ['a'] })
expect(text(echoed)).toBe('ok')
})
it('rejects raw dynamic ctx.tools.register calls that bypass harness helpers', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'raw-register',
inject: ['tools'],
apply(ctx) {
ctx.tools.register({
name: 'raw_dynamic_tool',
description: 'raw',
parameters: { type: 'object', properties: {} },
async execute() { return [] },
})
},
}
`,
})
expect(result.isError).toBe(true)
expect(text(result)).toContain('dynamic tool registration must use a tool returned by harness.defineTool')
expect(ctx.tools.get('raw_dynamic_tool')).toBeUndefined()
})
it('guards the registry reached through ctx.get(\'tools\') identically', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'raw-register-get',
apply(ctx) {
ctx.get('tools').register({ name: 'raw_via_get', description: 'raw', parameters: {}, async execute() { return [] } })
},
}
`,
})
expect(result.isError).toBe(true)
expect(text(result)).toContain('dynamic tool registration must use a tool returned by harness.defineTool')
expect(ctx.tools.get('raw_via_get')).toBeUndefined()
})
it('passes non-register registry members through the guard with correct binding', async () => {
const ctx = await setup()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'schema-reader',
inject: ['tools'],
apply(ctx) {
console.log('sees', ctx.tools.schemas().length, 'tools; mount is', typeof ctx.tools.get('cordis_mount'))
},
}
`,
})
expect(result.isError).toBe(false)
expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'sees', 3, 'tools; mount is', 'object')
})
it('keeps a plugin with unsatisfied inject mounted as pending and names what it waits for', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: 'return { name: \'waiter\', inject: [\'no-such-service\'], apply(ctx) {} }',
})
expect(result.isError).toBe(false)
expect(text(result)).toContain('state: pending')
expect(text(result)).toContain('waiting for service(s): no-such-service')
// Unmounting a pending mount works like any other.
const unmounted = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
expect(unmounted.isError).toBe(false)
})
it('rejects code that throws, leaving nothing mounted', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', { code: 'throw new Error(\'boom in sandbox\')' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('boom in sandbox')
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
})
it('passes non-Error and null throws through untouched (no SyntaxError misclassification)', async () => {
const ctx = await setup()
const primitive = await call(ctx, 'cordis_mount', { code: 'throw \'plain-string-throw\'' })
expect(primitive.isError).toBe(true)
expect(text(primitive)).toContain('plain-string-throw')
const nullish = await call(ctx, 'cordis_mount', { code: 'throw null' })
expect(nullish.isError).toBe(true)
})
it('rejects code that does not return a plugin', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', { code: 'return 42' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('must `return` a plugin')
})
it('answers a missing return with the two valid plugin forms', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', { code: 'const plugin = (ctx) => {}' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('did you forget `return`?')
})
it('disposes a plugin whose apply throws, and reports the error', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: 'return { name: \'broken\', apply(ctx) { throw new Error(\'apply exploded\') } }',
})
expect(result.isError).toBe(true)
expect(text(result)).toContain('apply exploded')
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
})
it('rolls back a plugin that collides with an existing tool name, keeping the original tool intact', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'usurper',
inject: ['tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'cordis_mount',
description: 'dup',
parameters: {},
async execute() { return [] },
}))
},
}
`,
})
expect(result.isError).toBe(true)
expect(text(result)).toContain('already registered')
expect(text(result)).toContain('first cordis_unmount')
// The original cordis_mount still dispatches — the failed fiber is gone.
const retry = await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
expect(retry.isError).toBe(false)
})
it('isolates sandbox globals: no process/Buffer, and globalThis writes do not leak to the host', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
globalThis.__cordis_tool_leak = 'leaked'
return { name: 'probe-' + typeof process + '-' + typeof Buffer, apply(ctx) {} }
`,
})
expect(result.isError).toBe(false)
expect(text(result)).toContain('plugin "probe-undefined-undefined"')
expect((globalThis as Record<string, unknown>).__cordis_tool_leak).toBeUndefined()
})
it.each([
['require(\'fs\')', 'require is not available in the mount sandbox', 'inject: [\'fs\']'],
['setTimeout(() => {}, 5)', 'setTimeout is not available in the mount sandbox', 'ctx.setTimeout'],
['fetch(\'https://example.com\')', 'fetch is not available in the mount sandbox', 'ctx.web'],
])('traps the Node API call %s with a redirect to the cordis alternative', async (invocation, trapMessage, redirect) => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', { code: `${invocation}\nreturn (ctx) => {}` })
expect(result.isError).toBe(true)
expect(text(result)).toContain(trapMessage)
expect(text(result)).toContain(redirect)
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
})
it('lets a mounted plugin schedule through the cordis timer service (inject: [\'timer\'])', async () => {
const ctx = await setup()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'ticker',
inject: ['timer'],
apply(ctx) {
ctx.setTimeout(() => console.log('tick'), 10)
},
}
`,
})
expect(result.isError).toBe(false)
expect(text(result)).toContain('state: active')
await new Promise(resolve => setTimeout(resolve, 50))
expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'tick')
})
it('provides btoa/atob and the tagged console variants inside the sandbox', async () => {
const ctx = await setup()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const result = await call(ctx, 'cordis_mount', {
code: `
console.warn('warned')
console.error('errored')
const round = atob(btoa('hi'))
const bytes = new TextEncoder().encode(round)
return { name: 'codec-' + new TextDecoder().decode(bytes), apply(ctx) { console.log('applied', typeof ctx.on) } }
`,
})
expect(result.isError).toBe(false)
expect(text(result)).toContain('plugin "codec-hi"')
expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'warned')
expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'applied', 'function')
expect(error).toHaveBeenCalledWith('[cordis:dyn-1]', 'errored')
})
it('answers TypeScript syntax in the plain-JS sandbox with the fix', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: 'return { name: \'ts\' as const, apply(ctx) {} }',
})
expect(result.isError).toBe(true)
expect(text(result)).toContain('plain JavaScript, not TypeScript')
})
it('surfaces the offending line + caret and the bracket-balance hint on a syntax error', async () => {
const ctx = await setup()
// The canonical model mistake: closing the returned object with `});` as
// if it were a callback argument. The word "as" in a STRING elsewhere must
// not trigger the TypeScript hint — the heuristic reads the failing line.
const result = await call(ctx, 'cordis_mount', {
code: 'const note = \'treat pattern as regex\'\nreturn {\n name: \'oops\',\n apply(ctx) {}\n});',
})
expect(result.isError).toBe(true)
const message = text(result)
expect(message).toContain('failed to parse')
expect(message).toContain('});')
expect(message).toContain('^')
expect(message).toContain('BODY of an async function')
expect(message).not.toContain('TypeScript')
})
it('syntaxErrorContext falls back to String(error) when the stack has no vm prelude', () => {
const doctored = new SyntaxError('boom')
delete (doctored as { stack?: string }).stack
expect(syntaxErrorContext(doctored)).toBe('SyntaxError: boom')
const plain = new SyntaxError('bang')
plain.stack = 'not-a-vm-stack'
expect(syntaxErrorContext(plain)).toBe('SyntaxError: bang')
})
it('handles a runtime-thrown SyntaxError (no source-line prelude) with the generic hint', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', { code: 'throw new SyntaxError(\'user-crafted\')' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('failed to parse')
expect(text(result)).toContain('user-crafted')
})
it('honors the configured vmTimeoutMs for the synchronous portion', async () => {
const ctx = await setup({ vmTimeoutMs: 50 })
const result = await call(ctx, 'cordis_mount', { code: 'while (true) {}' })
expect(result.isError).toBe(true)
expect(text(result)).toMatch(/timed? ?out/i)
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
})
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.
const ctx = await setup()
await call(ctx, 'cordis_mount', {
code: `
return {
name: 'probe-instanceof',
inject: ['tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'probe_instanceof',
description: 'report instanceof checks across realms',
parameters: { items: { type: 'array', required: true, items: { type: 'string' } } },
async execute(args) {
const checks = {
hostArray: args.items instanceof Array,
hostObject: args instanceof Object,
vmArray: [] instanceof Array,
vmObject: ({}) instanceof Object,
}
return [{ type: 'text', text: JSON.stringify(checks) }]
},
}))
},
}
`,
})
const probed = await call(ctx, 'probe_instanceof', { items: ['a'] })
expect(probed.isError).toBe(false)
expect(JSON.parse(text(probed))).toEqual({ hostArray: true, hostObject: true, vmArray: true, vmObject: true })
// The host realm's constructors keep their default instanceof: no own
// Symbol.hasInstance was added to them.
expect(Object.getOwnPropertySymbols(Object)).not.toContain(Symbol.hasInstance)
expect(Object.getOwnPropertySymbols(Array)).not.toContain(Symbol.hasInstance)
})
})

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { presentInspectCall, presentMountCall, presentUnmountCall } from '../src/present.ts'
import { setup } from './helpers.ts'
/**
* Render-intent presenters: pure functions of the call args (no I/O, no
* session state — they run on replay too), wired onto the registered tools.
*/
describe('presenters', () => {
it('cordis_inspect renders a generic read card titled with the section', () => {
expect(presentInspectCall({})).toEqual({ card: 'generic', kind: 'read', title: 'Inspect cordis runtime' })
expect(presentInspectCall({ what: 'api' })).toEqual({ card: 'generic', kind: 'read', title: 'Inspect cordis runtime: api' })
})
it('cordis_mount renders a generic execute card carrying the code as raw input', () => {
expect(presentMountCall({ code: 'return (ctx) => {}' })).toEqual({
card: 'generic',
kind: 'execute',
title: 'Mount plugin into live cordis runtime',
rawInput: { code: 'return (ctx) => {}' },
})
})
it('cordis_unmount renders a generic delete card titled with the id', () => {
expect(presentUnmountCall({ id: 'dyn-1' })).toEqual({ card: 'generic', kind: 'delete', title: 'Unmount dyn-1' })
})
it('is wired onto the registered definitions through the defineTool soft-validation path', async () => {
const ctx = await setup()
expect(ctx.tools.get('cordis_inspect')!.presentCall!({ what: 'tools' })).toEqual({
card: 'generic',
kind: 'read',
title: 'Inspect cordis runtime: tools',
})
expect(ctx.tools.get('cordis_mount')!.presentCall!({ code: 'return 1' })).toMatchObject({ kind: 'execute' })
expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 'dyn-2' })).toMatchObject({ title: 'Unmount dyn-2' })
// Soft validation: presenter args that fail the schema render as no card, never a throw.
expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 42 })).toBeUndefined()
})
})

View File

@@ -0,0 +1,295 @@
import { describe, expect, it } from 'vitest'
import { call, setup, text } from './helpers.ts'
/**
* The sandbox context façade is a whitelist, not a pass-through proxy: mount
* code reaches only the registration/eventing verbs, the timer helpers, a
* guarded `tools`, and its injected services. Every framework-plumbing member
* that could hand back an UNGUARDED context — through which a plugin could
* `ctx.<escape>.tools.register({…})` to bypass the marker check and host-realm
* normalization — is denied. These are the regression guards for that escape
* class (the review finding on the original pass-through proxy).
*/
/** Mount a plugin whose `apply` touches one framework member, and report the error text. */
async function mountTouching(ctx: Awaited<ReturnType<typeof setup>>, expr: string): Promise<string> {
const result = await call(ctx, 'cordis_mount', {
code: `return { name: 'probe', inject: ['tools'], apply(ctx) { ${expr} } }`,
})
expect(result.isError).toBe(true)
return text(result)
}
describe('sandbox context façade — escape surface is closed', () => {
it.each([
['ctx.root', 'const c = ctx.root'],
['ctx.parent', 'const c = ctx.parent'],
['ctx.scope', 'const c = ctx.scope'],
['ctx.fiber', 'const f = ctx.fiber'],
['ctx.reflect', 'const r = ctx.reflect'],
['ctx.registry', 'const r = ctx.registry'],
['ctx.events', 'const e = ctx.events'],
['ctx.extend()', 'ctx.extend({})'],
['ctx.isolate()', 'ctx.isolate("x")'],
['ctx.intercept()', 'ctx.intercept("x", {})'],
['ctx.plugin()', 'ctx.plugin({ apply() {} })'],
['ctx.set()', 'ctx.set("tools", 1)'],
['ctx.mixin()', 'ctx.mixin("x", [])'],
])('denies %s with a teaching error', async (_label, expr) => {
const ctx = await setup()
const message = await mountTouching(ctx, expr)
expect(message).toContain('sandbox ctx does not expose')
expect(message).toContain('withheld by design')
})
it('the classic ctx.root.tools.register bypass registers nothing and fails loud', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'root-bypass',
inject: ['tools'],
apply(ctx) {
ctx.root.tools.register({
name: 'smuggled',
description: 'raw, unguarded',
parameters: { type: 'object', properties: {} },
async execute() { return [] },
})
},
}
`,
})
expect(result.isError).toBe(true)
expect(text(result)).toContain('sandbox ctx does not expose "root"')
// The whole point: the bypass never reaches the registry.
expect(ctx.tools.get('smuggled')).toBeUndefined()
})
it('rejects assignment to the façade rather than silently dropping it', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: 'return { name: \'writer\', apply(ctx) { ctx.stash = 1 } }',
})
expect(result.isError).toBe(true)
expect(text(result)).toContain('sandbox ctx is read-only')
})
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.)
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'svc-ctx-escape',
inject: ['systemPrompt', 'tools'],
apply(ctx) {
ctx.systemPrompt.ctx.root.tools.register({
name: 'smuggled_via_service',
description: 'raw, unguarded',
parameters: { type: 'object', properties: {} },
async execute() { return [] },
})
},
}
`,
})
expect(result.isError).toBe(true)
expect(text(result)).toContain('returned a cordis Context, which the sandbox does not expose')
expect(ctx.tools.get('smuggled_via_service')).toBeUndefined()
})
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.
const ctx = await setup()
ctx.plugin({
name: 'host-async-svc',
apply(c) { c.provide('hostAsync', { grab: async () => 'host-fetched' }) },
})
await call(ctx, 'cordis_mount', {
code: `
return {
name: 'async-consumer',
inject: ['hostAsync', 'tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'do_fetch',
description: 'awaits the host async service',
parameters: {},
async execute() {
const value = await ctx.hostAsync.grab()
return [{ type: 'text', text: value }]
},
}))
},
}
`,
})
const result = await call(ctx, 'do_fetch', {})
expect(result.isError).toBe(false)
expect(text(result)).toBe('host-fetched')
})
it('reads a symbol property as undefined and answers the `in` operator without throwing', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'introspector',
inject: ['tools'],
apply(ctx) {
const sym = ctx[Symbol.iterator]
console.log('probe', sym === undefined, 'tools' in ctx, 'on' in ctx, 'root' in ctx)
},
}
`,
})
expect(result.isError).toBe(false)
})
})
describe('sandbox context façade — inject gate on services', () => {
it('denies an undeclared live service (property access), naming the inject fix', async () => {
// `systemPrompt` is a live global service in the setup harness, but this
// mount does not declare it — reaching it would let the mount depend on a
// provider cordis does not know about, so it is refused.
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: 'return { name: \'undeclared\', inject: [\'tools\'], apply(ctx) { const s = ctx.systemPrompt } }',
})
expect(result.isError).toBe(true)
expect(text(result)).toContain('service "systemPrompt" is not injected')
expect(text(result)).toContain('inject: [\'systemPrompt\', …]')
})
it('denies an undeclared live service reached through ctx.get too', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: 'return { name: \'undeclared-get\', inject: [\'tools\'], apply(ctx) { ctx.get(\'systemPrompt\') } }',
})
expect(result.isError).toBe(true)
expect(text(result)).toContain('service "systemPrompt" is not injected')
})
it('allows a service the mount DID declare in inject', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'declared',
inject: ['systemPrompt', 'tools'],
apply(ctx) { console.log('has systemPrompt:', typeof ctx.systemPrompt) }
}
`,
})
expect(result.isError).toBe(false)
expect(text(result)).toContain('state: active')
})
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.
const ctx = await setup()
await call(ctx, 'cordis_mount', {
code: 'return { name: \'greeter-provider\', apply(ctx) { ctx.provide(\'greeter\', { greet: (n) => \'hi \' + n }) } }',
})
const undeclared = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'sloppy-consumer',
inject: ['tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'greet_undeclared',
description: 'uses greeter without declaring it',
parameters: { n: { type: 'string', required: true } },
async execute(args) { return [{ type: 'text', text: ctx.greeter.greet(args.n) }] },
}))
},
}
`,
})
// The tool registers (its execute is lazy), but calling it hits the gate:
// `ctx.greeter` is undeclared, so it fails with the teaching error rather
// than silently working and later stranding.
expect(undeclared.isError).toBe(false)
const called = await call(ctx, 'greet_undeclared', { n: 'x' })
expect(called.isError).toBe(true)
expect(text(called)).toContain('service "greeter" is not injected')
})
})
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.
const ctx = await setup()
await call(ctx, 'cordis_mount', {
code: `
return {
name: 'reporter',
inject: ['tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'report_view',
description: 'reports the shape of a tool view',
parameters: {},
async execute() {
const view = ctx.tools.get('cordis_mount')
return [{ type: 'text', text: JSON.stringify({
hasExecute: 'execute' in view,
hasPresentCall: 'presentCall' in view,
name: view.name,
keys: Object.keys(view).sort(),
}) }]
},
}))
},
}
`,
})
const reported = await call(ctx, 'report_view', {})
expect(reported.isError).toBe(false)
const shape = JSON.parse(text(reported)) as { hasExecute: boolean; hasPresentCall: boolean; name: string; keys: string[] }
expect(shape.hasExecute).toBe(false)
expect(shape.hasPresentCall).toBe(false)
expect(shape.name).toBe('cordis_mount')
expect(shape.keys).toEqual(['description', 'name', 'parameters'])
})
it('ctx.tools.get returns undefined for an unknown tool', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', {
code: `
return {
name: 'unknown-probe',
inject: ['tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'probe_unknown',
description: 'reports whether an unknown tool resolves',
parameters: {},
async execute() {
return [{ type: 'text', text: String(ctx.tools.get('no_such_tool') === undefined) }]
},
}))
},
}
`,
})
expect(text(await call(ctx, 'probe_unknown', {}))).toBe('true')
})
})

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import Loader from '@cordisjs/plugin-loader'
import * as tool from '../src/index.ts'
import { setup } from './helpers.ts'
/**
* Export-shape and registration surface: the namespace-plugin contract the
* real Loader path depends on, the registered tool set, and the Config
* validator's defaults and rejections.
*/
describe('export shape', () => {
it('has no default export, and survives the real Loader unwrapExports', () => {
// A stray `export default` would make `unwrapExports` (`exports.default ??
// exports`) collapse the module to the bare function and DROP `inject`,
// crashing at real load (docs/postmortem/0001). Assert directly AND through
// the real unwrap so adding `export default apply` fails here.
expect('default' in tool).toBe(false)
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(tool) as Record<string, unknown>
expect(unwrapped).toBe(tool)
expect(unwrapped.name).toBe('tool-cordis')
expect(unwrapped.inject).toEqual(['tools'])
expect(typeof unwrapped.apply).toBe('function')
expect(typeof unwrapped.Config).toBe('function')
})
})
describe('tool registration', () => {
it('registers the three cordis tools with the documented schemas', async () => {
const ctx = await setup()
const names = ctx.tools.schemas().map(schema => schema.name)
expect(names).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount']))
const inspect = ctx.tools.schemas().find(schema => schema.name === 'cordis_inspect')!
const props = (inspect.parameters as { properties: Record<string, { enum?: string[] }> }).properties
expect(props.what?.enum).toEqual(['services', 'plugins', 'tools', 'dynamic', 'api', 'events'])
})
})
describe('Config', () => {
it('defaults vmTimeoutMs to 5000', () => {
expect(new tool.Config()).toEqual({ vmTimeoutMs: 5000 })
})
it('rejects a non-positive vmTimeoutMs at validation time (misconfiguration fails loud)', () => {
expect(() => new tool.Config({ vmTimeoutMs: 0 })).toThrow()
expect(() => new tool.Config({ vmTimeoutMs: -1 })).toThrow()
})
})

View File

@@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import * as tool from '../src/index.ts'
import { call, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
/**
* Disposal semantics: `cordis_unmount` reaches quiescence before returning,
* and disposing the tool-cordis fiber itself (the HMR path) cascades over the
* whole dynamic subtree through the ordinary parent→child fiber lifecycle.
*/
afterEach(() => {
vi.restoreAllMocks()
})
describe('cordis_unmount', () => {
it('disposes the mount and its registrations have stopped by the time it returns (quiescence)', async () => {
const ctx = await setup()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
ctx.tools.register(dummyTool('trigger_before'))
expect(log).toHaveBeenCalledTimes(1)
const result = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
expect(result.isError).toBe(false)
expect(text(result)).toContain('unmounted dyn-1')
// Immediately after the awaited unmount, the listener must be gone — no
// grace period, no eventual consistency.
ctx.tools.register(dummyTool('trigger_after'))
expect(log).toHaveBeenCalledTimes(1)
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
})
it('unregisters a self-made tool on unmount', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
expect(ctx.tools.get('reverse_text')).toBeDefined()
await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
expect(ctx.tools.get('reverse_text')).toBeUndefined()
})
it('rejects an unknown id, and a second unmount of the same id', async () => {
const ctx = await setup()
const unknown = await call(ctx, 'cordis_unmount', { id: 'dyn-99' })
expect(unknown.isError).toBe(true)
expect(text(unknown)).toContain('no dynamic plugin with id "dyn-99"')
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
const again = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
expect(again.isError).toBe(true)
})
})
describe('HMR safety', () => {
it('disposing the tool-cordis fiber cascades over the dynamic subtree and its registrations', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(tool)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
expect(ctx.tools.get('reverse_text')).toBeDefined()
await fiber.dispose()
// The whole subtree is gone: the self-made tool, the cordis tools, and the
// mounted listener (no log on a fresh tools/change).
expect(ctx.tools.get('reverse_text')).toBeUndefined()
expect(ctx.tools.get('cordis_mount')).toBeUndefined()
const calls = log.mock.calls.length
ctx.tools.register(dummyTool('trigger_post_dispose'))
expect(log).toHaveBeenCalledTimes(calls)
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/timer"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/tools"
}
]
}

View File

@@ -39,12 +39,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
```ts
import type { Config } from '@deepseek-ai/dsh-agent-core'
// { agents?, persona?, skills? } — the schema is z.intersect([AgentLoop.Config,
// SystemPrompt.Config, { skills }]), so validation and defaulting can never
// drift from the owners'.
// { agents?, persona?, toolOrder?, skills? } — the schema intersects the owner schemas,
// so validation and defaulting can never drift from the owners.
```
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` to `dsh-system-prompt` (default `''`), and `skills.registry` / `skills.local` to the skill registry and local provider. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` to `dsh-system-prompt` (default `''`), the deployment's persona section; `toolOrder` to `dsh-system-prompt` (absent — lexicographic), the explicit model-facing tool order; and `skills.registry` / `skills.local` to the skill registry and local provider. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
## Why a code bundle, not a shared YAML include

View File

@@ -73,17 +73,21 @@ 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` to the system-prompt plugin (the
* deployment's persona section), and `skills` to the skill registry/local
* provider. All three are optional INPUT here because each owner's schema supplies the default
* (`[]` / `''` / the DSH skill roots); the schema is the INTERSECTION of the
* owners' own schemas, so validation and defaulting can never drift from them.
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
* plugin (the deployment's persona section and the explicit model-facing tool
* order), and `skills` to the skill registry/local provider. Every field is
* optional INPUT here because each owner's schema supplies the default (`[]` /
* `''` / absent — lexicographic / the DSH skill roots); the schema is the
* INTERSECTION of the owners' own schemas, so validation and defaulting can
* never drift from them.
*/
export interface Config {
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
agents?: AgentLoopConfig['agents']
/** The deployment persona (see dsh-system-prompt's `Config`). */
persona?: SystemPromptConfig['persona']
/** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */
toolOrder?: SystemPromptConfig['toolOrder']
/** Skill registry and local provider config. */
skills?: SkillConfig
}
@@ -104,11 +108,11 @@ export const Config = z.intersect([
/**
* Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber;
* `agent-loop` receives the forwarded `agents` list and `system-prompt` the
* forwarded `persona`. Load order is irrelevant (cordis pends each fiber on
* its `inject` until the services it needs exist), but the listing mirrors the
* dependency layering for readability: the LLM vocabulary and core registries
* first, then the dev tripwire and the bash tool consumer, then the loop that
* drives them.
* forwarded `persona` and `toolOrder`. Load order is irrelevant (cordis pends
* each fiber on its `inject` until the services it needs exist), but the
* listing mirrors the dependency layering for readability: the LLM vocabulary
* and core registries first, then the dev tripwire and the bash tool consumer,
* then the loop that drives them.
*/
export function apply(ctx: Context, config: Config): void {
ctx.plugin(Timer)
@@ -117,8 +121,13 @@ export function apply(ctx: Context, config: Config): void {
// 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.
ctx.plugin(SystemPrompt, { persona: config.persona ?? '' })
// introduce different ones. toolOrder has no owner-supplied default value —
// ABSENT means "lexicographic order" — so it is forwarded conditionally
// rather than via ??.
ctx.plugin(SystemPrompt, {
persona: config.persona ?? '',
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
})
ctx.plugin(ToolRegistry)
ctx.plugin(SkillService, config.skills?.registry ?? {})
ctx.plugin(SkillLocal, config.skills?.local ?? {})

View File

@@ -4,6 +4,7 @@ import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import * as agentCore from '../src/index.ts'
import { AgentId } from '@deepseek-ai/dsh-agent'
@@ -152,6 +153,23 @@ describe('dsh-agent-core bundle', () => {
})
})
it('forwards toolOrder to the system-prompt assembly', async () => {
const ctx = await mount({ toolOrder: ['zulu', TOOL_ORDER_REST] })
// The bundle's own bash tools pend on the absent `ctx.bash` executor in
// this providerless mount, so register two plain tools to order.
for (const name of ['alpha', 'zulu']) {
ctx.get('tools')!.register({
name,
description: name,
parameters: {},
execute: async () => [],
})
}
const assembly = await ctx.get('systemPrompt')!.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'skill'])
await ctx.fiber.dispose()
})
it('re-exports the loop config schema as its own', () => {
expect(agentCore.Config).toBeDefined()
expect(agentCore.name).toBe('agent-core')

View File

@@ -0,0 +1,458 @@
/**
* 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'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { collectConfigCatalog, render } from '../../../../scripts/gen-config-catalog.ts'
/** Write one fixture package (package.json + src files) under a scan root. */
function writePkg(root: string, dir: string, name: string, files: Record<string, string>): void {
const pkgDir = join(root, 'packages', dir)
mkdirSync(join(pkgDir, 'src'), { recursive: true })
writeFileSync(join(pkgDir, 'package.json'), JSON.stringify({ name }))
for (const [rel, text] of Object.entries(files)) writeFileSync(join(pkgDir, rel), text)
}
const roots: string[] = []
const makeRoot = (): string => {
const root = mkdtempSync(join(tmpdir(), 'config-catalog-'))
roots.push(root)
return root
}
/** One-package fixture: the common case. */
const make = (files: Record<string, string>, name = '@fix/one'): string => {
const root = makeRoot()
writePkg(root, 'group/one', name, files)
return root
}
afterEach(() => {
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
})
const DOCUMENTED_CONFIG = `/** Fixture config. */
export interface Config {
/** A knob. */
knob?: string
}
`
describe('gen-config-catalog classification', () => {
it('classifies an apply plugin with a config parameter and extracts the paste', () => {
const entries = collectConfigCatalog(make({
'src/index.ts': `import type { Context } from 'cordis'
export const inject = ['tools']
${DOCUMENTED_CONFIG}
/** Load. */
export function apply(ctx: Context, config: Config): void {}
`,
}))
expect(entries).toHaveLength(1)
expect(entries[0]).toMatchObject({ pkg: '@fix/one', kind: 'config', configTypeName: 'Config', inject: ['tools'] })
expect(entries[0]?.pastes?.[0]?.text).toContain('/** A knob. */')
})
it('classifies a default service class, reading its constructor and static inject', () => {
const entries = collectConfigCatalog(make({
'src/index.ts': `import type { Context } from 'cordis'
import z from 'schemastery'
${DOCUMENTED_CONFIG}
/** Fixture service. */
export default class Fix {
static inject = ['llm']
static Config = z.object({ knob: z.string() }) as unknown as z<Config>
constructor(ctx: Context, config: Config) {}
}
`,
}))
expect(entries[0]).toMatchObject({ kind: 'config', className: 'Fix', inject: ['llm'], schemaKeys: ['knob'] })
})
it('classifies an abstract default class as a seam', () => {
const entries = collectConfigCatalog(make({
'src/index.ts': 'export default abstract class FixSeam { abstract run(): void }\n',
}))
expect(entries[0]).toMatchObject({ kind: 'seam', className: 'FixSeam' })
})
it('classifies a plugin whose apply takes no config as no-config', () => {
const entries = collectConfigCatalog(make({
'src/index.ts': 'import type { Context } from \'cordis\'\n/** Load. */\nexport function apply(ctx: Context): void {}\n',
}))
expect(entries[0]?.kind).toBe('no-config')
})
it('classifies a module with neither default export nor apply as a library', () => {
const entries = collectConfigCatalog(make({
'src/index.ts': 'export const helper = 1\n',
}))
expect(entries[0]?.kind).toBe('library')
})
it('hard-errors on a package with no entry file', () => {
const root = makeRoot()
mkdirSync(join(root, 'packages', 'group', 'one'), { recursive: true })
writeFileSync(join(root, 'packages', 'group', 'one', 'package.json'), JSON.stringify({ name: '@fix/one' }))
expect(() => collectConfigCatalog(root)).toThrow(/entry .* is missing or unreadable/)
})
it('hard-errors on a package.json without a name', () => {
const root = makeRoot()
mkdirSync(join(root, 'packages', 'group', 'one', 'src'), { recursive: true })
writeFileSync(join(root, 'packages', 'group', 'one', 'package.json'), '{}')
expect(() => collectConfigCatalog(root)).toThrow(/has no "name"/)
})
})
describe('gen-config-catalog config extraction guards', () => {
it('hard-errors on a config field with no JSDoc prose', () => {
expect(() => collectConfigCatalog(make({
'src/index.ts': `import type { Context } from 'cordis'
export interface Config {
knob?: string
}
/** Load. */
export function apply(ctx: Context, config: Config): void {}
`,
}))).toThrow(/config field 'Config\.knob' .* has no JSDoc prose/)
})
it('hard-errors on an undocumented field nested in a type literal', () => {
expect(() => collectConfigCatalog(make({
'src/index.ts': `import type { Context } from 'cordis'
/** Fixture config. */
export interface Config {
/** Entries. */
entries: {
id: string
}[]
}
/** Load. */
export function apply(ctx: Context, config: Config): void {}
`,
}))).toThrow(/config field 'Config\.entries\.id' .* has no JSDoc prose/)
})
it('pastes a package-local type transitively and records external refs', () => {
const entries = collectConfigCatalog(make({
'src/index.ts': `import type { Context } from 'cordis'
import type { Mode } from './types.ts'
import type { Remote } from '@fix/dep'
/** Fixture config. */
export interface Config {
/** The mode. */
mode?: Mode
/** The remote. */
remote?: Remote
}
/** Load. */
export function apply(ctx: Context, config: Config): void {}
`,
'src/types.ts': '/** Fixture mode. */\nexport type Mode = \'a\' | \'b\'\n',
}))
expect(entries[0]?.pastes?.map(p => p.source)).toEqual([
'packages/group/one/src/index.ts:5',
'packages/group/one/src/types.ts:2',
])
expect(entries[0]?.refs).toEqual([{ alias: 'Remote', imported: 'Remote', specifier: '@fix/dep' }])
})
it('hard-errors on a referenced type name that resolves nowhere', () => {
expect(() => collectConfigCatalog(make({
'src/index.ts': `import type { Context } from 'cordis'
/** Fixture config. */
export interface Config {
/** The ghost. */
ghost?: Ghost
}
/** Load. */
export function apply(ctx: Context, config: Config): void {}
`,
}))).toThrow(/references 'Ghost' .* neither declared in the package, imported, nor a known global/)
})
it('hard-errors on a config type imported from another package', () => {
expect(() => collectConfigCatalog(make({
'src/index.ts': `import type { Context } from 'cordis'
import type { Config } from '@fix/dep'
/** Load. */
export function apply(ctx: Context, config: Config): void {}
`,
}))).toThrow(/config type 'Config' is imported from '@fix\/dep'/)
})
it('hard-errors when one name resolves to two different declarations across the closure', () => {
expect(() => collectConfigCatalog(make({
'src/index.ts': `import type { Context } from 'cordis'
import type { A } from './a.ts'
import type { B } from './b.ts'
/** Fixture config. */
export interface Config {
/** A. */
a?: A
/** B. */
b?: B
}
/** Load. */
export function apply(ctx: Context, config: Config): void {}
`,
'src/a.ts': '/** First Option. */\nexport interface Option {\n /** X. */\n x?: string\n}\n/** A. */\nexport interface A {\n /** O. */\n o?: Option\n}\n',
'src/b.ts': '/** Second Option. */\nexport interface Option {\n /** Y. */\n y?: string\n}\n/** B. */\nexport interface B {\n /** O. */\n o?: Option\n}\n',
}))).toThrow(/type name 'Option' resolves to two different declarations/)
})
})
describe('gen-config-catalog schema cross-check', () => {
it('accepts a chained schema whose keys all appear on the config type', () => {
const entries = collectConfigCatalog(make({
'src/index.ts': `import type { Context } from 'cordis'
import z from 'schemastery'
${DOCUMENTED_CONFIG}
export const Config: z<Config> = z.object({ knob: z.string() }).default({})
/** Load. */
export function apply(ctx: Context, config: Config): void {}
`,
}))
expect(entries[0]?.schemaKeys).toEqual(['knob'])
})
it('hard-errors on a schema key the config type does not declare', () => {
expect(() => collectConfigCatalog(make({
'src/index.ts': `import type { Context } from 'cordis'
import z from 'schemastery'
${DOCUMENTED_CONFIG}
export const Config: z<Config> = z.object({ knob: z.string(), hidden: z.number() })
/** Load. */
export function apply(ctx: Context, config: Config): void {}
`,
}))).toThrow(/schema validates key 'hidden' but config type 'Config' declares no such member/)
})
it('hard-errors on a NESTED schema key the config type does not declare', () => {
expect(() => collectConfigCatalog(make({
'src/index.ts': `import type { Context } from 'cordis'
import z from 'schemastery'
/** Fixture config. */
export interface Config {
/** Entries. */
entries: {
/** Id. */
id: string
}[]
}
export const Config: z<Config> = z.object({ entries: z.array(z.object({ id: z.string(), ghost: z.string() })) })
/** Load. */
export function apply(ctx: Context, config: Config): void {}
`,
}))).toThrow(/schema validates key 'entries\[\]\.ghost'/)
})
it('resolves nested keys through a workspace-imported intersection part (re-export chains included)', () => {
const root = makeRoot()
writePkg(root, 'group/dep', '@fix/dep', {
'src/index.ts': 'export * from \'./types.ts\'\n',
'src/types.ts': '/** Shared options. */\nexport interface Opts {\n /** Model. */\n model?: string\n}\n',
})
writePkg(root, 'group/one', '@fix/one', {
'src/index.ts': `import type { Context } from 'cordis'
import z from 'schemastery'
import type { Opts } from '@fix/dep'
/** Fixture config. */
export interface Config {
/** Entries. */
entries: (Opts & {
/** Id. */
id: string
})[]
}
export const Config: z<Config> = z.object({ entries: z.array(z.object({ id: z.string(), model: z.string() })) })
/** Load. */
export function apply(ctx: Context, config: Config): void {}
`,
})
expect(() => collectConfigCatalog(root)).not.toThrow()
})
it('resolves nested keys through a Partial<> wrapper', () => {
expect(() => collectConfigCatalog(make({
'src/index.ts': `import type { Context } from 'cordis'
import z from 'schemastery'
/** Caps. */
export interface Caps {
/** X. */
x?: boolean
}
/** Fixture config. */
export interface Config {
/** Capabilities. */
capabilities?: Partial<Caps>
}
export const Config: z<Config> = z.object({ capabilities: z.object({ x: z.boolean() }) })
/** Load. */
export function apply(ctx: Context, config: Config): void {}
`,
}))).not.toThrow()
})
it('leaves a nested key under an external (unresolvable) type unreported', () => {
expect(() => collectConfigCatalog(make({
'src/index.ts': `import type { Context } from 'cordis'
import z from 'schemastery'
import type { External } from 'some-external-pkg'
/** Fixture config. */
export interface Config {
/** Options. */
options?: External
}
export const Config: z<Config> = z.object({ options: z.object({ whatever: z.string() }) })
/** Load. */
export function apply(ctx: Context, config: Config): void {}
`,
}))).not.toThrow()
})
it('folds an intersected workspace schema into the subset check', () => {
const root = makeRoot()
writePkg(root, 'group/leaf', '@fix/leaf', {
'src/index.ts': `import type { Context } from 'cordis'
import z from 'schemastery'
/** Leaf config. */
export interface Config {
/** Leaf knob. */
leaf?: string
}
/** Leaf service. */
export default class Leaf {
static Config = z.object({ leaf: z.string() }) as unknown as z<Config>
constructor(ctx: Context, config: Config) {}
}
`,
})
writePkg(root, 'group/bundle', '@fix/bundle', {
'src/index.ts': `import type { Context } from 'cordis'
import z from 'schemastery'
import Leaf from '@fix/leaf'
/** Bundle config. */
export interface Config {
/** Forwarded leaf knob. */
leaf?: string
}
export const Config = z.intersect([Leaf.Config]) as unknown as z<Config>
/** Load. */
export function apply(ctx: Context, config: Config): void {}
`,
})
const entries = collectConfigCatalog(root)
expect(entries.find(e => e.pkg === '@fix/bundle')?.schemaComposes).toEqual(['@fix/leaf'])
})
it('resolves composed nested keys through an indexed-access forwarder', () => {
const root = makeRoot()
writePkg(root, 'group/leaf', '@fix/leaf', {
'src/index.ts': `import type { Context } from 'cordis'
import z from 'schemastery'
/** Leaf config. */
export interface Config {
/** Agents. */
agents: {
/** Id. */
id: string
}[]
}
/** Leaf service. */
export default class Leaf {
static Config = z.object({ agents: z.array(z.object({ id: z.string() })) }) as unknown as z<Config>
constructor(ctx: Context, config: Config) {}
}
`,
})
writePkg(root, 'group/bundle', '@fix/bundle', {
'src/index.ts': `import type { Context } from 'cordis'
import z from 'schemastery'
import Leaf, { type Config as LeafConfig } from '@fix/leaf'
/** Bundle config forwarding the leaf's agents list. */
export interface Config {
/** Forwarded agents list. */
agents?: LeafConfig['agents']
}
export const Config = z.intersect([Leaf.Config]) as unknown as z<Config>
/** Load. */
export function apply(ctx: Context, config: Config): void {}
`,
})
expect(() => collectConfigCatalog(root)).not.toThrow()
})
it('hard-errors when an intersected schema key is missing from the bundle config type', () => {
const root = makeRoot()
writePkg(root, 'group/leaf', '@fix/leaf', {
'src/index.ts': `import type { Context } from 'cordis'
import z from 'schemastery'
/** Leaf config. */
export interface Config {
/** Leaf knob. */
leaf?: string
}
/** Leaf service. */
export default class Leaf {
static Config = z.object({ leaf: z.string() }) as unknown as z<Config>
constructor(ctx: Context, config: Config) {}
}
`,
})
writePkg(root, 'group/bundle', '@fix/bundle', {
'src/index.ts': `import type { Context } from 'cordis'
import z from 'schemastery'
import Leaf from '@fix/leaf'
/** Bundle config that forgot to declare the forwarded field. */
export interface Config {
/** Unrelated. */
other?: string
}
export const Config = z.intersect([Leaf.Config]) as unknown as z<Config>
/** Load. */
export function apply(ctx: Context, config: Config): void {}
`,
})
expect(() => collectConfigCatalog(root)).toThrow(/schema validates key 'leaf' but config type 'Config' declares no such member/)
})
})
describe('gen-config-catalog render', () => {
it('renders sections, fences, and the terse classification lists', () => {
const root = makeRoot()
writePkg(root, 'group/one', '@fix/one', {
'src/index.ts': `import type { Context } from 'cordis'
${DOCUMENTED_CONFIG}
/** Load. */
export function apply(ctx: Context, config: Config): void {}
`,
})
writePkg(root, 'group/lib', '@fix/lib', { 'src/index.ts': 'export const helper = 1\n' })
writePkg(root, 'group/seam', '@fix/seam', {
'src/index.ts': 'export default abstract class Seam { abstract run(): void }\n',
})
const page = render(collectConfigCatalog(root))
expect(page).toContain('## `@fix/one`')
expect(page).toContain('```ts config-catalog')
expect(page).toContain('/** A knob. */')
expect(page).toContain('- `@fix/lib` ([`packages/group/lib/src/index.ts`](../packages/group/lib/src/index.ts))')
expect(page).toContain('- `@fix/seam` — abstract `Seam`')
})
})

View File

@@ -56,12 +56,15 @@ forever:
STEP loop:
drain steering
assembly = systemPrompt.assemble({agent}) ⟵ renderPrompt(assembly) IS the full prompt
await serial agent/pre-step ⟵ surface mutation (compaction) outside the step
prefix ??= waterfall agent/session-prefix ⟵ once per instance (first step): frozen
session prefix; on the header, never history
await serial agent/pre-step(…, prefix) ⟵ surface mutation (compaction) outside the step;
pressure gates see the prefix the request carries
boundary = session.deriveMessages() ⟵ reconstruction boundary: same sync frame,
session('step/start') strictly before step/start
config = waterfall agent/request ⟵ frozen seed; return a replacement to switch
session('request/header'[-delta]) ⟵ the header event this request owes the log
stream llm.stream(freeze({header..., messages: boundary})) → session('assistant/chunk')
stream llm.stream(freeze({header..., messages: prefix+boundary})) → session('assistant/chunk')
message = waterfall agent/step-result
session('assistant/message')
each tool-call: session('tool/call')
@@ -85,7 +88,7 @@ Cancellation: `agent.cancel()` is the single public stop primitive — it clears
### What is NOT here
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
- Hooks: `agent/session-start`, `agent/prompt-submit`, `agent/pre-step`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation`
- Hooks: `agent/session-start`, `agent/prompt-submit`, `agent/pre-step`, `agent/request`, `agent/session-prefix`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation`
- Compaction: `agent/pre-step`
- Sandbox, permission, plan mode: `tools/pre-execute` (deny/ask gate), `tools/post-execute`
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while child streaming/progress and background/poll collection remain deferred.

View File

@@ -22,6 +22,10 @@ import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
* the agent/* event taxonomy — plugins never need this class.
*/
export class ReactLoopAgent implements Agent {
/**
* The queued + steering FIFOs behind {@link send}/{@link steer}. Public so
* the driver loop can drain it; {@link cancel} clears it wholesale.
*/
readonly inbox = new Inbox()
private _status: AgentStatus = 'idle'
@@ -256,6 +260,8 @@ export class ReactLoopAgent implements Agent {
* promise (unblocking the idle wait), releases any `whenIdle` waiters, and
* aborts the current request if any. The returned `agent.done` promise
* resolves once the loop exits.
* @returns the disposer — idempotent and infallible (it runs inside the
* fiber's LIFO disposal chain, where a throw would skip later disposers).
*/
start(): () => void {
this.done = runLoop(this.ctx, this, {

View File

@@ -24,30 +24,47 @@ export class Inbox {
private steeringMessages: InboxMessage[] = []
private wakeup: (() => void) | undefined
/** Resolves when a queued message arrives (used by the idle loop). */
/** True while queued messages are pending — read by the idle wait's fast path and the loop's turn-start checks. */
get hasQueued(): boolean {
return this.queuedMessages.length > 0
}
/** True while steering messages are pending — read by `cancel()`'s arm gate and the loop's stop-override check. */
get hasSteering(): boolean {
return this.steeringMessages.length > 0
}
/**
* Add a message to the queued FIFO and wake a parked {@link waitForQueued}.
* @param message - the message to queue for the next turn start.
*/
enqueue(message: InboxMessage): void {
this.queuedMessages.push(message)
this.wakeup?.()
}
/**
* Add a message to the steering FIFO. Deliberately no wakeup: steering is
* drained between steps of a running turn, never by the idle wait —
* `Agent.steer()` on an idle agent falls back to `send()` instead.
* @param message - the message to inject between steps of the running turn.
*/
steer(message: InboxMessage): void {
this.steeringMessages.push(message)
}
/** Drain all queued messages (turn start). */
/**
* Drain all queued messages (turn start).
* @returns the drained messages in arrival order; the queued FIFO is left empty.
*/
drainQueued(): InboxMessage[] {
return this.queuedMessages.splice(0)
}
/** Drain all steering messages (between steps). */
/**
* Drain all steering messages (between steps).
* @returns the drained messages in arrival order; the steering FIFO is left empty.
*/
drainSteering(): InboxMessage[] {
return this.steeringMessages.splice(0)
}
@@ -62,7 +79,12 @@ export class Inbox {
this.steeringMessages.length = 0
}
/** Wait until a queued message arrives or `cancel` resolves. */
/**
* Wait until a queued message arrives or `cancel` resolves.
* @param cancel - a promise whose resolution abandons the wait without a
* message (the driver loop passes the agent's disposed promise so a parked
* loop can exit).
*/
waitForQueued(cancel: Promise<void>): Promise<void> {
if (this.hasQueued) return Promise.resolve()
const { promise, resolve } = Promise.withResolvers<void>()

View File

@@ -29,9 +29,14 @@ declare module 'cordis' {
}
}
/**
* Plugin config: the agents to create — or resume, via `resumeSessionId` —
* declaratively at startup, so a cordis.yml deployment needs no code.
*/
export interface Config {
/** Agents created from configuration at startup. */
agents: (AgentOptions & {
/** Agent id to register under; also seeds the fresh per-run session id (`${id}-session-<uuid>`). */
id: AgentId
/** Optional workspace cwd for the config-created fresh session. */
cwd?: string

View File

@@ -157,13 +157,17 @@ export interface LoopHandle {
* drain steering → session('steering/message') ⟵ catches late steering
* assembly = ctx.systemPrompt.assemble({agent}) ⟵ waterfall system-prompt/assemble; renderPrompt
* (persona section + {{variables}}) IS the full prompt
* await ctx.serial('agent/pre-step') ⟵ surface mutation (compaction) OUTSIDE the step
* prefix ??= waterfall agent/session-prefix ⟵ once per loop instance (first step): frozen
* session prefix; logged on the header, never
* session history
* await ctx.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: boundary, sessionId, signal})
* 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
@@ -185,6 +189,9 @@ export interface LoopHandle {
* 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
@@ -469,6 +476,50 @@ async function runTurn(
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.
if (transmission.sessionPrefix === undefined) {
const emptyPrefix: Message[] = deepFreeze([])
const composed = await ctx.waterfall(
'agent/session-prefix', agent, emptyPrefix, abort.signal,
() => 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.
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
break
}
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
@@ -479,8 +530,10 @@ async function runTurn(
// 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.
await ctx.serial('agent/pre-step', agent, turn, step, fullSystemPrompt, abort.signal)
// 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.
await ctx.serial('agent/pre-step', agent, turn, step, fullSystemPrompt, transmission.sessionPrefix, abort.signal)
// Interruption landing during the pre-step seam: do not open an empty step.
if (handle.isCancelled() || handle.isDisposed()) {
@@ -671,11 +724,12 @@ function drainSteering(agent: ReactLoopAgent, turn: number): boolean {
}
/** One step: build the request from the boundary snapshot + the step's
* header → log the header event the request owes → stream model → record →
* execute tools. The caller assembles the system prompt, fires the
* `agent/pre-step` seam, snapshots the derivation, and opens the step BEFORE
* calling this, so `boundaryMessages` is exactly the surface prefix at
* step/start and already reflects any compaction. */
* header → compose the session prefix if this instance has none yet → log
* the header event the request owes → stream model → record → execute
* tools. The caller assembles the
* system prompt, fires the `agent/pre-step` seam, snapshots the derivation,
* and opens the step BEFORE calling this, so `boundaryMessages` is exactly
* the surface prefix at step/start and already reflects any compaction. */
async function runStep(
ctx: Context,
agent: ReactLoopAgent,
@@ -715,22 +769,30 @@ async function runStep(
throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
}
// The session prefix was composed (once per instance) before this step's
// pre-step seam — the caller guarantees it, so the cache is always set here.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call
const sessionPrefix = transmission.sessionPrefix!
// The request header (the log's request/header* vocabulary): canonical form,
// recorded before dispatch so the log always explains the request.
// recorded before dispatch so the log always explains the request
// including the session prefix, which no other event carries.
const header = canonicalHeader({
config,
...system ? { system } : {},
...assembly.tools.length > 0 ? { tools: assembly.tools } : {},
...sessionPrefix.length > 0 ? { messagePrefix: sessionPrefix } : {},
})
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.
// keys on. Message order: header.messagePrefix, then the boundary
// snapshot — the reconstruction equation the invariant recomputes.
const request: GenerateOptions = deepFreeze({
model: header.config.model,
messages: boundaryMessages,
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
...header.system !== undefined ? { system: header.system } : {},
...header.tools !== undefined ? { tools: header.tools } : {},
...header.config.temperature !== undefined ? { temperature: header.config.temperature } : {},
@@ -875,7 +937,11 @@ function withoutToolCalls(message: Message): Message {
return { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
}
/** The last turn number in a (possibly seeded) session log, or 0. */
/**
* The last turn number in a (possibly seeded) session log, or 0.
* @param session - the session whose log is scanned for the latest `turn/start`.
* @returns the latest `turn/start`'s turn number, or 0 when the log has none (the next turn is this plus one).
*/
export function lastTurnNumber(session: Session): number {
const lastStart = session.events.findLast(event => event.type === 'turn/start')
return lastStart?.data.turn ?? 0
@@ -889,6 +955,8 @@ export function lastTurnNumber(session: Session): number {
* 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).
* @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.
*/
export function isTurnOpen(session: Session): boolean {
const last = session.events.findLast(e => e.type === 'turn/start' || e.type === 'turn/end')

View File

@@ -12,14 +12,26 @@
import { diffHeader, headerEquals, applyHeaderDelta } from '@deepseek-ai/dsh-session'
import type { EpochHeader, Session } from '@deepseek-ai/dsh-session'
import type { Message } from '@deepseek-ai/dsh-llm'
/** Per-loop-instance bookkeeping: whether THIS instance has logged a header yet. */
export interface TransmissionLog {
/** True once this loop instance appended its anchoring `request/header` snapshot. */
loggedHeader: boolean
/**
* The instance's composed session prefix (the `agent/session-prefix`
* waterfall's deep-frozen product), cached on the instance's first
* request-building step and reused verbatim for every request it sends —
* the structural guarantee that the prefix never changes mid-session.
* `undefined` until composed.
*/
sessionPrefix?: Message[]
}
/** Fresh bookkeeping for a newly-started loop instance. */
/**
* Fresh bookkeeping for a newly-started loop instance.
* @returns state with `loggedHeader` false, so the instance's first request appends an anchoring snapshot.
*/
export function createTransmissionLog(): TransmissionLog {
return { loggedHeader: false }
}
@@ -58,7 +70,7 @@ export function recordRequestHeader(session: Session, state: TransmissionLog, he
const baseline = session.requestHeader()!
if (headerEquals(baseline, header)) return
const delta = diffHeader(baseline, header)
/* v8 ignore next -- headerEquals false ⟹ diffHeader defined: both compare the same three parts */
/* v8 ignore next -- headerEquals false ⟹ diffHeader defined: both compare the same four parts */
if (delta === undefined) return
if (headerEquals(applyHeaderDelta(baseline, delta), header)) {
session.append('request/header-delta', delta)

View File

@@ -12,7 +12,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import LlmService, { type Message } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
@@ -166,6 +166,103 @@ describe('Agent.cancel()', () => {
expect(reasons.length).toBe(2)
})
it('cancel from inside the agent/session-prefix waterfall drops the step (prefix-composition window)', async () => {
const adapter = new MockAdapter([textResponse('should not stream')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// Prefix composition runs before the pre-step seam on the instance's first
// step; a cancel landing inside it must drop the about-to-start step
// without running the seam or the model.
let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => {
agent.cancel('from prefix composition')
return next()
})
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(streamed).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from prefix composition' }])
})
it('disposal from inside the agent/session-prefix waterfall ends the turn disposed (prefix-composition window)', async () => {
const adapter = new MockAdapter([textResponse('should not stream')])
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
const handle = ctx.agents.create({
agentId: AgentId('a-dispose-prefix'),
sessionId: SessionId('dispose-prefix-session'),
agentOptions: { model: 'mock' },
})
const agent = handle.agent as ReactLoopAgent
let disposalDone: Promise<void> | undefined
let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => {
disposalDone = handle.dispose()
return next()
})
send(agent, 'go')
await new Promise(resolve => setTimeout(resolve, 0))
await disposalDone
await agent.done
// No step opened, no model call ran, and the turn closed disposed.
expect(streamed).toBe(false)
expect(adapter.requests).toHaveLength(0)
const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
})
it('a cancel-interrupted prefix composition is discarded: the next send recomposes and ships the fresh prefix (stale-cache guard)', async () => {
const adapter = new MockAdapter([textResponse('reply')])
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.
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[]> => {
compositions += 1
if (compositions === 1) {
agent.cancel('mid-composition')
return next()
}
return [opener, ...await next()]
})
send(agent, 'dropped')
await waitForIdle(ctx, agent)
send(agent, 'real prompt')
await waitForIdle(ctx, agent)
expect(compositions).toBe(2)
expect(adapter.requests).toHaveLength(1)
expect(adapter.requests[0]?.messages[0]).toEqual(opener)
const headerEvent = agent.session.events.find(e => e.type === 'request/header')
expect(headerEvent?.type === 'request/header' && headerEvent.data.header.messagePrefix).toEqual([opener])
})
it('cancel from a synchronous turn/start session-event listener drops the step (step-start window)', async () => {
const adapter = new MockAdapter([textResponse('should not stream')])
const ctx = await harness(adapter)

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm'
import SessionStore, { type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
@@ -310,6 +310,162 @@ describe('agent/session-start', () => {
})
})
describe('agent/session-prefix', () => {
it('composes once per loop instance and fronts every request; the header records it; history stays untouched', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', { text: 'ping' }),
textResponse('done'),
textResponse('again'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const reminder: Message = { role: 'user', content: [{ type: 'text', text: '<system-reminder>catalog</system-reminder>' }] }
let composed = 0
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
composed += 1
return [...await next(), reminder]
})
send(agent, 'go')
await waitForIdle(ctx, agent)
send(agent, 'next turn')
await waitForIdle(ctx, agent)
// Three requests (two turns), ONE composition: the frozen product is
// reused verbatim, so the prefix cannot drift mid-session.
expect(adapter.requests).toHaveLength(3)
expect(composed).toBe(1)
for (const request of adapter.requests) {
expect(request.messages[0]).toEqual(reminder)
}
// The anchoring snapshot is the prefix's durable record — and the ONLY
// header event: reuse means no request/header-delta ever.
const headerEvents = events(agent).filter(e => e.type === 'request/header' || e.type === 'request/header-delta')
expect(headerEvents).toHaveLength(1)
expect(headerEvents[0]?.type === 'request/header' && headerEvents[0].data.header.messagePrefix).toEqual([reminder])
// Never session history: the derivation starts at the real user prompt.
expect(agent.session.deriveMessages()[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'go' }] })
})
it('composes before the first pre-step and hands the prefix to the seam (pressure gates see the real value)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const reminder: Message = { role: 'user', content: [{ type: 'text', text: 'opener' }] }
const order: string[] = []
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
order.push('compose')
return [reminder, ...await next()]
})
const seen: (readonly Message[])[] = []
ctx.on('agent/pre-step', (_agent, _turn, _step, _system, sessionPrefix) => {
order.push('pre-step')
seen.push(sessionPrefix)
})
send(agent, 'hi')
await waitForIdle(ctx, agent)
// Composition precedes the pre-step seam, and the seam receives THIS
// instance's composed prefix — a token-pressure gate (compaction) counts
// what the request will actually carry, never a stale logged prefix.
expect(order).toEqual(['compose', 'pre-step'])
expect(seen[0]).toEqual([reminder])
})
it('the canonical prepend pattern composes contributions in registration order', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// Both listeners use the canonical `[mine, ...await next()]` prepend: the
// waterfall unwinds innermost-first (the second listener's array is built
// first), so prepending puts the FIRST-registered contribution first.
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
return [{ role: 'user', content: [{ type: 'text', text: 'first' }] }, ...await next()]
})
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
return [{ role: 'user', content: [{ type: 'text', text: 'second' }] }, ...await next()]
})
send(agent, 'hi')
await waitForIdle(ctx, agent)
const texts = adapter.requests[0]!.messages.map(m => m.content[0]?.type === 'text' ? m.content[0].text : '')
expect(texts).toEqual(['first', 'second', 'hi'])
})
it('with no contributions the header omits messagePrefix and the request is the bare derivation', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// A listener that delegates without contributing — the canonical no-op.
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => next())
send(agent, 'hi')
await waitForIdle(ctx, agent)
const headerEvent = events(agent).find(e => e.type === 'request/header')
expect(headerEvent?.type === 'request/header' && 'messagePrefix' in headerEvent.data.header).toBe(false)
expect(adapter.requests[0]!.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }])
})
it('the frozen seed rejects in-place mutation — a contribution is a returned extension', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let mutationError: unknown
ctx.on('agent/session-prefix', async (_agent, prefix, _signal, next): Promise<Message[]> => {
try {
prefix.push({ role: 'user', content: [{ type: 'text', text: 'smuggled' }] })
} catch (error: unknown) {
mutationError = error
}
return next()
})
send(agent, 'hi')
await waitForIdle(ctx, agent)
expect(mutationError).toBeInstanceOf(TypeError)
expect(adapter.requests[0]!.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }])
})
it('mutating a listener-held reference after composition cannot alter later requests (the cache is a frozen clone)', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', { text: 'ping' }),
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const held: Message = { role: 'user', content: [{ type: 'text', text: 'v1' }] }
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => [...await next(), held])
send(agent, 'go')
await waitForIdle(ctx, agent)
// The listener mutates the object it contributed AFTER composition; the
// cached prefix is a deep-frozen clone, so step 2's request is unchanged.
held.content = [{ type: 'text', text: 'v2' }]
expect(adapter.requests[1]!.messages[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'v1' }] })
expect(events(agent).filter(e => e.type === 'request/header-delta')).toHaveLength(0)
})
})
describe('agent/turn-continuation (ContinuationDecision)', () => {
it('a continue decision with a reason records next-step steering in the same turn', async () => {
const adapter = new MockAdapter([textResponse('step 1 no tools'), textResponse('step 2')])

View File

@@ -0,0 +1,117 @@
/**
* 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.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { foldRequestHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['toolOrder']) {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: 'stable base', ...toolOrder !== undefined ? { toolOrder } : {} })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
}
})
})
}
function registerNamed(ctx: Context, name: string) {
ctx.tools.register(defineTool({
name,
description: `the ${name} tool`,
parameters: {},
async execute() {
return [{ type: 'text', text: name }]
},
}))
}
/** Run one text-only turn and return the harness context + agent. */
async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConfig['toolOrder']) {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter, toolOrder)
for (const name of registrationOrder) registerNamed(ctx, name)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
return { ctx, agent, adapter }
}
describe('loop-level canonical tool order', () => {
it('logs the request/header with tools in canonical order, not registration order', async () => {
const { agent, adapter } = await runTurn(['zulu', 'alpha', 'mike'])
const header = foldRequestHeader(agent.session.events)
expect(header?.tools?.map(tool => tool.name)).toEqual(['alpha', 'mike', 'zulu'])
// The dispatched request is built FROM the logged header (whose tools the
// assembly already canonicalized) and reaches the adapter deep-frozen —
// the marker the reconstruction invariant keys on.
expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toEqual(['alpha', 'mike', 'zulu'])
expect(Object.isFrozen(adapter.requests[0])).toBe(true)
expect(adapter.requests[0]?.sessionId).toBe(agent.session.id)
})
it('produces the same header order for any registration order', async () => {
const first = await runTurn(['alpha', 'mike', 'zulu'])
const second = await runTurn(['zulu', 'mike', 'alpha'])
const names = (run: typeof first) => foldRequestHeader(run.agent.session.events)?.tools?.map(tool => tool.name)
expect(names(first)).toEqual(['alpha', 'mike', 'zulu'])
expect(names(second)).toEqual(names(first))
})
it('honors a configured toolOrder in the logged header and the dispatched request', async () => {
const { agent, adapter } = await runTurn(['alpha', 'zulu', 'mike'], ['zulu', TOOL_ORDER_REST])
const header = foldRequestHeader(agent.session.events)
expect(header?.tools?.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'mike'])
expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'mike'])
expect(Object.isFrozen(adapter.requests[0])).toBe(true)
})
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.
const adapter = new MockAdapter([textResponse('never sent')])
const ctx = await harness(adapter, ['ghost', TOOL_ORDER_REST])
registerNamed(ctx, 'alpha')
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; registered tools: alpha'])
expect(foldRequestHeader(agent.session.events)).toBeUndefined()
const end = agent.session.events.find(e => e.type === 'turn/end')
expect(end?.type === 'turn/end' && end.data.reason).toMatchObject({ kind: 'error', step: 1 })
// The turn is balanced (turn/start → turn/end) with no step events inside.
expect(agent.session.events.some(e => e.type === 'step/start')).toBe(false)
})
})

View File

@@ -43,8 +43,9 @@ Turn and step boundaries are NOT mirrored as `agent/*` emits: a consumer that ne
- `agent/session-start` (emit) — fired once before the first turn; a listener seeds context via `agent.inject()` (it cannot veto startup).
- `agent/prompt-submit` — decide what happens to one drained queued message before it becomes a `user/message`: `PromptDecision` = `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (drop it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`). Maps onto Claude Code's `UserPromptSubmit`.
- `agent/pre-step` (serial) — mutate the session surface before the step opens and history is derived (compaction). Fires after `turn/start` and before `step/start`, so a listener's appended events land outside the step.
- `agent/pre-step` (serial) — mutate the session surface before the step opens and history is derived (compaction). Fires after `turn/start` and before `step/start`, so a listener's appended events land outside the step; carries the assembled system prompt and the instance's composed session prefix so a token-pressure gate counts everything the request will carry.
- `agent/request` — shape the call config before the model call: a frozen `LlmCallConfig` seed in, a replacement out (model switching, sampling overrides). Content is not shapeable here — every request is a pure function of the session log ([reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)); the loop logs whatever config the request actually uses as a `request/header*` event
- `agent/session-prefix` — compose the session prefix: request-only messages placed in front of the ENTIRE derived history on every request. Fired ONCE per loop instance, lazily before its first pre-step (so pressure gates see this instance's real prefix, never a previous instance's logged one); the composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the anchoring `request/header` snapshot, and reused verbatim afterwards — the prefix cannot change mid-session, so the provider prefix cache holds by construction (resume = a new instance = a recompose, attributably anchored by its `'resume'` snapshot). The home for session-stable openers that must not become durable history (a skills catalog, an AGENTS.md digest); `deriveMessages()` never returns it. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, `tools/post-execute` `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter
- `agent/step-result` — post-process the assembled assistant message before tool dispatch (validates what the log records)
- `agent/turn-continuation` — override the continue/stop decision via `ContinuationDecision` = `{action:'stop'}` or `{action:'continue', reason?}` (a `continue` `reason` is recorded as next-step steering in the same turn — the typed `/goal` pattern). Force-continue `/loop`, force-stop budget guard.

View File

@@ -17,7 +17,8 @@
* 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/step-result`/`agent/turn-continuation` waterfalls and
* `agent/request`/`agent/session-prefix`/`agent/step-result`/
* `agent/turn-continuation` waterfalls and
* the serial `agent/pre-step`) that mutate/veto, and TRANSIENT emits
* (`agent/status`, `agent/error`, `agent/created`/
* `agent/disposed`, `agent/queued`, `agent/session-start`)
@@ -50,7 +51,11 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
/** Identifies one live agent in the registry. */
export type AgentId = Branded<'AgentId'>
/** Brand a string as an {@link AgentId}. */
/**
* Brand a string as an {@link AgentId}.
* @param id - the raw agent id string.
* @returns the same string, branded (a compile-time cast — no runtime cost).
*/
export function AgentId(id: string): AgentId {
return id as AgentId
}
@@ -80,10 +85,22 @@ export interface AgentOptions {
model?: string
}
/**
* Options for {@link Agent.send}/{@link Agent.steer}/{@link Agent.inject}. An
* absent `source` resolves to `{ kind: 'user' }`, so a plugin supplying content
* must label itself here or its message is recorded as a user prompt (see
* {@link HookContext} on why that label is load-bearing).
*/
export interface SendOptions {
source?: MessageSource
}
/**
* An agent's lifecycle state, emitted on every transition as `agent/status`:
* `idle` (parked, waiting for queued work), `running` (a turn is in progress),
* `disposed` (terminal — no transition leaves it, and `send`/`steer`/`inject`
* throw).
*/
export type AgentStatus = 'idle' | 'running' | 'disposed'
/**
@@ -316,21 +333,28 @@ declare module 'cordis' {
* 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). `signal` cancels any in-flight work a listener starts (e.g. a
* 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).
* @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 fullSystemPrompt - the assembled prompt, for measuring token pressure.
* @param sessionPrefix - the instance's frozen session prefix, for the same measurement.
* @param signal - aborts in-flight listener work when the turn is torn down.
* @mode serial
*/
// TODO: `fullSystemPrompt` is a smell on a generic per-step seam — compaction
// is its only consumer, so a wide event carries a string just one listener
// 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.
'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise<void> | void
'agent/pre-step'(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
@@ -351,8 +375,9 @@ declare module 'cordis' {
* 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`
* never through request mutation, and the loop records whatever config
* `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
@@ -367,6 +392,53 @@ declare module 'cordis' {
* @mode waterfall
*/
'agent/request'(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.
*
* 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.
* @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.
* @mode waterfall
*/
'agent/session-prefix'(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, …).

View File

@@ -0,0 +1,555 @@
/**
* 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.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { collectExportJsdocViolations } from '../../../../scripts/verify-export-jsdoc.ts'
const roots: string[] = []
afterEach(() => {
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
})
/** Write fixture files under `packages/group/fix/src/` and return the scan root. */
function fixture(files: Record<string, string>): string {
const root = mkdtempSync(join(tmpdir(), 'export-jsdoc-'))
roots.push(root)
for (const [rel, content] of Object.entries(files)) {
const abs = join(root, 'packages', 'group', 'fix', 'src', rel)
mkdirSync(dirname(abs), { recursive: true })
writeFileSync(abs, content)
}
return root
}
/** Single-file fixture shorthand: the content becomes `src/index.ts`. */
const make = (content: string): string => fixture({ 'index.ts': content })
describe('verify-export-jsdoc functions and consts', () => {
it('accepts a fully documented surface', () => {
expect(collectExportJsdocViolations(make(`
/**
* Add one to a count.
* @param n - the count to bump.
* @returns the count plus one.
*/
export function bump(n: number): number { return n + 1 }
/**
* Fire-and-forget (void needs no @returns).
* @param flag - whether to arm.
*/
export function poke(flag: boolean): void { void flag }
/** The default retry budget. */
export const RETRIES = 3
/**
* Halve a count.
* @param n - the count to halve.
* @returns the count halved.
*/
export const halve = (n: number): number => n / 2
`))).toEqual([])
})
it('flags an exported function with no JSDoc at all', () => {
expect(collectExportJsdocViolations(make(
'export function bare(): void {}\n',
))).toEqual([expect.stringMatching(/exported function 'bare' .* has no JSDoc\./)])
})
it('flags a missing @param and a missing @returns', () => {
const violations = collectExportJsdocViolations(make(
'/** Docs without tags. */\nexport function f(x: number): number { return x }\n',
))
expect(violations).toEqual([
expect.stringMatching(/exported function 'f' .* is missing @param x\./),
expect.stringMatching(/exported function 'f' .* is missing @returns \(return type: number\)\./),
])
})
it('flags an unannotated (inferred) return type', () => {
expect(collectExportJsdocViolations(make(
'/**\n * Docs.\n * @param x - value.\n */\nexport function f(x: number) { return x }\n',
))).toEqual([expect.stringMatching(/no return type annotation/)])
})
it('flags tags-only JSDoc with no description prose', () => {
expect(collectExportJsdocViolations(make(
'/**\n * @param x - value.\n */\nexport function f(x: number): void {}\n',
))).toEqual([expect.stringMatching(/no description prose above its block tags/)])
})
it('flags a stale @param and a binding-pattern parameter', () => {
const violations = collectExportJsdocViolations(make(
'/**\n * Docs.\n * @param ghost - not real.\n */\nexport function f({ a }: { a: number }): void {}\n',
))
expect(violations).toEqual([
expect.stringMatching(/parameter '\{ a \}' is a binding pattern; the export surface needs simple identifier parameters/),
expect.stringMatching(/@param ghost does not match any parameter \(stale tag\?\)/),
])
})
it('exempts a `this` receiver annotation from @param', () => {
expect(collectExportJsdocViolations(make(
'/**\n * Docs.\n * @param x - value.\n */\nexport function f(this: object, x: number): void {}\n',
))).toEqual([])
})
it('waives @returns for a declarator-annotated const but not an unannotated one', () => {
expect(collectExportJsdocViolations(make(`
type Fn = (x: number) => number
/**
* Uses the named signature.
* @param x - value.
*/
export const good: Fn = x => x
/**
* No signature anywhere.
* @param x - value.
*/
export const bad = (x: number) => x
`))).toEqual([expect.stringMatching(/exported const 'bad' .* has no return type annotation/)])
})
it('requires description prose on a non-function const', () => {
expect(collectExportJsdocViolations(make(
'export const LIMIT = 10\n',
))).toEqual([expect.stringMatching(/exported const 'LIMIT' .* has no JSDoc\./)])
})
})
describe('verify-export-jsdoc type-level exports', () => {
it('requires description prose on interfaces, type aliases, and enums', () => {
const violations = collectExportJsdocViolations(make(
'export interface I { a: number }\nexport type T = number\nexport enum E { A }\n',
))
expect(violations).toEqual([
expect.stringMatching(/exported interface 'I' .* has no JSDoc\./),
expect.stringMatching(/exported type 'T' .* has no JSDoc\./),
expect.stringMatching(/exported enum 'E' .* has no JSDoc\./),
])
})
it('skips `declare module` augmentation bodies (the cordis gate owns them)', () => {
expect(collectExportJsdocViolations(make(
"declare module 'cordis' {\n interface Events {\n 'fix/x'(): void\n }\n}\nexport {}\n",
))).toEqual([])
})
})
describe('verify-export-jsdoc export forms', () => {
it('resolves an `export { … }` list to the local declaration', () => {
expect(collectExportJsdocViolations(make(
'function f(): void {}\nexport { f }\n',
))).toEqual([expect.stringMatching(/exported function 'f' .* has no JSDoc\./)])
})
it('does not treat a never-exported sibling declarator as surface (review round 2)', () => {
// `export { publicValue }` resolves to the whole variable statement; only
// the named declarator is surface — the gate must not demand JSDoc for
// the private sibling sharing the statement.
expect(collectExportJsdocViolations(make(
'/** The public knob. */\nconst publicValue = 1, privateHelper = 2\nexport { publicValue }\nvoid privateHelper\n',
))).toEqual([])
})
it('unions declarators across multiple export lists over one statement (review round 2)', () => {
// Two lists each name one declarator of the same undocumented statement:
// both are surface (deduplicating on first resolution would drop `b`),
// while the never-exported `c` stays out.
const violations = collectExportJsdocViolations(make(
'const a = 1, b = 2, c = 3\nexport { a }\nexport { b }\nvoid c\n',
))
expect(violations).toEqual([
expect.stringMatching(/exported const 'a' .* has no JSDoc\./),
expect.stringMatching(/exported const 'b' .* has no JSDoc\./),
])
})
it('scopes a default-export identifier to its own declarator (review round 2)', () => {
// `export default` of an identifier reaches the statement through the
// same name lookup as an export list; the sibling stays private.
expect(collectExportJsdocViolations(make(
'/** The app entry. */\nconst app = 1, scratch = 2\nexport default app\nvoid scratch\n',
))).toEqual([])
})
it('reports a re-exported module once, at its defining file', () => {
const violations = collectExportJsdocViolations(fixture({
'index.ts': "export * from './other.ts'\n",
'other.ts': 'export function f(): void {}\n',
}))
expect(violations).toEqual([expect.stringMatching(/other\.ts:1\) has no JSDoc\./)])
})
it('exempts overload implementations when the signatures are documented', () => {
expect(collectExportJsdocViolations(make(`
/**
* From a number.
* @param x - the number.
* @returns its text.
*/
export function f(x: number): string
/**
* From a flag.
* @param x - the flag.
* @returns its text.
*/
export function f(x: boolean): string
export function f(x: number | boolean): string { return String(x) }
`))).toEqual([])
})
})
describe('verify-export-jsdoc classes', () => {
it('flags an undocumented class, method, property, and accessor', () => {
const violations = collectExportJsdocViolations(make(`
export class C {
state = 1
get view(): number { return this.state }
run(x: number): number { return x }
}
`))
expect(violations).toEqual([
expect.stringMatching(/exported class 'C' .* has no JSDoc\./),
expect.stringMatching(/exported class property 'C.state' .* has no JSDoc\./),
expect.stringMatching(/exported class accessor 'C.view' .* has no JSDoc\./),
expect.stringMatching(/exported class method 'C.run' .* has no JSDoc\./),
])
})
it('exempts members declared by an extends/implements heritage type', () => {
expect(collectExportJsdocViolations(make(`
/** Seam. */
export abstract class Base {
/**
* Do it.
* @param x - input.
* @returns output.
*/
abstract run(x: number): number
}
/** Iface. */
export interface Sized {
/** Byte size. */
size: number
}
/** Impl. */
export class Impl extends Base implements Sized {
size = 0
run(x: number): number { return x }
}
`))).toEqual([])
})
it('skips private/protected/#private members and constructors', () => {
expect(collectExportJsdocViolations(make(`
/** Documented. */
export class C {
#secret = 1
private hidden(): void {}
protected hook(): void {}
constructor(x: number) { void x }
}
`))).toEqual([])
})
it('exempts plugin-protocol statics but checks other statics', () => {
const violations = collectExportJsdocViolations(make(`
/** Plugin. */
export class C {
static Config = { a: 1 }
static inject = ['bash']
static reusable = true
static other = 1
}
`))
expect(violations).toEqual([expect.stringMatching(/exported class property 'C.other' .* has no JSDoc\./)])
})
it("covers a set accessor by the getter's doc", () => {
expect(collectExportJsdocViolations(make(`
/** Documented. */
export class C {
/** The current width. */
get width(): number { return 1 }
set width(_v: number) {}
}
`))).toEqual([])
})
})
describe('verify-export-jsdoc plugin protocol and namespaces', () => {
it('exempts top-level plugin-protocol exports', () => {
expect(collectExportJsdocViolations(make(`
export const name = 'fix'
export const inject = ['bash']
export const reusable = true
export const Config = { parse: true }
export function apply(): void {}
`))).toEqual([])
})
it('recurses into namespaces with qualified names and honors the merge idiom', () => {
const violations = collectExportJsdocViolations(make(`
/** The plugin class. */
export class Fix {}
export namespace Fix {
export interface Config { a: number }
}
export namespace Loose {
export const x = 1
}
`))
expect(violations).toEqual([
expect.stringMatching(/exported interface 'Fix.Config' .* has no JSDoc\./),
expect.stringMatching(/exported namespace 'Loose' .* has no JSDoc\./),
expect.stringMatching(/exported const 'Loose.x' .* has no JSDoc\./),
])
})
})
describe('verify-export-jsdoc fail-closed forms (review round 1)', () => {
it('checks the function contract on a non-identifier default export', () => {
expect(collectExportJsdocViolations(make(
'/** Doubles. */\nexport default (x: number): number => x * 2\n',
))).toEqual([
expect.stringMatching(/default export .* is missing @param x\./),
expect.stringMatching(/default export .* is missing @returns \(return type: number\)\./),
])
expect(collectExportJsdocViolations(make(
'/**\n * Doubles.\n * @param x - the input.\n * @returns twice the input.\n */\nexport default (x: number): number => x * 2\n',
))).toEqual([])
})
it('treats an inline function-type annotation as the surface signature', () => {
expect(collectExportJsdocViolations(make(
'/** Maps a number. */\nexport declare const f: (x: number) => number\n',
))).toEqual([
expect.stringMatching(/exported const 'f' .* is missing @param x\./),
expect.stringMatching(/exported const 'f' .* is missing @returns \(return type: number\)\./),
])
expect(collectExportJsdocViolations(make(
'/**\n * Maps a number.\n * @param x - the input.\n * @returns the mapped value.\n */\nexport const f: (x: number) => number = v => v\n',
))).toEqual([])
})
it('recurses into an ambient declare namespace where members export implicitly', () => {
expect(collectExportJsdocViolations(make(
'export declare namespace N {\n function f(x: number): number\n}\n',
))).toEqual([
expect.stringMatching(/exported namespace 'N' .* has no JSDoc\./),
expect.stringMatching(/exported function 'N.f' .* has no JSDoc\./),
])
})
it('requires an export-import alias to document itself (its target may be unwalked)', () => {
expect(collectExportJsdocViolations(make(
'/** Holder. */\nexport namespace N {\n /** The value. */\n export const x = 1\n}\nexport import y = N.x\n',
))).toEqual([expect.stringMatching(/exported alias 'y' .* has no JSDoc\./)])
expect(collectExportJsdocViolations(make(
'namespace N {\n export const x = 1\n}\n/** Alias surfacing the internal counter. */\nexport import y = N.x\n',
))).toEqual([])
})
it('refuses an export-import alias to a callable, class, or namespace target', () => {
const refusal = /exported alias 'g' .* aliases a callable, class, or namespace target/
expect(collectExportJsdocViolations(make(
'namespace N {\n export function f(x: number): number { return x }\n}\n/** Alias. */\nexport import g = N.f\n',
))).toEqual([expect.stringMatching(refusal)])
expect(collectExportJsdocViolations(make(
'namespace N {\n export class C {\n run(x: number): number { return x }\n }\n}\n/** Alias. */\nexport import g = N.C\n',
))).toEqual([expect.stringMatching(refusal)])
expect(collectExportJsdocViolations(make(
'namespace N {\n export namespace Sub {\n export function f(x: number): number { return x }\n }\n}\n/** Alias. */\nexport import g = N.Sub\n',
))).toEqual([expect.stringMatching(refusal)])
})
it('classifies wrapped function initializers and default exports (parens, satisfies)', () => {
expect(collectExportJsdocViolations(make(
'type Fn = (x: number) => number\n/** Wrapped. */\nexport const f = (((x: number): number => x)) satisfies Fn\n',
))).toEqual([
expect.stringMatching(/exported const 'f' .* is missing @param x\./),
expect.stringMatching(/exported const 'f' .* is missing @returns \(return type: number\)\./),
])
expect(collectExportJsdocViolations(make(
'type Fn = (x: number) => number\n/** Wrapped. */\nexport default (((x: number): number => x * 2) satisfies Fn)\n',
))).toEqual([
expect.stringMatching(/default export .* is missing @param x\./),
expect.stringMatching(/default export .* is missing @returns \(return type: number\)\./),
])
})
it('treats a single-call-signature type literal as the surface signature', () => {
expect(collectExportJsdocViolations(make(
'/** Maps. */\nexport declare const f: { (x: number): number }\n',
))).toEqual([
expect.stringMatching(/exported const 'f' .* is missing @param x\./),
expect.stringMatching(/exported const 'f' .* is missing @returns \(return type: number\)\./),
])
})
it('refuses a hybrid callable type literal instead of narrowing the check', () => {
expect(collectExportJsdocViolations(make(
'/** Hybrid. */\nexport declare const f: { (x: number): number; flush: () => void }\n',
))).toEqual([expect.stringMatching(/exported const 'f'.*callable type literal is not gate-classifiable; extract a named type/)])
})
it('refuses an export-equals assignment instead of failing open', () => {
expect(collectExportJsdocViolations(make(
'const x = 1\nexport = x\n',
))).toEqual([expect.stringMatching(/export-equals assignment .* is not a gate-supported export form/)])
})
})
describe('verify-export-jsdoc heritage refinement (review round 1)', () => {
it('requires @param for parameters the base member never names', () => {
const violations = collectExportJsdocViolations(make(`
/** Seam. */
export abstract class Base {
/**
* Do it.
* @param x - input.
* @returns output.
*/
abstract run(x: number): number
}
/** Impl. */
export class Impl extends Base {
override run(x: number, verbose?: boolean): number { return verbose ? x : -x }
}
`))
expect(violations).toEqual([expect.stringMatching(/exported class method 'Impl.run' .* is missing @param verbose\./)])
})
it('does not exempt a public override of a protected-only base member', () => {
expect(collectExportJsdocViolations(make(`
/** Seam. */
export abstract class Base {
/** Subclass hook. */
protected hook(): void {}
}
/** Impl. */
export class Impl extends Base {
override hook(): void {}
}
`))).toEqual([expect.stringMatching(/exported class method 'Impl.hook' .* has no JSDoc\./)])
})
it('treats an underscore-prefixed rename of a base parameter as the same parameter', () => {
expect(collectExportJsdocViolations(make(`
/** Seam. */
export abstract class Base {
/**
* Load it.
* @param cwd - the working directory to scope the lookup.
* @returns the loaded value.
*/
abstract load(cwd: string): number
}
/** Impl (ignores cwd). */
export class Impl extends Base {
load(_cwd: string): number { return 1 }
}
`))).toEqual([])
})
it('flags a binding-pattern parameter an override adds beyond the base', () => {
expect(collectExportJsdocViolations(make(`
/** Seam. */
export abstract class Base {
/**
* Do it.
* @param x - input.
* @returns output.
*/
abstract run(x: number): number
}
/** Impl. */
export class Impl extends Base {
override run(x: number, { verbose }: { verbose?: boolean } = {}): number { return verbose ? x : -x }
}
`))).toEqual([expect.stringMatching(/exported class method 'Impl.run' .* is a binding pattern/)])
})
it('revives the @returns duty when an override grows a concrete result over a void base', () => {
const voidBase = `
/** Seam. */
export abstract class Base {
/** Do it (fire-and-forget). */
abstract run(): void
}
`
expect(collectExportJsdocViolations(make(`${voidBase}
/** Impl. */
export class Impl extends Base {
override run(): number { return 1 }
}
`))).toEqual([expect.stringMatching(/exported class method 'Impl.run' .* is missing @returns \(return type: number\)\./)])
expect(collectExportJsdocViolations(make(`${voidBase}
/** Impl. */
export class Impl extends Base {
/**
* Do it and count.
* @returns how many were done.
*/
override run(): number { return 1 }
}
`))).toEqual([])
})
it('classifies an unannotated override return over a void base via the checker', () => {
const voidBase = `
/** Seam. */
export abstract class Base {
/** Do it (fire-and-forget). */
abstract run(): void
}
`
expect(collectExportJsdocViolations(make(`${voidBase}
/** Impl. */
export class Impl extends Base {
override run() { return 1 }
}
`))).toEqual([expect.stringMatching(/exported class method 'Impl.run' .* non-void result its heritage declaration does not document/)])
expect(collectExportJsdocViolations(make(`${voidBase}
/** Impl (faithful void, no annotation needed). */
export class Impl extends Base {
override run() {}
}
`))).toEqual([])
})
it('keeps the full exemption when the base return already carries the @returns duty', () => {
expect(collectExportJsdocViolations(make(`
/** Seam. */
export abstract class Base {
/**
* Count things.
* @returns the count.
*/
abstract run(): number
}
/** Impl. */
export class Impl extends Base {
override run(): number { return 1 }
}
`))).toEqual([])
})
})

View File

@@ -9,6 +9,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
### Public API
- `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber.
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata.
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
@@ -50,11 +51,11 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
### Request-header reconstruction (`request-header.ts`)
The `request/header` (full `EpochHeader` snapshot with a `RequestHeaderReason`) and `request/header-delta` (system line-trim / name-keyed tools delta / whole config) events make the request envelope logged session state, so every conversation request is a pure function of the log. The pure trio reconstructs it: `foldRequestHeader(events)` folds a log (or any prefix) into the header in force; `diffHeader(prev, next)` encodes a change (undefined when equal); `applyHeaderDelta(prev, delta)` replays one. Writer contract: every logged delta is round-trip-verified (`apply(prev, delta)` deep-equals the new header) with a `'fallback'` snapshot when the encoding cannot express the change (a pure tool reordering), so folding never needs error recovery on a well-formed log. `canonicalHeader` pins the one representation of absence (empty system/tools ≡ absent fields).
The `request/header` (full `EpochHeader` snapshot with a `RequestHeaderReason`) and `request/header-delta` (system line-trim / name-keyed tools delta / whole config / whole session prefix) events make the request envelope logged session state, so every conversation request is a pure function of the log. The pure trio reconstructs it: `foldRequestHeader(events)` folds a log (or any prefix) into the header in force; `diffHeader(prev, next)` encodes a change (undefined when equal); `applyHeaderDelta(prev, delta)` replays one. Writer contract: every logged delta is round-trip-verified (`apply(prev, delta)` deep-equals the new header) with a `'fallback'` snapshot when the encoding cannot express the change (a pure tool reordering), so folding never needs error recovery on a well-formed log. `canonicalHeader` pins the one representation of absence (empty system/tools/messagePrefix ≡ absent fields; a delta's EMPTY prefix array encodes the transition back to absence). `EpochHeader.messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product — composed once per loop instance, the request is `messagePrefix + derived history`, and `deriveMessages()` never returns it.
### Session event vocabulary (`types.ts`)
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog/log-events.md). Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, the hook bridges' `hook/*`); merged members appear in the same catalog.
@@ -72,9 +73,9 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
### Extension points
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME invariants `append` enforces — including that every surface-eligible event (`SurfaceEventType`) carries a `surfaceOp` marker — so a marker-less message event is rejected at construction rather than silently vanishing from `deriveMessages()` (the surface is the sole derivation path) on resume.
- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME always-on invariants `append` enforces — contiguous seqs, JSON-serializable data, and required `surfaceOp` markers on surface-eligible events — so marker-less message events are rejected at construction rather than silently vanishing from `deriveMessages()`. Broader turn-enclosure checks stay in `dsh-invariants` and persistence repair. Ordinary live-session forks use `ctx.sessions.fork(source, boundary?, childSessionId?)`, where `boundary` is the inclusive source event seq to fork through.
- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint.
### What is NOT here (TODO)
- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond seed-based forking.
- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`.

View File

@@ -153,10 +153,15 @@ export class Session {
this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
}
/**
* The append-only event log, exposed live by reference (readonly-typed, not
* a snapshot): later appends are visible through the same array.
*/
get events(): readonly SessionEvent[] {
return this.log
}
/** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
get seq(): number {
return this.log.length
}
@@ -175,6 +180,9 @@ export class Session {
* 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
@@ -362,6 +370,32 @@ export class Session {
}
}
/** A fork source: either the live session object or its live store id. */
export type SessionForkSource = Session | SessionId
/**
* Rejection codes for session forking: the fork source id is unknown to the
* live store (`SESSION_NOT_FOUND`) or names a session object that is not the
* store's live instance (`SESSION_NOT_LIVE`); the requested child id is
* already taken (`SESSION_ALREADY_EXISTS`); the boundary is not a contiguous
* existing seq (`INVALID_BOUNDARY`); or the boundary event is not a
* `turn/end` — a fork must cut on a closed turn (`OPEN_TURN`).
*/
export type SessionForkErrorCode =
| 'SESSION_NOT_FOUND'
| 'SESSION_NOT_LIVE'
| 'SESSION_ALREADY_EXISTS'
| 'INVALID_BOUNDARY'
| 'OPEN_TURN'
/** Typed error for session fork rejections. */
export class SessionForkError extends Error {
constructor(message: string, public readonly code: SessionForkErrorCode) {
super(message)
this.name = 'SessionForkError'
}
}
/**
* In-memory session store (`ctx.sessions`).
*
@@ -496,6 +530,92 @@ export class SessionStore extends Service {
list(): Session[] {
return [...this.store.values()]
}
/**
* 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`.
*
* @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 childSessionId - Optional child session id; omitted delegates to
* `SessionStore`'s id policy.
* @returns The created live child session.
*/
fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session {
if (childSessionId !== undefined && this.get(childSessionId) !== undefined) {
throw new SessionForkError(`session "${childSessionId}" already exists`, 'SESSION_ALREADY_EXISTS')
}
const liveSource = this._resolveForkSource(source)
const seed = this._forkSeed(liveSource, boundary)
return this.create(childSessionId, {
seed,
meta: {
...liveSource.header.cwd !== undefined ? { cwd: liveSource.header.cwd } : {},
parentSession: liveSource.id,
seedLength: seed.length,
},
})
}
private _forkSeed(session: Session, requestedBoundary: number | undefined): SessionEvent[] {
const events = session.events
const lastEvent = events.at(-1)
let boundary: number
if (requestedBoundary !== undefined) {
boundary = requestedBoundary
} else {
if (lastEvent === undefined) return []
boundary = lastEvent.seq
}
if (!Number.isSafeInteger(boundary) || boundary < 0) {
throw new SessionForkError(
`fork boundary for session "${session.id}" must be a non-negative safe integer, got ${String(boundary)}`,
'INVALID_BOUNDARY',
)
}
if (boundary >= events.length) {
const lastSeq = events.at(-1)?.seq
throw new SessionForkError(
`fork boundary ${boundary} does not exist in session "${session.id}" (last seq: ${lastSeq ?? 'none'})`,
'INVALID_BOUNDARY',
)
}
const boundaryEvent = events[boundary]
if (boundaryEvent === undefined || boundaryEvent.seq !== boundary) {
throw new SessionForkError(
`fork boundary ${boundary} does not match a contiguous event seq in session "${session.id}"`,
'INVALID_BOUNDARY',
)
}
if (boundaryEvent.type !== 'turn/end') {
throw new SessionForkError(
`fork boundary ${boundary} in session "${session.id}" must be turn/end, got ${boundaryEvent.type}`,
'OPEN_TURN',
)
}
return events.slice(0, boundary + 1).map(event => structuredClone(event))
}
private _resolveForkSource(source: SessionForkSource): Session {
if (typeof source === 'string') {
const session = this.get(source)
if (session === undefined) throw new SessionForkError(`session "${source}" not found`, 'SESSION_NOT_FOUND')
return session
}
const live = this.get(source.id)
if (live === undefined) {
throw new SessionForkError(`session "${source.id}" not found`, 'SESSION_NOT_FOUND')
}
if (live !== source) throw new SessionForkError(`session "${source.id}" is not the live store instance`, 'SESSION_NOT_LIVE')
return source
}
}
export default SessionStore

View File

@@ -40,6 +40,10 @@ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key:
* 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.
* @returns true when `value` survives a JSON round-trip losslessly.
*/
export function isJsonValue(value: unknown, seen: Set<object> = new Set()): boolean {
if (value === null) return true

View File

@@ -54,6 +54,8 @@ import type { SessionEvent } from './types.ts'
* 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

View File

@@ -13,15 +13,23 @@
*/
import { callConfigEquals } from '@deepseek-ai/dsh-llm'
import type { LlmCallConfig, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { LlmCallConfig, Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { EpochHeader, SessionEvent, SystemDelta, ToolsDelta } from './types.ts'
/** The `request/header-delta` payload shape: each present field amends the folded header. */
type HeaderDelta = {
system?: SystemDelta
tools?: ToolsDelta
config?: LlmCallConfig
messagePrefix?: Message[]
}
/**
* Normalize a header to canonical form: an empty system prompt and an empty
* tool list become ABSENT fields, matching how requests are built (both
* request-build spreads skip empty values). Diff, fold, and comparison all
* operate on canonical headers, so "no system prompt" has exactly one
* representation.
* Normalize a header to canonical form: an empty system prompt, an empty
* tool list, and an empty session prefix become ABSENT fields, matching how
* requests are built (the request-build spreads skip empty values). Diff,
* fold, and comparison all operate on canonical headers, so "no system
* prompt" (and "no session prefix") has exactly one representation.
* @param header - the header to normalize (not mutated).
* @returns the canonical header.
*/
@@ -30,6 +38,7 @@ export function canonicalHeader(header: EpochHeader): EpochHeader {
config: header.config,
...header.system !== undefined && header.system.length > 0 ? { system: header.system } : {},
...header.tools !== undefined && header.tools.length > 0 ? { tools: header.tools } : {},
...header.messagePrefix !== undefined && header.messagePrefix.length > 0 ? { messagePrefix: header.messagePrefix } : {},
}
}
@@ -109,37 +118,46 @@ function applyTools(prev: readonly ToolSchema[], delta: ToolsDelta): ToolSchema[
* 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.
* 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).
* @param a - one canonical header.
* @param b - the other.
* @returns whether config, system, and tools (in order) all match.
* @returns whether config, system, tools (in order), and the session prefix all match.
*/
export function headerEquals(a: EpochHeader, b: EpochHeader): boolean {
if (!callConfigEquals(a.config, b.config) || a.system !== b.system) return false
if (!sameMessages(a.messagePrefix, b.messagePrefix)) return false
const at = a.tools ?? []
const bt = b.tools ?? []
return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema))
}
/** Canonical JSON equality over session-prefix arrays; absence equals the empty array. */
function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] | undefined): boolean {
return JSON.stringify(a ?? []) === JSON.stringify(b ?? [])
}
/**
* 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".
* @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.
*/
export function diffHeader(
prev: EpochHeader, next: EpochHeader,
): { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } | undefined {
const delta: { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } = {}
export function diffHeader(prev: EpochHeader, next: EpochHeader): HeaderDelta | undefined {
const delta: HeaderDelta = {}
if (prev.system !== next.system) delta.system = diffSystem(prev.system, next.system)
const prevTools = prev.tools ?? []
const nextTools = next.tools ?? []
if (JSON.stringify(prevTools) !== JSON.stringify(nextTools)) delta.tools = diffTools(prevTools, nextTools)
if (!callConfigEquals(prev.config, next.config)) delta.config = next.config
if (!sameMessages(prev.messagePrefix, next.messagePrefix)) delta.messagePrefix = next.messagePrefix ?? []
return Object.keys(delta).length > 0 ? delta : undefined
}
@@ -151,15 +169,15 @@ export function diffHeader(
* @param delta - the logged delta payload.
* @returns the canonical header after the delta.
*/
export function applyHeaderDelta(
prev: EpochHeader, delta: { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig },
): EpochHeader {
export function applyHeaderDelta(prev: EpochHeader, delta: HeaderDelta): EpochHeader {
const system = delta.system !== undefined ? applySystem(prev.system, delta.system) : prev.system
const tools = delta.tools !== undefined ? applyTools(prev.tools ?? [], delta.tools) : prev.tools
const messagePrefix = delta.messagePrefix ?? prev.messagePrefix
return canonicalHeader({
config: delta.config ?? prev.config,
...system !== undefined ? { system } : {},
...tools !== undefined ? { tools } : {},
...messagePrefix !== undefined ? { messagePrefix } : {},
})
}

View File

@@ -29,6 +29,8 @@ const SURFACE_EVENT_TYPES = new Set<string>([
* surface-eligible event that is MISSING its mandatory marker (e.g. validating
* a seed/load log); use {@link isSurfaceEvent} to narrow to a fully-formed
* {@link SurfaceEvent} with `surfaceOp` present.
* @param type - the event type string to test.
* @returns true when the type is one of the five message-producing types.
*/
export function isSurfaceEligibleType(type: string): boolean {
return SURFACE_EVENT_TYPES.has(type)
@@ -38,6 +40,8 @@ export function isSurfaceEligibleType(type: string): boolean {
* Narrow a {@link SessionEvent} to {@link SurfaceEvent}: checks that the
* event's `type` is surface-eligible AND that `surfaceOp` is present.
* The narrowed type has mandatory {@link SurfaceOp}.
* @param event - the event to narrow.
* @returns true when the event is surface-eligible and carries its `surfaceOp` marker.
*/
export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent {
if (!SURFACE_EVENT_TYPES.has(event.type)) return false

View File

@@ -74,6 +74,12 @@ function nodeDelta(event: SessionEvent): number {
* surface successor (`SurfaceNode.next`), or `null` when `end` is the tail —
* for the cut after `end`.
*
* @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

View File

@@ -1,10 +1,14 @@
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { CallId, ContentBlock, LlmCallConfig, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { CallId, ContentBlock, LlmCallConfig, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
/** Identifies one session in the store (and its persistence artifacts). */
export type SessionId = Branded<'SessionId'>
/** Brand a string as a {@link SessionId}. */
/**
* Brand a string as a {@link SessionId}.
* @param id - the raw session id string.
* @returns the same string, branded (a compile-time cast — no runtime cost).
*/
export function SessionId(id: string): SessionId {
return id as SessionId
}
@@ -102,6 +106,7 @@ export interface TurnTriggerMap {
injection: { kind: 'injection'; source: MessageSource }
}
/** The union over {@link TurnTriggerMap} — what started a turn; plugins extend it by merging variants into the map. */
export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
/**
@@ -156,6 +161,7 @@ export interface TurnEndReasonMap {
interrupted: { kind: 'interrupted' }
}
/** The union over {@link TurnEndReasonMap} — why a turn ended; plugins extend it by merging variants into the map. */
export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap]
/**
@@ -177,14 +183,15 @@ export interface TodoItem {
}
/**
* The request header: everything about an LLM request besides its message
* content — the call configuration plus the rendered system prompt and tool
* schemas. Logged session state (the reconstructability RFC): a
* The request header: everything about an LLM request besides its derived
* message history — the call configuration plus the rendered system prompt,
* tool schemas, and the session prefix. Logged session state (the
* reconstructability RFC): a
* {@link SessionEventMap} `request/header` snapshot installs one, a
* `request/header-delta` amends it, and folding those events over the log
* (`foldRequestHeader`) reconstructs the header any request was built under.
* Canonical form: an empty system prompt and an empty tool list are ABSENT
* fields, matching how requests are built.
* Canonical form: an empty system prompt, an empty tool list, and an empty
* prefix are ABSENT fields, matching how requests are built.
*/
export interface EpochHeader {
/** The conversation's call configuration (model + sampling scalars). */
@@ -193,6 +200,14 @@ export interface EpochHeader {
system?: string
/** Assembled tool schemas; absent for a tool-less request. */
tools?: ToolSchema[]
/**
* The session prefix: request-only messages sent BEFORE the entire derived
* history (the `agent/session-prefix` waterfall's product, composed once
* per loop instance and reused for every request it sends). Not session
* history — `deriveMessages()` never returns it — so the header is its
* only durable record; absent when the instance composed none.
*/
messagePrefix?: Message[]
}
/**
@@ -350,17 +365,24 @@ export interface SessionEventMap {
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
/**
* Amendment to the folded {@link EpochHeader}: at least one of a
* {@link SystemDelta}, a {@link ToolsDelta}, or a whole replacement
* {@link LlmCallConfig} (four scalars — not worth diffing). Appended by the
* {@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}.
*/
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig }
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] }
}
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
export type SessionEventType = keyof SessionEventMap
/**

View File

@@ -0,0 +1,240 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionForkError, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
async function setup(): Promise<{ ctx: Context; sessions: SessionStore }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
return { ctx, sessions: ctx.sessions }
}
function appendClosedTurn(
session: Session,
turn: number,
text = `hello ${turn}`,
reason: TurnEndReason = { kind: 'completed' },
): void {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', {
content: [{ type: 'text', text }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason })
}
function appendOpenTurn(session: Session, turn: number): void {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', {
content: [{ type: 'text', text: `open ${turn}` }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}
function firstUserMessage(events: readonly SessionEvent[]): SessionEvent<'user/message'> {
const event = events.find((e): e is SessionEvent<'user/message'> => e.type === 'user/message')
if (event === undefined) throw new Error('missing user/message')
return event
}
function lastSeq(session: Session): number {
const event = session.events.at(-1)
if (event === undefined) throw new Error('missing last event')
return event.seq
}
describe('SessionStore.fork', () => {
it('forks an empty live session as an empty child with lineage metadata', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('empty-parent'), { meta: { cwd: '/workspace' } })
const child = sessions.fork(source, undefined, SessionId('empty-child'))
expect(child.events).toEqual([])
expect(child.header).toMatchObject({
id: SessionId('empty-child'),
cwd: '/workspace',
parentSession: SessionId('empty-parent'),
seedLength: 0,
})
})
it('forks the latest completed boundary by default and deep-clones seed events', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
appendClosedTurn(source, 1, 'hello')
const child = sessions.fork(SessionId('parent'), undefined, SessionId('child'))
expect(child.events).toEqual(source.events)
expect(child.events).not.toBe(source.events)
expect(child.events[1]).not.toBe(source.events[1])
firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' }
expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }])
expect(child.header).toMatchObject({
id: SessionId('child'),
cwd: '/workspace',
parentSession: SessionId('parent'),
seedLength: source.events.length,
})
})
it('forks from an earlier turn boundary even when the source currently has an open tail', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
appendClosedTurn(source, 1, 'first')
const firstBoundary = lastSeq(source)
appendClosedTurn(source, 2, 'second')
appendOpenTurn(source, 3)
const child = sessions.fork(source, firstBoundary, SessionId('child-from-first'))
expect(child.events).toEqual(source.events.slice(0, firstBoundary + 1))
expect(child.header.seedLength).toBe(firstBoundary + 1)
expect(child.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'first' }] }])
})
it('accepts every turn/end reason as an explicit fork boundary', async () => {
const { ctx, sessions } = await setup()
const reasons: TurnEndReason[] = [
{ kind: 'completed' },
{ kind: 'aborted', reason: 'cancelled by user' },
{ kind: 'error', step: 1, message: 'model failed', code: 'MODEL' },
{ kind: 'disposed' },
{ kind: 'max-tokens' },
{ kind: 'interrupted' },
]
for (const reason of reasons) {
const source = ctx.sessions.create(SessionId(`parent-${reason.kind}`))
appendClosedTurn(source, 1, reason.kind, reason)
const child = sessions.fork(source, lastSeq(source), SessionId(`child-${reason.kind}`))
expect(child.events.at(-1)?.type).toBe('turn/end')
expect(child.header.seedLength).toBe(source.events.length)
}
})
it('rejects invalid boundaries before creating a child', async () => {
const { ctx, sessions } = await setup()
const empty = ctx.sessions.create(SessionId('empty'))
expect(() => sessions.fork(empty, 0, SessionId('empty-child')))
.toThrow(new SessionForkError('fork boundary 0 does not exist in session "empty" (last seq: none)', 'INVALID_BOUNDARY'))
expect(ctx.sessions.get(SessionId('empty-child'))).toBeUndefined()
const source = ctx.sessions.create(SessionId('parent'))
appendClosedTurn(source, 1)
expect(() => sessions.fork(source, -1, SessionId('negative')))
.toThrow(/non-negative safe integer/)
expect(() => sessions.fork(source, 0.5, SessionId('fraction')))
.toThrow(/non-negative safe integer/)
expect(() => sessions.fork(source, Number.MAX_SAFE_INTEGER + 1, SessionId('unsafe')))
.toThrow(/non-negative safe integer/)
expect(() => sessions.fork(source, source.seq, SessionId('past-end')))
.toThrow(new SessionForkError(`fork boundary ${source.seq} does not exist in session "parent" (last seq: ${source.seq - 1})`, 'INVALID_BOUNDARY'))
})
it('rejects a corrupted live source whose array index no longer matches event seq', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('corrupt-parent'))
appendClosedTurn(source, 1)
const mutableLog = (source as unknown as { log: SessionEvent[] }).log
mutableLog[2] = { ...mutableLog[2]!, seq: 99 }
expect(() => sessions.fork(source, 2, SessionId('corrupt-child')))
.toThrow(new SessionForkError('fork boundary 2 does not match a contiguous event seq in session "corrupt-parent"', 'INVALID_BOUNDARY'))
expect(ctx.sessions.get(SessionId('corrupt-child'))).toBeUndefined()
})
it('rejects an unknown live session id', async () => {
const { sessions } = await setup()
expect(() => sessions.fork(SessionId('missing')))
.toThrow(new SessionForkError('session "missing" not found', 'SESSION_NOT_FOUND'))
})
it('rejects a detached Session object that is not live in ctx.sessions', async () => {
const { sessions } = await setup()
const detached = new Session(SessionId('detached'))
expect(() => sessions.fork(detached))
.toThrow(new SessionForkError('session "detached" not found', 'SESSION_NOT_FOUND'))
})
it('rejects a stale Session object whose id is live on a different instance', async () => {
const { ctx, sessions } = await setup()
ctx.sessions.create(SessionId('same-id'))
const stale = new Session(SessionId('same-id'))
expect(() => sessions.fork(stale))
.toThrow(new SessionForkError('session "same-id" is not the live store instance', 'SESSION_NOT_LIVE'))
})
it('rejects selected slices whose boundary is inside an open turn', async () => {
const { ctx, sessions } = await setup()
const cases: [string, (session: Session) => number][] = [
['turn/start', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
return lastSeq(session)
}],
['step/start', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
return lastSeq(session)
}],
['user/message', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'open' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
return lastSeq(session)
}],
['assistant/message', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'partial' }] }, { surfaceOp: 'append' })
return lastSeq(session)
}],
['tool/call', (session) => {
const callId = CallId('call-open')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' })
return lastSeq(session)
}],
]
for (const [lastType, build] of cases) {
const source = ctx.sessions.create(SessionId(`open-${lastType}`))
const boundary = build(source)
expect(() => sessions.fork(source, boundary))
.toThrow(new SessionForkError(`fork boundary ${boundary} in session "open-${lastType}" must be turn/end, got ${lastType}`, 'OPEN_TURN'))
}
})
it('rejects a child session id that is already live with a typed fork error', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('parent'))
appendClosedTurn(source, 1)
ctx.sessions.create(SessionId('child'))
expect(() => sessions.fork(source, undefined, SessionId('child')))
.toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS'))
})
it('rejects a duplicate child session id before validating the boundary', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('open-parent'))
source.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
ctx.sessions.create(SessionId('child'))
expect(() => sessions.fork(source, undefined, SessionId('child')))
.toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS'))
})
})

View File

@@ -8,9 +8,9 @@
*/
import { describe, expect, it } from 'vitest'
import { Session, SessionId, applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader } from '@deepseek-ai/dsh-session'
import { Session, SessionId, applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session'
import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
const CONFIG = { model: 'm' }
@@ -18,6 +18,10 @@ function tool(name: string, description = 'd'): ToolSchema {
return { name, description, parameters: { type: 'object' } }
}
function msg(text: string): Message {
return { role: 'user', content: [{ type: 'text', text }] }
}
/** Round-trip helper: diff must reproduce `next` from `prev` exactly. */
function roundTrip(prev: EpochHeader, next: EpochHeader): ReturnType<typeof diffHeader> {
const delta = diffHeader(prev, next)
@@ -103,6 +107,48 @@ describe('diffHeader / applyHeaderDelta', () => {
})
})
describe('the session prefix (messagePrefix)', () => {
it('canonicalHeader normalizes an empty prefix to an absent field', () => {
expect(canonicalHeader({ config: CONFIG, messagePrefix: [] })).toEqual({ config: CONFIG })
const full = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')] })
expect(full.messagePrefix).toEqual([msg('p')])
})
it('headerEquals treats absence and empty as one representation, content differences as unequal', () => {
expect(headerEquals(canonicalHeader({ config: CONFIG }), { config: CONFIG, messagePrefix: [] })).toBe(true)
expect(headerEquals({ config: CONFIG, messagePrefix: [msg('a')] }, { config: CONFIG, messagePrefix: [msg('b')] })).toBe(false)
expect(headerEquals({ config: CONFIG, messagePrefix: [msg('a')] }, { config: CONFIG })).toBe(false)
})
it('replaces a changed prefix whole and leaves untouched parts alone', () => {
const prev = canonicalHeader({ config: CONFIG, system: 'keep', messagePrefix: [msg('old')] })
const next = canonicalHeader({ config: CONFIG, system: 'keep', messagePrefix: [msg('new'), msg('more')] })
const delta = roundTrip(prev, next)
expect(delta).toEqual({ messagePrefix: [msg('new'), msg('more')] })
})
it('round-trips a prefix gained from a bare header and lost back to one (empty array encodes absence)', () => {
const none = canonicalHeader({ config: CONFIG })
const some = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')] })
const gained = roundTrip(none, some)
expect(gained).toEqual({ messagePrefix: [msg('p')] })
const lost = roundTrip(some, none)
expect(lost).toEqual({ messagePrefix: [] })
})
it('folds prefix deltas over the log like any other header amendment', () => {
const session = new Session(SessionId('fold-prefix'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const first = canonicalHeader({ config: CONFIG, messagePrefix: [msg('catalog v1')] })
session.append('request/header', { header: first, reason: 'initial' })
const second = canonicalHeader({ config: CONFIG, messagePrefix: [msg('catalog v2')] })
session.append('request/header-delta', diffHeader(first, second)!)
expect(foldRequestHeader(session.events)).toEqual(second)
session.append('request/header-delta', diffHeader(second, canonicalHeader({ config: CONFIG }))!)
expect(foldRequestHeader(session.events)).toEqual({ config: CONFIG })
})
})
describe('foldRequestHeader', () => {
function headerEvents(session: Session): readonly SessionEvent[] {
return session.events

View File

@@ -61,8 +61,10 @@ describe('Session', () => {
it('replays identically from a seeded event log', () => {
const original = new Session(SessionId('s3'))
original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
original.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
original.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, { surfaceOp: 'append' })
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const replayed = new Session(SessionId('s3-replay'), [...original.events])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
@@ -423,7 +425,9 @@ describe('todo/write event', () => {
it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {
const original = new Session(SessionId('t4'))
original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// Seeding a non-surface event with no surfaceOp must not throw.
const replayed = new Session(SessionId('t4-replay'), [...original.events])
expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)

View File

@@ -22,7 +22,11 @@ const RUNTIME_PROVIDER = 'runtime'
const RUNTIME_RANK = 250
const SKILL_PROMPT_SECTION_ORDER = 1000
/** Return whether a string is a valid kebab-case skill name. */
/**
* Return whether a string is a valid kebab-case skill name.
* @param name - candidate skill name to validate.
* @returns whether the name matches the public skill-name grammar.
*/
export function isSkillName(name: string): boolean {
return SKILL_NAME.test(name)
}

View File

@@ -7,15 +7,16 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool-
| Key | Default | Meaning |
|---|---|---|
| `persona` | `''` | The deployment persona: the ONE deployment-authored prompt fragment, rendered as the order-0 `deployment:persona` section and shared by every agent in the context (subagents included). A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. |
| `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `'<unlisted-tools>'` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, unlisted tools land at the rest entry in lexicographic name order. Absent ⇒ plain lexicographic name order. Applied to the collected tools BEFORE the `system-prompt/assemble` waterfall — like the sections' `order` sort, it canonicalizes what the registry contributed (registration order is a plugin-load artifact), and a waterfall listener that mutates the list owns the determinism of what it emits. Misconfiguration fails loud: a list without exactly one rest entry, or with duplicates, throws at load; a listed name with no registered tool rejects every `assemble()`; a tool provider returning the reserved rest-entry name also rejects. Under the shipped loop the turn fails before any model request. Why a central list and not per-plugin weights: [Explicit model-facing tool order](../../../docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md). |
## Service: `SystemPrompt` (ctx key: `systemPrompt`)
### Public API
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. Duplicate names throw. Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: () => ToolSchema[]): () => void` Contribute tool schemas (evaluated at each assembly). Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: () => ToolSchema[]): () => void` Contribute tool schemas (evaluated at each assembly). A provider must not return a schema named `TOOL_ORDER_REST`; that name is reserved for `toolOrder`'s rest entry. Disposed with the calling fiber.
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Duplicate or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller. Runs through the `system-prompt/assemble` waterfall.
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller. Runs through the `system-prompt/assemble` waterfall. Rejects when a configured `toolOrder` names a tool no provider contributed, or when a provider returns the reserved rest-entry name.
### Events

View File

@@ -88,7 +88,8 @@ export interface AssembledSection {
*
* 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.
* `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
@@ -110,6 +111,71 @@ const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
/** A complete `{{...}}` reference group at the scan position (validated after). */
const GROUP_AT = /^\{\{([^{}]*)\}\}/
/**
* The rest entry for {@link Config.toolOrder}: the position where registered
* tools not named in the list are inserted (in lexicographic name order).
* Reserved: collected tool schemas using this name are rejected before
* ordering, so the marker can never collide with a real model-facing tool.
*/
export const TOOL_ORDER_REST = '<unlisted-tools>'
/**
* Validate a configured tool-order list's shape at service construction:
* the {@link TOOL_ORDER_REST} rest entry exactly once, no duplicate names.
* Returns the list (or undefined when unconfigured); throws otherwise,
* failing the service at load — a bad order config must never reach an
* assembly. Whether every listed name matches a registered tool is checked
* at each assembly instead ({@link orderTools}): tool plugins register after
* this service constructs, so the tool set does not exist yet here.
*/
function validateToolOrder(toolOrder: string[] | undefined): string[] | undefined {
if (toolOrder === undefined) return undefined
const seen = new Set<string>()
for (const name of toolOrder) {
if (seen.has(name)) throw new Error(`toolOrder lists "${name}" more than once`)
seen.add(name)
}
if (!seen.has(TOOL_ORDER_REST)) {
throw new Error(`toolOrder must contain the "${TOOL_ORDER_REST}" rest entry (where unlisted tools are inserted)`)
}
return toolOrder
}
/**
* 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 with no collected tool throws — misconfiguration fails loud, and this
* 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. Never drops a tool, and both sorts are stable, so tools sharing a
* name keep their collection order.
*/
function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined): ToolSchema[] {
const reserved = tools.find(tool => tool.name === TOOL_ORDER_REST)
if (reserved !== undefined) {
throw new Error(`tool provider returned reserved tool name "${TOOL_ORDER_REST}" (reserved for toolOrder's rest entry)`)
}
if (toolOrder === undefined) return tools.sort(compareToolNames)
const registered = new Set(tools.map(tool => tool.name))
const unknown = toolOrder.filter(name => name !== TOOL_ORDER_REST && !registered.has(name))
if (unknown.length > 0) {
throw new Error(`toolOrder lists unregistered tool${unknown.length > 1 ? 's' : ''} ${unknown.map(name => `"${name}"`).join(', ')}; registered tools: ${[...registered].sort().join(', ') || '(none)'}`)
}
const listed = new Set(toolOrder)
const rest = tools.filter(tool => !listed.has(tool.name)).sort(compareToolNames)
return toolOrder.flatMap(name =>
name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name))
}
/** Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine. */
function compareToolNames(a: ToolSchema, b: ToolSchema): number {
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
}
/** 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
@@ -124,6 +190,29 @@ export interface Config {
* deployment opens with the harness identity alone.
*/
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.
*/
toolOrder?: string[]
}
/**
@@ -138,6 +227,10 @@ export interface Config {
* 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
* (the caller then sends no system prompt at all).
*/
export function renderPrompt(assembly: PromptAssembly): string {
return assembly.sections
@@ -198,14 +291,23 @@ 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.
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
})
private sections: PromptSection[] = []
private toolProviders: (() => ToolSchema[])[] = []
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
private readonly toolOrder: string[] | undefined
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
@@ -261,7 +363,10 @@ export class SystemPrompt extends Service {
/**
* Contribute a tool-schema provider that is evaluated at each assembly
* call (so it can reflect the live registry state). The provider is
* removed when the calling fiber is disposed. Emits `system-prompt/change`.
* 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`.
* @param provider - evaluated at every {@link assemble} for fresh schemas.
* @returns the disposer that removes the provider.
*/
@@ -318,19 +423,28 @@ export class SystemPrompt extends Service {
/**
* Assemble the current prompt for one caller: section texts are resolved
* against `context` and sorted by order, tools collected from all
* providers, and every registered 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 before it reaches the model. Await the
* result before reading the assembly values — waterfall listeners may be
* async. Interpolation happens later, in {@link renderPrompt}.
* against `context` and sorted by order, tools collected from all providers
* 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 no provider contributed rejects the assembly), and every
* registered 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 before it reaches the model — like the sections' `order` sort,
* tool canonicalization happens on the initial assembly, and a listener
* owns the determinism of whatever it emits. 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}).
* @returns the assembly after the waterfall has run.
*/
assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
// 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 assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
const variables: Record<string, string | undefined> = {}
for (const [name, provider] of this.variableProviders) {
variables[name] = provider(context)
@@ -343,8 +457,10 @@ export class SystemPrompt extends Service {
text: typeof section.text === 'function' ? section.text(context) : section.text,
}))
.sort((a, b) => a.order - b.order),
tools: this.toolProviders.flatMap(provider =>
provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))),
tools: orderTools(
this.toolProviders.flatMap(provider =>
provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))),
this.toolOrder),
variables,
}
return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly))

View File

@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SystemPrompt, { PromptAssembly, TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
function tool(name: string, description = name): ToolSchema {
return { name, description, parameters: { type: 'object', properties: {} } }
}
async function mount(config: { persona?: string; toolOrder?: string[] } = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt, config)
return ctx
}
function names(assembly: PromptAssembly): string[] {
return assembly.tools.map(t => t.name)
}
describe('SystemPrompt tool order', () => {
// The ONE place the public constant's value is pinned; everything else
// (tests and deployment configs alike) references TOOL_ORDER_REST.
it('exports the rest entry as "<unlisted-tools>"', () => {
expect(TOOL_ORDER_REST).toBe('<unlisted-tools>')
})
it('assembles tools in lexicographic name order when no toolOrder is configured', async () => {
const ctx = await mount()
ctx.systemPrompt.tools(() => [tool('charlie'), tool('alpha')])
ctx.systemPrompt.tools(() => [tool('bravo')])
expect(names(await ctx.systemPrompt.assemble())).toEqual(['alpha', 'bravo', 'charlie'])
})
it('assembles the same order regardless of provider registration order', async () => {
const forward = await mount()
forward.systemPrompt.tools(() => [tool('alpha')])
forward.systemPrompt.tools(() => [tool('zulu')])
const backward = await mount()
backward.systemPrompt.tools(() => [tool('zulu')])
backward.systemPrompt.tools(() => [tool('alpha')])
expect(names(await forward.systemPrompt.assemble())).toEqual(['alpha', 'zulu'])
expect(names(await backward.systemPrompt.assemble())).toEqual(['alpha', 'zulu'])
})
it('applies a configured toolOrder: listed positions, rest at the rest entry lexicographically', async () => {
const ctx = await mount({ toolOrder: ['todo_write', TOOL_ORDER_REST, 'bash'] })
ctx.systemPrompt.tools(() => [tool('bash'), tool('echo_b'), tool('todo_write'), tool('echo_a')])
expect(names(await ctx.systemPrompt.assemble())).toEqual(['todo_write', 'echo_a', 'echo_b', 'bash'])
})
it('rejects the assembly when toolOrder names a tool that is not registered (misconfiguration blocks work)', async () => {
const ctx = await mount({ toolOrder: ['todo_write', 'ghost', TOOL_ORDER_REST, 'wraith'] })
ctx.systemPrompt.tools(() => [tool('bash'), tool('todo_write')])
await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
'toolOrder lists unregistered tools "ghost", "wraith"; registered tools: bash, todo_write')
})
it('names the single unregistered tool when no tools are registered at all', async () => {
const ctx = await mount({ toolOrder: ['ghost', TOOL_ORDER_REST] })
await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
'toolOrder lists unregistered tool "ghost"; registered tools: (none)')
})
it.each([
['without an explicit toolOrder', undefined],
['with only the rest entry configured', [TOOL_ORDER_REST]],
])('rejects a provider tool named like the reserved rest entry %s', async (_case, toolOrder) => {
const ctx = await mount(toolOrder === undefined ? {} : { toolOrder })
ctx.systemPrompt.tools(() => [tool(TOOL_ORDER_REST)])
await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
`tool provider returned reserved tool name "${TOOL_ORDER_REST}"`)
})
it('keeps collection order between tools that share a name (stable sort)', async () => {
const ctx = await mount()
ctx.systemPrompt.tools(() => [tool('dup', 'first'), tool('anchor'), tool('dup', 'second')])
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.tools.map(t => t.description)).toEqual(['anchor', 'first', 'second'])
})
it('canonicalizes BEFORE the assemble waterfall: listeners see the ordered list and own their own edits', async () => {
const ctx = await mount()
ctx.systemPrompt.tools(() => [tool('zulu'), tool('alpha')])
let seen: string[] | undefined
ctx.on('system-prompt/assemble', function (assembly, _context, next) {
seen = assembly.tools.map(t => t.name)
// A listener-appended tool is NOT re-sorted — same contract as sections:
// canonicalization applies to what the registry contributed, and a
// listener owns the determinism of what it emits.
assembly.tools.push(tool('aardvark'))
return next()
})
const assembly = await ctx.systemPrompt.assemble()
expect(seen).toEqual(['alpha', 'zulu'])
expect(names(assembly)).toEqual(['alpha', 'zulu', 'aardvark'])
})
it.each([
['an empty list', []],
['a list without the rest entry', ['bash', 'todo_write']],
])('rejects %s at load (the rest entry is required)', async (_case, toolOrder) => {
await expect(new Context().plugin(SystemPrompt, { toolOrder })).rejects.toThrow(`must contain the "${TOOL_ORDER_REST}" rest entry`)
})
it.each([
['a duplicate tool name', ['bash', 'bash', TOOL_ORDER_REST]],
['a duplicate rest entry', [TOOL_ORDER_REST, 'bash', TOOL_ORDER_REST]],
])('rejects %s at load', async (_case, toolOrder) => {
await expect(new Context().plugin(SystemPrompt, { toolOrder })).rejects.toThrow('more than once')
})
it('throws from direct construction too', () => {
expect(() => new SystemPrompt(new Context(), { toolOrder: ['bash'] })).toThrow('rest entry')
})
})

View File

@@ -1,6 +1,6 @@
# dsh-tools
Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the allow/deny gate) → core dispatch`tools/post-execute` (inspect/replace the result, attach context).
Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the allow/deny gate) → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins)`tools/post-execute` (inspect/replace the result, attach context).
## Service: `ToolRegistry` (ctx key: `tools`)
@@ -8,8 +8,8 @@ Tool registry and execution pipeline. Tool plugins register their schemas and ex
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a tool. Disposed with the calling fiber.
- `ctx.tools.get(name: string): ToolDefinition | undefined`
- `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog/tools.md](../../../docs/tool-catalog/tools.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/pre-execute`dispatch`tools/post-execute` pipeline.
- `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/pre-execute``tools/execute``tools/post-execute` pipeline.
### Injected services
@@ -20,12 +20,13 @@ Tool registry and execution pipeline. Tool plugins register their schemas and ex
| Event | Mode | Purpose |
|---|---|---|
| `tools/pre-execute` | waterfall | Allow/deny gate BEFORE a tool runs (sandbox, permission, hooks); returns `PreToolDecision` |
| `tools/execute` | waterfall | Around-dispatch wrapper (timeout, retry, metrics): `(exec, next)` → the dispatched `ToolExecutionResult`; `next()` is dispatch-with-normalization |
| `tools/post-execute` | waterfall | Inspect/replace the result AFTER a tool runs, attach context; returns `PostToolDecision` |
| `tools/change` | emit | A tool was registered or unregistered |
### Key types
- `ToolDefinition``ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below).
- `ToolDefinition``ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, never sent to the model.
- `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`.
- `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering.
- `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` degrades to `deny` until the permission system lands.
@@ -35,7 +36,7 @@ Tool registry and execution pipeline. Tool plugins register their schemas and ex
### Extension points
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
- `tools/pre-execute` is the allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny`/`ask` skips dispatch and yields an `isError` result. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch sits between them as plain code; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. Both follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)).
- `tools/pre-execute` is the allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny`/`ask` skips dispatch and yields an `isError` result. `tools/execute` is the around-dispatch seam (timeout, retry, metrics): listeners receive `(exec, next)` and call `next()` to delegate to core dispatch (returning its `ToolExecutionResult`, optionally wrapped), or return a replacement result to short-circuit dispatch; the base `next()` IS dispatch-with-normalization, so `await next()` already yields an `isError` result for a thrown/unknown tool (never a raw throw). A wrapper mutates `exec` in place before `next()` — e.g. replacing `exec.signal` with a per-call deadline — because cordis `next()` ignores passed arguments. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch is the base of the `tools/execute` waterfall; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. All follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)); `@deepseek-ai/dsh-timeout-policy` is the reference `tools/execute` wrapper.
- MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
### Typed tool parameter schemas
@@ -71,6 +72,14 @@ A `defineTool` tool also **validates the model-generated arguments against its `
See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details.
`defineTool` also validates an optional `timeoutMs` at definition time when present: it must be a positive finite number, or the helper throws — the budget is attached to the produced `ToolDefinition` (for `@deepseek-ai/dsh-timeout-policy`) and never reaches the model.
### Structured-output schema subset
A separate vocabulary for callers that DEMAND a machine-readable value from an agent — the subagent seam's `SubagentStartRequest.outputSchema` (and, by extension, a workflow's `agent({ schema })`). Unlike `SchemaSpec` (the author-facing DSL for tool parameters), a `StructuredOutputSchema` is an object-rooted **raw JSON Schema subset** as data: it travels verbatim to the model as a forced tool's `parameters`, and the produced value is validated against it.
The subset is deliberately narrow and REJECTS LOUD outside it — accepting a keyword the validator doesn't enforce would validate less than the schema promises (accepted-then-ignored). Supported: single-string `type` (`object`/`array`/`string`/`number`/`integer`/`boolean`/`null`; type arrays rejected), `properties`/`required`/`additionalProperties` (boolean; every `required` key must be declared), `items`, scalar-only `enum`/`const`; annotations (`description`/`title`/`default`/`examples`) are ignored but must still be JSON data. `assertSupportedOutputSchema(schema)` throws `OutputSchemaError` (`code: 'UNSUPPORTED_SCHEMA'`, listing every violation) for anything else; `validateStructuredValue(schema, value)` returns path-qualified violations (empty = valid, total — never throws).
### Tool-owned UI presentation
A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods that return a **`card`-tagged render intent** (a discriminated union — a tool declares its card kind once and a UI bridge switches on `card`):

View File

@@ -1,9 +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 allow/deny gate) → core dispatch →
* `tools/post-execute` (inspect/replace the result, attach context) for
* sandbox, permission, and hook plugins to gate or transform a call.
* through `tools/pre-execute` (the allow/deny gate) → `tools/execute` (an
* around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute`
* (inspect/replace the result, attach context) for sandbox, permission, and hook
* plugins to gate or transform a call.
*
* @module @deepseek-ai/dsh-tools
*/
@@ -28,6 +29,16 @@ export {
type JsonSchemaObject,
} from './schema.ts'
export {
assertSupportedOutputSchema,
validateStructuredValue,
OutputSchemaError,
type StructuredOutputSchema,
type StructuredSchemaNode,
type StructuredSchemaType,
type StructuredScalar,
} from './json-schema.ts'
// The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
// lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools`
// stays the single public surface for consumers (producers + the ACP bridge).
@@ -64,17 +75,37 @@ declare module 'cordis' {
* @mode waterfall
*/
'tools/pre-execute'(this: 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
* mutate `exec` (e.g. replace `exec.signal` with a per-call deadline) BEFORE
* `next()` and inspect the result AFTER. (Cordis `next()` ignores any passed
* arguments and re-invokes downstream with the shared payload, so a wrapper
* mutates `exec` 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.
* @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
* @mode waterfall
*/
'tools/execute'(this: 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. The core tool
* dispatch sits between the two waterfalls as plain code, 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).
* 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).
* @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
@@ -107,6 +138,14 @@ export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta
/** A registered tool: its schema plus the execution function. */
export interface ToolDefinition extends ToolSchema {
execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn>
/**
* Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
* Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it
* is NEVER sent to the model — `schemas()` whitelists only name/description/
* parameters. Declaring it asserts this tool forwards `exec.signal` to a
* cooperative implementation that can reach quiescence when the signal aborts.
*/
timeoutMs?: number
/**
* Optional: how to present the PENDING state of one call in a UI, derived from
* the call's `args` (parsed arguments, `unknown` — the tool validates/narrows
@@ -261,7 +300,7 @@ function errorInfo(error: unknown): ToolErrorInfo | undefined {
/**
* Tool registry (`ctx.tools`): tool plugins register definitions; the agent
* loop executes calls through the `tools/pre-execute` → dispatch
* loop executes calls through the `tools/pre-execute` → `tools/execute`
* `tools/post-execute` pipeline. The registry contributes its schemas into the
* system-prompt assembly.
*/
@@ -335,18 +374,20 @@ export class ToolRegistry extends Service {
}
/**
* Execute one tool call through the `tools/pre-execute` → dispatch →
* `tools/post-execute` pipeline. The two waterfalls are the gate (allow/deny)
* and the inspect/transform seam; core dispatch sits between them as plain
* code. The whole thing is wrapped in one outer try/catch so a throwing
* listener (in either 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 `post-execute` listeners can
* still inspect. If the tool is not registered, the result is an `isError`
* carrying a `UNKNOWN_TOOL` structured error. A thrown {@link HarnessError}
* surfaces its `{ name, code }` on the result.
* Execute one tool call through the `tools/pre-execute` → `tools/execute`
* (around dispatch) → `tools/post-execute` pipeline. `pre-execute` is the gate
* (allow/deny), `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, the result is an `isError` carrying a `UNKNOWN_TOOL`
* structured error. A thrown {@link HarnessError} surfaces its `{ name, code }`
* on the result.
* @param exec - the call to run (name, parsed arguments, caller agent, signal).
* @returns the final result after both waterfalls; failures resolve as
* @returns the final result after every waterfall; failures resolve as
* `isError` results, never rejections.
*/
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
@@ -372,23 +413,30 @@ export class ToolRegistry extends Service {
return await this.postExecute(exec, denied)
}
// --- Core dispatch (plain code between the waterfalls). The tool body's
// own try/catch turns a throw into an isError result so post-execute can
// inspect it; an unknown tool routes through the same catch. ---
let result: ToolExecutionResult
try {
const tool = this.store.get(exec.name)
if (!tool) throw new ToolNotFoundError(exec.name)
// Normalize the two `execute` return shapes: a bare ContentBlock[] (no
// meta) or a { content, meta } object (a tool attaching a private
// presentation payload). An array IS the content; the object carries it.
const returned = await tool.execute(exec.arguments, exec)
const content = Array.isArray(returned) ? returned : returned.content
const meta = Array.isArray(returned) ? undefined : returned.meta
result = { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
} catch (error: unknown) {
result = toolErrorResult(exec.callId, error)
}
// --- 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 mutate `exec` before
// delegating and inspect the normalized result after. ---
const result = await this.ctx.waterfall(
this, 'tools/execute', exec,
async (): Promise<ToolExecutionResult> => {
try {
const tool = this.store.get(exec.name)
if (!tool) throw new ToolNotFoundError(exec.name)
// Normalize the two `execute` return shapes: a bare ContentBlock[] (no
// meta) or a { content, meta } object (a tool attaching a private
// presentation payload). An array IS the content; the object carries it.
const returned = await tool.execute(exec.arguments, exec)
const content = Array.isArray(returned) ? returned : returned.content
const meta = Array.isArray(returned) ? undefined : returned.meta
return { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
} catch (error: unknown) {
return toolErrorResult(exec.callId, error)
}
},
)
return await this.postExecute(exec, result)
} catch (error: unknown) {

View File

@@ -0,0 +1,345 @@
/**
* 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).
*
* @module dsh-tools/json-schema
*/
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
/** The scalar values `enum`/`const` may carry (finite numbers only). */
export type StructuredScalar = string | number | boolean | null
/** The `type` keywords the subset accepts. */
export type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
/**
* One node of the structured-output schema subset. Recursive via `properties`
* and `items`; see the module doc for the exact keyword semantics.
*/
export interface StructuredSchemaNode {
type: StructuredSchemaType
/** Nested property schemas (`type: 'object'` only). */
properties?: Record<string, StructuredSchemaNode>
/** Required property names; each must appear in `properties`. */
required?: string[]
/** `false` rejects undeclared keys; absent/`true` allows them (JSON Schema default). */
additionalProperties?: boolean
/** Item schema (`type: 'array'` only); absent ⇒ any JSON items. */
items?: StructuredSchemaNode
/** Allowed values (scalar types only). */
enum?: StructuredScalar[]
/** The single allowed value (scalar types only). */
const?: StructuredScalar
/** Annotation, ignored for validation. */
description?: string
/** Annotation, ignored for validation. */
title?: string
/** Annotation, ignored for validation (must still be JSON data). */
default?: unknown
/** Annotation, ignored for validation (must still be JSON data). */
examples?: unknown
}
/** A structured-output schema: an OBJECT-rooted {@link StructuredSchemaNode}. */
export type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' }
/**
* Thrown by {@link assertSupportedOutputSchema} when a schema falls outside the
* supported subset. Extends {@link HarnessError} (`code: 'UNSUPPORTED_SCHEMA'`)
* so seam code and tool results can route on it; `violations` lists every
* offending path, not just the first.
*/
export class OutputSchemaError extends HarnessError {
/** The individual violation messages, in walk order. */
readonly violations: string[]
constructor(violations: string[]) {
super(`unsupported output schema: ${violations.join('; ')}`, 'UNSUPPORTED_SCHEMA')
this.name = 'OutputSchemaError'
this.violations = violations
}
}
/** The keywords the subset accepts, checked (`constraint`) or ignored (`annotation`). */
const CONSTRAINT_KEYWORDS = new Set(['type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const'])
const ANNOTATION_KEYWORDS = new Set(['description', 'title', 'default', 'examples'])
const SCHEMA_TYPES: readonly StructuredSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']
/**
* Whether a value is a PLAIN JSON object — non-null, non-array, and with a
* prototype chain of at most one link (`null`-proto, or any realm's
* `Object.prototype`, whose own prototype is `null`). Realm-agnostic on
* purpose: a schema materialized in another realm carries THAT realm's
* `Object.prototype`, which an identity check would wrongly reject. Exotic
* hosts (`Date`, `Map`, class instances) have longer chains and are rejected —
* they would serialize lossily (`Date` → string, `Map` → `{}`) instead of
* failing loud.
*/
function isObjectLike(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
const proto: unknown = Object.getPrototypeOf(value)
return proto === null || Object.getPrototypeOf(proto) === null
}
/** Whether a value is a supported scalar (`enum`/`const` member): string, finite number, boolean, or null. */
function isStructuredScalar(value: unknown): value is StructuredScalar {
return value === null || typeof value === 'string' || typeof value === 'boolean'
|| (typeof value === 'number' && Number.isFinite(value))
}
/**
* Whether a value is JSON data (annotation payloads only): scalars, arrays, and
* object-likes of such values. Realm-agnostic on purpose (no prototype check) —
* the schema may have been materialized from another realm; structural JSON-ness
* is what the wire needs. Cycles are rejected via `seen`.
*/
function isJsonData(value: unknown, seen: Set<object>): boolean {
if (isStructuredScalar(value)) return true
// The scalar check above already returned for null, so `object` here is a real object.
if (typeof value !== 'object') return false
if (seen.has(value)) return false
seen.add(value)
try {
if (Array.isArray(value)) return value.every(entry => isJsonData(entry, seen))
// A non-plain object (Date, Map, class instance) is NOT JSON data even when
// it has no enumerable values — it would serialize lossily, not loudly.
if (!isObjectLike(value)) return false
return Object.values(value).every(entry => isJsonData(entry, seen))
} finally {
seen.delete(value)
}
}
/** Collect subset violations for one schema node (recursive walk). */
function checkSchemaNode(node: unknown, path: string, violations: string[], seen: Set<object>): void {
if (!isObjectLike(node)) {
violations.push(`${path} must be a schema object`)
return
}
if (seen.has(node)) {
violations.push(`${path} is circular`)
return
}
seen.add(node)
for (const key of Object.keys(node)) {
if (CONSTRAINT_KEYWORDS.has(key)) continue
if (ANNOTATION_KEYWORDS.has(key)) {
if (!isJsonData(node[key], new Set())) violations.push(`${path}.${key} annotation must be JSON data`)
continue
}
violations.push(`${path}.${key} is not a supported keyword (subset: type/properties/required/additionalProperties/items/enum/const + annotations)`)
}
if (typeof node.description !== 'undefined' && typeof node.description !== 'string') {
violations.push(`${path}.description must be a string`)
}
if (typeof node.title !== 'undefined' && typeof node.title !== 'string') {
violations.push(`${path}.title must be a string`)
}
const type = node.type
if (typeof type !== 'string' || !(SCHEMA_TYPES as readonly unknown[]).includes(type)) {
violations.push(Array.isArray(type)
? `${path}.type must be a single type string (type arrays are not supported)`
: `${path}.type must be one of ${SCHEMA_TYPES.join('/')}`)
seen.delete(node)
return
}
const schemaType = type as StructuredSchemaType
// Keywords that only make sense on one type are rejected elsewhere — an
// `items` on an object (or `properties` on a string) is a schema-author bug
// the subset surfaces rather than ignores.
const allowedFor: Record<string, StructuredSchemaType[]> = {
properties: ['object'],
required: ['object'],
additionalProperties: ['object'],
items: ['array'],
enum: ['string', 'number', 'integer', 'boolean', 'null'],
const: ['string', 'number', 'integer', 'boolean', 'null'],
}
for (const [key, types] of Object.entries(allowedFor)) {
if (key in node && !types.includes(schemaType)) {
violations.push(`${path}.${key} is not supported on type "${schemaType}"`)
}
}
switch (schemaType) {
case 'object': {
const properties = node.properties
if (properties !== undefined) {
if (!isObjectLike(properties)) {
violations.push(`${path}.properties must be an object of schemas`)
} else {
for (const [key, child] of Object.entries(properties)) {
checkSchemaNode(child, `${path}.properties.${key}`, violations, seen)
}
}
}
const required = node.required
if (required !== undefined) {
if (!Array.isArray(required) || required.some(entry => typeof entry !== 'string')) {
violations.push(`${path}.required must be an array of strings`)
} else {
const declared = isObjectLike(properties) ? properties : {}
// The guard above proved every entry is a string.
for (const key of required as string[]) {
// Own-property check: `in` would let inherited names (`toString`)
// satisfy the declared-in-properties contract via the prototype.
if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names "${key}" which is not in properties`)
}
}
}
if (node.additionalProperties !== undefined && typeof node.additionalProperties !== 'boolean') {
violations.push(`${path}.additionalProperties must be a boolean`)
}
break
}
case 'array': {
if (node.items !== undefined) checkSchemaNode(node.items, `${path}.items`, violations, seen)
break
}
case 'string':
case 'number':
case 'integer':
case 'boolean':
case 'null': {
const allowed = node.enum
if (allowed !== undefined) {
if (!Array.isArray(allowed) || allowed.length === 0 || !allowed.every(entry => isStructuredScalar(entry))) {
violations.push(`${path}.enum must be a non-empty array of scalars`)
}
}
if ('const' in node && !isStructuredScalar(node.const)) {
violations.push(`${path}.const must be a scalar`)
}
break
}
/* v8 ignore start -- defensive: schemaType was membership-checked against SCHEMA_TYPES above, so no runtime value reaches here */
default:
assertNever(schemaType, 'assertSupportedOutputSchema')
/* v8 ignore stop */
}
seen.delete(node)
}
/**
* Assert `schema` is a supported {@link StructuredOutputSchema} — object-rooted
* and entirely within the enforced subset. Throws {@link OutputSchemaError}
* (`UNSUPPORTED_SCHEMA`) listing EVERY violation; returns (and narrows) on
* success. Call this at the seam boundary, before any child is created.
* @param schema - the caller-supplied schema (unknown until asserted).
* @returns nothing — the assertion signature narrows `schema` to
* {@link StructuredOutputSchema} in the caller's scope on normal return.
*/
export function assertSupportedOutputSchema(schema: unknown): asserts schema is StructuredOutputSchema {
const violations: string[] = []
checkSchemaNode(schema, 'schema', violations, new Set())
if (violations.length === 0 && (schema as StructuredSchemaNode).type !== 'object') {
violations.push('schema.type must be "object" (structured output is object-rooted)')
}
if (violations.length > 0) throw new OutputSchemaError(violations)
}
/** Collect violations for one value against an (already asserted) schema node. */
function checkValue(node: StructuredSchemaNode, value: unknown, path: string): string[] {
switch (node.type) {
case 'object': {
if (!isObjectLike(value)) return [`"${path}" must be an object`]
const violations: string[] = []
const properties = node.properties ?? {}
// Own-property discipline throughout: JSON carries own enumerable
// properties only, so an inherited `toString` must not satisfy
// `required`, dodge `additionalProperties: false`, or be validated as if
// the value carried it.
for (const key of node.required ?? []) {
if (!Object.hasOwn(value, key) || value[key] === undefined) violations.push(`missing required property "${path}.${key}"`)
}
for (const [key, child] of Object.entries(properties)) {
if (!Object.hasOwn(value, key) || value[key] === undefined) continue
violations.push(...checkValue(child, value[key], `${path}.${key}`))
}
if (node.additionalProperties === false) {
for (const key of Object.keys(value)) {
if (!Object.hasOwn(properties, key)) violations.push(`"${path}.${key}" is not a declared property (additionalProperties: false)`)
}
}
return violations
}
case 'array': {
if (!Array.isArray(value)) return [`"${path}" must be an array`]
if (!node.items) return []
const items = node.items
return value.flatMap((entry, index) => checkValue(items, entry, `${path}[${index}]`))
}
case 'string': {
if (typeof value !== 'string') return [`"${path}" must be a string`]
break
}
case 'number': {
if (typeof value !== 'number' || !Number.isFinite(value)) return [`"${path}" must be a finite number`]
break
}
case 'integer': {
if (typeof value !== 'number' || !Number.isInteger(value)) return [`"${path}" must be an integer`]
break
}
case 'boolean': {
if (typeof value !== 'boolean') return [`"${path}" must be a boolean`]
break
}
case 'null': {
if (value !== null) return [`"${path}" must be null`]
break
}
default:
return assertNever(node.type, 'validateStructuredValue')
}
// Scalar constraint checks, shared by every scalar branch above.
if (node.enum && !node.enum.includes(value)) {
return [`"${path}" must be one of ${JSON.stringify(node.enum)}`]
}
if ('const' in node && value !== node.const) {
return [`"${path}" must be ${JSON.stringify(node.const)}`]
}
return []
}
/**
* Validate a value against an (already {@link assertSupportedOutputSchema}-
* asserted) schema. Returns human-readable, path-qualified violation messages
* — empty means valid. Total: never throws, however malformed the value.
* @param schema - the asserted schema to check against.
* @param value - the candidate value (e.g. parsed tool-call arguments).
* @returns every violation found, in walk order (empty = valid).
*/
export function validateStructuredValue(schema: StructuredOutputSchema, value: unknown): string[] {
return checkValue(schema, value, 'value')
}

View File

@@ -155,6 +155,9 @@ export interface JsonSchemaObject {
* `properties`, `required` array).
*
* This is a plain function — no schemastery or other framework dependency.
* @param spec - the author-facing per-property schema to convert.
* @returns the wire-format JSON Schema; the top-level `required` array is
* omitted entirely when no property is marked required.
*/
export function schemaSpecToJsonSchema(spec: SchemaSpec): JsonSchemaObject {
const properties: Record<string, unknown> = {}
@@ -269,6 +272,9 @@ function checkSpec(spec: SchemaSpec, value: unknown, path: string): string[] {
* 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.
*/
export function validateArgs(spec: SchemaSpec, args: unknown): string[] {
return checkSpec(spec, args, '')
@@ -289,6 +295,13 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* standard JSON Schema at runtime.
*/
parameters: S
/**
* Optional cooperative tool-call timeout budget in milliseconds. When given it
* must be a positive finite number; it is attached to the produced
* {@link ToolDefinition} for `@deepseek-ai/dsh-timeout-policy` to enforce and
* is never sent to the model.
*/
timeoutMs?: number
/**
* Tool execution function. `args` is typed as {@link InferArgs<S>} — zero
* casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing
@@ -340,6 +353,13 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* 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
* raw args first (throwing {@link ToolArgsError} on mismatch, which the
* registry turns into an isError result), and its presenters validate softly
* (returning undefined on mismatch, since replay may feed them older-schema
* args).
*/
export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
// Object-literal execute methods don't use `this`; the reference is safe.
@@ -349,10 +369,14 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
const userPresentCall = options.presentCall
// eslint-disable-next-line @typescript-eslint/unbound-method
const userPresentResult = options.presentResult
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`)
}
const tool: ToolDefinition = {
name: options.name,
description: options.description,
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

View File

@@ -35,7 +35,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'edit', 'read', 'skill', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'read', 'skill', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {

View File

@@ -0,0 +1,304 @@
import { describe, expect, it } from 'vitest'
import {
assertSupportedOutputSchema,
OutputSchemaError,
validateStructuredValue,
type StructuredOutputSchema,
} from '../src/json-schema.ts'
/** Assert-and-narrow helper: the asserted schema, typed. */
function asserted(schema: unknown): StructuredOutputSchema {
assertSupportedOutputSchema(schema)
return schema
}
/** The violations OutputSchemaError carries for a bad schema (throws if it passes). */
function violationsOf(schema: unknown): string[] {
try {
assertSupportedOutputSchema(schema)
} catch (error: unknown) {
if (error instanceof OutputSchemaError) return error.violations
throw error
}
throw new Error('expected the schema to be rejected')
}
describe('assertSupportedOutputSchema', () => {
it('accepts a representative subset schema (all supported keywords)', () => {
const schema = asserted({
type: 'object',
description: 'a finding',
title: 'Finding',
properties: {
file: { type: 'string', description: 'path' },
line: { type: 'integer' },
severity: { type: 'string', enum: ['low', 'high'] },
kind: { type: 'string', const: 'bug' },
score: { type: 'number' },
confirmed: { type: 'boolean' },
parent: { type: 'null' },
tags: { type: 'array', items: { type: 'string' } },
nested: {
type: 'object',
properties: { x: { type: 'number', default: 3, examples: [1, 2] } },
additionalProperties: false,
},
anything: { type: 'array' },
},
required: ['file', 'line'],
additionalProperties: true,
})
expect(schema.type).toBe('object')
})
it('rejects a non-object root (scalar/array-rooted schemas)', () => {
expect(violationsOf({ type: 'string' })).toEqual(['schema.type must be "object" (structured output is object-rooted)'])
expect(violationsOf({ type: 'array', items: { type: 'string' } }))
.toContain('schema.type must be "object" (structured output is object-rooted)')
})
it('rejects non-object schema nodes and missing/unknown type', () => {
expect(violationsOf('nope')).toEqual(['schema must be a schema object'])
expect(violationsOf(null)).toEqual(['schema must be a schema object'])
expect(violationsOf([])).toEqual(['schema must be a schema object'])
expect(violationsOf({})).toEqual(['schema.type must be one of object/array/string/number/integer/boolean/null'])
expect(violationsOf({ type: 'tuple' })[0]).toMatch(/type must be one of/)
expect(violationsOf({ type: 'object', properties: { a: 'str' } })).toEqual(['schema.properties.a must be a schema object'])
})
it('rejects type ARRAYS with a dedicated message', () => {
expect(violationsOf({ type: ['string', 'null'] }))
.toEqual(['schema.type must be a single type string (type arrays are not supported)'])
})
it('rejects unsupported constraint keywords loudly (never accepted-then-ignored)', () => {
for (const keyword of ['oneOf', 'anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) {
const bad = violationsOf({ type: 'object', [keyword]: [] })
expect(bad.some(v => v.includes(`schema.${keyword} is not a supported keyword`))).toBe(true)
}
})
it('reports EVERY violation, not just the first', () => {
const bad = violationsOf({
type: 'object',
pattern: 'x',
properties: { a: { type: 'weird' }, b: { type: 'string', minimum: 1 } },
})
expect(bad.length).toBe(3)
})
it('rejects keywords on the wrong type (items on object, properties on string, enum on object)', () => {
expect(violationsOf({ type: 'object', items: { type: 'string' } }))
.toEqual(['schema.items is not supported on type "object"'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', properties: {} } } }))
.toEqual(['schema.properties.a.properties is not supported on type "string"'])
expect(violationsOf({ type: 'object', enum: [1] }))
.toEqual(['schema.enum is not supported on type "object"'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'array', const: 1 } } }))
.toEqual(['schema.properties.a.const is not supported on type "array"'])
})
it('validates required: must be string[] naming declared properties', () => {
expect(violationsOf({ type: 'object', required: 'file' }))
.toEqual(['schema.required must be an array of strings'])
expect(violationsOf({ type: 'object', required: [1] }))
.toEqual(['schema.required must be an array of strings'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string' } }, required: ['b'] }))
.toEqual(['schema.required names "b" which is not in properties'])
expect(violationsOf({ type: 'object', required: ['a'] }))
.toEqual(['schema.required names "a" which is not in properties'])
})
it('validates additionalProperties must be boolean and enum/const must be scalars', () => {
expect(violationsOf({ type: 'object', additionalProperties: {} }))
.toEqual(['schema.additionalProperties must be a boolean'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [] } } }))
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [{}] } } }))
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: 'x' } } }))
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'number', enum: [Number.NaN] } } }))
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', const: {} } } }))
.toEqual(['schema.properties.a.const must be a scalar'])
})
it('rejects non-string description/title and non-JSON annotation payloads', () => {
expect(violationsOf({ type: 'object', description: 7 }))
.toEqual(['schema.description must be a string'])
expect(violationsOf({ type: 'object', title: 7 }))
.toEqual(['schema.title must be a string'])
expect(violationsOf({ type: 'object', default: () => 1 }))
.toEqual(['schema.default annotation must be JSON data'])
expect(violationsOf({ type: 'object', examples: [undefined] }))
.toEqual(['schema.examples annotation must be JSON data'])
expect(violationsOf({ type: 'object', examples: [Number.POSITIVE_INFINITY] }))
.toEqual(['schema.examples annotation must be JSON data'])
// A cyclic annotation payload is caught by the JSON-data walk.
const cyclicAnnotation: Record<string, unknown> = {}
cyclicAnnotation.self = cyclicAnnotation
expect(violationsOf({ type: 'object', default: cyclicAnnotation }))
.toEqual(['schema.default annotation must be JSON data'])
// Object/array annotations that ARE JSON data pass.
asserted({ type: 'object', default: { a: [1, 'x', null, true] } })
})
it('rejects a circular schema instead of recursing forever', () => {
const node: Record<string, unknown> = { type: 'object' }
node.properties = { self: node }
expect(violationsOf(node)).toEqual(['schema.properties.self is circular'])
})
it('accepts the same subschema object reused in two SIBLING positions (a DAG, not a cycle)', () => {
const leaf = { type: 'string' }
asserted({ type: 'object', properties: { a: leaf, b: leaf } })
})
it('required cannot be satisfied by INHERITED names — `toString` is not a declared property', () => {
// `'toString' in {}` is true via Object.prototype; the declared-property
// contract must be an own-property check.
expect(violationsOf({ type: 'object', properties: {}, required: ['toString'] }))
.toEqual(['schema.required names "toString" which is not in properties'])
})
it('rejects exotic host objects where the subset expects plain JSON structure', () => {
// A Map as `properties` has no own enumerable entries: structurally it
// would read as "no properties" and serialize to {} — lossy, not loud.
expect(violationsOf({ type: 'object', properties: new Map() }))
.toEqual(['schema.properties must be an object of schemas'])
// A Date node is not a schema object even though Object.values(date) is [].
expect(violationsOf({ type: 'object', properties: { at: new Date(0) } }))
.toEqual(['schema.properties.at must be a schema object'])
})
it('rejects exotic annotation payloads that would serialize lossily', () => {
expect(violationsOf({ type: 'object', default: new Date(0) }))
.toEqual(['schema.default annotation must be JSON data'])
expect(violationsOf({ type: 'object', examples: [new Map()] }))
.toEqual(['schema.examples annotation must be JSON data'])
})
})
describe('validateStructuredValue', () => {
const schema = asserted({
type: 'object',
properties: {
file: { type: 'string' },
line: { type: 'integer' },
score: { type: 'number' },
confirmed: { type: 'boolean' },
parent: { type: 'null' },
severity: { type: 'string', enum: ['low', 'high'] },
kind: { type: 'string', const: 'bug' },
tags: { type: 'array', items: { type: 'string' } },
free: { type: 'array' },
nested: { type: 'object', properties: { x: { type: 'number' } }, required: ['x'], additionalProperties: false },
},
required: ['file'],
})
it('accepts a fully valid value (empty violations)', () => {
expect(validateStructuredValue(schema, {
file: 'a.ts', line: 3, score: 0.5, confirmed: true, parent: null,
severity: 'high', kind: 'bug', tags: ['x'], free: [1, { any: true }], nested: { x: 1 },
})).toEqual([])
})
it('reports missing required and wrong root type', () => {
expect(validateStructuredValue(schema, {})).toEqual(['missing required property "value.file"'])
expect(validateStructuredValue(schema, 'nope')).toEqual(['"value" must be an object'])
expect(validateStructuredValue(schema, [])).toEqual(['"value" must be an object'])
})
it('type-checks every scalar branch with path-qualified messages', () => {
expect(validateStructuredValue(schema, { file: 1 })).toEqual(['"value.file" must be a string'])
expect(validateStructuredValue(schema, { file: 'a', line: 1.5 })).toEqual(['"value.line" must be an integer'])
expect(validateStructuredValue(schema, { file: 'a', line: 'x' })).toEqual(['"value.line" must be an integer'])
expect(validateStructuredValue(schema, { file: 'a', score: 'x' })).toEqual(['"value.score" must be a finite number'])
expect(validateStructuredValue(schema, { file: 'a', score: Number.NaN })).toEqual(['"value.score" must be a finite number'])
expect(validateStructuredValue(schema, { file: 'a', confirmed: 'yes' })).toEqual(['"value.confirmed" must be a boolean'])
expect(validateStructuredValue(schema, { file: 'a', parent: 0 })).toEqual(['"value.parent" must be null'])
})
it('enforces enum membership and const equality', () => {
expect(validateStructuredValue(schema, { file: 'a', severity: 'mid' }))
.toEqual(['"value.severity" must be one of ["low","high"]'])
expect(validateStructuredValue(schema, { file: 'a', kind: 'feature' }))
.toEqual(['"value.kind" must be "bug"'])
})
it('checks arrays per index; an items-less array accepts anything', () => {
expect(validateStructuredValue(schema, { file: 'a', tags: 'x' })).toEqual(['"value.tags" must be an array'])
expect(validateStructuredValue(schema, { file: 'a', tags: ['ok', 2] })).toEqual(['"value.tags[1]" must be a string'])
expect(validateStructuredValue(schema, { file: 'a', free: [{ deep: [1] }, null] })).toEqual([])
})
it('recurses into nested objects: required + additionalProperties: false', () => {
expect(validateStructuredValue(schema, { file: 'a', nested: {} }))
.toEqual(['missing required property "value.nested.x"'])
expect(validateStructuredValue(schema, { file: 'a', nested: { x: 1, y: 2 } }))
.toEqual(['"value.nested.y" is not a declared property (additionalProperties: false)'])
expect(validateStructuredValue(schema, { file: 'a', nested: 3 }))
.toEqual(['"value.nested" must be an object'])
})
it('a required key present-but-undefined counts as missing', () => {
expect(validateStructuredValue(schema, { file: undefined })).toEqual(['missing required property "value.file"'])
})
it('inherited properties satisfy nothing: required, additionalProperties, and recursion are own-property only', () => {
// required: ['toString'] must NOT be satisfied by Object.prototype.toString.
expect(validateStructuredValue(
asserted({ type: 'object', properties: { toString: { type: 'string' } }, required: ['toString'] }),
{},
)).toEqual(['missing required property "value.toString"'])
// additionalProperties: false must flag an OWN `toString` key even though
// `'toString' in properties` is true via the prototype.
expect(validateStructuredValue(
asserted({ type: 'object', additionalProperties: false }),
{ toString: 1 },
)).toEqual(['"value.toString" is not a declared property (additionalProperties: false)'])
// A declared property the value does NOT carry must not be validated
// against the value's INHERITED member (constructor is a function on
// every plain object's prototype, not a carried property).
expect(validateStructuredValue(
asserted({ type: 'object', properties: { constructor: { type: 'string' } } }),
{},
)).toEqual([])
})
it('a non-plain object value is not an object in the JSON sense', () => {
expect(validateStructuredValue(asserted({ type: 'object' }), new Date(0)))
.toEqual(['"value" must be an object'])
})
it('collects multiple violations across branches in one pass', () => {
expect(validateStructuredValue(schema, { line: 'x', severity: 'mid' })).toEqual([
'missing required property "value.file"',
'"value.line" must be an integer',
'"value.severity" must be one of ["low","high"]',
])
})
it('null-typed const/enum work through the scalar path', () => {
const nullish = asserted({ type: 'object', properties: { a: { type: 'null', const: null } } })
expect(validateStructuredValue(nullish, { a: null })).toEqual([])
})
it('rejects a non-object properties value in the schema walk', () => {
expect(violationsOf({ type: 'object', properties: [] }))
.toEqual(['schema.properties must be an object of schemas'])
})
it('an object schema without properties/required only type-checks its value', () => {
const bare = asserted({ type: 'object' })
expect(validateStructuredValue(bare, { any: ['thing'] })).toEqual([])
expect(validateStructuredValue(bare, 7)).toEqual(['"value" must be an object'])
})
it('validateStructuredValue throws on a type the assert would never let through (assertNever backstop)', () => {
const forged = { type: 'tuple' } as unknown as StructuredOutputSchema
expect(() => validateStructuredValue(forged, 1)).toThrow(/tuple/)
})
})

View File

@@ -5,6 +5,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, {
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolExecution, type ToolExecutionResult,
} from '@deepseek-ai/dsh-tools'
async function setup() {
@@ -62,6 +63,17 @@ describe('ToolRegistry', () => {
expect(schema.execute).toBeUndefined()
})
it('schemas() excludes timeoutMs — the budget must never reach the model', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: 'budgeted', description: 'has a budget', parameters: {}, timeoutMs: 5_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
}))
const schema = ctx.tools.schemas().find(s => s.name === 'budgeted')
expect(schema).toBeDefined()
expect('timeoutMs' in (schema as object)).toBe(false)
})
it('executes a tool and returns its content', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -272,6 +284,151 @@ describe('ToolRegistry', () => {
expect(order).toEqual(['pre:before', 'pre:after', 'post:before', 'post:after'])
})
it('runs tools/execute after an allowed pre-execute, around dispatch, and before post-execute', async () => {
const ctx = await setup()
const order: string[] = []
ctx.tools.register(defineTool({
name: 'traced',
description: 'echo',
parameters: { text: { type: 'string' } },
async execute(args) {
order.push('dispatch')
return [{ type: 'text' as const, text: args.text ?? '' }]
},
}))
ctx.on('tools/pre-execute', async (_exec, next) => { order.push('pre'); return next() })
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
order.push('execute:before')
const result = await next()
order.push('execute:after')
return result
})
ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } })
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false })
// The around seam wraps dispatch; pre gates before it, post runs over its result.
expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post'])
})
it('a pre-execute deny short-circuits before tools/execute (the seam never runs)', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
let entered = false
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'deny', reason: 'nope' }))
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
entered = true
return next()
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: nope' })
expect(entered).toBe(false) // a denied call never enters the around-dispatch seam
})
it('a thrown tool is normalized to an isError result BEFORE a tools/execute listener sees next()', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'boom',
async execute() { throw new HarnessError('kaboom', 'BOOM') },
})
let seen: { isError: boolean; error?: unknown } | undefined
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
const result = await next()
// The base next() IS dispatch-with-normalization: the wrapper sees the
// normalized isError result, never a raw throw from the tool body.
seen = { isError: result.isError, error: result.error }
return result
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} })
expect(seen).toEqual({ isError: true, error: { name: 'HarnessError', code: 'BOOM' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' })
})
it('a thrown tool normalized inside tools/execute still reaches post-execute', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'boom',
async execute() { throw new Error('exploded') },
})
let postSaw: boolean | undefined
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => next())
ctx.on('tools/post-execute', async (_exec, result, next) => {
postSaw = result.isError
return next()
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} })
expect(postSaw).toBe(true) // the normalized isError still flows through post-execute
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: exploded' })
})
it('a tools/execute listener can replace exec.signal for the dispatched tool (deadline pattern)', async () => {
const ctx = await setup()
let seenSignal: AbortSignal | undefined
ctx.tools.register({
...echoTool,
name: 'signal-probe',
async execute(_args, exec) {
seenSignal = exec.signal
return [{ type: 'text' as const, text: 'ok' }]
},
})
const upstream = new AbortController().signal
const replacement = new AbortController().signal
ctx.on('tools/execute', async (exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
expect(exec.signal).toBe(upstream)
// Cordis next() ignores passed arguments, so a wrapper mutates exec in
// place (the documented "mutate the shared object, then delegate" idiom).
exec.signal = replacement
return next()
})
await ctx.tools.execute({ callId: CallId('c1'), name: 'signal-probe', arguments: {}, signal: upstream })
expect(seenSignal).toBe(replacement) // dispatch saw the wrapper's replacement, not the upstream
})
it('a tools/execute listener can short-circuit dispatch by returning a result without next()', async () => {
const ctx = await setup()
let dispatched = false
ctx.tools.register({
...echoTool,
name: 'never-runs',
async execute() { dispatched = true; return [] },
})
ctx.on('tools/execute', async (exec: ToolExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
({ callId: exec.callId, content: [{ type: 'text', text: 'short-circuited' }], isError: false }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} })
expect(dispatched).toBe(false) // returning without next() skips core dispatch
expect(result.content[0]).toMatchObject({ text: 'short-circuited' })
})
it('returns an isError result when a tools/execute listener throws', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async () => { throw new Error('wrapper broke') })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({
callId: CallId('c1'),
content: [{ type: 'text', text: 'Error: wrapper broke' }],
isError: true,
})
})
it('returns an isError result when a tools/pre-execute listener throws', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -989,6 +1146,38 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'raw', arguments: {} })
expect(result.isError).toBe(false)
})
it('attaches a positive-finite timeoutMs to the definition', () => {
const tool = defineTool({
name: 'x', description: 'd', parameters: {}, timeoutMs: 30_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
})
expect(tool.timeoutMs).toBe(30_000)
})
it('omits timeoutMs when not declared', () => {
const tool = defineTool({
name: 'x', description: 'd', parameters: {},
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
})
expect(tool.timeoutMs).toBeUndefined()
})
it('throws when timeoutMs is zero or negative', () => {
const make = (ms: number) => defineTool({
name: 'x', description: 'd', parameters: {}, timeoutMs: ms,
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
})
expect(() => make(0)).toThrow('timeoutMs must be a positive finite number')
expect(() => make(-5)).toThrow('positive finite number')
})
it('throws when timeoutMs is non-finite', () => {
expect(() => defineTool({
name: 'x', description: 'd', parameters: {}, timeoutMs: Infinity,
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
})).toThrow('positive finite number')
})
})
describe('defineTool presentation (presentCall / presentResult)', () => {

View File

@@ -10,3 +10,7 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) |
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it.
## No timeouts on file IO
`read`/`write`/`edit` take **no** `timeoutMs`, and the provider seam arms no deadline — unlike bash and web, which consume [`@deepseek-ai/dsh-timeout`](../util/timeout/README.md). A local syscall is best-effort-abortable at most: a timeout could not force an in-progress `fsync`/`rename` to stop, so a deadline here would be a knob that cannot deliver on its promise. Adding one would also be an implicit default in the exact place explicit-over-implicit forbids. Both reference agents (Claude Code, Codex) leave file IO untimed for the same reason; cancellation still propagates through the tool-execution signal for best-effort abort at syscall boundaries.

View File

@@ -129,6 +129,9 @@ export interface LocalDirEntry {
* 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.
* @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.
*/
export async function resolveLocalTarget(cwd: string, path: string): Promise<LocalTarget> {
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
@@ -165,7 +168,11 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
}
}
/** Probe a path for its version, mode, type, and size. Null if absent. */
/**
* Probe a path for its version, mode, type, and size. Null if absent.
* @param absolutePath - the path to stat (typically a target key; symlinks are followed).
* @returns the metadata, or null when the path — or a parent segment — does not exist.
*/
export async function probe(absolutePath: string): Promise<PathInfo | null> {
try {
const info = await stat(absolutePath)
@@ -200,6 +207,9 @@ async function resolveListedChildTarget(parent: LocalTarget, name: string): Prom
* List direct children of a directory in stable name order. Each child includes
* a resolved target plus stat metadata when still available; file contents are
* never read.
* @param target - the resolved directory to list; a missing or non-directory target throws.
* @param signal - aborts the listing, checked between children (`FS_ABORTED`).
* @returns one entry per direct child, sorted by name.
*/
export async function listDirectory(target: LocalTarget, signal?: AbortSignal): Promise<LocalDirEntry[]> {
throwIfAborted(signal, 'list')
@@ -290,6 +300,9 @@ async function statRegularFile(target: LocalTarget, verb: 'read', signal?: Abort
/**
* Read a whole regular UTF-8 text file into a single decoded string. Rejects
* non-regular files, invalid UTF-8, and NUL-byte binary samples.
* @param target - the resolved file to read.
* @param signal - aborts the read (`FS_ABORTED`).
* @returns the full decoded text, byte-for-byte (no normalization).
*/
export async function readWholeText(target: LocalTarget, signal?: AbortSignal): Promise<string> {
await statRegularFile(target, 'read', signal)
@@ -305,6 +318,9 @@ export async function readWholeText(target: LocalTarget, signal?: AbortSignal):
* Stream a whole regular UTF-8 text file as decoded text chunks. Same text
* semantics as {@link readWholeText} (regular-file check, binary/NUL rejection,
* cross-chunk UTF-8 decoding), but never holds the whole file in memory.
* @param target - the resolved file to stream.
* @param signal - aborts the stream, including between chunks (`FS_ABORTED`).
* @returns decoded text chunks in file order; chunk boundaries carry no meaning.
*/
export async function* streamWholeText(target: LocalTarget, signal?: AbortSignal): AsyncIterable<string> {
await statRegularFile(target, 'read', signal)
@@ -352,6 +368,11 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow
* (`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.
* @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 internals - test seam for pinning temp names and observing the staged file.
*/
export async function writeFileAtomic(
absolutePath: string,
@@ -409,6 +430,12 @@ export async function writeFileAtomic(
/** Line ending style detected before LF normalization. */
export type LineEndings = 'LF' | 'CRLF'
/**
* Collapse CRLF to LF — the canonical in-memory form every edit/diff basis
* uses. Lone `\r` bytes (not followed by `\n`) are left untouched.
* @param content - decoded text in whatever line-ending style the file had.
* @returns the text with every `\r\n` pair replaced by `\n`.
*/
function normalizeLineEndings(content: string): string {
return content.replaceAll('\r\n', '\n')
}
@@ -420,6 +447,14 @@ function detectLineEndings(raw: string): LineEndings {
return crlfCount > lfCount ? 'CRLF' : 'LF'
}
/**
* Convert LF-normalized content back to the line-ending style detected at read
* time, for write-back. `LF` returns the content unchanged; `CRLF` re-normalizes
* first so an already-CRLF sequence is never doubled to `\r\r\n`.
* @param content - the LF-normalized (edited) text.
* @param lineEndings - the original file's style, as detected by {@link readForEdit}.
* @returns the text in the original file's line-ending style.
*/
function restoreLineEndings(content: string, lineEndings: LineEndings): string {
return lineEndings === 'LF' ? content : normalizeLineEndings(content).split('\n').join('\r\n')
}
@@ -438,6 +473,10 @@ function countOccurrences(content: string, needle: string): number {
/**
* Read and decode a file for editing: rejects binaries, returns LF-normalized
* content plus the original line-ending style for write-back.
* @param absolutePath - the file to read (typically a target key).
* @param displayPath - the caller-facing path used in error messages.
* @param signal - aborts the read (`FS_ABORTED`).
* @returns the LF-normalized content and the detected style to restore on write-back.
*/
export async function readForEdit(
absolutePath: string,
@@ -459,6 +498,9 @@ export async function readForEdit(
* 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).
* @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.
*/
export async function readTextForDiff(absolutePath: string, signal?: AbortSignal): Promise<string | null> {
const buffer = await readFileAbortable(absolutePath, 'read', signal)
@@ -477,6 +519,12 @@ export async function readTextForDiff(absolutePath: string, signal?: AbortSignal
* `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.
* @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 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.
* @returns the edited LF-normalized content plus how many occurrences were replaced.
*/
export function applyLiteralEdit(
content: string,

View File

@@ -73,6 +73,7 @@ export class LocalFileSystem extends FileSystem {
cwd: z.string().default(process.cwd()),
})
/** Validated config (schemastery applied the defaults before construction). */
readonly config: ResolvedConfig
/** Test seam forwarded to fsio (force streaming path, pin temp names). */
internals: FsIoInternals = {}

View File

@@ -29,7 +29,12 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
*/
export type FsTargetKey = Branded<'FsTargetKey'>
/** Brand a string as an {@link FsTargetKey}. */
/**
* Brand a string as an {@link FsTargetKey}. For backend use only — a consumer
* never manufactures a key, it receives one from `resolve()`.
* @param key - the backend's raw key string (the local backend passes a realpath).
* @returns the same string, branded; no validation is performed.
*/
export function FsTargetKey(key: string): FsTargetKey {
return key as FsTargetKey
}
@@ -42,7 +47,12 @@ export function FsTargetKey(key: string): FsTargetKey {
*/
export type FsVersion = Branded<'FsVersion'>
/** Brand a string as an {@link FsVersion}. */
/**
* Brand a string as an {@link FsVersion}. For backend use only — a consumer
* never manufactures a version, it receives one from `stat`/write/edit outcomes.
* @param v - the backend's raw version string (the local backend derives it from mtime+size).
* @returns the same string, branded; no validation is performed.
*/
export function FsVersion(v: string): FsVersion {
return v as FsVersion
}

View File

@@ -40,6 +40,10 @@ export type FsDiffMeta = { diffs: FileDiff[] }
* (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 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.
*/
export function computeHunkDiffs(path: string, before: string, after: string): FileDiff[] {
const patch = structuredPatch('', '', before, after, undefined, undefined, { context: DIFF_CONTEXT })
@@ -83,6 +87,8 @@ function isFileDiff(value: unknown): value is FileDiff {
* 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.
*/
export function diffsFromMeta(meta: unknown): FileDiff[] | undefined {
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined

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