Browse Source

refactor(timeout-policy): read budget from ToolDefinition, drop config

The enforcer now reads ctx.tools.get(exec.name).timeoutMs instead of a
free-text tool-name config map, so a mistyped name is impossible and the
tools/change warn-once apparatus is gone. exec.name always resolves in the
registry during dispatch, so there is no unknown-name path to warn about.
Dudu-0223 3 tháng trước cách đây
mục cha
commit
534b1dc6d0

+ 7 - 21
packages/timeout/timeout-policy/README.md

@@ -1,47 +1,33 @@
 # dsh-timeout-policy
 
-Tool-call timeout policy: a single `tools/execute` around-dispatch listener that arms a per-call cooperative deadline on `exec.signal` for each configured tool and returns a structured `TOOL_TIMEOUT` result when that deadline wins. It is the reference `tools/execute` wrapper and the deployment-owned home for model-facing tool-call budgets (the timeout-library RFC's foreseen middleware).
+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` / `Config` / `apply`), not a service. It registers no tool and injects nothing — it consumes `ctx.tools`'s `tools/execute` waterfall, which the `dsh-tools` registry always provides.
-
-### Config
-
-Per-tool policy, keyed by the model-facing tool name. There is deliberately **no global default** (a global budget would silently start failing any tool that runs long once the plugin loads) and **no model-facing override** (timeout is deployment policy, not prompt semantics) in this version.
+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'
-  config:
-    tools:
-      web_fetch:
-        timeoutMs: 30000
-      web_search:
-        timeoutMs: 30000
 ```
 
-| Key | Type | Meaning |
-|---|---|---|
-| `tools` | `Record<string, { timeoutMs }>` | Per-tool timeout policy; an unlisted tool gets no deadline. `timeoutMs` is required per configured tool and must be positive finite. |
-
-A configured tool name that never registers (a typo like `web_fech`, or a stale key) would silently apply the timeout to nothing. Because the tool set is dynamic (plugins register in `cordis.yml` order, HMR re-registers), this is not a load-time error — a real tool may register later. Instead, on every `tools/change` (and once at load) the plugin `logger.warn`s each configured name still absent from `ctx.tools`, warning each name at most once so a late registration silences it. This mirrors `dsh-tool-subagent`'s lifecycle-driven handling of a configured-but-unregistered provider name.
+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 **configured** tool the listener:
+For a tool that **declares a `timeoutMs`** the listener:
 
-1. Arms `deadline(exec.signal, timeoutMs, 'TOOL_TIMEOUT')` — one signal fusing the caller's abort with this plugin's timer (`@deepseek-ai/dsh-timeout`).
+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' }`.
 
-An **unconfigured** tool delegates untouched (no deadline).
+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). **"Configured" therefore means "cooperative with `exec.signal`"**: a tool that ignores the signal will not stop on timeout. A deployment must only configure tools that forward the signal to their implementation — 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.
+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
 

+ 29 - 90
packages/timeout/timeout-policy/src/index.ts

@@ -1,16 +1,20 @@
 /**
- * `@deepseek-ai/dsh-timeout-policy`: the tool-call timeout policy plugin. It
- * registers ONE `tools/execute` around-dispatch listener that, for each
- * configured tool, arms a per-call deadline on `exec.signal` and returns a
- * structured `TOOL_TIMEOUT` result when that deadline wins.
+ * `@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 configured tool (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`). "Configured" therefore MEANS "cooperative with `exec.signal`":
- * a tool that ignores the signal will not stop on timeout, so a deployment must
- * only list tools that forward it (the shipped web tools are the reference).
+ * 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
@@ -29,7 +33,6 @@
  */
 
 import type { Context } from 'cordis'
-import z from 'schemastery'
 import type { CallId } from '@deepseek-ai/dsh-llm'
 import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
 import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
@@ -46,40 +49,9 @@ 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 (`tools/change`, `get`). */
+/** The tool registry seam this plugin wraps (`tools/execute`) and reads (`get`). */
 export const inject = ['tools']
 
