Merge branch 'master' into agent-request-messages
This commit is contained in:
@@ -16,6 +16,7 @@ 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 |
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
@@ -30,6 +31,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)
|
||||
}
|
||||
|
||||
@@ -101,6 +101,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 +113,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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -62,6 +62,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 +283,148 @@ 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, next) => {
|
||||
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, next) => { 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, next) => {
|
||||
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, next) => 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, next) => {
|
||||
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, _next): Promise<import('@deepseek-ai/dsh-tools').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 +1142,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,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