feat(timeout): add tools/execute seam + tool-timeout policy plugin
Model-facing tool-call budgets were tangled into each capability's schema (bash timeoutMs, web_fetch timeout_ms) with no shared home. Add a tools/execute around-dispatch waterfall to dsh-tools whose base next() is the dispatch-with-normalization thunk, and a new @deepseek-ai/dsh-timeout-policy plugin (packages/timeout/) that arms a per-tool deadline on exec.signal and returns a structured TOOL_TIMEOUT when it wins. Migrate web_fetch (drop the model-facing timeout_ms) and web_search onto it; the fetch provider keeps its timeout only as a resource backstop for direct callers. bash and hook command execution keep BASH_TIMEOUT unchanged. Named the plugin timeout-policy (not the RFC's tool-timeout) so it does not trip the gen-tool-catalog packages/*/tool-* completeness guard, and replace exec.signal by in-place mutation before next() since cordis waterfall next() ignores passed arguments. RFC moved to implemented/ recording both deviations.
This commit is contained in:
@@ -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 — the tool-call budget is deployment policy owned 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
|
||||
|
||||
|
||||
@@ -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,12 @@
|
||||
* 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 owned 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,12 +21,9 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { htmlToMarkdown } from './html.ts'
|
||||
|
||||
/** Validate value constraints the schema DSL can't express. */
|
||||
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 }
|
||||
}
|
||||
|
||||
/** Render a fetched body to model-facing markdown text. */
|
||||
@@ -44,7 +47,7 @@ export function formatFetchOutput(result: WebFetchResult): string {
|
||||
}
|
||||
|
||||
/** Pending-call presentation: a fetch card titled by the URL. */
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -61,12 +64,11 @@ 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).' },
|
||||
},
|
||||
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) }]
|
||||
|
||||
@@ -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,9 @@ 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 deployment policy over
|
||||
// the model tools, set above the provider backstop so the policy normally wins.
|
||||
await ctx.plugin(TimeoutPolicy, { tools: { web_fetch: { timeoutMs: 30_000 }, web_search: { timeoutMs: 30_000 } } })
|
||||
fiber = await ctx.plugin(ToolWeb)
|
||||
})
|
||||
|
||||
@@ -96,3 +101,69 @@ 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, { tools: { web_fetch: { timeoutMs: 50 } } })
|
||||
tfiber = await tctx.plugin(ToolWeb)
|
||||
})
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user