-/** Per-tool timeout policy. `timeoutMs` is required and must be positive finite. */
-export interface ToolTimeoutPolicy {
-  /** The per-call cooperative deadline for this tool, in milliseconds. */
-  timeoutMs: number
-}
-
-/**
- * Plugin config: per-tool timeout policy, keyed by the model-facing tool name.
- * There is deliberately NO global default (a global budget would silently start
- * failing any tool that happens to run long once the plugin loads) and NO model
- * override (timeout is deployment policy, not prompt semantics) in this version.
- */
-export interface Config {
-  /** Timeout policy per tool name; an unlisted tool gets no deadline from this plugin. */
-  tools?: Record<string, ToolTimeoutPolicy>
-}
-
-export const Config: z<Config> = z.object({
-  tools: z.dict(z.object({ timeoutMs: z.number() })).default({}),
-})
-
-/** The shape after schemastery fills `tools` with its `{}` default. */
-type ResolvedConfig = Required<Config>
-
-/** A per-tool timeout must be a positive finite number (0 is not a "disable" value). */
-function assertPositiveFinite(toolName: string, value: number): void {
-  if (!Number.isFinite(value) || value <= 0) {
-    throw new Error(`timeout-policy: tools.${toolName}.timeoutMs must be a positive finite number`)
-  }
-}
-
 /**
  * The structured result substituted when this plugin's deadline wins. `content`
  * is the model-facing message; `error.code` is the same {@link TOOL_TIMEOUT}
@@ -99,56 +71,23 @@ export function toolTimeoutResult(callId: CallId, timeoutMs: number): ToolExecut
 }
 
 /**
- * Register the tool-call timeout policy. For a configured tool 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. An unconfigured tool
- * delegates untouched.
+ * 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.
  *
- * A configured tool name that is never registered is almost always a typo or a
- * stale config key (e.g. `web_fech` for `web_fetch`): the wrapper would then
- * silently never fire for the intended tool. Since the tool set is dynamic
- * (plugins register in `cordis.yml` order, and HMR re-registers), this cannot
- * be a load-time hard error — a real tool may register later. Instead, mirror
- * `dsh-tool-subagent`'s lifecycle-driven approach: on every `tools/change` (and
- * once at apply), `logger.warn` each configured name still absent from the
- * registry, warning each name at most once so a late registration silences it.
+ * 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, config: Config): void {
-  // schemastery (Config) has already filled `tools` with its {} default.
-  const resolved = config as ResolvedConfig
-  for (const [toolName, policy] of Object.entries(resolved.tools)) {
-    assertPositiveFinite(toolName, policy.timeoutMs)
-  }
-
-  // Warn once per configured name that no registered tool matches, so a typo'd
-  // or stale config key is visible instead of silently applying to nothing. A
-  // name that later registers is dropped from `pending` before it is warned; a
-  // name that never registers is warned at most once (moved to `warned`), so a
-  // busy `tools/change` stream cannot spam the same key.
-  const pending = new Set(Object.keys(resolved.tools))
-  const warned = new Set<string>()
-  const warnUnknownToolNames = (): void => {
-    const nowUnknown: string[] = []
-    for (const name of pending) {
-      if (ctx.tools.get(name) !== undefined) { pending.delete(name); continue }
-      if (!warned.has(name)) { warned.add(name); nowUnknown.push(name) }
-    }
-    if (nowUnknown.length > 0) {
-      ctx.logger.warn(
-        `timeout-policy: configured timeout for unregistered tool(s) ${nowUnknown.map(n => `"${n}"`).join(', ')} `
-        + '— check for a typo or stale config key; the timeout applies to nothing until the tool registers.',
-      )
-    }
-  }
-  ctx.on('tools/change', warnUnknownToolNames)
-  warnUnknownToolNames()
-
+export function apply(ctx: Context): void {
   ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
-    const timeoutMs = resolved.tools[exec.name]?.timeoutMs
-    // Unconfigured tool: no deadline, delegate unchanged.
+    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)

+ 46 - 166
packages/timeout/timeout-policy/tests/timeout-policy.spec.ts

@@ -15,188 +15,86 @@ import ToolRegistry, { defineTool, type ToolExecution, type ToolExecutionResult,
 import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
 import { TOOL_TIMEOUT, toolTimeoutResult } from '@deepseek-ai/dsh-timeout-policy'
 
-/** Mount the registry + the timeout-policy plugin with the given per-tool config. */
-async function setup(tools: Record<string, { timeoutMs: number }> = {}) {
+/** 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, { tools })
+  await ctx.plugin(timeoutPolicy)
   return ctx
 }
 
-/** A fast tool: returns immediately, ignoring the signal. */
-const fastTool = defineTool({
-  name: 'fast',
-  description: 'returns at once',
-  parameters: {},
-  async execute() { return [{ type: 'text' as const, text: 'ok' }] },
-})
-
 /** A cooperative tool that settles ONLY when its exec.signal aborts (returns text). */
 const cooperativeTool = defineTool({
-  name: 'slow',
-  description: 'stops when aborted',
-  parameters: {},
+  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) })
-    })
+    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: {},
+  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')) })
-    })
+    return new Promise((_resolve, reject) => { exec.signal?.addEventListener('abort', () => { reject(new HarnessError('web fetch aborted', 'WEB_ABORTED')) }) })
   },
 })
 
