Merge branch 'master' into recall-renderer-shared

This commit is contained in:
Tianyi Cui
2026-07-09 22:34:56 +08:00
committed by GitHub
130 changed files with 8449 additions and 220 deletions

View File

@@ -16,11 +16,13 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
| [`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 |
| [`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 |
@@ -30,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
@@ -71,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)
@@ -94,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
}
@@ -343,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);
@@ -358,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
@@ -401,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', () => {
@@ -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,
@@ -75,8 +74,6 @@ describe('runBash', () => {
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('')
@@ -111,11 +108,16 @@ 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()
})
@@ -147,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')
})
@@ -224,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)
})
})
@@ -352,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)
})
})
@@ -409,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

@@ -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,849 @@
/**
* 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 = {}): 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, 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: '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, 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-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: '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: '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

@@ -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`)
@@ -9,7 +9,7 @@ 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.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`dispatch`tools/post-execute` pipeline.
- `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,8 @@ 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.

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
*/
@@ -74,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
@@ -117,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
@@ -271,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.
*/
@@ -345,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> {
@@ -382,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

@@ -295,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
@@ -362,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', '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', '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

@@ -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

@@ -0,0 +1,9 @@
# timeout/ — tool-call timeout policy
The tool-call timeout policy plugin. A single **product** package: it is a deployment-policy consumer of the `tools/execute` around-dispatch seam (owned by [`dsh-tools`](../core/tools)) and the pure [`dsh-timeout`](../util/timeout) library — not a swappable capability with an interface/implementation split, so it needs no seam trio.
| Package | Role | ctx key |
|---|---|---|
| `timeout-policy/` | A `tools/execute` wrapper: for each configured tool it arms a per-call deadline on `exec.signal` and returns a structured `TOOL_TIMEOUT` result when that deadline wins | (registers a `tools/execute` listener; injects nothing) |
Timeout is split across three layers: [`dsh-timeout`](../util/timeout) owns the pure timing/classification primitive (`deadline`/`timeoutOf`), each capability owns termination (bash kills its process group, the fetch provider tears down its socket), and this package owns the *model-facing tool-call budget as deployment policy* — no model-facing timeout argument, no global default. It is the middleware the [timeout-library RFC](../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md) foresaw. `bash` and hook command execution keep their own `BASH_TIMEOUT` backend timeout and do not route through this policy.

View File

@@ -0,0 +1,34 @@
# dsh-timeout-policy
Tool-call timeout enforcer: a single `tools/execute` around-dispatch listener that arms a per-call cooperative deadline on `exec.signal` for a tool declaring `timeoutMs` on its `ToolDefinition` and returns a structured `TOOL_TIMEOUT` result when that deadline wins. The budget is read from the tool's own declaration (`ToolDefinition.timeoutMs`, set by the owning tool plugin), so this plugin is **zero-config**. It is the reference `tools/execute` wrapper and the enforcement home for model-facing tool-call budgets (the timeout-library RFC's foreseen middleware).
## Plugin (namespace: `timeout-policy`)
A function/namespace plugin (`name` / `inject` / `apply`), not a service. It registers no tool and takes no config — it consumes `ctx.tools`'s `tools/execute` waterfall (which the `dsh-tools` registry always provides) and reads each dispatched tool's declared `timeoutMs` from the registry (`ctx.tools.get(exec.name)`).
```yaml
- id: timeout-policy
name: '@deepseek-ai/dsh-timeout-policy'
```
The per-tool budget is declared by the tool plugin (e.g. `dsh-tool-web`'s `fetchTimeoutMs`/`searchTimeoutMs` config, attached as `ToolDefinition.timeoutMs`); this plugin only enforces it, so a mistyped tool name is not possible.
### Behavior
For a tool that **declares a `timeoutMs`** the listener:
1. Reads the budget from the tool's own declaration in the registry (`ctx.tools.get(exec.name)?.timeoutMs`) and arms `deadline(exec.signal, timeoutMs, 'TOOL_TIMEOUT')` — one signal fusing the caller's abort with this plugin's timer (`@deepseek-ai/dsh-timeout`).
2. Swaps that derived signal onto `exec` for the downstream dispatch, then restores the caller's own signal afterward (cordis `next()` ignores passed arguments, so the wrapper mutates the shared `exec` in place; restoring keeps `tools/post-execute` seeing the caller's signal).
3. After dispatch, if `timeoutOf(d.signal, 'TOOL_TIMEOUT')` matches — this plugin's own timer fired — replaces the result with a structured `TOOL_TIMEOUT` tool result: `{ isError: true, error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, content: 'Error: tool call timed out after <ms>ms' }`.
A tool that **declares no budget** delegates untouched (no deadline).
The base `next()` of `tools/execute` is the registry's dispatch-with-normalization thunk, so when the timeout signal reaches a provider that throws its own upstream-abort error, dispatch first turns it into a normal error result, and this wrapper then replaces that with `TOOL_TIMEOUT`. That ordering is why the replacement is keyed off the signal (`timeoutOf`), not off the dispatched result's shape.
### Cooperative, not a hard kill
The derived signal only **notifies**; termination stays with the tool and the capability it forwards `exec.signal` to (the `dsh-timeout` library owns no kill). **Declaring `timeoutMs` therefore means "cooperative with `exec.signal`"**: a tool that ignores the signal will not stop on timeout. Only signal-forwarding tools should declare it — the shipped `web_fetch`/`web_search` (which forward through `ctx.web` to providers) are the reference. `TOOL_TIMEOUT` needs no session event for reconstructability: it is the final model-facing `tool/result`, already logged by the loop.
### Composing with other `tools/execute` wrappers
Multiple `tools/execute` listeners compose by cordis registration order. Combined with a future retry/sandbox/metrics wrapper, registration order chooses the semantics — "timeout covers the whole retry operation" (timeout registered outer) versus "timeout covers each attempt" (timeout registered inner).

View File

@@ -0,0 +1,36 @@
{
"name": "@deepseek-ai/dsh-timeout-policy",
"description": "Tool-call timeout policy: a tools/execute wrapper that arms a per-tool deadline on exec.signal and returns TOOL_TIMEOUT when it wins",
"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-llm": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,115 @@
/**
* `@deepseek-ai/dsh-timeout-policy`: the tool-call timeout ENFORCER. It registers
* ONE `tools/execute` around-dispatch listener that, for a tool declaring a
* `timeoutMs` on its {@link ToolDefinition}, arms a per-call deadline on
* `exec.signal` and returns a structured `TOOL_TIMEOUT` result when that deadline
* wins. The budget is DECLARED by the tool (see `ToolDefinition.timeoutMs`, set
* by the owning tool plugin from its own config); this plugin only enforces it,
* so it is zero-config and there is no tool-name map to mistype.
*
* This is a COOPERATIVE deadline, not a hard kill: the derived signal only
* NOTIFIES. A tool that declares `timeoutMs` (and the capability it forwards
* `exec.signal` to) must honor that signal and reach quiescence — the plugin
* never races the tool promise or terminates work itself (see the timeout-library
* RFC's rejection of `Promise.race`). Declaring `timeoutMs` therefore MEANS "this
* tool is cooperative with `exec.signal`": a tool that ignores the signal will
* not stop on timeout, so only signal-forwarding tools should declare it (the
* shipped web tools are the reference).
*
* Ownership of the `TOOL_TIMEOUT` code is entirely here: it is both the internal
* {@link deadline} code (so {@link timeoutOf} scopes the classification to THIS
* plugin's own timer, reading a foreign/nested outer deadline as an ordinary
* cancel) and the structured `{ name, code }` on the replacement tool result.
* No new session event is needed for reconstructability: the `TOOL_TIMEOUT`
* result IS the final model-facing `tool/result`, already logged by the loop.
*
* Why a `tools/execute` around seam and not a `pre`/`post` pair: the deadline
* needs ONE lexical scope — arm on `exec.signal`, delegate to dispatch, classify
* the result, dispose the timer — which the around seam gives directly. A
* pre/post split would spread one deadline's lifetime across two independent
* waterfalls (a call-id map, cleanup on every deny/throw/dispose path).
*
* @module @deepseek-ai/dsh-timeout-policy
*/
import type { Context } from 'cordis'
import type { CallId } from '@deepseek-ai/dsh-llm'
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
/**
* The code owned by this plugin, used BOTH as the internal {@link deadline}
* classification code AND as the structured error `code` on the replacement
* tool result. Scoping {@link timeoutOf} to it keeps a nested outer deadline
* (another `tools/execute` wrapper's timer that fired first) from being misread
* as this plugin's own timeout — it reads as an ordinary upstream cancel.
*/
export const TOOL_TIMEOUT = 'TOOL_TIMEOUT'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'timeout-policy'
/** The tool registry seam this plugin wraps (`tools/execute`) and reads (`get`). */
export const inject = ['tools']
/**
* The structured result substituted when this plugin's deadline wins. `content`
* is the model-facing message; `error.code` is the same {@link TOOL_TIMEOUT}
* this plugin owns, so a retry/sandbox plugin (and replay) can route on it.
*
* @param callId - the timed-out call's id, carried onto the replacement result.
* @param timeoutMs - the elapsed budget, rendered into the model-facing message.
* @returns the `isError` {@link ToolExecutionResult} with a `TOOL_TIMEOUT` error.
*/
export function toolTimeoutResult(callId: CallId, timeoutMs: number): ToolExecutionResult {
return {
callId,
content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }],
isError: true,
error: { name: 'ToolTimeoutError', code: TOOL_TIMEOUT },
}
}
/**
* Register the tool-call timeout enforcer. For a tool whose {@link ToolDefinition}
* declares `timeoutMs`, the listener arms a {@link deadline} on the caller's
* `exec.signal`, swaps it onto `exec` for the downstream dispatch (cordis
* `next()` ignores passed arguments, so a wrapper mutates the shared `exec` in
* place), restores the original signal afterward so `tools/post-execute` sees the
* caller's own signal, and replaces the result with {@link toolTimeoutResult}
* when its own timer fired. A tool that declares no budget delegates untouched.
*
* The budget source is the tool's own declaration read from the registry
* (`ctx.tools.get(exec.name)?.timeoutMs`), NOT a plugin config map — `exec.name`
* is the tool being dispatched, so the lookup always resolves and there is no
* mistypable tool name and no unknown-name path to warn or throw about.
*/
export function apply(ctx: Context): void {
ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
const timeoutMs = ctx.tools.get(exec.name)?.timeoutMs
// A tool that declares no budget: no deadline, delegate unchanged.
if (timeoutMs === undefined) return next()
using d = deadline(exec.signal, timeoutMs, TOOL_TIMEOUT)
// Swap the derived deadline onto exec for dispatch, then restore the
// caller's own signal so post-execute listeners never see this plugin's
// (possibly already-aborted) timeout signal. `undefined` is not assignable to
// the optional `signal` under exactOptionalPropertyTypes, so branch on it.
const upstream = exec.signal
exec.signal = d.signal
try {
const result = await next()
// If OUR timer fired (scoped by code — a nested outer deadline reads as
// undefined here), the tool/capability saw the abort and reached
// quiescence; replace whatever it returned (its own abort result) with the
// structured TOOL_TIMEOUT the model sees.
if (timeoutOf(d.signal, TOOL_TIMEOUT) !== undefined) {
return toolTimeoutResult(exec.callId, timeoutMs)
}
return result
} finally {
if (upstream === undefined) delete exec.signal
else exec.signal = upstream
}
})
}

View File

@@ -0,0 +1,200 @@
/**
* Unit + real-load-path coverage for @deepseek-ai/dsh-timeout-policy. The
* timeout-wins cases drive the deadline under fake timers (deterministic — no
* wall-clock race) and use a COOPERATIVE tool that settles only when its
* `exec.signal` aborts, mirroring how a real capability forwards the signal and
* reaches quiescence.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type ToolExecution, type ToolExecutionResult, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
import { TOOL_TIMEOUT, toolTimeoutResult } from '@deepseek-ai/dsh-timeout-policy'
/** Mount the registry + the zero-config timeout-policy enforcer. */
async function setup() {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(timeoutPolicy)
return ctx
}
/** A cooperative tool that settles ONLY when its exec.signal aborts (returns text). */
const cooperativeTool = defineTool({
name: 'slow', description: 'stops when aborted', parameters: {}, timeoutMs: 100,
execute(_args, exec): Promise<{ type: 'text'; text: string }[]> {
const done = [{ type: 'text' as const, text: 'stopped cooperatively' }]
if (exec.signal?.aborted) return Promise.resolve(done)
return new Promise((resolve) => { exec.signal?.addEventListener('abort', () => { resolve(done) }) })
},
})
/** A cooperative tool that THROWS its own upstream-abort error when aborted (web-provider shape). */
const abortThrowingTool = defineTool({
name: 'aborter', description: 'throws WEB_ABORTED when aborted', parameters: {}, timeoutMs: 100,
execute(_args, exec): Promise<never> {
if (exec.signal?.aborted) return Promise.reject(new HarnessError('web fetch aborted', 'WEB_ABORTED'))
return new Promise((_resolve, reject) => { exec.signal?.addEventListener('abort', () => { reject(new HarnessError('web fetch aborted', 'WEB_ABORTED')) }) })
},
})
describe('timeout-policy delegation (unconfigured / fast)', () => {
it('delegates a tool with NO declared budget unchanged and does not touch exec.signal', async () => {
const ctx = await setup()
let seenSignal: AbortSignal | undefined
ctx.tools.register(defineTool({ name: 'probe', description: 'd', parameters: {},
async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
const upstream = new AbortController().signal
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
expect(result.isError).toBe(false)
expect(seenSignal).toBe(upstream)
})
it('a tool with a budget that returns fast keeps its own result (no timeout)', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} })
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false })
})
it('a budgeted tool receives the DERIVED deadline signal (not the caller signal) during dispatch', async () => {
const ctx = await setup()
let seenSignal: AbortSignal | undefined
ctx.tools.register(defineTool({ name: 'probe', description: 'd', parameters: {}, timeoutMs: 10_000,
async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
const upstream = new AbortController().signal
await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
expect(seenSignal).toBeDefined()
expect(seenSignal).not.toBe(upstream)
})
})
describe('timeout-policy signal restoration', () => {
it('restores the caller signal for post-execute after wrapping', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
let postSignal: AbortSignal | undefined | 'unset' = 'unset'
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { postSignal = exec.signal; return next() })
const upstream = new AbortController().signal
await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {}, signal: upstream })
expect(postSignal).toBe(upstream)
})
it('deletes exec.signal again when the caller passed none', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
let hadSignal: boolean | undefined
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { hadSignal = 'signal' in exec && exec.signal !== undefined; return next() })
await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} })
expect(hadSignal).toBe(false)
})
})
describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
beforeEach(() => { vi.useFakeTimers() })
afterEach(() => { vi.useRealTimers() })
it('replaces a cooperative tool result with TOOL_TIMEOUT when its own deadline fires', async () => {
const ctx = await setup()
ctx.tools.register(cooperativeTool)
const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'slow', arguments: {} })
await vi.advanceTimersByTimeAsync(150)
const result = await pending
expect(result).toEqual({
callId: CallId('c1'),
content: [{ type: 'text', text: 'Error: tool call timed out after 100ms' }],
isError: true,
error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
})
})
it('replaces a provider-owned abort ERROR result with TOOL_TIMEOUT when the signal was ours', async () => {
const ctx = await setup()
ctx.tools.register(abortThrowingTool)
const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'aborter', arguments: {} })
await vi.advanceTimersByTimeAsync(150)
const result = await pending
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' })
expect(result.content[0]).toMatchObject({ text: 'Error: tool call timed out after 100ms' })
})
it('does NOT replace when the caller aborts first (upstream cancel, not our timeout)', async () => {
const ctx = await setup()
ctx.tools.register(cooperativeTool)
const upstream = new AbortController()
const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'slow', arguments: {}, signal: upstream.signal })
upstream.abort('user cancelled')
await vi.advanceTimersByTimeAsync(0)
const result = await pending
expect(result.isError).toBe(false)
expect(result.content[0]).toMatchObject({ text: 'stopped cooperatively' })
})
})
describe('toolTimeoutResult', () => {
it('builds the structured TOOL_TIMEOUT result', () => {
expect(toolTimeoutResult(CallId('c9'), 250)).toEqual({
callId: CallId('c9'),
content: [{ type: 'text', text: 'Error: tool call timed out after 250ms' }],
isError: true,
error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
} satisfies ToolExecutionResult)
})
it('exposes the owned code constant', () => {
expect(TOOL_TIMEOUT).toBe('TOOL_TIMEOUT')
})
})
describe('timeout-policy disposal (HMR safety)', () => {
it('removes its tools/execute listener when the plugin fiber disposes', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
let seenSignal: AbortSignal | undefined
ctx.tools.register(defineTool({ name: 'probe', description: 'd', parameters: {}, timeoutMs: 10_000,
async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
const fiber = await ctx.plugin(timeoutPolicy)
const upstream = new AbortController().signal
await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
expect(seenSignal).not.toBe(upstream)
await fiber.dispose()
await ctx.tools.execute({ callId: CallId('c2'), name: 'probe', arguments: {}, signal: upstream })
expect(seenSignal).toBe(upstream)
})
})
describe('dsh-timeout-policy real-load-path guard', () => {
it('has no default export and keeps name/inject through unwrapExports', () => {
expect('default' in timeoutPolicy).toBe(false)
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(timeoutPolicy) as Record<string, unknown>
expect(unwrapped).toBe(timeoutPolicy)
expect(unwrapped.name).toBe('timeout-policy')
expect(unwrapped.inject).toEqual(['tools'])
expect(typeof unwrapped.apply).toBe('function')
})
it('boots over ctx.tools through the unwrapped module and wraps a budgeted tool', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 5_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(timeoutPolicy) as Parameters<Context['plugin']>[0]
const fiber = await ctx.plugin(unwrapped)
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} } satisfies ToolExecution)
expect(result.isError).toBe(false)
await fiber.dispose()
})
})

