diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md index e99eda5564..ab1326a21e 100644 --- a/packages/web/tool-web/README.md +++ b/packages/web/tool-web/README.md @@ -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. 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. +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 }`). @@ -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 diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index 4bf9b5e2ff..571ce00797 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -5,10 +5,11 @@ * 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. + * 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' @@ -23,7 +24,8 @@ import { htmlToMarkdown } from './html.ts' /** * 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 (`@deepseek-ai/dsh-timeout-policy`), not a model argument. + * 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 as the seam's request fields. @@ -80,8 +82,10 @@ export function presentFetchCall(args: { url: string }): GenericCallView { * * @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, @@ -94,6 +98,7 @@ export function applyWebFetchTool(ctx: Context): void { parameters: { url: { type: 'string', required: true, description: 'The HTTP(S) URL to fetch.' }, }, + timeoutMs, async execute(args, exec): Promise { const input = parseFetchArgs(args) const result = await ctx.web.fetch( diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index 78b6a4bdf3..0f948eacb6 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -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 = 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) } diff --git a/packages/web/tool-web/src/search.ts b/packages/web/tool-web/src/search.ts index 3776940cde..a7587d328b 100644 --- a/packages/web/tool-web/src/search.ts +++ b/packages/web/tool-web/src/search.ts @@ -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 { const input = parseSearchArgs(args) const result = await ctx.web.search( diff --git a/packages/web/tool-web/tests/integration.spec.ts b/packages/web/tool-web/tests/integration.spec.ts index 03ad76ca0f..de804e2bcd 100644 --- a/packages/web/tool-web/tests/integration.spec.ts +++ b/packages/web/tool-web/tests/integration.spec.ts @@ -41,9 +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 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 } } }) + // 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) }) @@ -135,8 +137,9 @@ describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetc 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) + 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 () => { diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index e060f90a4c..4bb2728df7 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -349,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`)) + }) +})