diff --git a/packages/fold-agent/src/Tools/WebFetchTool.ts b/packages/fold-agent/src/Tools/WebFetchTool.ts index 661b23b..1844e79 100644 --- a/packages/fold-agent/src/Tools/WebFetchTool.ts +++ b/packages/fold-agent/src/Tools/WebFetchTool.ts @@ -206,7 +206,7 @@ export const webFetchTool = (): FoldTool => { return yield* failWith('URL must start with http:// or https://') } - const timeoutMs = Math.min(params.timeout ?? defaultTimeoutMs, maxTimeoutMs) + const timeoutMs = Math.min((params.timeout_seconds ?? defaultTimeoutMs / 1000) * 1000, maxTimeoutMs) const document = yield* fetchDocument(params.url, timeoutMs) return yield* renderDocument(params.url, document, params.format ?? 'markdown', turndown) }).pipe( diff --git a/packages/fold-agent/src/Tools/WebSearchTool.ts b/packages/fold-agent/src/Tools/WebSearchTool.ts index 1899172..0045ec9 100644 --- a/packages/fold-agent/src/Tools/WebSearchTool.ts +++ b/packages/fold-agent/src/Tools/WebSearchTool.ts @@ -6,7 +6,8 @@ import { webSearchToolContract, type FoldTool, } from '@humanlayer/fold-core' -import { Effect, Predicate } from 'effect' +import { Effect, Fiber, Predicate } from 'effect' +import { FetchHttpClient } from 'effect/unstable/http' const defaultTimeoutMs = 25_000 const maxNumResults = 20 @@ -106,8 +107,12 @@ const callMcp = (input: { readonly timeoutMs: number }): Effect.Effect => Effect.gen(function* () { + const fetch = yield* FetchHttpClient.Fetch const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), input.timeoutMs) + const timer = yield* Effect.sleep(input.timeoutMs).pipe( + Effect.andThen(Effect.sync(() => controller.abort())), + Effect.forkChild, + ) return yield* Effect.gen(function* () { const response = yield* Effect.tryPromise({ @@ -150,7 +155,7 @@ const callMcp = (input: { }), }) return yield* parseMcpResponse(body) - }).pipe(Effect.ensuring(Effect.sync(() => clearTimeout(timer)))) + }).pipe(Effect.ensuring(Fiber.interrupt(timer))) }) export const webSearchTool = (options?: WebSearchToolOptions): FoldTool => @@ -162,7 +167,10 @@ export const webSearchTool = (options?: WebSearchToolOptions): FoldTool => const provider = selectProvider(currentAgent.agentId, options) const numResults = Math.min(params.numResults ?? 8, maxNumResults) const contextMaxCharacters = Math.min(params.contextMaxCharacters ?? 10_000, maxContextCharacters) - const timeoutMs = options?.timeoutMs ?? defaultTimeoutMs + const timeoutMs = + params.timeout_seconds === undefined + ? (options?.timeoutMs ?? defaultTimeoutMs) + : params.timeout_seconds * 1000 const result = provider === 'exa' diff --git a/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts b/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts index 2fd48b4..f1fcf6e 100644 --- a/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts +++ b/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts @@ -51,7 +51,7 @@ const firstText = (result: unknown): string => { const fetchResult = ( url: string, - options?: { readonly format?: 'markdown' | 'text' | 'html'; readonly timeout?: number }, + options?: { readonly format?: 'markdown' | 'text' | 'html'; readonly timeout_seconds?: number }, ) => runHandler(handlerOf(webFetchTool())({ url, ...options })) let server: Server @@ -202,7 +202,7 @@ it.live('rejects non-http(s) URLs before making a request', () => it.live('times out a response that never arrives', () => Effect.gen(function* () { - const failure = yield* fetchResult(`${baseUrl}/slow`, { timeout: 300 }).pipe(Effect.flip) + const failure = yield* fetchResult(`${baseUrl}/slow`, { timeout_seconds: 0.3 }).pipe(Effect.flip) expect(messageOf(failure)).toContain('timed out') }), diff --git a/packages/fold-agent/test/Tools/WebTimeouts.vi.test.ts b/packages/fold-agent/test/Tools/WebTimeouts.vi.test.ts new file mode 100644 index 0000000..33aae9c --- /dev/null +++ b/packages/fold-agent/test/Tools/WebTimeouts.vi.test.ts @@ -0,0 +1,142 @@ +import { it } from '@effect/vitest' +import { webFetchToolContract, webSearchToolContract, type FoldTool } from '@humanlayer/fold-core' +import { Effect, Fiber, Schema } from 'effect' +import { TestClock } from 'effect/testing' +import { FetchHttpClient } from 'effect/unstable/http' +import { expect } from 'vitest' + +import { webFetchTool } from '../../src/Tools/WebFetchTool' +import { webSearchTool } from '../../src/Tools/WebSearchTool' +import { handlerOf, messageOf, runHandler } from '../TestHelpers' + +const cases: ReadonlyArray<{ + readonly name: string + readonly tool: () => FoldTool + readonly params: Record + readonly timeoutMs: number + readonly message: string +}> = [ + { + name: 'fetch interprets fractional timeout_seconds as seconds', + tool: webFetchTool, + params: { url: 'https://example.com', timeout_seconds: 1.5 }, + timeoutMs: 1500, + message: 'Request timed out after 1500ms', + }, + { + name: 'fetch defaults to 30 seconds', + tool: webFetchTool, + params: { url: 'https://example.com' }, + timeoutMs: 30_000, + message: 'Request timed out after 30000ms', + }, + { + name: 'fetch caps timeout_seconds at 120 seconds', + tool: webFetchTool, + params: { url: 'https://example.com', timeout_seconds: 200 }, + timeoutMs: 120_000, + message: 'Request timed out after 120000ms', + }, + ...(['exa', 'parallel'] as const).flatMap((provider) => [ + { + name: `${provider} search interprets seconds and overrides internal milliseconds`, + tool: () => webSearchTool({ provider, timeoutMs: 100 }), + params: { query: 'test', timeout_seconds: 1.5 }, + timeoutMs: 1500, + message: `${provider === 'exa' ? 'web_search_exa' : 'web_search'} request timed out`, + }, + { + name: `${provider} search retains the 25-second default`, + tool: () => webSearchTool({ provider }), + params: { query: 'test' }, + timeoutMs: 25_000, + message: `${provider === 'exa' ? 'web_search_exa' : 'web_search'} request timed out`, + }, + { + name: `${provider} search retains internal timeoutMs units`, + tool: () => webSearchTool({ provider, timeoutMs: 250 }), + params: { query: 'test' }, + timeoutMs: 250, + message: `${provider === 'exa' ? 'web_search_exa' : 'web_search'} request timed out`, + }, + { + name: `${provider} search does not inherit the fetch timeout cap`, + tool: () => webSearchTool({ provider }), + params: { query: 'test', timeout_seconds: 150 }, + timeoutMs: 150_000, + message: `${provider === 'exa' ? 'web_search_exa' : 'web_search'} request timed out`, + }, + ]), +] + +for (const testCase of cases) { + it.effect(testCase.name, () => + Effect.gen(function* () { + const signals: Array = [] + const fetch: typeof globalThis.fetch = Object.assign( + (_url: string | URL | Request, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = init?.signal + if (signal === undefined || signal === null) throw new Error('expected an abort signal') + signals.push(signal) + signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { + once: true, + }) + }), + { preconnect: globalThis.fetch.preconnect }, + ) + const fiber = yield* runHandler(handlerOf(testCase.tool())(testCase.params)).pipe( + Effect.provideService(FetchHttpClient.Fetch, fetch), + Effect.flip, + Effect.forkChild, + ) + + yield* TestClock.adjust(testCase.timeoutMs - 1) + expect(signals).toHaveLength(1) + expect(signals[0]?.aborted).toBe(false) + expect(fiber.pollUnsafe()).toBeUndefined() + + yield* TestClock.adjust(1) + const failure = yield* Fiber.join(fiber) + expect(messageOf(failure)).toBe(testCase.message) + expect(signals[0]?.aborted).toBe(true) + }), + ) +} + +it.effect('web contracts decode optional numeric seconds and reject strings', () => + Effect.gen(function* () { + for (const contract of [webFetchToolContract, webSearchToolContract]) { + const params = { url: 'https://example.com', query: 'test' } + const decode = Schema.decodeUnknownEffect(contract.parameters) + expect(yield* decode({ ...params, timeout_seconds: 1.5 })).toHaveProperty('timeout_seconds', 1.5) + expect(yield* decode(params)).not.toHaveProperty('timeout_seconds') + expect(yield* decode({ ...params, timeout_seconds: '1.5' }).pipe(Effect.isFailure)).toBe(true) + } + expect(webFetchToolContract.parameters.fields).not.toHaveProperty('timeout') + }), +) + +it.effect('search clears its timeout after a successful response', () => + Effect.gen(function* () { + const signals: Array = [] + const fetch: typeof globalThis.fetch = Object.assign( + (_url: string | URL | Request, init?: RequestInit) => { + const signal = init?.signal + if (signal === undefined || signal === null) throw new Error('expected an abort signal') + signals.push(signal) + return Promise.resolve( + new Response(JSON.stringify({ result: { content: [{ text: 'Search result' }] } })), + ) + }, + { preconnect: globalThis.fetch.preconnect }, + ) + const result = yield* runHandler( + handlerOf(webSearchTool({ provider: 'exa' }))({ query: 'test', timeout_seconds: 1 }), + ).pipe(Effect.provideService(FetchHttpClient.Fetch, fetch)) + expect(messageOf(result)).toBe('Search result') + yield* TestClock.adjust('2 seconds') + expect(signals).toHaveLength(1) + expect(signals[0]?.aborted).toBe(false) + }), +) diff --git a/packages/fold-core/src/Tools/Contracts.ts b/packages/fold-core/src/Tools/Contracts.ts index 7561d73..bcd0bc2 100644 --- a/packages/fold-core/src/Tools/Contracts.ts +++ b/packages/fold-core/src/Tools/Contracts.ts @@ -164,8 +164,8 @@ const WebFetchParameters = Schema.Struct({ format: Schema.optionalKey(Schema.Literals(['markdown', 'text', 'html'])).annotate({ description: 'Output format. Defaults to markdown; html returns raw HTML; text strips HTML tags.', }), - timeout: Schema.optionalKey(Schema.Number).annotate({ - description: 'Request timeout in milliseconds. Defaults to 30000; maximum 120000.', + timeout_seconds: Schema.optionalKey(Schema.Number).annotate({ + description: 'Request timeout in seconds. Defaults to 30 seconds; capped at 120 seconds.', }), }) @@ -184,6 +184,10 @@ export const webFetchToolContract = { const WebSearchParameters = Schema.Struct({ query: Schema.String.annotate({ description: 'Search query to run against the web.' }), + timeout_seconds: Schema.optionalKey(Schema.Number).annotate({ + description: + 'Request timeout in seconds. Overrides the configured timeout; defaults to 25 seconds when unconfigured.', + }), numResults: Schema.optionalKey(Schema.Number).annotate({ description: 'Number of search results to return. Defaults to 8; maximum 20.', }),