View File

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

View File

@@ -5,10 +5,14 @@ Integrations that expose the agent to an external editor or client. These are **
| Package | Role | ctx key |
|---|---|---|
| `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
| `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) |
| `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) |
| `app-boot/` | Shared boot glue for the two app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline UI is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own.
`user-interaction` and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. The seam remains provider-neutral (`ctx.userInteraction`), while the tool is the model-facing consumer and the app/bridge packages provide concrete providers.
`stdio-agent` and `acp-agent` are the two **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is the swappable backends plus one app entry plus any optional product tools. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention.

View File

@@ -11,8 +11,10 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it
| Plugin | Why |
|---|---|
| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating **no** agents (ACP `session/new` creates them on demand) |
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests |
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) |
| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC |
| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool |
| ~~`@deepseek-ai/dsh-tool-ask-user`~~ | **omitted by default** — ACP elicitation support is still client-dependent, so leaves must opt in deliberately |
| ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../acp/README.md)) |
| ~~`hmr`~~ | **omitted** — the editor owns the subprocess |

View File

@@ -36,6 +36,7 @@
"@deepseek-ai/dsh-acp": "^0.0.1",
"@deepseek-ai/dsh-agent-core": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.6",
"schemastery": "^3.17.0"
},
@@ -47,6 +48,7 @@
"@deepseek-ai/dsh-agent-core": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.6",
"schemastery": "^3.17.0"
}

View File

@@ -35,6 +35,7 @@ import z from 'schemastery'
import * as acp from '@deepseek-ai/dsh-acp'
import * as agentCore from '@deepseek-ai/dsh-agent-core'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
export const name = 'acp-agent'
@@ -79,6 +80,7 @@ export function apply(ctx: Context, config: Config): void {
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
})
ctx.plugin(UserInteractionService)
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
ctx.plugin(acp, { model: config.model })
}

View File

@@ -30,6 +30,8 @@ describe('dsh-acp-agent composition', () => {
expect(ctx.get('sessions')).toBeDefined()
expect(ctx.get('sessionPersistence')).toBeDefined()
expect(ctx.get('agentLoop')).toBeDefined()
expect(ctx.get('userInteraction')).toBeDefined()
expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined()
// No pre-created agents — ACP session/new creates them on demand.
expect(ctx.get('agents')!.list()).toHaveLength(0)
await ctx.fiber.dispose()

View File

@@ -26,6 +26,12 @@
{
"path": "../../core/agent-core"
},
{
"path": "../user-interaction"
},
{
"path": "../tool-ask-user"
},
{
"path": "../../session-persistence/session-persistence-jsonl"
}

View File

@@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline `
`apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface.
`inject: ['agents', 'sessions', 'sessionPersistence', 'tools']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`; `tools` lets a tool own how its calls render (`presentCall`/`presentResult`) — the bridge looks the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation).
`inject: ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`; `tools` lets a tool own how its calls render (`presentCall`/`presentResult`) — the bridge looks the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation). `userInteraction` lets agent-owned `ask_user_question` calls become ACP form elicitations routed to the owning session.
### Config
@@ -30,6 +30,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
| `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) |
| `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) |
| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (the render intent — a `card`-tagged `ToolCallView`/`ToolResultView` — owned by the TOOL via `presentCall`/`presentResult`, which the bridge switches on to build the wire shape — see Tool-call presentation) |
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |
## Multi-session

View File

@@ -47,7 +47,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
| `terminal/wait_for_exit` | S | ❌ | ❌ | ❌ | As above. |
| `terminal/kill` | S | ❌ | ❌ | ❌ | As above. |
| `terminal/release` | S | ❌ | ❌ | ❌ | As above. |
| `elicitation/create` · `elicitation/complete` | U | | ✅ | ⚠️ | Structured user-input forms. Claude calls the `unstable_*` elicitation methods (to surface MCP server elicitations); Codex does NOT — its `CodexElicitationHandler` maps elicitations onto `session/request_permission` instead. |
| `elicitation/create` · `elicitation/complete` | U | ⚠️ | ✅ | ⚠️ | The bridge drives `unstable_createElicitation` for `ask_user_question` form prompts (session-scoped, no URL-mode flow yet). Claude calls the `unstable_*` elicitation methods for MCP server elicitations; Codex maps elicitations onto `session/request_permission`. |
## 3. Capabilities
@@ -140,7 +140,7 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them
Ranked by how commonly the reference adapters ship them and how much UX they unlock:
1. **Permission gate**`session/request_permission` + permission options. Tracked `TODO(rfc010-permission-gate)`; the reverse map is already wired. Foundational, and a prerequisite for modes.
1. **Permission gate**`session/request_permission` + permission options. Tracked `TODO(rfc010-permission-gate)`; the reverse map is already wired and shared with `ask_user_question` routing. Foundational, and a prerequisite for modes.
2. **Session lifecycle**`session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`.
3. **Modes / config options / model selection** — coupled to the permission gate.
4. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries.

View File

@@ -32,6 +32,7 @@
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
@@ -49,6 +50,8 @@
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -47,6 +47,9 @@ import {
type AuthenticateRequest,
type CancelNotification,
type ContentBlock as AcpContentBlock,
type CreateElicitationRequest,
type ElicitationContentValue,
type EnumOption,
type InitializeRequest,
type InitializeResponse,
type LoadSessionRequest,
@@ -71,6 +74,14 @@ import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } f
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
// Context (the bridge injects it and reads `list()` for load cwd validation).
import type {} from '@deepseek-ai/dsh-session-persistence'
import {
UserInteractionError,
type AskUserQuestionAnswer,
type AskUserQuestionAnswerItem,
type AskUserQuestionItem,
type AskUserQuestionOption,
type AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
import {
acpPromptToText,
harnessBlockToAcpContent,
@@ -84,7 +95,7 @@ export const name = 'acp'
// because `initialize` advertises `loadSession: true`. `tools` lets a tool own
// how its calls render (`presentCall`/`presentResult`); the bridge looks up the
// definition by name and falls back to a generic presentation when absent.
export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools']
export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']
/**
* Build an ACP "invalid params" error whose human detail rides in the message.
@@ -111,6 +122,116 @@ function sameWorkspaceCwd(left: string, right: string): boolean {
return resolvePath(left) === resolvePath(right)
}
function optionDescription(option: AskUserQuestionOption): string {
return option.description === undefined
? option.label
: `${option.label}: ${option.description}`
}
function requireStringContent(
content: Record<string, ElicitationContentValue> | null | undefined,
key: string,
): string | undefined {
const value = content?.[key]
return typeof value === 'string' && value.trim().length > 0 ? value : undefined
}
function askAbortError(): UserInteractionError {
return new UserInteractionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED')
}
function withAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (signal === undefined) return promise
if (signal.aborted) return Promise.reject(askAbortError())
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
signal.removeEventListener('abort', onAbort)
reject(askAbortError())
}
signal.addEventListener('abort', onAbort, { once: true })
promise.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(error: unknown) => {
signal.removeEventListener('abort', onAbort)
reject(new Error(String(error), { cause: error }))
},
)
})
}
function elicitationForQuestion(
sessionId: SessionId,
question: AskUserQuestionItem,
options: AskUserQuestionOption[],
): CreateElicitationRequest {
const title = question.header ?? 'Question'
if (options.length === 0) {
return {
sessionId,
mode: 'form',
message: question.question,
requestedSchema: {
type: 'object',
title,
properties: {
custom: { type: 'string', title: question.question },
},
required: ['custom'],
},
}
}
const choiceOptions: EnumOption[] = options.map(option => ({
const: option.label,
title: optionDescription(option),
}))
const choice = question.multiSelect === true
? {
type: 'array' as const,
title: question.question,
description: 'Choose one or more options, or fill a custom answer below.',
items: {
anyOf: choiceOptions,
},
}
: {
type: 'string' as const,
title: question.question,
description: 'Choose one option, or fill a custom answer below.',
oneOf: choiceOptions,
}
return {
sessionId,
mode: 'form',
message: question.question,
requestedSchema: {
type: 'object',
title,
properties: {
choice,
custom: {
type: 'string',
title: 'Custom answer',
description: 'Optional free-form answer. Leave empty to use the selected option.',
},
},
required: [],
},
}
}
function stringArrayContent(
content: Record<string, ElicitationContentValue> | null | undefined,
key: string,
): string[] {
const value = content?.[key]
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string' && item.length > 0)
return typeof value === 'string' && value.length > 0 ? [value] : []
}
/** Plugin config: the agent template ACP sessions are created from. */
export interface AcpConfig {
/** Model name for created agents (must have a registered adapter). */
@@ -211,6 +332,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
const sessionPersistence = ctx.sessionPersistence
const logger = ctx.logger
const tools = ctx.tools
const userInteraction = ctx.userInteraction
// A new ToolPresenter per session (and a throwaway per load replay), each given
// this warn sink so a throwing tool presenter is logged, not propagated.
const makePresenter = (): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) })
@@ -241,6 +363,42 @@ export function apply(ctx: Context, config: AcpConfig): void {
// `notify` never observes it unset — no undefined guard needed.
let conn: AgentSideConnection
userInteraction.registerProvider({
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
if (request.agent === undefined) {
throw new UserInteractionError('ACP user questions must come from an agent-owned request', 'NO_AGENT')
}
const sessionId = bySession.get(request.agent)
if (sessionId === undefined) {
throw new UserInteractionError('ACP user question has no matching session', 'NO_SESSION')
}
const answers: AskUserQuestionAnswerItem[] = []
for (const question of request.questions) {
const options = question.options ?? []
const response = await withAbort(conn.unstable_createElicitation(
elicitationForQuestion(sessionId, question, options),
), request.signal).catch((error: unknown) => {
if (error instanceof UserInteractionError) throw error
throw new UserInteractionError('ACP elicitation request failed', 'ASK_FAILED', { cause: error })
})
if (response.action !== 'accept') {
throw new UserInteractionError('ask_user_question was cancelled by the user', 'ASK_CANCELLED')
}
const custom = requireStringContent(response.content, 'custom')
const selected = stringArrayContent(response.content, 'choice')
if (custom === undefined && selected.length === 0) {
throw new UserInteractionError('ask_user_question returned no answer', 'NO_ANSWER')
}
answers.push({
id: question.id,
selected: custom === undefined ? selected : [],
...custom !== undefined ? { custom } : {},
})
}
return { answers }
},
})
/**
* Reject any RPC after the bridge has torn down. The `AgentSideConnection`
* receive loop can outlive the plugin fiber — under an ACP-only HMR reload the

View File

@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness } from './harness.ts'
/**
* End-to-end bridge specs over an in-memory transport: a real
@@ -53,6 +53,209 @@ describe('acp bridge', () => {
expect(text).toBe('hello there')
})
it('routes ask_user_question through ACP form elicitation and continues with the selected option', async () => {
harness = await makeBridgeHarness({
storageDir,
withAskUser: true,
script: [
toolCallResponse('ask-1', 'ask_user_question', {
questions: [{
id: 'language',
header: 'Project config',
question: 'Which language should I use?',
options: [
{ label: 'TypeScript', description: 'Good for UI apps' },
{ label: 'Python', description: 'Good for scripts' },
],
}],
}),
textResponse('Python it is.'),
],
})
harness.onElicitation = () => ({ action: 'accept', content: { choice: 'Python' } })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'ask me' }] })
expect(result.stopReason).toBe('end_turn')
expect(harness.elicitationRequests).toHaveLength(1)
expect(harness.elicitationRequests[0]).toMatchObject({
sessionId,
mode: 'form',
message: 'Which language should I use?',
requestedSchema: {
title: 'Project config',
properties: {
choice: {
oneOf: [
{ const: 'TypeScript', title: 'TypeScript: Good for UI apps' },
{ const: 'Python', title: 'Python: Good for scripts' },
],
},
custom: { type: 'string' },
},
required: [],
},
})
const toolResult = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'tool/result')
const toolResultBlock = toolResult?.type === 'tool/result' ? toolResult.data.content[0] : undefined
const toolResultText = toolResultBlock?.type === 'text' ? toolResultBlock.text : undefined
expect(toolResultText).toBe('{"answers":[{"id":"language","selected":["Python"]}]}')
})
it('routes optionless ask_user_question through an ACP free-form answer field', async () => {
harness = await makeBridgeHarness({
storageDir,
withAskUser: true,
script: [
toolCallResponse('ask-1', 'ask_user_question', {
questions: [{ id: 'name', question: 'What should I name it?' }],
}),
textResponse('Name recorded.'),
],
})
harness.onElicitation = () => ({ action: 'accept', content: { custom: 'apollo' } })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'ask me' }] })
expect(harness.elicitationRequests[0]).toMatchObject({
requestedSchema: {
properties: { custom: { type: 'string', title: 'What should I name it?' } },
required: ['custom'],
},
})
const toolResult = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'tool/result')
expect(JSON.stringify(toolResult)).toContain('apollo')
})
it('supports ACP custom answers alongside choices', async () => {
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
harness.onElicitation = () => ({ action: 'accept', content: { custom: 'Use Zig' } })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(AgentId(sessionId))!
const result = await harness.ctx.userInteraction.ask({
agent,
questions: [{
id: 'language',
question: 'Which language?',
options: [{ label: 'TypeScript' }],
}],
})
expect(result).toEqual({ answers: [{ id: 'language', selected: [], custom: 'Use Zig' }] })
expect(harness.elicitationRequests[0]).toMatchObject({
requestedSchema: {
properties: {
choice: {
description: 'Choose one option, or fill a custom answer below.',
oneOf: [{ const: 'TypeScript', title: 'TypeScript' }],
},
custom: { type: 'string' },
},
required: [],
},
})
})
it('treats ACP custom answers as overriding selected choices', async () => {
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
harness.onElicitation = () => ({ action: 'accept', content: { choice: 'TypeScript', custom: 'Use Zig' } })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(AgentId(sessionId))!
await expect(harness.ctx.userInteraction.ask({
agent,
questions: [{
id: 'language',
question: 'Which language?',
options: [{ label: 'TypeScript' }],
}],
})).resolves.toEqual({ answers: [{ id: 'language', selected: [], custom: 'Use Zig' }] })
})
it('supports ACP multi-select answers', async () => {
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
harness.onElicitation = () => ({ action: 'accept', content: { choice: ['Tests', 'Docs'] } })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(AgentId(sessionId))!
await expect(harness.ctx.userInteraction.ask({
agent,
questions: [{
id: 'targets',
question: 'Pick',
options: [{ label: 'Tests' }, { label: 'Docs' }],
multiSelect: true,
}],
})).resolves.toEqual({ answers: [{ id: 'targets', selected: ['Tests', 'Docs'] }] })
})
it('reports ACP ask-user routing and answer failures as structured errors', async () => {
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(AgentId(sessionId))!
await expect(harness.ctx.userInteraction.ask({ questions: [{ id: 'x', question: 'No agent?' }] }))
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_AGENT' })
await expect(harness.ctx.userInteraction.ask({ agent: { id: 'other' } as typeof agent, questions: [{ id: 'x', question: 'No session?' }] }))
.rejects.toMatchObject({ code: 'NO_SESSION' })
harness.onElicitation = () => ({ action: 'cancel' })
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Cancel?' }] }))
.rejects.toMatchObject({ code: 'ASK_CANCELLED' })
harness.onElicitation = () => ({ action: 'accept', content: {} })
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Empty?' }] }))
.rejects.toMatchObject({ code: 'NO_ANSWER' })
harness.onElicitation = () => { throw new Error('client boom') }
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Client fails?' }], signal: new AbortController().signal }))
.rejects.toMatchObject({ code: 'ASK_FAILED' })
})
it('aborts ACP ask-user requests before and while waiting for elicitation', async () => {
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(AgentId(sessionId))!
const alreadyAborted = new AbortController()
alreadyAborted.abort()
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Already?' }], signal: alreadyAborted.signal }))
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
let abortedReads = 0
const racingAbort = {
get aborted() { return abortedReads++ > 0 },
addEventListener() {},
removeEventListener() {},
dispatchEvent() { return false },
onabort: null,
reason: undefined,
throwIfAborted() {},
} as AbortSignal
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Raced?' }], signal: racingAbort }))
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
let release: ((value: { action: 'accept'; content: { custom: string } }) => void) | undefined
harness.onElicitation = () => new Promise((resolve) => { release = resolve })
const pendingAbort = new AbortController()
const ask = harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Pending?' }], signal: pendingAbort.signal })
await new Promise(resolve => setImmediate(resolve))
pendingAbort.abort()
await expect(ask).rejects.toMatchObject({ code: 'ASK_ABORTED' })
release?.({ action: 'accept', content: { custom: 'too late' } })
})
it('allows multiple concurrent sessions, each with a distinct id', async () => {
harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })

View File

@@ -29,11 +29,15 @@ import {
ndJsonStream,
type Agent as AcpAgent,
type Client,
type CreateElicitationRequest,
type CreateElicitationResponse,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
type Stream,
} from '@agentclientprotocol/sdk'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as AcpPlugin from '../src/index.ts'
import { type AcpConfig } from '../src/index.ts'
@@ -121,6 +125,10 @@ export interface BridgeHarness {
permissionRequests: RequestPermissionRequest[]
/** Decide each permission request's outcome (default: cancelled). */
onPermission: (req: RequestPermissionRequest) => RequestPermissionResponse
/** Elicitation requests the bridge issued for ask_user_question. */
elicitationRequests: CreateElicitationRequest[]
/** Decide each elicitation response (default: cancel). */
onElicitation: (req: CreateElicitationRequest) => CreateElicitationResponse | Promise<CreateElicitationResponse>
/** If set, the client's sessionUpdate throws this (tests notify error path). */
onSessionUpdateError: (() => void) | undefined
/**
@@ -164,6 +172,8 @@ export async function makeBridgeHarness(options: {
* implementation over a mock in tests").
*/
withBash?: boolean
/** Plug the REAL `ask_user_question` tool and ACP user-interaction provider. */
withAskUser?: boolean
/**
* Plug the REAL `dsh-tool-todo` tool so a test can drive `todo_write` through
* the bridge and assert the resulting `plan` sessionUpdate — the shipping
@@ -190,6 +200,10 @@ export async function makeBridgeHarness(options: {
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir })
await ctx.plugin(UserInteractionService)
if (options.withAskUser) {
await ctx.plugin(ToolAskUser)
}
if (options.withBash) {
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(ToolBash)
@@ -226,6 +240,7 @@ export async function makeBridgeHarness(options: {
const updates: CapturedUpdate[] = []
const sessionUpdates: { sessionId: string; update: CapturedUpdate }[] = []
const permissionRequests: RequestPermissionRequest[] = []
const elicitationRequests: CreateElicitationRequest[] = []
const harness: BridgeHarness = {
ctx,
adapter,
@@ -233,6 +248,8 @@ export async function makeBridgeHarness(options: {
sessionUpdates,
permissionRequests,
onPermission: () => ({ outcome: { outcome: 'cancelled' } }),
elicitationRequests,
onElicitation: () => ({ action: 'cancel' }),
onSessionUpdateError: undefined,
client: undefined as unknown as ClientSideConnection,
acpFiber: undefined as unknown as BridgeHarness['acpFiber'],
@@ -258,6 +275,10 @@ export async function makeBridgeHarness(options: {
permissionRequests.push(params)
return Promise.resolve(harness.onPermission(params))
},
unstable_createElicitation(params: CreateElicitationRequest): Promise<CreateElicitationResponse> {
elicitationRequests.push(params)
return Promise.resolve(harness.onElicitation(params))
},
})
// Wire the bridge (agent side) and the client (test side). The test config

View File

@@ -29,6 +29,9 @@
{
"path": "../../core/tools"
},
{
"path": "../user-interaction"
},
{
"path": "../../session-persistence/session-persistence"
}

View File

@@ -13,6 +13,8 @@ A terminal chat always wants the same cluster, so the package owns it rather tha
| `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) |
| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model` and carrying its `persona` |
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools |
| `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool |
| `stdio-chat` (in-package module) | the readline UI, bound to the `main` agent |
`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`.

