Merge branch 'master' into recall-renderer-shared
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
}
|
||||
|
||||
7
packages/cordis/README.md
Normal file
7
packages/cordis/README.md
Normal 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` |
|
||||
33
packages/cordis/tool-cordis/README.md
Normal file
33
packages/cordis/tool-cordis/README.md
Normal 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)).
|
||||
42
packages/cordis/tool-cordis/package.json
Normal file
42
packages/cordis/tool-cordis/package.json
Normal 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:^"
|
||||
}
|
||||
}
|
||||
849
packages/cordis/tool-cordis/src/api-catalog.ts
Normal file
849
packages/cordis/tool-cordis/src/api-catalog.ts
Normal 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).' },
|
||||
]
|
||||
39
packages/cordis/tool-cordis/src/fiber-state.ts
Normal file
39
packages/cordis/tool-cordis/src/fiber-state.ts
Normal 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',
|
||||
}
|
||||
447
packages/cordis/tool-cordis/src/guard.ts
Normal file
447
packages/cordis/tool-cordis/src/guard.ts
Normal 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>'
|
||||
}
|
||||
236
packages/cordis/tool-cordis/src/index.ts
Normal file
236
packages/cordis/tool-cordis/src/index.ts
Normal 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,
|
||||
}))
|
||||
}
|
||||
191
packages/cordis/tool-cordis/src/inspect.ts
Normal file
191
packages/cordis/tool-cordis/src/inspect.ts
Normal 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
|
||||
}
|
||||
64
packages/cordis/tool-cordis/src/mount.ts
Normal file
64
packages/cordis/tool-cordis/src/mount.ts
Normal 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)
|
||||
}
|
||||
51
packages/cordis/tool-cordis/src/present.ts
Normal file
51
packages/cordis/tool-cordis/src/present.ts
Normal 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}`,
|
||||
}
|
||||
}
|
||||
201
packages/cordis/tool-cordis/src/sandbox.ts
Normal file
201
packages/cordis/tool-cordis/src/sandbox.ts
Normal 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 `)`.',
|
||||
)
|
||||
}
|
||||
}
|
||||
143
packages/cordis/tool-cordis/tests/cross-mount.spec.ts
Normal file
143
packages/cordis/tool-cordis/tests/cross-mount.spec.ts
Normal 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]')
|
||||
})
|
||||
})
|
||||
104
packages/cordis/tool-cordis/tests/helpers.ts
Normal file
104
packages/cordis/tool-cordis/tests/helpers.ts
Normal 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 []
|
||||
},
|
||||
}
|
||||
}
|
||||
117
packages/cordis/tool-cordis/tests/inspect.spec.ts
Normal file
117
packages/cordis/tool-cordis/tests/inspect.spec.ts
Normal 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.',
|
||||
])
|
||||
})
|
||||
})
|
||||
74
packages/cordis/tool-cordis/tests/integration.spec.ts
Normal file
74
packages/cordis/tool-cordis/tests/integration.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
575
packages/cordis/tool-cordis/tests/mount.spec.ts
Normal file
575
packages/cordis/tool-cordis/tests/mount.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
41
packages/cordis/tool-cordis/tests/present.spec.ts
Normal file
41
packages/cordis/tool-cordis/tests/present.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
295
packages/cordis/tool-cordis/tests/sandbox-context.spec.ts
Normal file
295
packages/cordis/tool-cordis/tests/sandbox-context.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
49
packages/cordis/tool-cordis/tests/tool-cordis.spec.ts
Normal file
49
packages/cordis/tool-cordis/tests/tool-cordis.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
82
packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts
Normal file
82
packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
27
packages/cordis/tool-cordis/tsconfig.json
Normal file
27
packages/cordis/tool-cordis/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
9
packages/timeout/README.md
Normal file
9
packages/timeout/README.md
Normal 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.
|
||||
34
packages/timeout/timeout-policy/README.md
Normal file
34
packages/timeout/timeout-policy/README.md
Normal 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).
|
||||
36
packages/timeout/timeout-policy/package.json
Normal file
36
packages/timeout/timeout-policy/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
115
packages/timeout/timeout-policy/src/index.ts
Normal file
115
packages/timeout/timeout-policy/src/index.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
200
packages/timeout/timeout-policy/tests/timeout-policy.spec.ts
Normal file
200
packages/timeout/timeout-policy/tests/timeout-policy.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
16
packages/timeout/timeout-policy/tsconfig.json
Normal file
16
packages/timeout/timeout-policy/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
{
|
||||
"path": "../../core/agent-core"
|
||||
},
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../tool-ask-user"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-jsonl"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {} })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
{
|
||||
"path": "../../core/agent-core"
|
||||
},
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../tool-ask-user"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-jsonl"
|
||||
}
|
||||
|
||||
20
packages/ui/tool-ask-user/README.md
Normal file
20
packages/ui/tool-ask-user/README.md
Normal 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.
|
||||
38
packages/ui/tool-ask-user/package.json
Normal file
38
packages/ui/tool-ask-user/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
71
packages/ui/tool-ask-user/src/index.ts
Normal file
71
packages/ui/tool-ask-user/src/index.ts
Normal 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) }]
|
||||
},
|
||||
}))
|
||||
}
|
||||
253
packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts
Normal file
253
packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
36
packages/ui/tool-ask-user/tsconfig.json
Normal file
36
packages/ui/tool-ask-user/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
24
packages/ui/user-interaction/README.md
Normal file
24
packages/ui/user-interaction/README.md
Normal 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.
|
||||
34
packages/ui/user-interaction/package.json
Normal file
34
packages/ui/user-interaction/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
128
packages/ui/user-interaction/src/index.ts
Normal file
128
packages/ui/user-interaction/src/index.ts
Normal 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
|
||||
86
packages/ui/user-interaction/tests/user-interaction.spec.ts
Normal file
86
packages/ui/user-interaction/tests/user-interaction.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
24
packages/ui/user-interaction/tsconfig.json
Normal file
24
packages/ui/user-interaction/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)).
|
||||
|
||||
42
packages/util/timeout/README.md
Normal file
42
packages/util/timeout/README.md
Normal 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).
|
||||
30
packages/util/timeout/package.json
Normal file
30
packages/util/timeout/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
162
packages/util/timeout/src/index.ts
Normal file
162
packages/util/timeout/src/index.ts
Normal 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
|
||||
}
|
||||
185
packages/util/timeout/tests/timeout.spec.ts
Normal file
185
packages/util/timeout/tests/timeout.spec.ts
Normal 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
|
||||
})
|
||||
})
|
||||
11
packages/util/timeout/tsconfig.json
Normal file
11
packages/util/timeout/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": []
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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) }]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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`))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/tools" },
|
||||
{ "path": "../../core/system-prompt" },
|
||||
{ "path": "../../timeout/timeout-policy" },
|
||||
{ "path": "../web" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
},
|
||||
{
|
||||
"path": "../web"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user