-describe('timeout-policy config validation', () => {
-  it('rejects a non-positive timeout at apply', async () => {
-    const ctx = new Context()
-    await ctx.plugin(SystemPrompt)
-    await ctx.plugin(ToolRegistry)
-    await expect(ctx.plugin(timeoutPolicy, { tools: { web_fetch: { timeoutMs: 0 } } }))
-      .rejects.toThrow('tools.web_fetch.timeoutMs must be a positive finite number')
-  })
-
-  it('rejects a non-finite timeout at apply', async () => {
-    const ctx = new Context()
-    await ctx.plugin(SystemPrompt)
-    await ctx.plugin(ToolRegistry)
-    await expect(ctx.plugin(timeoutPolicy, { tools: { web_fetch: { timeoutMs: Infinity } } }))
-      .rejects.toThrow('must be a positive finite number')
-  })
-
-  it('mounts with no config (empty tools default) and delegates every call', async () => {
-    const ctx = await setup()
-    ctx.tools.register(fastTool)
-    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 })
-  })
-})
-
-describe('timeout-policy unknown-tool-name diagnostics', () => {
-  it('warns for a configured tool name that is never registered (typo/stale key)', async () => {
-    const ctx = new Context()
-    await ctx.plugin(SystemPrompt)
-    await ctx.plugin(ToolRegistry)
-    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
-    // web_fech is a typo for web_fetch, and no tool by that name is registered.
-    await ctx.plugin(timeoutPolicy, { tools: { web_fech: { timeoutMs: 30_000 } } })
-    expect(warn).toHaveBeenCalledTimes(1)
-    expect(warn.mock.calls[0]?.[0]).toContain('"web_fech"')
-    expect(warn.mock.calls[0]?.[0]).toContain('unregistered tool')
-  })
-
-  it('does NOT warn when the configured tool is already registered at load', async () => {
-    const ctx = new Context()
-    await ctx.plugin(SystemPrompt)
-    await ctx.plugin(ToolRegistry)
-    ctx.tools.register(fastTool)
-    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
-    await ctx.plugin(timeoutPolicy, { tools: { fast: { timeoutMs: 30_000 } } })
-    expect(warn).not.toHaveBeenCalled()
-  })
-
-  it('does NOT warn once a configured tool registers LATER (load-order safe)', async () => {
-    const ctx = new Context()
-    await ctx.plugin(SystemPrompt)
-    await ctx.plugin(ToolRegistry)
-    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
-    // Plugin loads before the tool it configures — the initial check would warn,
-    // so register first is the interesting case: mount with a not-yet-present
-    // name, then register it; the tools/change listener must clear it.
-    await ctx.plugin(timeoutPolicy, { tools: { late: { timeoutMs: 30_000 } } })
-    expect(warn).toHaveBeenCalledTimes(1) // absent at load → warned once
-    warn.mockClear()
-    ctx.tools.register({ ...fastTool, name: 'late' }) // now it registers
-    // A subsequent tools/change must NOT re-warn the now-registered name.
-    ctx.tools.register({ ...fastTool, name: 'other' })
-    expect(warn).not.toHaveBeenCalled()
-  })
-
-  it('warns at most once per unknown name across repeated tools/change', async () => {
-    const ctx = new Context()
-    await ctx.plugin(SystemPrompt)
-    await ctx.plugin(ToolRegistry)
-    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
-    await ctx.plugin(timeoutPolicy, { tools: { ghost: { timeoutMs: 30_000 } } })
-    expect(warn).toHaveBeenCalledTimes(1) // apply-time check
-    // Each register/unregister emits tools/change; the ghost stays unknown but
-    // must not be warned again.
-    const dispose = ctx.tools.register(fastTool)
-    dispose()
-    ctx.tools.register({ ...fastTool, name: 'another' })
-    expect(warn).toHaveBeenCalledTimes(1)
-  })
-})
-
 describe('timeout-policy delegation (unconfigured / fast)', () => {
-  it('delegates an UNCONFIGURED tool unchanged and does not touch exec.signal', async () => {
-    const ctx = await setup({ other: { timeoutMs: 50 } })
+  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({ ...fastTool, name: 'probe', async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } })
-
+    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) // no deadline derived for an unconfigured tool
+    expect(seenSignal).toBe(upstream)
   })
 