View File

@@ -39,6 +39,8 @@
"@deepseek-ai/dsh-agent-core": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-tool-ask-user": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.6",
"schemastery": "^3.17.0"
},
@@ -53,6 +55,8 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.6",
"schemastery": "^3.17.0"
}

View File

@@ -45,6 +45,8 @@ import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as agentCore from '@deepseek-ai/dsh-agent-core'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as uiStdio from './stdio-chat.ts'
export const name = 'stdio-agent'
@@ -107,5 +109,7 @@ export function apply(ctx: Context, config: Config): void {
}],
})
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
ctx.plugin(UserInteractionService)
ctx.plugin(toolAskUser)
ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' })
}

View File

@@ -20,9 +20,17 @@ import type { Readable, Writable } from 'node:stream'
import type { Context } from 'cordis'
import z from 'schemastery'
import { AgentId } from '@deepseek-ai/dsh-agent'
import {
UserInteractionError,
type AskUserQuestionAnswer,
type AskUserQuestionAnswerItem,
type AskUserQuestionItem,
type AskUserQuestionOption,
type AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
export const name = 'ui-stdio'
export const inject = ['agents']
export const inject = ['agents', 'userInteraction']
/** Serializable plugin configuration (cordis-native, schemastery). */
export interface Config {
@@ -57,6 +65,20 @@ function isTTYPair(input: Readable, output: Writable): boolean {
return Boolean((input as { isTTY?: boolean }).isTTY && (output as { isTTY?: boolean }).isTTY)
}
interface PendingQuestion {
request: AskUserQuestionRequest
questionIndex: number
answers: AskUserQuestionAnswerItem[]
resolve(answer: AskUserQuestionAnswer): void
reject(error: unknown): void
onAbort: () => void
}
type OptionSelection =
| { kind: 'selected'; options: AskUserQuestionOption[] }
| { kind: 'custom' }
| { kind: 'invalid' }
/**
* The plugin body, parameterized over its I/O runtime. `apply` is the thin
* production wrapper that binds the real `process` streams; tests call this
@@ -154,6 +176,8 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
let submittedWork = false
let sawRunning = false
let exitTimer: ReturnType<typeof setTimeout> | undefined
let activeQuestion: PendingQuestion | undefined
const questionQueue: PendingQuestion[] = []
const maybeExit = (): void => {
if (disposed || !stdinClosed) return
@@ -180,7 +204,152 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
if (status === 'idle') maybeExit()
})
const activeQuestionItem = (pending: PendingQuestion): AskUserQuestionItem =>
pending.request.questions[pending.questionIndex] as AskUserQuestionItem
const renderQuestion = (pending: PendingQuestion): void => {
const question = activeQuestionItem(pending)
const options = question.options ?? []
output.write('\n')
output.write(question.header ? `[${question.header}] ${question.question}\n` : `${question.question}\n`)
options.forEach((option, index) => {
output.write(` ${index + 1}. ${option.label}\n`)
if (option.description) output.write(` ${option.description}\n`)
})
output.write('> ')
}
const removeAbortListener = (pending: PendingQuestion): void => {
pending.request.signal?.removeEventListener('abort', pending.onAbort)
}
const startNextQuestion = (): void => {
if (activeQuestion !== undefined) return
const pending = questionQueue.shift()
if (pending === undefined) return
// The queue never contains an aborted pending ask: the seam rejects an
// already-aborted request synchronously, and queued asks attach their
// abort listener before enqueueing.
activeQuestion = pending
renderQuestion(pending)
}
const disposeQuestion = (pending: PendingQuestion): void => {
removeAbortListener(pending)
pending.reject(new UserInteractionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED'))
}
const disposePendingQuestions = (): void => {
if (activeQuestion !== undefined) {
disposeQuestion(activeQuestion)
activeQuestion = undefined
}
for (const pending of questionQueue.splice(0)) {
disposeQuestion(pending)
}
}
const finishQuestion = (pending: PendingQuestion): void => {
activeQuestion = undefined
removeAbortListener(pending)
pending.resolve({ answers: pending.answers })
output.write('\n')
startNextQuestion()
}
const answerCurrentQuestion = (pending: PendingQuestion, answer: AskUserQuestionAnswerItem): void => {
pending.answers.push(answer)
pending.questionIndex += 1
if (pending.questionIndex >= pending.request.questions.length) {
finishQuestion(pending)
return
}
renderQuestion(pending)
}
const selectedOptions = (text: string, options: AskUserQuestionOption[], multiSelect: boolean): OptionSelection => {
if (text === '') return { kind: 'invalid' }
if (!multiSelect) {
if (!/^\d+$/.test(text)) return { kind: 'custom' }
const selected = options[Number(text) - 1]
return selected === undefined ? { kind: 'invalid' } : { kind: 'selected', options: [selected] }
}
const indices = text.split(/[,\s]+/).filter(Boolean)
if (indices.length === 0) return { kind: 'invalid' }
if (indices.some(part => !/^\d+$/.test(part))) return { kind: 'custom' }
const uniqueIndices = [...new Set(indices)]
const selected = uniqueIndices.map(part => options[Number(part) - 1])
return selected.some(option => option === undefined)
? { kind: 'invalid' }
: { kind: 'selected', options: selected as AskUserQuestionOption[] }
}
const answerQuestion = (line: string): void => {
const pending = activeQuestion as PendingQuestion
const question = activeQuestionItem(pending)
const text = line.trim()
const options = question.options ?? []
const selection = options.length > 0
? selectedOptions(text, options, question.multiSelect ?? false)
: { kind: text === '' ? 'invalid' : 'custom' } as OptionSelection
if (selection.kind === 'selected') {
answerCurrentQuestion(pending, { id: question.id, selected: selection.options.map(option => option.label) })
return
}
if (selection.kind === 'custom' && text !== '') {
answerCurrentQuestion(pending, { id: question.id, selected: [], custom: text })
return
}
output.write(options.length > 0
? 'Please enter one of the option numbers'
+ (question.multiSelect ? ' (comma or space separated)' : '')
+ ' or a custom answer'
+ '.\n> '
: 'Please enter an answer.\n> ')
}
const disposeUserInteractionProvider = ctx.userInteraction.registerProvider({
ask(request) {
if (disposed || stdinClosed) {
return Promise.reject(
new UserInteractionError('ask_user_question cannot be answered because stdin is closed', 'ASK_ABORTED'),
)
}
return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
const pending: PendingQuestion = {
request,
questionIndex: 0,
answers: [],
resolve,
reject,
onAbort: () => {
if (activeQuestion === pending) {
activeQuestion = undefined
disposeQuestion(pending)
startNextQuestion()
return
}
// If it is not active, this listener can only fire while the ask
// remains queued; settled asks remove the listener first.
questionQueue.splice(questionQueue.indexOf(pending), 1)
disposeQuestion(pending)
},
}
request.signal?.addEventListener('abort', pending.onAbort, { once: true })
questionQueue.push(pending)
startNextQuestion()
})
},
})
reader.on('line', (line) => {
if (activeQuestion !== undefined) {
answerQuestion(line)
return
}
const text = line.trim()
if (!text) return
const agent = ctx.agents.get(agentId)
@@ -199,12 +368,15 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
// Fires for BOTH stdin EOF and plugin disposal (reader.close() below);
// `disposed` guards teardown so HMR/dispose never exits the process.
stdinClosed = true
if (!disposed) disposePendingQuestions()
maybeExit()
})
output.write(`${welcome}\n> `)
return () => {
disposed = true
if (exitTimer !== undefined) clearTimeout(exitTimer)
disposePendingQuestions()
disposeUserInteractionProvider()
disposeStatusListener()
reader.close()
}

View File

@@ -19,6 +19,7 @@ function fakeContext(): Context {
// The UI seeds its label map from the registry at install; this suite only
// exercises readline terminal-mode selection, so an empty roster suffices.
agents: { list: vi.fn(() => []) },
userInteraction: { registerProvider: vi.fn(() => vi.fn()) },
} as unknown as Context
}

View File

@@ -37,6 +37,8 @@ describe('dsh-stdio-agent app', () => {
expect(ctx.get('agents')).toBeDefined()
expect(ctx.get('agentLoop')).toBeDefined()
expect(ctx.get('sessionPersistence')).toBeDefined()
expect(ctx.get('userInteraction')).toBeDefined()
expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined()
// The pre-created `main` agent the UI drives.
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
await ctx.fiber.dispose()
@@ -92,7 +94,7 @@ describe('dsh-stdio-agent app', () => {
})
}
const assembly = await ctx.get('systemPrompt')!.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha'])
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'ask_user_question'])
await ctx.fiber.dispose()
})

View File

@@ -1,10 +1,11 @@
import { Readable } from 'node:stream'
import { Readable, Writable } from 'node:stream'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { createStdioChat, type Config, type StdioRuntime } from '../src/stdio-chat.ts'
/**
@@ -79,10 +80,11 @@ const CONFIG: Config = { welcome: 'hi there', agent: 'main' }
async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const { runtime, input, out, exit } = makeRuntime(runtimeOver)
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
createStdioChat(inner, config, runtime)
}, { inject: ['agents'] }))
}, { inject: ['agents', 'userInteraction'] }))
return { ctx, fiber, input, out, exit }
}
@@ -105,6 +107,30 @@ describe('createStdioChat rendering', () => {
// And it drives the default agent id 'main'.
})
it('detects readline terminal mode from both stream TTY flags', async () => {
for (const [inputTTY, outputTTY] of [[true, false], [true, true]] as const) {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
let text = ''
const output = new Writable({
write(chunk, _encoding, callback) {
text += String(chunk)
callback()
},
}) as Writable & { isTTY?: boolean }
const { runtime } = makeRuntime({ output })
;(runtime.input as Readable & { isTTY?: boolean }).isTTY = inputTTY
output.isTTY = outputTTY
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
createStdioChat(inner, CONFIG, runtime)
}, { inject: ['agents', 'userInteraction'] }))
expect(text).toContain('hi there')
await fiber.dispose()
}
})
it('renders text-delta chunks verbatim', async () => {
const { ctx, out } = await setup()
ctx.emit('session/event', makeSession('main'), chunkEvent({ type: 'text-delta', index: 0, text: 'hello' }))
@@ -160,12 +186,13 @@ describe('createStdioChat rendering', () => {
// of the raw session id.
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const agent = makeAgent('main')
ctx.agents.register(agent) // registered BEFORE the UI plugin below
const { runtime, out } = makeRuntime()
await ctx.plugin(Object.assign((inner: Context) => {
createStdioChat(inner, CONFIG, runtime)
}, { inject: ['agents'] }))
}, { inject: ['agents', 'userInteraction'] }))
ctx.emit('session/event', makeSession('main'), {
type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } },
} as SessionEvent)
@@ -264,6 +291,347 @@ describe('createStdioChat rendering', () => {
})
describe('createStdioChat input', () => {
it('answers a pending user question instead of sending the line to the agent', async () => {
const { ctx, input, out } = await setup()
const agent = makeAgent('main', 'idle')
ctx.agents.register(agent)
const answer = ctx.userInteraction.ask({
questions: [{
id: 'confirm',
header: 'Confirm',
question: 'Proceed with the edit?',
options: [{ label: 'Yes', description: 'Apply the edit now.' }],
}],
})
await new Promise(r => setImmediate(r))
input.feed('Use a smaller change')
await expect(answer).resolves.toEqual({ answers: [{ id: 'confirm', selected: [], custom: 'Use a smaller change' }] })
expect(agent.sent).toEqual([])
expect(out.text()).toContain('[Confirm] Proceed with the edit?')
expect(out.text()).toContain('1. Yes')
expect(out.text()).toContain('Apply the edit now.')
})
it('answers a pending user question by numeric option selection', async () => {
const { ctx, input } = await setup()
const answer = ctx.userInteraction.ask({
questions: [{
id: 'mode',
question: 'Which mode?',
options: [
{ label: 'Safe' },
{ label: 'Fast' },
],
}],
})
await new Promise(r => setImmediate(r))
input.feed('2')
await expect(answer).resolves.toEqual({
answers: [{ id: 'mode', selected: ['Fast'] }],
})
})
it('renders options in input order and selects by displayed number', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({
questions: [{
id: 'topic',
question: 'Which topic?',
options: [
{ label: 'Hobbies' },
{ label: 'Work', description: 'Questions about current projects.' },
{ label: 'Casual', description: 'Easy conversation.' },
],
}],
})
await new Promise(r => setImmediate(r))
expect(out.text()).toContain([
'Which topic?',
' 1. Hobbies',
' 2. Work',
' Questions about current projects.',
' 3. Casual',
' Easy conversation.',
].join('\n'))
input.feed('3')
await expect(answer).resolves.toEqual({
answers: [{ id: 'topic', selected: ['Casual'] }],
})
})
it('answers a multi-select question with multiple numeric selections', async () => {
const { ctx, input } = await setup()
const answer = ctx.userInteraction.ask({
questions: [{
id: 'targets',
question: 'What should I update?',
options: [{ label: 'Tests' }, { label: 'Docs' }, { label: 'Code' }],
multiSelect: true,
}],
})
await new Promise(r => setImmediate(r))
input.feed('1 1, 3')
await expect(answer).resolves.toEqual({
answers: [{ id: 'targets', selected: ['Tests', 'Code'] }],
})
})
it('accepts non-numeric multi-select input as a custom answer', async () => {
const { ctx, input } = await setup()
const answer = ctx.userInteraction.ask({
questions: [{
id: 'targets',
question: 'What should I update?',
options: [{ label: 'Tests' }, { label: 'Docs' }],
multiSelect: true,
}],
})
await new Promise(r => setImmediate(r))
input.feed('the release notes')
await expect(answer).resolves.toEqual({
answers: [{ id: 'targets', selected: [], custom: 'the release notes' }],
})
})
it('asks every question in a batch and returns answers by id', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({
questions: [
{ id: 'language', question: 'Which language?', options: [{ label: 'Python' }, { label: 'TypeScript' }] },
{ id: 'note', question: 'Any note?' },
],
})
await new Promise(r => setImmediate(r))
input.feed('2')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('\nAny note?\n')
input.feed('ship today')
await expect(answer).resolves.toEqual({
answers: [
{ id: 'language', selected: ['TypeScript'] },
{ id: 'note', selected: [], custom: 'ship today' },
],
})
})
it('re-prompts when option input is invalid', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({
questions: [{
id: 'mode',
question: 'Which mode?',
options: [{ label: 'Safe' }],
multiSelect: true,
}],
})
await new Promise(r => setImmediate(r))
input.feed('2')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('Please enter one of the option numbers (comma or space separated) or a custom answer.')
input.feed('1')
await expect(answer).resolves.toEqual({
answers: [{ id: 'mode', selected: ['Safe'] }],
})
})
it('re-prompts when single-select option input is out of range', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({
questions: [{
id: 'mode',
question: 'Which mode?',
options: [{ label: 'Safe' }],
}],
})
await new Promise(r => setImmediate(r))
input.feed('2')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('Please enter one of the option numbers or a custom answer.')
input.feed('1')
await expect(answer).resolves.toEqual({
answers: [{ id: 'mode', selected: ['Safe'] }],
})
})
it('re-prompts when multi-select input contains no option numbers', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({
questions: [{
id: 'mode',
question: 'Which mode?',
options: [{ label: 'Safe' }],
multiSelect: true,
}],
})
await new Promise(r => setImmediate(r))
input.feed(',')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('Please enter one of the option numbers (comma or space separated) or a custom answer.')
input.feed('1')
await expect(answer).resolves.toEqual({
answers: [{ id: 'mode', selected: ['Safe'] }],
})
})
it('re-prompts when an option question receives an empty answer', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({
questions: [{
id: 'mode',
question: 'Which mode?',
options: [{ label: 'Safe' }],
}],
})
await new Promise(r => setImmediate(r))
input.feed('')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('Please enter one of the option numbers or a custom answer.')
input.feed('1')
await expect(answer).resolves.toEqual({
answers: [{ id: 'mode', selected: ['Safe'] }],
})
})
it('re-prompts when a question receives an empty answer', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({ questions: [{ id: 'path', question: 'What should I use?' }] })
await new Promise(r => setImmediate(r))
input.feed('')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('Please enter an answer.')
input.feed('Use defaults')
await expect(answer).resolves.toEqual({ answers: [{ id: 'path', selected: [], custom: 'Use defaults' }] })
})
it('rejects an active question when its signal aborts', async () => {
const { ctx } = await setup()
const controller = new AbortController()
const answer = ctx.userInteraction.ask({ questions: [{ id: 'continue', question: 'Continue?' }], signal: controller.signal })
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await new Promise(r => setImmediate(r))
controller.abort()
await rejected
})
it('continues to the next queued question when the active question aborts', async () => {
const { ctx, input, out } = await setup()
const controller = new AbortController()
const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }], signal: controller.signal })
const firstRejected = expect(first).rejects.toMatchObject({ code: 'ASK_ABORTED' })
const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }] })
await new Promise(r => setImmediate(r))
controller.abort()
await firstRejected
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('\nSecond?\n')
input.feed('second answer')
await expect(second).resolves.toEqual({ answers: [{ id: 'second', selected: [], custom: 'second answer' }] })
})
it('skips a queued question whose signal aborted before it became active', async () => {
const { ctx, input, out } = await setup()
const controller = new AbortController()
const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }] })
const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }], signal: controller.signal })
await new Promise(r => setImmediate(r))
controller.abort()
await expect(Promise.race([
second.then(
() => 'resolved',
(error: unknown) => (error as { code?: string }).code,
),
new Promise<string>((resolve) => { setImmediate(() => { resolve('pending') }) }),
])).resolves.toBe('ASK_ABORTED')
expect(out.text()).not.toContain('\nSecond?\n')
input.feed('first answer')
await expect(first).resolves.toEqual({ answers: [{ id: 'first', selected: [], custom: 'first answer' }] })
})
it('removes an aborted queued question without promoting later queued work early', async () => {
const { ctx, input, out } = await setup()
const controller = new AbortController()
const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }] })
const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }], signal: controller.signal })
const third = ctx.userInteraction.ask({ questions: [{ id: 'third', question: 'Third?' }] })
await new Promise(r => setImmediate(r))
controller.abort()
await expect(second).rejects.toMatchObject({ code: 'ASK_ABORTED' })
expect(out.text()).toContain('\nFirst?\n')
expect(out.text()).not.toContain('\nSecond?\n')
expect(out.text()).not.toContain('\nThird?\n')
input.feed('first answer')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('\nThird?\n')
input.feed('third answer')
await expect(first).resolves.toEqual({ answers: [{ id: 'first', selected: [], custom: 'first answer' }] })
await expect(third).resolves.toEqual({ answers: [{ id: 'third', selected: [], custom: 'third answer' }] })
})
it('rejects active and queued questions when the UI is disposed', async () => {
const { ctx, fiber } = await setup()
const active = ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] })
const queued = ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] })
const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await new Promise(r => setImmediate(r))
await fiber.dispose()
await activeRejected
await queuedRejected
})
it('rejects active and queued questions when stdin closes before the user answers', async () => {
const { ctx, input, exit } = await setup()
const active = ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] })
const queued = ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] })
const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await new Promise(r => setImmediate(r))
input.finish()
await new Promise(r => setImmediate(r))
await activeRejected
await queuedRejected
expect(exit).not.toHaveBeenCalled()
})
it('rejects new questions immediately after stdin has closed', async () => {
const { ctx, input, out } = await setup()
input.finish()
await new Promise(r => setImmediate(r))
const before = out.text()
const answer = ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Too late?' }] })
await expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
expect(out.text()).toBe(before)
})
it('sends a typed line to an idle agent', async () => {
const { ctx, input } = await setup()
const agent = makeAgent('main', 'idle')

View File

@@ -32,6 +32,12 @@
{
"path": "../../core/agent-core"
},
{
"path": "../user-interaction"
},
{
"path": "../tool-ask-user"
},
{
"path": "../../session-persistence/session-persistence-jsonl"
}

View File

@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-tool-ask-user
Model-facing `ask_user_question` tool over `ctx.userInteraction`. It lets the model ask the human a concise question when it needs confirmation, a choice, or missing information before continuing.
## Tool
`ask_user_question` accepts:
- `questions` — required non-empty array of question objects.
- `id` — required stable id on each question, echoed in the answer.
- `question` — required question text for each question.
- `header` — optional short heading.
- `options` — optional choices with `label` and `description`. If recommending a choice, put it first and append `(Recommended)` to that label.
- `multi_select` — whether that question may return more than one selected option.
The tool calls `ctx.userInteraction.ask()` and returns JSON text shaped as `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`. `selected` contains option labels; `custom` is present only for a free-form answer and overrides selected choices.
## Role
This is the consumer package for the user-interaction seam. It does not render UI and does not know how input is collected; it only translates model arguments into `AskUserQuestionRequest` and returns the human answer to the agent loop.

View File

@@ -0,0 +1,38 @@
{
"name": "@deepseek-ai/dsh-tool-ask-user",
"description": "Model-facing ask_user_question tool over the ctx.userInteraction 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/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,71 @@
/**
* Model-facing `ask_user_question` tool over the `ctx.userInteraction` seam.
* The tool pauses until a UI provider returns a human answer, then feeds that
* answer back into the agent loop as an ordinary tool result.
*
* @module @deepseek-ai/dsh-tool-ask-user
*/
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import '@deepseek-ai/dsh-user-interaction'
export const name = 'tool-ask-user'
export const inject = ['tools', 'userInteraction']
const description = 'Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. '
+ 'Send one or more questions, each with a stable id that will be echoed in the answer.'
export function apply(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'ask_user_question',
description,
parameters: {
questions: {
type: 'array',
required: true,
description: 'Questions to ask the user before continuing.',
items: {
type: 'object',
properties: {
id: { type: 'string', required: true, description: 'Stable id for this question; echoed in the answer.' },
question: { type: 'string', required: true, description: 'The specific question to ask the user.' },
header: {
type: 'string',
description: 'Optional short heading for the question, such as "Confirm" or "Choose Mode".',
},
options: {
type: 'array',
description: 'Optional choices to show the user. If you recommend one, put it first and append "(Recommended)" to that label.',
items: {
type: 'object',
properties: {
label: { type: 'string', required: true, description: 'Short user-facing option label.' },
description: { type: 'string', description: 'One sentence explaining the tradeoff or impact.' },
},
},
},
multi_select: {
type: 'boolean',
description: 'Whether the user may select more than one option. Defaults to false.',
},
},
},
},
},
async execute(args, exec) {
const result = await ctx.userInteraction.ask({
questions: args.questions.map(question => ({
id: question.id,
question: question.question,
...question.header !== undefined ? { header: question.header } : {},
...question.options !== undefined ? { options: question.options } : {},
...question.multi_select !== undefined ? { multiSelect: question.multi_select } : {},
})),
...exec.agent !== undefined ? { agent: exec.agent } : {},
...exec.signal !== undefined ? { signal: exec.signal } : {},
})
return [{ type: 'text', text: JSON.stringify(result) }]
},
}))
}

