Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/fold-agent/src/Tools/WebFetchTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
16 changes: 12 additions & 4 deletions packages/fold-agent/src/Tools/WebSearchTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -106,8 +107,12 @@ const callMcp = (input: {
readonly timeoutMs: number
}): Effect.Effect<string | undefined, { message: string }> =>
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({
Expand Down Expand Up @@ -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 =>
Expand All @@ -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'
Expand Down
4 changes: 2 additions & 2 deletions packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
}),
Expand Down
142 changes: 142 additions & 0 deletions packages/fold-agent/test/Tools/WebTimeouts.vi.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
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<AbortSignal> = []
const fetch: typeof globalThis.fetch = Object.assign(
(_url: string | URL | Request, init?: RequestInit) =>
new Promise<Response>((_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<AbortSignal> = []
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)
}),
)
8 changes: 6 additions & 2 deletions packages/fold-core/src/Tools/Contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
}),
})

Expand All @@ -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.',
}),
Expand Down
Loading