-  it('a configured tool that returns fast keeps its own result (no timeout)', async () => {
-    const ctx = await setup({ fast: { timeoutMs: 10_000 } })
-    ctx.tools.register(fastTool)
+  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 configured tool receives the DERIVED deadline signal (not the caller signal) during dispatch', async () => {
-    const ctx = await setup({ probe: { timeoutMs: 10_000 } })
+  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({ ...fastTool, name: 'probe', async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } })
-
+    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) // the plugin swapped in its fused deadline signal
+    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({ fast: { timeoutMs: 10_000 } })
-    ctx.tools.register(fastTool)
+    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()
-    })
-
+    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) // restored to the caller's own signal, not the deadline
+    expect(postSignal).toBe(upstream)
   })
 
   it('deletes exec.signal again when the caller passed none', async () => {
-    const ctx = await setup({ fast: { timeoutMs: 10_000 } })
-    ctx.tools.register(fastTool)
+    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()
-    })
-
+    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) // no caller signal → exec.signal absent again after wrapping
+    expect(hadSignal).toBe(false)
   })
 })
 
@@ -205,13 +103,11 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
   afterEach(() => { vi.useRealTimers() })
 
   it('replaces a cooperative tool result with TOOL_TIMEOUT when its own deadline fires', async () => {
-    const ctx = await setup({ slow: { timeoutMs: 100 } })
+    const ctx = await setup()
     ctx.tools.register(cooperativeTool)
-
     const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'slow', arguments: {} })
-    await vi.advanceTimersByTimeAsync(150) // past the 100ms deadline: the timer fires, the tool settles
+    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' }],
@@ -220,33 +116,25 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
     })
   })
 
-  it('replaces a provider-owned abort ERROR result with TOOL_TIMEOUT (not WEB_ABORTED) when the signal was ours', async () => {
-    const ctx = await setup({ aborter: { timeoutMs: 100 } })
+  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
-
-    // Dispatch first normalized the thrown WEB_ABORTED into an isError result;
-    // the plugin then replaced THAT with TOOL_TIMEOUT because its own timer won.
     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({ slow: { timeoutMs: 100 } })
+    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') // fires before the 100ms timer
+    upstream.abort('user cancelled')
     await vi.advanceTimersByTimeAsync(0)
     const result = await pending
-
-    // Our timer never fired, so timeoutOf(code) is undefined: the tool's own
-    // cooperative result stands, not a TOOL_TIMEOUT.
     expect(result.isError).toBe(false)
     expect(result.content[0]).toMatchObject({ text: 'stopped cooperatively' })
   })
@@ -273,46 +161,38 @@ describe('timeout-policy disposal (HMR safety)', () => {
     await ctx.plugin(SystemPrompt)
     await ctx.plugin(ToolRegistry)
     let seenSignal: AbortSignal | undefined
-    ctx.tools.register({ ...fastTool, name: 'probe', async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } })
-
-    // Mount the policy on its OWN fiber so disposing it removes only the wrapper.
-    const fiber = await ctx.plugin(timeoutPolicy, { tools: { probe: { timeoutMs: 10_000 } } })
+    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) // wrapper live: dispatch saw the derived deadline signal
-
+    expect(seenSignal).not.toBe(upstream)
     await fiber.dispose()
-    // Listener gone: the tool now receives the caller's own signal unwrapped. A
-    // leaked stale wrapper would still derive a deadline and fail this.
     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/Config through unwrapExports', () => {
+  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')
-    expect(unwrapped.Config).toBeDefined()
   })
 
-  it('boots over ctx.tools through the unwrapped module and wraps a configured tool', async () => {
+  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(fastTool)
-
+    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, { tools: { fast: { timeoutMs: 5_000 } } })
-    // A configured fast tool still succeeds (deadline never fires); this proves
-    // the wrapper is live through the real Loader path.
+    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()