View File

@@ -0,0 +1,253 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
interface OptionSchemaShape {
properties: {
questions: {
items: {
properties: {
options: {
items: {
properties: Record<string, { type: string }>
}
}
} & Record<string, unknown>
}
}
}
}
async function setup() {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(toolAskUser)
return ctx
}
describe('ask_user_question tool', () => {
it('registers a model-facing tool schema', async () => {
const ctx = await setup()
const schema = ctx.tools.schemas().find(tool => tool.name === 'ask_user_question')
expect(schema).toMatchObject({
name: 'ask_user_question',
parameters: {
type: 'object',
properties: {
questions: { type: 'array' },
},
required: ['questions'],
},
})
const parameters = schema?.parameters as unknown as OptionSchemaShape
expect(parameters.properties.questions.items.properties).toMatchObject({
id: { type: 'string' },
question: { type: 'string' },
header: { type: 'string' },
options: { type: 'array' },
multi_select: { type: 'boolean' },
})
expect(parameters.properties.questions.items.properties.options.items.properties).toMatchObject({
label: { type: 'string' },
description: { type: 'string' },
})
expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('value')
expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('recommended')
expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('preview')
})
it('asks the registered user-interaction provider and projects structured answers to text', async () => {
const ctx = await setup()
const seen: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
async ask(request) {
seen.push(request)
return { answers: [{ id: 'pkg', selected: ['pnpm'] }] }
},
})
const result = await ctx.tools.execute({
callId: CallId('ask-1'),
name: 'ask_user_question',
arguments: {
questions: [{
id: 'pkg',
question: 'Which package manager should I use?',
options: [{ label: 'pnpm', description: 'Use pnpm workspaces.' }],
}],
},
})
expect(result).toMatchObject({
isError: false,
content: [{ type: 'text', text: '{"answers":[{"id":"pkg","selected":["pnpm"]}]}' }],
})
expect(seen).toMatchObject([{
questions: [{
id: 'pkg',
question: 'Which package manager should I use?',
options: [{ label: 'pnpm', description: 'Use pnpm workspaces.' }],
}],
}])
})
it('passes recommended option labels through without adding schema fields', async () => {
const ctx = await setup()
const seen: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
async ask(request) {
seen.push(request)
return { answers: [{ id: 'pkg', selected: ['pnpm (Recommended)'] }] }
},
})
await ctx.tools.execute({
callId: CallId('ask-recommended'),
name: 'ask_user_question',
arguments: {
questions: [{
id: 'pkg',
question: 'Which package manager should I use?',
options: [
{ label: 'pnpm (Recommended)' },
{ label: 'npm' },
],
}],
},
})
expect(seen[0]?.questions[0]?.options).toEqual([
{ label: 'pnpm (Recommended)' },
{ label: 'npm' },
])
})
it('projects custom answers and multi-select choices', async () => {
const ctx = await setup()
ctx.userInteraction.registerProvider({
async ask() {
return {
answers: [
{ id: 'targets', selected: ['tests', 'docs'] },
{ id: 'notes', selected: [], custom: 'ship today' },
],
}
},
})
const result = await ctx.tools.execute({
callId: CallId('ask-multi'),
name: 'ask_user_question',
arguments: {
questions: [
{
id: 'targets',
question: 'What should I update?',
options: [{ label: 'tests' }, { label: 'docs' }],
multi_select: true,
},
{ id: 'notes', question: 'Any note?' },
],
},
})
expect(result.content).toEqual([{
type: 'text',
text: '{"answers":[{"id":"targets","selected":["tests","docs"]},{"id":"notes","selected":[],"custom":"ship today"}]}',
}])
})
it('passes the tool abort signal to the user-interaction request', async () => {
const ctx = await setup()
const seen: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
async ask(request) {
seen.push(request)
return { answers: [{ id: 'continue', selected: ['ok'] }] }
},
})
const controller = new AbortController()
await ctx.tools.execute({
callId: CallId('ask-2'),
name: 'ask_user_question',
arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
signal: controller.signal,
})
expect(seen[0]?.signal).toBe(controller.signal)
})
it('passes optional header and agent through to the user-interaction request', async () => {
const ctx = await setup()
const seen: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
async ask(request) {
seen.push(request)
return { answers: [{ id: 'continue', selected: ['ok'] }] }
},
})
const agent = { id: 'main' } as unknown as Agent
const result = await ctx.tools.execute({
callId: CallId('ask-3'),
name: 'ask_user_question',
arguments: { questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }] },
agent,
})
expect(result.content).toEqual([{ type: 'text', text: '{"answers":[{"id":"continue","selected":["ok"]}]}' }])
expect(seen[0]).toMatchObject({ questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }], agent })
})
it('returns structured user-interaction errors through tool execution', async () => {
const ctx = await setup()
const result = await ctx.tools.execute({
callId: CallId('ask-no-provider'),
name: 'ask_user_question',
arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
})
expect(result).toMatchObject({
isError: true,
error: { name: 'UserInteractionError', code: 'NO_PROVIDER' },
})
})
it('returns a structured error for empty question batches', async () => {
const ctx = await setup()
const result = await ctx.tools.execute({
callId: CallId('ask-empty'),
name: 'ask_user_question',
arguments: { questions: [] },
})
expect(result).toMatchObject({
isError: true,
error: { name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' },
})
})
it('unregisters the tool when its plugin fiber is disposed', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
const fiber = await ctx.plugin(toolAskUser)
expect(ctx.tools.get('ask_user_question')).toBeDefined()
await fiber.dispose()
expect(ctx.tools.get('ask_user_question')).toBeUndefined()
})
})

