feat(tool-web): declare web tool timeout budgets via config
fetchTimeoutMs/searchTimeoutMs (default 30000) resolve to each tool's ToolDefinition.timeoutMs, moving the budget's declaration home onto the owning tool plugin and preserving per-tool deployment override without a mistypable central tool-name map.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# @deepseek-ai/dsh-tool-web
|
# @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 }`).
|
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`. |
|
| `search` | `true` | Register `web_search`. |
|
||||||
| `fetch` | `true` | Register `web_fetch`. |
|
| `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). |
|
| `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
|
```yaml
|
||||||
- id: tool-web
|
- id: tool-web
|
||||||
|
|||||||
@@ -5,10 +5,11 @@
|
|||||||
* while the fetch provider owns safe retrieval (transport, redirects, caps).
|
* while the fetch provider owns safe retrieval (transport, redirects, caps).
|
||||||
*
|
*
|
||||||
* The model-facing schema exposes NO timeout knob: the tool-call budget is
|
* 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`
|
* deployment policy DECLARED via this package's `fetchTimeoutMs` config (attached
|
||||||
* wrapper), matching the reference-agent `WebFetch` shape. This tool just
|
* as `ToolDefinition.timeoutMs`) and ENFORCED by `@deepseek-ai/dsh-timeout-policy`
|
||||||
* forwards the (possibly deadline-derived) `exec.signal` to `ctx.web`; the
|
* (a `tools/execute` wrapper), matching the reference-agent `WebFetch` shape. This
|
||||||
* provider keeps its own timeout only as a resource backstop for direct callers.
|
* 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'
|
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`.
|
* 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
|
* 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.
|
* @param args - the schema-validated `web_fetch` arguments.
|
||||||
* @returns the arguments as the seam's request fields.
|
* @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
|
* @param ctx - context whose `tools` and `systemPrompt` registries receive the
|
||||||
* registrations; both are effect-scoped and unregister on plugin dispose.
|
* 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({
|
ctx.systemPrompt.section({
|
||||||
name: 'tool:web_fetch',
|
name: 'tool:web_fetch',
|
||||||
order: 111,
|
order: 111,
|
||||||
@@ -94,6 +98,7 @@ export function applyWebFetchTool(ctx: Context): void {
|
|||||||
parameters: {
|
parameters: {
|
||||||
url: { type: 'string', required: true, description: 'The HTTP(S) URL to fetch.' },
|
url: { type: 'string', required: true, description: 'The HTTP(S) URL to fetch.' },
|
||||||
},
|
},
|
||||||
|
timeoutMs,
|
||||||
async execute(args, exec): Promise<ContentBlock[]> {
|
async execute(args, exec): Promise<ContentBlock[]> {
|
||||||
const input = parseFetchArgs(args)
|
const input = parseFetchArgs(args)
|
||||||
const result = await ctx.web.fetch(
|
const result = await ctx.web.fetch(
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ export const name = 'tool-web'
|
|||||||
/** Services required by the web tool suite. */
|
/** Services required by the web tool suite. */
|
||||||
export const inject = ['tools', 'web', 'systemPrompt']
|
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 {
|
export interface Config {
|
||||||
/** Register `web_search`. Defaults to true. */
|
/** Register `web_search`. Defaults to true. */
|
||||||
search?: boolean
|
search?: boolean
|
||||||
@@ -41,12 +44,18 @@ export interface Config {
|
|||||||
fetch?: boolean
|
fetch?: boolean
|
||||||
/** Upper bound on sources returned by one `web_search` call. */
|
/** Upper bound on sources returned by one `web_search` call. */
|
||||||
searchMaxResults?: number
|
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({
|
export const Config: z<Config> = z.object({
|
||||||
search: z.boolean().default(true),
|
search: z.boolean().default(true),
|
||||||
fetch: z.boolean().default(true),
|
fetch: z.boolean().default(true),
|
||||||
searchMaxResults: z.number().default(WEB_SEARCH_MAX_RESULTS),
|
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. */
|
/** 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
|
* 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
|
* fiber-scoped (the effect-based registries clean up on dispose), so no manual
|
||||||
* teardown is needed.
|
* teardown is needed.
|
||||||
*/
|
*/
|
||||||
@@ -69,6 +81,8 @@ export function apply(ctx: Context, config: Config): void {
|
|||||||
// schemastery (Config) has already filled every defaulted field.
|
// schemastery (Config) has already filled every defaulted field.
|
||||||
const resolved = config as ResolvedConfig
|
const resolved = config as ResolvedConfig
|
||||||
assertPositiveInteger('searchMaxResults', resolved.searchMaxResults)
|
assertPositiveInteger('searchMaxResults', resolved.searchMaxResults)
|
||||||
if (resolved.search) applyWebSearchTool(ctx, resolved.searchMaxResults)
|
assertPositiveInteger('fetchTimeoutMs', resolved.fetchTimeoutMs)
|
||||||
if (resolved.fetch) applyWebFetchTool(ctx)
|
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.
|
* registrations; both are effect-scoped and unregister on plugin dispose.
|
||||||
* @param maxResults - the deployment's source cap, sent as every seam
|
* @param maxResults - the deployment's source cap, sent as every seam
|
||||||
* request's `maxResults`.
|
* 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({
|
ctx.systemPrompt.section({
|
||||||
name: 'tool:web_search',
|
name: 'tool:web_search',
|
||||||
order: 110,
|
order: 110,
|
||||||
@@ -106,6 +108,7 @@ export function applyWebSearchTool(ctx: Context, maxResults: number): void {
|
|||||||
parameters: {
|
parameters: {
|
||||||
query: { type: 'string', required: true, description: 'The search query.' },
|
query: { type: 'string', required: true, description: 'The search query.' },
|
||||||
},
|
},
|
||||||
|
timeoutMs,
|
||||||
async execute(args, exec): Promise<ContentBlock[]> {
|
async execute(args, exec): Promise<ContentBlock[]> {
|
||||||
const input = parseSearchArgs(args)
|
const input = parseSearchArgs(args)
|
||||||
const result = await ctx.web.search(
|
const result = await ctx.web.search(
|
||||||
|
|||||||
@@ -41,9 +41,11 @@ beforeEach(async () => {
|
|||||||
await ctx.plugin(WebService, { searchProvider: WebSearchExa.EXA_PROVIDER_ID, fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID })
|
await ctx.plugin(WebService, { searchProvider: WebSearchExa.EXA_PROVIDER_ID, fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID })
|
||||||
await ctx.plugin(WebFetchLocal, {})
|
await ctx.plugin(WebFetchLocal, {})
|
||||||
await ctx.plugin(WebSearchExa, { apiKey: 'exa-key', baseURL: 'https://api.exa.test' })
|
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 shipped deployment shape: the tool-call budget is declared by tool-web
|
||||||
// the model tools, set above the provider backstop so the policy normally wins.
|
// config (default 30s, attached as ToolDefinition.timeoutMs) and enforced by
|
||||||
await ctx.plugin(TimeoutPolicy, { tools: { web_fetch: { timeoutMs: 30_000 }, web_search: { timeoutMs: 30_000 } } })
|
// 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)
|
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 })
|
await tctx.plugin(WebService, { fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID })
|
||||||
// Provider backstop well ABOVE the tool-call budget, so the policy wins.
|
// 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(WebFetchLocal, { timeoutMs: 30_000, maxTimeoutMs: 60_000 })
|
||||||
await tctx.plugin(TimeoutPolicy, { tools: { web_fetch: { timeoutMs: 50 } } })
|
await tctx.plugin(TimeoutPolicy)
|
||||||
tfiber = await tctx.plugin(ToolWeb)
|
// The tool-call budget is declared by tool-web config, enforced by the policy.
|
||||||
|
tfiber = await tctx.plugin(ToolWeb, { fetchTimeoutMs: 50 })
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|||||||
@@ -349,3 +349,31 @@ describe('searchMaxResults is plugin config', () => {
|
|||||||
.rejects.toThrow(/tool-web: searchMaxResults must be a positive integer/)
|
.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`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user