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:
Dudu-0223
2026-07-08 10:06:07 +08:00
parent 6beed9a883
commit 8190016e2b
32 changed files with 1004 additions and 84 deletions

View File

@@ -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 = {