View File

@@ -0,0 +1,36 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../core/tools"
},
{
"path": "../user-interaction"
}
]
}

View File

@@ -0,0 +1,24 @@
# @deepseek-ai/dsh-user-interaction
Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a model-facing tool or permission plugin uses when it needs to pause work and ask the human for a decision.
## Service: `UserInteractionService` (ctx key: `userInteraction`)
### Public API
- `ctx.userInteraction.registerProvider(provider): () => void` Register the UI-side provider. Only one provider may be active in a context; disposal unregisters it.
- `ctx.userInteraction.ask(request): Promise<AskUserQuestionAnswer>` Ask the active provider and wait for the answer.
### Key Types
- `AskUserQuestionRequest``{ questions: [{ id, question, header?, options?, multiSelect? }], agent?, signal? }`.
- `AskUserQuestionOption``{ label, description? }`.
- `AskUserQuestionAnswer``{ answers: [{ id, selected, custom? }] }`.
- `UserInteractionProvider` — UI implementation with `ask(request)`.
- `UserInteractionError``HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices.
## Role
This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the `stdio-agent` readline module and the `acp` bridge provide the provider. The loop stays unchanged: a tool call simply awaits a promise, and the tool result resumes the normal agent loop.

View File

@@ -0,0 +1,34 @@
{
"name": "@deepseek-ai/dsh-user-interaction",
"description": "Abstract user-interaction seam (ctx.userInteraction) for asking the human during agent runs",
"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-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,128 @@
/**
* User-interaction seam (`ctx.userInteraction`): a UI-backed service for
* pausing an agent tool call until the human answers a question. The model-
* facing tool lives in `@deepseek-ai/dsh-tool-ask-user`; UI packages provide
* the single active provider.
*
* @module @deepseek-ai/dsh-user-interaction
*/
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { HarnessError } from '@deepseek-ai/dsh-llm'
declare module 'cordis' {
interface Context {
userInteraction: UserInteractionService
}
}
/** One selectable answer offered to the user. */
export interface AskUserQuestionOption {
/** User-facing label. */
label: string
/** Optional extra context rendered by capable UIs. */
description?: string
}
/** One question in an ask_user_question request. */
export interface AskUserQuestionItem {
/** Stable model-provided question id, echoed in the answer. */
id: string
/** The question to display. */
question: string
/** Optional short heading/group label. */
header?: string
/** Optional choices the UI can render as a menu. */
options?: AskUserQuestionOption[]
/** Whether more than one option may be selected. Defaults to single-select. */
multiSelect?: boolean
}
/** Request for a human answer. */
export interface AskUserQuestionRequest {
/** Questions to display. */
questions: AskUserQuestionItem[]
/** Calling agent, when the request came from an agent tool call. */
agent?: Agent
/** Abort signal for the owning tool/step. */
signal?: AbortSignal
}
/** Answer to one question. */
export interface AskUserQuestionAnswerItem {
/** The answered question id. */
id: string
/** Selected option labels. Empty when the answer is purely custom text. */
selected: string[]
/** Optional free-text "Other" answer. */
custom?: string
}
/** The human's answer. */
export interface AskUserQuestionAnswer {
/** Structured answers keyed by question id. */
answers: AskUserQuestionAnswerItem[]
}
/** UI-side provider for user questions. */
export interface UserInteractionProvider {
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
}
/** Stable error taxonomy for user-interaction failures. */
export class UserInteractionError extends HarnessError {
constructor(message: string, code: string, options?: ErrorOptions) {
super(message, code, options)
this.name = 'UserInteractionError'
}
}
/** `ctx.userInteraction`: one active UI provider plus an `ask()` surface. */
export class UserInteractionService extends Service {
private provider: UserInteractionProvider | undefined
constructor(ctx: Context) {
super(ctx, 'userInteraction')
}
/**
* Register the UI provider. Only one provider may be active in a context.
*
* @param provider UI-side implementation that collects answers.
* @returns Disposer that unregisters this provider.
*/
registerProvider(provider: UserInteractionProvider): () => void {
const dispose = this.ctx.effect(function* (this: UserInteractionService) {
if (this.provider !== undefined) {
throw new UserInteractionError('a user-interaction provider is already registered', 'DUPLICATE_PROVIDER')
}
this.provider = provider
yield () => {
this.provider = undefined
}
}.bind(this), 'userInteraction.registerProvider()')
return () => void dispose()
}
/**
* Ask the active UI provider and wait for the user's answer.
*
* @param request Questions, owner agent, and abort signal.
* @returns The answer chosen or typed by the human.
*/
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
if (request.signal?.aborted) {
throw new UserInteractionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED')
}
if (request.questions.length === 0) {
throw new UserInteractionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS')
}
if (this.provider === undefined) {
throw new UserInteractionError('no user-interaction provider is registered', 'NO_PROVIDER')
}
return this.provider.ask(request)
}
}
export default UserInteractionService

View File

@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import UserInteractionService, {
UserInteractionError,
type AskUserQuestionRequest,
type UserInteractionProvider,
} from '@deepseek-ai/dsh-user-interaction'
function provider(answer = 'approved'): UserInteractionProvider & { seen: AskUserQuestionRequest[] } {
const seen: AskUserQuestionRequest[] = []
return {
seen,
async ask(request) {
seen.push(request)
return { answers: [{ id: request.questions[0]?.id ?? 'missing', selected: [answer] }] }
},
}
}
describe('UserInteractionService', () => {
it('delegates ask requests to the registered provider', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = provider('yes')
ctx.userInteraction.registerProvider(p)
const result = await ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] })
expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] })
expect(p.seen).toEqual([{ questions: [{ id: 'confirm', question: 'Proceed?' }] }])
})
it('rejects ask requests when no provider is registered', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }))
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_PROVIDER' })
})
it('registers providers with HMR-safe disposal', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = provider()
const dispose = ctx.userInteraction.registerProvider(p)
dispose()
dispose()
await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }))
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
})
it('rejects duplicate providers instead of replacing the active UI', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
ctx.userInteraction.registerProvider(provider('first'))
expect(() => ctx.userInteraction.registerProvider(provider('second')))
.toThrow(UserInteractionError)
})
it('fails before reaching the provider when the signal is already aborted', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = { ask: vi.fn(async () => ({ answers: [{ id: 'confirm', selected: ['too late'] }] })) }
ctx.userInteraction.registerProvider(p)
const controller = new AbortController()
controller.abort()
await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }], signal: controller.signal }))
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
expect(p.ask).not.toHaveBeenCalled()
})
it('rejects empty question batches before reaching the provider', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = { ask: vi.fn(async () => ({ answers: [] })) }
ctx.userInteraction.registerProvider(p)
await expect(ctx.userInteraction.ask({ questions: [] }))
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' })
expect(p.ask).not.toHaveBeenCalled()
})
})

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": "../../core/agent"
},
{
"path": "../../llm/llm"
}
]
}

View File

@@ -5,5 +5,8 @@ Zero-dependency primitives shared across the other groups. A package lands here
| Package | Role |
|---|---|
| `brand/` | The type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) |
| `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability |
`dsh-brand` is the canonical case: it owns ONLY the `Branded<B>` helper, so a capability package can brand the ids it owns (`dsh-bash`'s `BashTaskId`/`OwnerToken`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`.
`dsh-timeout` follows the same shape for the timeout family: `dsh-bash` and `dsh-web-fetch-local` each fuse a caller's cancellation with a deadline and later classify "timed out" vs "cancelled" by depending on `dsh-timeout` alone. It deliberately owns only the timing/classification half — the *termination* (SIGKILL a process group, tear down a fetch socket) stays in each capability, because no shared layer can own every capability's kill (see [the timeout-library RFC](../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md)).

View File

@@ -0,0 +1,42 @@
# dsh-timeout
The **timing-and-classification** half of a timeout — a zero-dependency library of pure functions (no runtime harness deps) shared by every capability that clamps a caller's timeout hint, arms a deadline, and later has to tell "timed out" apart from "cancelled".
It owns **no termination**. The signal it hands out only *notifies*; actually stopping the work stays in each capability, because that mechanism differs — bash SIGKILLs an OS process group, web tears down a `fetch` socket — and no shared layer can own all of them. This is the boundary the [RFC](../../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md) draws: share the timing/classification, keep the hard kill local.
It is a **library, not a service or plugin**: no `ctx`, registers nothing, holds no state, emits no events. A "timeout service" would have to understand how to stop every capability's work — exactly the knowledge a microkernel keeps out of shared layers.
## Surface
```ts
import { clampTimeout, deadline, timeoutOf, TimeoutReason } from '@deepseek-ai/dsh-timeout'
```
| Export | Role |
|---|---|
| `clampTimeout(requested, def, max, name?)` | Validate the caller's optional positive-finite hint, fill from `def`, cap at `max`. Throws (with `name`) on a non-positive/non-finite hint. |
| `deadline(upstream, timeoutMs, code)` | Fuse `upstream` cancellation with a timeout into one `AbortSignal` (`AbortSignal.any`); the timeout carries a `TimeoutReason`. `[Symbol.dispose]` clears the timer. |
| `timeoutOf(signal \| { reason }, code?)` | Recover the `TimeoutReason` from an aborted signal/error, else `undefined` — the timeout-vs-cancel classifier. Pass `code` to match only THIS deadline's timer (see nesting below). |
| `TimeoutReason` | The internal reason (`code` + `timeoutMs`) stamped on a timeout abort. Not a public error — providers translate it into their own error/field. |
## The `timeoutMs <= 0` sentinel
`0` is the **internal** "no timeout" value for backend-owned background work (bash `start()`): `deadline()` arms no timer and forwards only `upstream`; with no upstream either, it returns a never-aborting signal plus a no-op disposer, so every caller keeps one call shape. External request hints validate as **positive finite** via `clampTimeout` before they reach `deadline`, so `0` is never a model-/plugin-facing "disable timeout" value.
## Usage shape
```ts ignore-check
// Scope-lifetime consumer (foreground bash, one fetch): `using` disposes the timer.
using d = deadline(upstream, timeoutMs, 'BASH_TIMEOUT')
const outcome = await runWork({ signal: d.signal }) // work listens on d.signal and terminates itself
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined // classify the first abort, scoped to OUR code
const aborted = d.signal.aborted && !timedOut // mutually exclusive: timeout won, or cancel did
```
The signal only *notifies* — the caller MUST attach its own termination (`d.signal.addEventListener('abort', kill)`, or hand `d.signal` to `fetch`). Racing a promise against a timer would resolve the tool-call while the child process or socket leaks on; handing out a signal forces a real termination path to exist.
Pass your own `code` to `timeoutOf` so classification composes under nesting: when the `upstream` you were handed is *itself* a deadline signal (a future `tools/execute` middleware arming a per-call deadline), `AbortSignal.any` preserves the outer `TimeoutReason` if the outer timer fires first. Scoping to your `code` makes a foreign timeout read as an ordinary upstream cancel — the correct classification from your capability's view — instead of your own timeout firing when your local timer never expired.
## What does NOT get a timeout
Local file `read`/`write`/`edit` take no `timeoutMs`: a syscall is best-effort-abortable at most, a timeout could not force `fsync`/`rename` to stop, and adding one would be an implicit default that violates explicit-over-implicit. See [`fs/`](../../fs/README.md).

View File

@@ -0,0 +1,30 @@
{
"name": "@deepseek-ai/dsh-timeout",
"description": "Zero-dependency timeout/deadline primitive: clampTimeout, deadline, timeoutOf, TimeoutReason (timing + classification only, no termination)",
"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,162 @@
/**
* The timing-and-classification half of a timeout — a zero-dependency library
* of pure functions shared by every capability that clamps a caller's timeout
* hint, arms a deadline, and later has to tell "timed out" apart from
* "cancelled". It owns NO termination: the returned {@link deadline} signal only
* NOTIFIES; actually stopping the work (SIGKILL a process group, tear down a
* fetch socket, …) stays in each capability's implementation, because that
* mechanism differs per capability and no shared layer can own all of them.
*
* This is deliberately a library, not a cordis service or plugin: it takes no
* `ctx`, registers nothing, holds no cross-call state, and emits no events. A
* "timeout service" would have to understand how to stop every capability's
* work — exactly the knowledge a microkernel keeps out of shared layers.
*
* The four exports and their division of labor:
* - {@link clampTimeout} — validate a caller's optional positive hint, fill the
* backend default, cap at the backend max (pure arithmetic + the shared
* positive-finite request contract).
* - {@link deadline} — fuse upstream cancellation with a timeout into one
* `AbortSignal`, the timeout carrying an identifiable {@link TimeoutReason};
* `[Symbol.dispose]` clears the timer.
* - {@link timeoutOf} — classify an aborted signal (or error): a
* {@link TimeoutReason} means the timeout fired, anything else (or nothing)
* means it did not.
* - {@link TimeoutReason} — the internal classification reason; providers
* translate it into their own public error/result shape before returning.
*
* @module @deepseek-ai/dsh-timeout
*/
/**
* The internal reason attached to a timeout abort so consumers can classify it
* after the fact. It carries the failing `code` (each capability's own string —
* `BASH_TIMEOUT`, `WEB_FETCH_TIMEOUT`, …) and the `timeoutMs` that elapsed.
*
* It is an INTERNAL classification reason, not a public error: providers
* translate it into their seam-specific error code or result field (via
* {@link timeoutOf}) before returning to callers. Native `AbortSignal.timeout()`
* yields a fixed `TimeoutError` indistinguishable across timeout kinds; this
* type is identifiable and carries the code/duration.
*/
export class TimeoutReason extends Error {
override name = 'TimeoutReason'
/**
* @param code Capability-owned timeout code (e.g. `BASH_TIMEOUT`).
* @param timeoutMs The deadline that elapsed, in milliseconds.
*/
constructor(readonly code: string, readonly timeoutMs: number) {
super(`${code} after ${timeoutMs}ms`)
}
}
/**
* Validate a caller's optional timeout hint, fill it from the backend default,
* then cap at the backend max. The shared positive-finite request contract:
* a supplied `requested` must be a positive finite number or this throws —
* `0` is NOT a caller-facing "disable timeout" value (that sentinel is internal
* to {@link deadline}). A missing `requested` falls back to `def`.
*
* @param requested The caller's optional hint; validated when present.
* @param def The backend default applied when `requested` is absent.
* @param max The backend upper bound the result is capped to.
* @param name Field name used in the thrown message (so the caller sees which input was bad).
* @returns The effective timeout in milliseconds: `min(requested ?? def, max)`.
*/
export function clampTimeout(
requested: number | undefined,
def: number,
max: number,
name = 'timeoutMs',
): number {
if (requested !== undefined && (!Number.isFinite(requested) || requested <= 0)) {
throw new Error(`${name} must be a positive finite number`)
}
return Math.min(requested ?? def, max)
}
/** A deadline signal plus the cleanup that clears its timer (dispose-once). */
export interface Deadline {
/** Aborts on upstream cancellation OR on timeout (the timeout carries a {@link TimeoutReason}). */
readonly signal: AbortSignal
/** Clear the timer. Safe to call once; `using` calls it at scope exit. */
[Symbol.dispose](): void
}
/**
* Build a deadline signal that aborts on upstream cancellation OR on timeout,
* with the timeout carrying an identifiable {@link TimeoutReason} (unlike
* native `AbortSignal.timeout()`, whose fixed `TimeoutError` is opaque). It is
* `AbortSignal.any([upstream, <timeout>])` — the single primitive that fuses
* two abort sources — with the reason and a disposable timer added on top.
*
* `timeoutMs <= 0` is the INTERNAL "no timeout" sentinel for backend-owned
* background work: arm no timer and forward only the upstream signal; with no
* upstream either, return a never-aborting signal so callers keep one call
* shape. External request hints validate as positive finite via
* {@link clampTimeout} before reaching here, so `0` never arrives from a model
* or plugin.
*
* The returned object's `[Symbol.dispose]` clears the timer — use `using` for a
* scope-lifetime consumer, or call it manually for an event-lifetime one. The
* signal only NOTIFIES; the caller must attach its own termination (kill the
* process group, abort the fetch, …).
*
* @param upstream The caller's cancellation signal, if any, fused into the result.
* @param timeoutMs Deadline in milliseconds; `<= 0` means "no timeout" (arm no timer).
* @param code Capability-owned code stamped onto the timeout's {@link TimeoutReason}.
* @returns The fused {@link Deadline} (signal + timer cleanup).
*/
export function deadline(
upstream: AbortSignal | undefined,
timeoutMs: number,
code: string,
): Deadline {
if (timeoutMs <= 0) {
// No timeout (background work): forward only the upstream signal, or a
// never-aborting one when there is no upstream. No timer, so dispose is a
// no-op — the empty method keeps the one call shape for every caller.
return { signal: upstream ?? new AbortController().signal, [Symbol.dispose]() {} }
}
const timer = new AbortController()
const id = setTimeout(() => { timer.abort(new TimeoutReason(code, timeoutMs)) }, timeoutMs)
return {
// AbortSignal.any adopts the reason of whichever source aborts FIRST, so a
// race resolves to a single cause: timeoutOf() reads TimeoutReason only
// when the timeout won, and upstream-wins leaves an ordinary abort reason.
signal: upstream !== undefined ? AbortSignal.any([upstream, timer.signal]) : timer.signal,
[Symbol.dispose]() { clearTimeout(id) },
}
}
/**
* Recover the {@link TimeoutReason} from an aborted signal (or any object with a
* `reason`), else `undefined`. This is the classification half: a provider
* calls it on the deadline signal after an abort to decide whether the cause
* was its timeout (translate to the capability's timeout error/field) or an
* ordinary upstream cancellation (`undefined` → the cancel path).
*
* Pass `code` to scope the match to THIS deadline's timer. It matters under
* nesting: when the `upstream` handed to {@link deadline} is itself a deadline
* signal (e.g. a future `tools/execute` middleware arming a per-call deadline),
* `AbortSignal.any` preserves the OUTER `TimeoutReason` if the outer timer fires
* first. Without `code`, the inner capability would misclassify that outer
* timeout as its own (`timedOut:true` / `WEB_FETCH_TIMEOUT`) though its local
* timer never expired; with `code`, a foreign timeout reads as `undefined` and
* falls through to the upstream-cancel path, which is the correct classification
* from the inner capability's view. Omit `code` only to ask "was this ANY
* timeout" (a generic middleware that owns no single code).
*
* @param x An {@link AbortSignal} or any `{ reason }` carrier (e.g. a caught abort error).
* @param code When provided, only a {@link TimeoutReason} with this exact `code` matches.
* @returns The matching {@link TimeoutReason}, else `undefined`.
*/
export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): TimeoutReason | undefined {
// AbortSignal.reason is typed `any`; pin it to `unknown` so no `any` leaks and
// the instanceof narrows cleanly for both a signal and a bare reason carrier.
const reason: unknown = x.reason
if (!(reason instanceof TimeoutReason)) return undefined
return code === undefined || reason.code === code ? reason : undefined
}

View File

@@ -0,0 +1,185 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clampTimeout, deadline, timeoutOf, TimeoutReason } from '@deepseek-ai/dsh-timeout'
describe('TimeoutReason', () => {
it('is an Error carrying the code and elapsed ms', () => {
const reason = new TimeoutReason('BASH_TIMEOUT', 100)
expect(reason).toBeInstanceOf(Error)
expect(reason.name).toBe('TimeoutReason')
expect(reason.code).toBe('BASH_TIMEOUT')
expect(reason.timeoutMs).toBe(100)
expect(reason.message).toBe('BASH_TIMEOUT after 100ms')
})
})
describe('clampTimeout', () => {
it('fills the default when the hint is absent', () => {
expect(clampTimeout(undefined, 120_000, 600_000)).toBe(120_000)
})
it('caps the hint at max', () => {
expect(clampTimeout(999_999, 120_000, 600_000)).toBe(600_000)
})
it('keeps a valid hint under the cap', () => {
expect(clampTimeout(5_000, 120_000, 600_000)).toBe(5_000)
})
it('caps the default itself when the default exceeds max', () => {
// min(def, max) applies even with no hint — a misconfigured backend never
// exceeds its own cap.
expect(clampTimeout(undefined, 900_000, 600_000)).toBe(600_000)
})
it('rejects a non-finite hint with the caller-provided name', () => {
expect(() => clampTimeout(Number.NaN, 100, 200, 'bash-local: request.timeoutMs'))
.toThrow(/bash-local: request\.timeoutMs must be a positive finite number/)
expect(() => clampTimeout(Number.POSITIVE_INFINITY, 100, 200))
.toThrow(/timeoutMs must be a positive finite number/)
})
it('rejects a non-positive hint', () => {
expect(() => clampTimeout(0, 100, 200)).toThrow(/must be a positive finite number/)
expect(() => clampTimeout(-1, 100, 200)).toThrow(/must be a positive finite number/)
})
})
describe('deadline — timeout arm', () => {
afterEach(() => { vi.useRealTimers() })
it('aborts on timeout with a TimeoutReason after the elapsed ms', () => {
vi.useFakeTimers()
using d = deadline(undefined, 100, 'BASH_TIMEOUT')
expect(d.signal.aborted).toBe(false)
vi.advanceTimersByTime(100)
expect(d.signal.aborted).toBe(true)
const reason = timeoutOf(d.signal)
expect(reason).toBeInstanceOf(TimeoutReason)
expect(reason?.code).toBe('BASH_TIMEOUT')
expect(reason?.timeoutMs).toBe(100)
})
it('[Symbol.dispose] clears the timer so no abort fires afterward', () => {
vi.useFakeTimers()
const d = deadline(undefined, 100, 'BASH_TIMEOUT')
d[Symbol.dispose]()
vi.advanceTimersByTime(1_000)
expect(d.signal.aborted).toBe(false)
expect(timeoutOf(d.signal)).toBeUndefined()
})
})
describe('deadline — fuse with upstream', () => {
it('aborts on upstream cancellation, classified as NOT a timeout', () => {
const upstream = new AbortController()
using d = deadline(upstream.signal, 60_000, 'BASH_TIMEOUT')
upstream.abort('user cancelled')
expect(d.signal.aborted).toBe(true)
expect(timeoutOf(d.signal)).toBeUndefined()
})
it('cancel wins when it fires before the timeout', () => {
vi.useFakeTimers()
try {
const upstream = new AbortController()
using d = deadline(upstream.signal, 100, 'BASH_TIMEOUT')
upstream.abort('user cancelled') // fires first, before the 100ms timer
vi.advanceTimersByTime(200)
expect(d.signal.aborted).toBe(true)
// AbortSignal.any adopts the FIRST source's reason: cancel won, so no
// TimeoutReason even though the timer later elapsed.
expect(timeoutOf(d.signal)).toBeUndefined()
} finally {
vi.useRealTimers()
}
})
it('timeout wins when it fires before upstream cancellation', () => {
vi.useFakeTimers()
try {
const upstream = new AbortController()
using d = deadline(upstream.signal, 100, 'WEB_FETCH_TIMEOUT')
vi.advanceTimersByTime(150) // past the 100ms deadline: the timer fires first
expect(d.signal.aborted).toBe(true)
expect(timeoutOf(d.signal)?.code).toBe('WEB_FETCH_TIMEOUT')
// A later upstream abort is a no-op on the already-aborted fused signal:
// AbortSignal.any keeps the FIRST cause, so the timeout classification stands.
upstream.abort('too late')
expect(timeoutOf(d.signal)?.code).toBe('WEB_FETCH_TIMEOUT')
} finally {
vi.useRealTimers()
}
})
it('forwards a pre-aborted upstream signal immediately', () => {
const upstream = new AbortController()
upstream.abort('already gone')
using d = deadline(upstream.signal, 60_000, 'BASH_TIMEOUT')
expect(d.signal.aborted).toBe(true)
expect(timeoutOf(d.signal)).toBeUndefined()
})
})
describe('deadline — timeoutMs <= 0 (no-timeout sentinel)', () => {
afterEach(() => { vi.useRealTimers() })
it('arms no timer and forwards only the upstream signal', () => {
vi.useFakeTimers()
const upstream = new AbortController()
using d = deadline(upstream.signal, 0, 'BASH_TIMEOUT')
vi.advanceTimersByTime(1_000_000)
expect(d.signal.aborted).toBe(false) // no timer ever armed
upstream.abort('kill')
expect(d.signal.aborted).toBe(true)
expect(timeoutOf(d.signal)).toBeUndefined() // never a timeout
})
it('returns a never-aborting signal with a no-op disposer when there is no upstream', () => {
vi.useFakeTimers()
const d = deadline(undefined, 0, 'BASH_TIMEOUT')
expect(() => { d[Symbol.dispose]() }).not.toThrow()
vi.advanceTimersByTime(1_000_000)
expect(d.signal.aborted).toBe(false)
expect(timeoutOf(d.signal)).toBeUndefined()
})
it('treats a negative timeout the same as zero', () => {
const d = deadline(undefined, -5, 'BASH_TIMEOUT')
expect(d.signal.aborted).toBe(false)
d[Symbol.dispose]()
})
})
describe('timeoutOf', () => {
it('classifies a bare reason carrier that holds a TimeoutReason', () => {
const reason = new TimeoutReason('WEB_FETCH_TIMEOUT', 50)
expect(timeoutOf({ reason })).toBe(reason)
})
it('returns undefined for a non-timeout reason', () => {
expect(timeoutOf({ reason: new Error('other') })).toBeUndefined()
expect(timeoutOf({ reason: 'user cancelled' })).toBeUndefined()
expect(timeoutOf({})).toBeUndefined()
})
it('matches only the requested code when one is given', () => {
const reason = new TimeoutReason('BASH_TIMEOUT', 100)
expect(timeoutOf({ reason }, 'BASH_TIMEOUT')).toBe(reason)
expect(timeoutOf({ reason }, 'WEB_FETCH_TIMEOUT')).toBeUndefined()
})
})
describe('deadline — nested deadlines', () => {
it("does not misclassify an outer deadline's timeout as the inner code", () => {
// The upstream handed to the inner deadline is ITSELF a deadline that has
// already timed out (outer). AbortSignal.any preserves the outer reason;
// scoping timeoutOf to the inner code keeps the inner capability from
// reporting the outer timeout as its own — it reads as an upstream cancel.
const outer = new AbortController()
outer.abort(new TimeoutReason('OUTER_TIMEOUT', 30))
using inner = deadline(outer.signal, 60_000, 'BASH_TIMEOUT')
expect(inner.signal.aborted).toBe(true)
expect(timeoutOf(inner.signal, 'BASH_TIMEOUT')).toBeUndefined() // not ours → upstream-cancel path
expect(timeoutOf(inner.signal)?.code).toBe('OUTER_TIMEOUT') // but IS a timeout, unscoped
})
})

View File

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

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-tool-web
The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and `presentCall`. All web access goes through `ctx.web`; this package never imports a concrete provider.
The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and `presentCall`. All web access goes through `ctx.web`; this package never imports a concrete provider. Neither tool exposes a model-facing timeout — each tool's cooperative tool-call budget is declared here via config (`fetchTimeoutMs`/`searchTimeoutMs`, attached as `ToolDefinition.timeoutMs`) and enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) (a `tools/execute` wrapper); each tool just forwards `exec.signal` to the seam.
Each tool is registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`).
@@ -9,7 +9,7 @@ Each tool is registered independently; a product that wants only one disables th
| Tool | Args | Behavior |
|---|---|---|
| `web_search` | `query` (string) | Discovery. Returns an optional answer plus source URLs. `max_results` is **not** model-facing — the tool sets the bound (the `searchMaxResults` config, default 8) and passes it to the seam. |
| `web_fetch` | `url` (string), `timeout_ms` (number, optional) | Retrieves a specific URL. HTML bodies are rendered to markdown-ish text; text bodies pass through. A non-2xx status is reported, not an error. |
| `web_fetch` | `url` (string) | Retrieves a specific URL. HTML bodies are rendered to markdown-ish text; text bodies pass through. A non-2xx status is reported, not an error. The tool-call timeout is deployment policy (`dsh-timeout-policy`), not a model argument. |
## Config
@@ -18,6 +18,10 @@ Each tool is registered independently; a product that wants only one disables th
| `search` | `true` | Register `web_search`. |
| `fetch` | `true` | Register `web_fetch`. |
| `searchMaxResults` | `8` | Upper bound on sources returned by one `web_search` call (the seam truncates a longer provider list and flags it). |
| `fetchTimeoutMs` | `30000` | Cooperative tool-call timeout budget (ms) for `web_fetch`. |
| `searchTimeoutMs` | `30000` | Cooperative tool-call timeout budget (ms) for `web_search`. |
`fetchTimeoutMs`/`searchTimeoutMs` declare each tool's cooperative timeout budget (attached as `ToolDefinition.timeoutMs`), enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md); the model-facing schema exposes no timeout argument.
```yaml
- id: tool-web

View File

@@ -37,6 +37,7 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
"@deepseek-ai/dsh-web": "workspace:^",
"@deepseek-ai/dsh-web-fetch-local": "workspace:^",
"@deepseek-ai/dsh-web-search-exa": "workspace:^",

View File

@@ -3,6 +3,13 @@
* Execution goes through `ctx.web` — this module owns the model-facing schema,
* argument validation, and PRESENTATION (HTML→markdown, truncation formatting),
* while the fetch provider owns safe retrieval (transport, redirects, caps).
*
* The model-facing schema exposes NO timeout knob: the tool-call budget is
* deployment policy DECLARED via this package's `fetchTimeoutMs` config (attached
* as `ToolDefinition.timeoutMs`) and ENFORCED by `@deepseek-ai/dsh-timeout-policy`
* (a `tools/execute` wrapper), matching the reference-agent `WebFetch` shape. This
* tool just forwards the (possibly deadline-derived) `exec.signal` to `ctx.web`;
* the provider keeps its own timeout only as a resource backstop for direct callers.
*/
import type { Context } from 'cordis'
@@ -15,18 +22,17 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
import { htmlToMarkdown } from './html.ts'
/**
* Validate value constraints the schema DSL can't express: a non-blank `url`,
* and a positive `timeout_ms` when present. Throws a plain `Error` otherwise.
* Validate value constraints the schema DSL can't express: a non-blank `url`.
* Throws a plain `Error` otherwise. No timeout parameter — the tool-call budget
* is deployment policy declared via `fetchTimeoutMs` config and enforced by
* `@deepseek-ai/dsh-timeout-policy`, not a model argument.
*
* @param args - the schema-validated `web_fetch` arguments.
* @returns the arguments renamed to the seam's camelCase request fields.
* @returns the arguments as the seam's request fields.
*/
export function parseFetchArgs(args: { url: string; timeout_ms?: number }): { url: string; timeoutMs?: number } {
export function parseFetchArgs(args: { url: string }): { url: string } {
if (args.url.trim().length === 0) throw new Error('url must be a non-empty string')
if (args.timeout_ms !== undefined && (!Number.isFinite(args.timeout_ms) || args.timeout_ms <= 0)) {
throw new Error('timeout_ms must be a positive number')
}
return { url: args.url, ...args.timeout_ms !== undefined ? { timeoutMs: args.timeout_ms } : {} }
return { url: args.url }
}
/**
@@ -67,7 +73,7 @@ export function formatFetchOutput(result: WebFetchResult): string {
* @param args - the raw tool arguments; only `url` feeds the view.
* @returns the generic card view (`kind: 'fetch'`) shown while the call runs.
*/
export function presentFetchCall(args: { url: string; timeout_ms?: number }): GenericCallView {
export function presentFetchCall(args: { url: string }): GenericCallView {
return { card: 'generic', title: args.url, kind: 'fetch', rawInput: args.url }
}
@@ -76,8 +82,10 @@ export function presentFetchCall(args: { url: string; timeout_ms?: number }): Ge
*
* @param ctx - context whose `tools` and `systemPrompt` registries receive the
* registrations; both are effect-scoped and unregister on plugin dispose.
* @param timeoutMs - the cooperative tool-call budget (ms) attached as the tool's
* `ToolDefinition.timeoutMs` for `@deepseek-ai/dsh-timeout-policy` to enforce.
*/
export function applyWebFetchTool(ctx: Context): void {
export function applyWebFetchTool(ctx: Context, timeoutMs: number): void {
ctx.systemPrompt.section({
name: 'tool:web_fetch',
order: 111,
@@ -89,12 +97,12 @@ export function applyWebFetchTool(ctx: Context): void {
description: 'Fetch the content of a specific HTTP(S) URL and return it decoded to text.',
parameters: {
url: { type: 'string', required: true, description: 'The HTTP(S) URL to fetch.' },
timeout_ms: { type: 'number', description: 'Optional fetch timeout in milliseconds (capped by the provider).' },
},
timeoutMs,
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseFetchArgs(args)
const result = await ctx.web.fetch(
{ url: input.url, ...input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {} },
{ url: input.url },
exec.signal ? { signal: exec.signal } : undefined,
)
return [{ type: 'text', text: formatFetchOutput(result) }]

View File

@@ -33,7 +33,10 @@ export const name = 'tool-web'
/** Services required by the web tool suite. */
export const inject = ['tools', 'web', 'systemPrompt']
/** Plugin config: which web tools to register, and the `web_search` source cap. */
/** Default cooperative tool-call timeout budget (ms) for the web tools. */
export const DEFAULT_WEB_TOOL_TIMEOUT_MS = 30_000
/** Plugin config: which web tools to register, the source cap, and per-tool budgets. */
export interface Config {
/** Register `web_search`. Defaults to true. */
search?: boolean
@@ -41,12 +44,18 @@ export interface Config {
fetch?: boolean
/** Upper bound on sources returned by one `web_search` call. */
searchMaxResults?: number
/** Cooperative timeout budget (ms) for `web_fetch`. Defaults to 30000. */
fetchTimeoutMs?: number
/** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */
searchTimeoutMs?: number
}
export const Config: z<Config> = z.object({
search: z.boolean().default(true),
fetch: z.boolean().default(true),
searchMaxResults: z.number().default(WEB_SEARCH_MAX_RESULTS),
fetchTimeoutMs: z.number().default(DEFAULT_WEB_TOOL_TIMEOUT_MS),
searchTimeoutMs: z.number().default(DEFAULT_WEB_TOOL_TIMEOUT_MS),
})
/** The shape after schemastery applies its defaults to every field. */
@@ -61,7 +70,10 @@ function assertPositiveInteger(name: string, value: number): void {
/**
* Register the enabled web tools. `search`/`fetch` default to true; a product
* that wants only one disables the other in config. The tools' disposers are
* that wants only one disables the other in config. Each tool's cooperative
* timeout budget (`fetchTimeoutMs`/`searchTimeoutMs`, default 30000) is resolved
* here and attached to the tool as `ToolDefinition.timeoutMs` for
* `@deepseek-ai/dsh-timeout-policy` to enforce. The tools' disposers are
* fiber-scoped (the effect-based registries clean up on dispose), so no manual
* teardown is needed.
*/
@@ -69,6 +81,8 @@ export function apply(ctx: Context, config: Config): void {
// schemastery (Config) has already filled every defaulted field.
const resolved = config as ResolvedConfig
assertPositiveInteger('searchMaxResults', resolved.searchMaxResults)
if (resolved.search) applyWebSearchTool(ctx, resolved.searchMaxResults)
if (resolved.fetch) applyWebFetchTool(ctx)
assertPositiveInteger('fetchTimeoutMs', resolved.fetchTimeoutMs)
assertPositiveInteger('searchTimeoutMs', resolved.searchTimeoutMs)
if (resolved.search) applyWebSearchTool(ctx, resolved.searchMaxResults, resolved.searchTimeoutMs)
if (resolved.fetch) applyWebFetchTool(ctx, resolved.fetchTimeoutMs)
}

View File

@@ -92,8 +92,10 @@ export function presentSearchCall(args: { query: string }): GenericCallView {
* registrations; both are effect-scoped and unregister on plugin dispose.
* @param maxResults - the deployment's source cap, sent as every seam
* request's `maxResults`.
* @param timeoutMs - the cooperative tool-call budget (ms) attached as the tool's
* `ToolDefinition.timeoutMs` for `@deepseek-ai/dsh-timeout-policy` to enforce.
*/
export function applyWebSearchTool(ctx: Context, maxResults: number): void {
export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs: number): void {
ctx.systemPrompt.section({
name: 'tool:web_search',
order: 110,
@@ -106,6 +108,7 @@ export function applyWebSearchTool(ctx: Context, maxResults: number): void {
parameters: {
query: { type: 'string', required: true, description: 'The search query.' },
},
timeoutMs,
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseSearchArgs(args)
const result = await ctx.web.search(

View File

@@ -1,10 +1,11 @@
/**
* Integration: the real fetch backend (`dsh-web-fetch-local`) + a real search
* provider (`dsh-web-search-exa`) + the real seam (`dsh-web`) + the model tool
* (`dsh-tool-web`), exercised through `ctx.tools.execute()` — nothing bypasses
* the tool registry. Fetch hits a real loopback HTTP server (verifying the
* WORLD); search runs the real Exa provider over a stubbed global `fetch` (the
* network is the one boundary we mock).
* (`dsh-tool-web`) + the tool-call timeout policy (`dsh-timeout-policy`),
* exercised through `ctx.tools.execute()` — nothing bypasses the tool registry.
* Fetch hits a real loopback HTTP server (verifying the WORLD); search runs the
* real Exa provider over a stubbed global `fetch` (the network is the one
* boundary we mock).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -18,6 +19,7 @@ import WebService from '@deepseek-ai/dsh-web'
import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local'
import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa'
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
type Handler = (req: IncomingMessage, res: ServerResponse) => void
@@ -39,6 +41,11 @@ beforeEach(async () => {
await ctx.plugin(WebService, { searchProvider: WebSearchExa.EXA_PROVIDER_ID, fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID })
await ctx.plugin(WebFetchLocal, {})
await ctx.plugin(WebSearchExa, { apiKey: 'exa-key', baseURL: 'https://api.exa.test' })
// The shipped deployment shape: the tool-call budget is declared by tool-web
// config (default 30s, attached as ToolDefinition.timeoutMs) and enforced by
// the zero-config timeout-policy plugin, set above the provider backstop so the
// policy normally wins.
await ctx.plugin(TimeoutPolicy)
fiber = await ctx.plugin(ToolWeb)
})
@@ -96,3 +103,70 @@ describe('web_search integration over the real Exa provider', () => {
expect(out.content.map(b => b.text).join('')).toContain('[Result](https://result.test)')
})
})
describe('tool-call timeout policy over the migrated web tools', () => {
it('neither model schema exposes a timeout parameter after the migration', () => {
const byName = new Map(ctx.tools.schemas().map(s => [s.name, s]))
const fetchParams = byName.get('web_fetch')!.parameters as { properties: Record<string, unknown> }
const searchParams = byName.get('web_search')!.parameters as { properties: Record<string, unknown> }
expect(Object.keys(fetchParams.properties)).toEqual(['url'])
expect('timeout_ms' in fetchParams.properties).toBe(false)
expect(Object.keys(searchParams.properties)).toEqual(['query'])
})
})
describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetch)', () => {
let slowServer: Server
let slowBase: string
let openSockets: ServerResponse[]
let tctx: Context
let tfiber: Awaited<ReturnType<Context['plugin']>>
beforeEach(async () => {
// A server that never responds: it holds the connection open until the
// client aborts. The cooperative deadline (via exec.signal → the fetch
// provider → undici) is what ends the call.
openSockets = []
slowServer = createServer((_req, res) => { openSockets.push(res) })
await new Promise<void>(resolve => slowServer.listen(0, '127.0.0.1', resolve))
slowBase = `http://127.0.0.1:${(slowServer.address() as AddressInfo).port}`
tctx = new Context()
await tctx.plugin(SystemPrompt)
await tctx.plugin(ToolRegistry)
await tctx.plugin(WebService, { fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID })
// Provider backstop well ABOVE the tool-call budget, so the policy wins.
await tctx.plugin(WebFetchLocal, { timeoutMs: 30_000, maxTimeoutMs: 60_000 })
await tctx.plugin(TimeoutPolicy)
// The tool-call budget is declared by tool-web config, enforced by the policy.
tfiber = await tctx.plugin(ToolWeb, { fetchTimeoutMs: 50 })
})
afterEach(async () => {
for (const res of openSockets) res.destroy()
await tfiber.dispose()
await new Promise<void>(resolve => slowServer.close(() => { resolve() }))
})
it('returns a structured TOOL_TIMEOUT (not the provider WEB_FETCH_TIMEOUT) when the tool-call budget wins', async () => {
const out = await tctx.tools.execute({ callId: CallId('slow-1'), name: 'web_fetch', arguments: { url: slowBase } })
expect(out.isError).toBe(true)
// The outer tool-call deadline won: TOOL_TIMEOUT, owned by dsh-timeout-policy,
// NOT the provider's own WEB_FETCH_TIMEOUT (its 30s backstop never fired).
expect(out.error?.code).toBe('TOOL_TIMEOUT')
const text = out.content.map(b => (b.type === 'text' ? b.text : '')).join('')
expect(text).toContain('timed out after 50ms')
})
it('the provider backstop still protects a DIRECT ctx.web.fetch() call (no tool-call policy in that path)', async () => {
// A direct seam caller does not go through tools/execute, so the tool-call
// policy never applies; the provider's OWN timeout is the only budget. A
// short per-request hint proves the provider backstop is intact and classifies
// as WEB_FETCH_TIMEOUT (the provider-owned code), never TOOL_TIMEOUT.
const err = await tctx.web.fetch({ url: slowBase, timeoutMs: 50 }).then(
() => undefined,
(e: unknown) => e as { code?: string },
)
expect(err?.code).toBe('WEB_FETCH_TIMEOUT')
})
})

View File

@@ -110,10 +110,9 @@ describe('fetch formatting', () => {
expect(renderBody({ kind: 'html', content: '<p>y</p>' })).toBe('y')
})
it('validates url and timeout', () => {
it('validates url (non-empty), no timeout parameter', () => {
expect(() => parseFetchArgs({ url: ' ' })).toThrow('non-empty')
expect(() => parseFetchArgs({ url: 'https://a.test', timeout_ms: -1 })).toThrow('positive')
expect(parseFetchArgs({ url: 'https://a.test', timeout_ms: 5 })).toEqual({ url: 'https://a.test', timeoutMs: 5 })
expect(parseFetchArgs({ url: 'https://a.test' })).toEqual({ url: 'https://a.test' })
})
it('presents a fetch call as a fetch-kind card titled by the url', () => {
@@ -249,7 +248,7 @@ describe('tool-web execution through the real registry', () => {
expect('default' in ToolWeb).toBe(false)
})
it('executes web_fetch, forwarding timeout_ms and the abort signal to the seam', async () => {
it('executes web_fetch, forwarding the url (no timeout param) and the abort signal to the seam', async () => {
const seen: { request?: { url: string; timeoutMs?: number }; signal?: AbortSignal | undefined } = {}
const fetchProvider = {
id: 'stub-fetch',
@@ -262,13 +261,35 @@ describe('tool-web execution through the real registry', () => {
}
const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
const controller = new AbortController()
const out = await ctx.tools.execute({ callId: CallId('fetch-1'), name: 'web_fetch', arguments: { url: 'https://a.test', timeout_ms: 1234 }, signal: controller.signal })
const out = await ctx.tools.execute({ callId: CallId('fetch-1'), name: 'web_fetch', arguments: { url: 'https://a.test' }, signal: controller.signal })
expect(out.isError).toBe(false)
expect(seen.request).toEqual({ url: 'https://a.test', timeoutMs: 1234 })
// The model schema exposes no timeout: the tool forwards only the url; the
// tool-call budget is owned by dsh-timeout-policy over exec.signal.
expect(seen.request).toEqual({ url: 'https://a.test' })
expect(seen.signal).toBe(controller.signal)
await fiber.dispose()
})
it('executes web_fetch with no caller signal (forwards undefined to the seam)', async () => {
const seen: { signal?: AbortSignal | undefined; passedExec?: boolean } = {}
const fetchProvider = {
id: 'stub-fetch',
status: () => available,
fetch: (request: { url: string }, exec?: { signal?: AbortSignal }) => {
seen.passedExec = exec !== undefined
seen.signal = exec?.signal
return Promise.resolve({ providerId: 'stub-fetch', url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false })
},
}
const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
// No signal on the execution: the tool passes `undefined` (not `{ signal: undefined }`).
const out = await ctx.tools.execute({ callId: CallId('fetch-2'), name: 'web_fetch', arguments: { url: 'https://a.test' } })
expect(out.isError).toBe(false)
expect(seen.passedExec).toBe(false)
expect(seen.signal).toBeUndefined()
await fiber.dispose()
})
it('executes web_search, forwarding the abort signal to the seam', async () => {
const seen: { signal?: AbortSignal | undefined } = {}
const provider: WebSearchProvider = {
@@ -328,3 +349,31 @@ describe('searchMaxResults is plugin config', () => {
.rejects.toThrow(/tool-web: searchMaxResults must be a positive integer/)
})
})
describe('tool-call timeout budget is plugin config', () => {
it('attaches the default 30s budget to web_fetch and web_search', async () => {
const { fiber, ctx } = await mountTools()
expect(ctx.tools.get('web_fetch')?.timeoutMs).toBe(30_000)
expect(ctx.tools.get('web_search')?.timeoutMs).toBe(30_000)
await fiber.dispose()
})
it('honors per-tool timeout overrides from config', async () => {
const { fiber, ctx } = await mountTools({ config: { fetchTimeoutMs: 60_000, searchTimeoutMs: 10_000 } })
expect(ctx.tools.get('web_fetch')?.timeoutMs).toBe(60_000)
expect(ctx.tools.get('web_search')?.timeoutMs).toBe(10_000)
await fiber.dispose()
})
it.each([
['fetchTimeoutMs', { fetchTimeoutMs: 0 }],
['searchTimeoutMs', { searchTimeoutMs: -5 }],
])('rejects a non-positive-integer %s at load', async (key, config) => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(WebService, {})
await expect(ctx.plugin(ToolWeb, config))
.rejects.toThrow(new RegExp(`tool-web: ${key} must be a positive integer`))
})
})

View File

@@ -12,6 +12,7 @@
{ "path": "../../llm/llm" },
{ "path": "../../core/tools" },
{ "path": "../../core/system-prompt" },
{ "path": "../../timeout/timeout-policy" },
{ "path": "../web" }
]
}

View File

@@ -6,7 +6,9 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
## Responsibility split
The provider owns **safe resource retrieval**: URL validation, HTTP transport, redirect policy, timeout, abort propagation, byte caps, charset decoding, content-type classification, and binary rejection. `@deepseek-ai/dsh-tool-web` owns **presentation** (HTML→markdown, truncation formatting). A non-2xx HTTP response is a *result* (status code + decoded body), not an error; `WebError` is reserved for failures to safely retrieve or represent the resource.
The provider owns **safe resource retrieval**: URL validation, HTTP transport, redirect policy, a resource-backstop timeout, abort propagation, byte caps, charset decoding, content-type classification, and binary rejection. `@deepseek-ai/dsh-tool-web` owns **presentation** (HTML→markdown, truncation formatting). A non-2xx HTTP response is a *result* (status code + decoded body), not an error; `WebError` is reserved for failures to safely retrieve or represent the resource.
The provider's `timeoutMs`/`maxTimeoutMs` is a **resource backstop** for direct `ctx.web.fetch()` callers and misconfigured deployments — it is NOT the model-facing tool-call budget. The tool-call budget for `web_fetch` is deployment policy owned by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md), which arms a per-call deadline on `exec.signal`. A shipped web-tool deployment sets the provider backstop **above** the `tool-timeout` budget, so the tool-call policy normally wins for model calls (returning `TOOL_TIMEOUT`); when the outer deadline signal reaches this provider first, it classifies as `WEB_ABORTED` and the outer wrapper replaces the result with `TOOL_TIMEOUT`. The provider's own `WEB_FETCH_TIMEOUT` only fires for a direct seam caller whose own budget elapsed.
## Transport hygiene
@@ -24,8 +26,8 @@ The provider owns **safe resource retrieval**: URL validation, HTTP transport, r
| `maxUrlLength` | `2048` | Maximum accepted request URL length. |
| `maxResponseBytes` | `5_000_000` | Maximum response body size in bytes. |
| `maxBodyChars` | `100_000` | Maximum decoded body length in characters. |
| `timeoutMs` | `30_000` | Default fetch timeout. |
| `maxTimeoutMs` | `120_000` | Upper bound for a per-request timeout override. |
| `timeoutMs` | `30_000` | Default fetch timeout — a resource backstop for direct `ctx.web.fetch()` callers, not the model-facing tool-call budget (that is `dsh-timeout-policy`). |
| `maxTimeoutMs` | `120_000` | Upper bound for a per-request timeout override (direct callers). |
| `maxRedirects` | `5` | Maximum same-origin redirect hops (`0` follows none). |
| `userAgent` | `deepseek-harness/…` | `User-Agent` header. |

View File

@@ -22,6 +22,7 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-timeout": "^0.0.1",
"@deepseek-ai/dsh-web": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
@@ -29,6 +30,7 @@
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-web": "workspace:^",
"cordis": "^4.0.0-rc.6"
}

View File

@@ -21,6 +21,7 @@
import { WebError } from '@deepseek-ai/dsh-web'
import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult, WebProviderStatus } from '@deepseek-ai/dsh-web'
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from './policy.ts'
/** Resolved provider limits (the plugin's schemastery Config supplies defaults). */
@@ -56,35 +57,25 @@ export class LocalFetchProvider implements WebFetchProvider {
}
async fetch(request: WebFetchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebFetchResult> {
const timeoutMs = request.timeoutMs !== undefined
? Math.min(request.timeoutMs, this.limits.maxTimeoutMs)
: this.limits.timeoutMs
if (exec?.signal?.aborted) throw new WebError('web fetch aborted', 'WEB_ABORTED')
const timeoutMs = clampTimeout(request.timeoutMs, this.limits.timeoutMs, this.limits.maxTimeoutMs)
// One controller drives both the caller's abort and our own timeout, so the
// network request and the streaming read both stop on either.
const controller = new AbortController()
const onAbort = (): void => { controller.abort() }
if (exec?.signal !== undefined) {
if (exec.signal.aborted) throw new WebError('web fetch aborted', 'WEB_ABORTED')
exec.signal.addEventListener('abort', onAbort, { once: true })
}
const timer = setTimeout(() => { controller.abort(new WebError('web fetch timed out', 'WEB_FETCH_TIMEOUT')) }, timeoutMs)
try {
return await this.followAndRead(request.url, controller)
} finally {
clearTimeout(timer)
if (exec?.signal !== undefined) exec.signal.removeEventListener('abort', onAbort)
}
// One deadline signal fuses the caller's abort with our own timeout, so the
// network request and the streaming read both stop on either. The timeout
// abort carries a TimeoutReason we recover afterward to classify the cause
// (translateAbortOrNetwork), instead of hand-rolling a controller + timer +
// reason-recovery dance.
using d = deadline(exec?.signal, timeoutMs, 'WEB_FETCH_TIMEOUT')
return await this.followAndRead(request.url, d.signal)
}
/** Follow same-origin redirects up to the hop cap, then read the final response. */
private async followAndRead(initialUrl: string, controller: AbortController): Promise<WebFetchResult> {
private async followAndRead(initialUrl: string, signal: AbortSignal): Promise<WebFetchResult> {
let currentUrl = validateFetchUrl(initialUrl, this.limits.maxUrlLength)
let redirectsFollowed = 0
for (;;) {
const response = await this.requestOnce(currentUrl, controller)
const response = await this.requestOnce(currentUrl, signal)
if (isRedirectStatus(response.status)) {
// The redirect budget is enforced BEFORE this hop's target is resolved
@@ -127,20 +118,20 @@ export class LocalFetchProvider implements WebFetchProvider {
continue
}
return await this.readBody(response, currentUrl, controller.signal)
return await this.readBody(response, currentUrl, signal)
}
}
private async requestOnce(url: URL, controller: AbortController): Promise<Response> {
private async requestOnce(url: URL, signal: AbortSignal): Promise<Response> {
try {
return await fetch(url, {
method: 'GET',
redirect: 'manual',
headers: { 'user-agent': this.limits.userAgent, 'accept': 'text/html,application/xhtml+xml,text/*;q=0.9,application/json;q=0.8' },
signal: controller.signal,
signal,
})
} catch (error: unknown) {
throw translateAbortOrNetwork(error, controller.signal)
throw translateAbortOrNetwork(error, signal)
}
}
@@ -255,24 +246,18 @@ function resolveRedirect(location: string, base: URL): URL {
}
/**
* Translate a thrown fetch/stream error into a `WebError`. Our own
* `WEB_FETCH_TIMEOUT` (passed to `controller.abort(reason)`) and any other
* already-typed `WebError` pass through; an `AbortError` becomes `WEB_ABORTED`,
* UNLESS the abort was our timeout — the body-read reader surfaces a generic
* `AbortError` rather than the abort reason, so we recover the timeout's
* `WebError` from `signal.reason`; anything else is a transport/network failure
* (`WEB_PROVIDER_ERROR`).
* Translate a thrown fetch/stream error into a `WebError`, classified by the
* deadline signal rather than the error's shape (which differs by phase: the
* request-phase `fetch` rejects with the abort reason, while the read-phase
* reader surfaces a bare `AbortError`). `timeoutOf(signal, 'WEB_FETCH_TIMEOUT')`
* recovering OUR reason means our timeout fired (`WEB_FETCH_TIMEOUT`); any other
* abort — an upstream cancel, or a foreign/outer deadline's timeout under
* nesting — is `WEB_ABORTED`; a throw with the signal NOT aborted is a
* transport/network failure (`WEB_PROVIDER_ERROR`).
*/
function translateAbortOrNetwork(error: unknown, signal?: AbortSignal): WebError {
if (error instanceof WebError) return error
if (error instanceof DOMException && error.name === 'AbortError') {
// A timeout abort carries its WebError as the signal reason; honor the
// WEB_FETCH_TIMEOUT contract instead of reporting a generic cancellation.
// (Node rejects WITH the reason — the WebError branch above — so this only
// fires on a runtime that surfaces a bare AbortError while reason is set.)
/* v8 ignore next */
if (signal?.reason instanceof WebError) return signal.reason
return new WebError('web fetch aborted', 'WEB_ABORTED', { cause: error })
}
function translateAbortOrNetwork(error: unknown, signal: AbortSignal): WebError {
const timeout = timeoutOf(signal, 'WEB_FETCH_TIMEOUT')
if (timeout !== undefined) return new WebError('web fetch timed out', 'WEB_FETCH_TIMEOUT', { cause: timeout })
if (signal.aborted) return new WebError('web fetch aborted', 'WEB_ABORTED', { cause: error })
return new WebError(`web fetch failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
}

View File

@@ -17,6 +17,9 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/timeout"
},
{
"path": "../web"
}